morozovka-portal/app/templates/project_board.html

289 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.title }} — Доска — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 20px; margin: 0; }
.container { max-width: 1400px; margin: 0 auto; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
h1 { color: #58a6ff; margin: 0; font-size: 1.8rem; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.board { display: grid; grid-template-columns: repeat(5, minmax(240px, 1fr)); gap: 15px; align-items: flex-start; overflow-x: auto; padding-bottom: 15px; }
.column { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 12px; min-height: 200px; }
.column-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
.column-title { font-weight: 700; color: #e6edf3; font-size: .9rem; text-transform: uppercase; letter-spacing: .5px; }
.column-count { color: #8b949e; font-size: .8rem; background: #0d1117; padding: 2px 8px; border-radius: 10px; }
.column.drop-target { border-color: #58a6ff; background: rgba(88,166,255,.05); }
.task-list { min-height: 50px; display: flex; flex-direction: column; gap: 8px; }
.task-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; cursor: grab; position: relative; transition: all .15s; }
.task-card:hover { border-color: #58a6ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.3); }
.task-card.dragging { opacity: .5; cursor: grabbing; }
.task-card::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 2px; }
.priority-low::before { background: #8b949e; }
.priority-medium::before { background: #58a6ff; }
.priority-high::before { background: #d29922; }
.priority-critical::before { background: #f85149; }
.task-title { color: #e6edf3; font-weight: 600; font-size: .9rem; margin-left: 6px; margin-bottom: 6px; word-wrap: break-word; }
.task-desc { color: #8b949e; font-size: .8rem; margin-left: 6px; margin-bottom: 8px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.task-meta { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-left: 6px; font-size: .75rem; }
.task-assignee { color: #58a6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.task-due { color: #8b949e; }
.task-due.overdue { color: #f85149; }
.task-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity .15s; }
.task-card:hover .task-actions { opacity: 1; }
.task-action-btn { background: none; border: none; color: #8b949e; cursor: pointer; padding: 2px 4px; font-size: .85rem; border-radius: 3px; }
.task-action-btn:hover { background: #30363d; color: #e6edf3; }
.task-action-btn.danger:hover { color: #f85149; }
.add-task-btn { width: 100%; padding: 8px; margin-top: 8px; background: transparent; border: 1px dashed #30363d; color: #8b949e; border-radius: 6px; cursor: pointer; font-size: .85rem; transition: all .15s; }
.add-task-btn:hover { border-color: #58a6ff; color: #58a6ff; }
/* Модалка */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; width: 100%; max-width: 550px; padding: 25px; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin: 0 0 20px; color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 80px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
@media (max-width: 1100px) {
.board { grid-template-columns: repeat(5, 280px); }
}
</style>
</head>
<body>
<div class="container">
<a href="/projects/{{ project.id }}" class="back-link">← К проекту</a>
<div class="header">
<h1>{{ project.title }} — Доска</h1>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab active">
Доска
</a>
<a href="/projects/{{ project.id }}#files" class="tab">
Файлы
</a>
</div>
<div class="board" id="board">
{% for status in TASK_STATUSES %}
<div class="column" data-status="{{ status }}">
<div class="column-header">
<span class="column-title">{{ TASK_STATUS_LABELS[status] }}</span>
<span class="column-count">{{ columns[status]|length }}</span>
</div>
<div class="task-list" data-status="{{ status }}">
{% for item in columns[status] %}
{% set task = item.task %}
<div class="task-card priority-{{ task.priority }}"
draggable="true"
data-task-id="{{ task.id }}"
data-status="{{ task.status }}">
<div class="task-title">{{ task.title }}</div>
{% if task.description %}
<div class="task-desc">{{ task.description }}</div>
{% endif %}
<div class="task-meta">
{% if item.assignee %}
<span class="task-assignee">👤 {{ item.assignee.full_name or item.assignee.username }}</span>
{% else %}
<span class="task-assignee" style="color:#6e7681;">— не назначено</span>
{% endif %}
{% if task.due_date %}
<span class="task-due">{{ task.due_date|date_ru }}</span>
{% endif %}
{% if perms.can_edit %}
<div class="task-actions">
<a href="/projects/{{ project.id }}/tasks/{{ task.id }}/edit" class="task-action-btn" title="Редактировать">✎</a>
{% if perms.can_manage %}
<form action="/projects/{{ project.id }}/tasks/{{ task.id }}/delete" method="POST" onsubmit="return confirm('Удалить задачу?');" style="display:inline;">
<button type="submit" class="task-action-btn danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if perms.can_edit %}
<button type="button" class="add-task-btn" onclick="openTaskModal('{{ status }}')">+ Добавить задачу</button>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Модалка создания задачи -->
<div class="modal-overlay" id="task-modal">
<div class="modal">
<h2>Новая задача</h2>
<form id="task-form" method="POST" action="/projects/{{ project.id }}/tasks/new">
<input type="hidden" name="status" id="task-status" value="backlog">
<div class="form-group">
<label>Название *</label>
<input type="text" name="title" required maxlength="200">
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Приоритет</label>
<select name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label>Дедлайн</label>
<input type="date" name="due_date">
</div>
</div>
<div class="form-group">
<label>Исполнитель</label>
<select name="assignee_id">
<option value="">— не назначено —</option>
{% for member_obj, user in members %}
<option value="{{ user.id }}">{{ user.full_name or user.username }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<script>
const PROJECT_ID = {{ project.id }};
const CAN_EDIT = {{ 'true' if perms.can_edit else 'false' }};
function openTaskModal(status) {
document.getElementById('task-status').value = status;
document.getElementById('task-modal').classList.add('open');
setTimeout(() => document.querySelector('#task-form input[name="title"]').focus(), 100);
}
function closeTaskModal() {
document.getElementById('task-modal').classList.remove('open');
document.getElementById('task-form').reset();
}
document.getElementById('task-modal').addEventListener('click', function(e) {
if (e.target === this) closeTaskModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeTaskModal();
});
// Drag & drop
let draggedCard = null;
let draggedFromStatus = null;
document.querySelectorAll('.task-card').forEach(card => {
card.addEventListener('dragstart', e => {
if (!CAN_EDIT) { e.preventDefault(); return; }
draggedCard = card;
draggedFromStatus = card.dataset.status;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.dataset.taskId);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
draggedCard = null;
document.querySelectorAll('.column').forEach(c => c.classList.remove('drop-target'));
});
});
document.querySelectorAll('.column').forEach(column => {
column.addEventListener('dragover', e => {
if (!draggedCard) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
column.classList.add('drop-target');
});
column.addEventListener('dragleave', e => {
if (!column.contains(e.relatedTarget)) column.classList.remove('drop-target');
});
column.addEventListener('drop', async e => {
e.preventDefault();
column.classList.remove('drop-target');
if (!draggedCard || !CAN_EDIT) return;
const newStatus = column.dataset.status;
const taskId = draggedCard.dataset.taskId;
const list = column.querySelector('.task-list');
// Определяем позицию: относительно других карточек
let position = list.children.length;
const cards = Array.from(list.querySelectorAll('.task-card'));
const mouseY = e.clientY;
for (let i = 0; i < cards.length; i++) {
const rect = cards[i].getBoundingClientRect();
if (mouseY < rect.top + rect.height / 2) { position = i; break; }
}
// Оптимистичное обновление UI
const insertBefore = position < cards.length ? cards[position] : null;
list.insertBefore(draggedCard, insertBefore);
draggedCard.dataset.status = newStatus;
updateCounts();
// Отправка на сервер
try {
const res = await fetch(`/projects/${PROJECT_ID}/tasks/${taskId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus, position: position })
});
if (!res.ok) {
console.error('Move failed', await res.text());
location.reload();
}
} catch (err) {
console.error(err);
location.reload();
}
});
});
function updateCounts() {
document.querySelectorAll('.column').forEach(col => {
const count = col.querySelectorAll('.task-card').length;
col.querySelector('.column-count').textContent = count;
});
}
</script>
</body>
</html>