morozovka-portal/app/templates/project_board.html

266 lines
13 KiB
HTML
Raw Permalink 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.

{% extends "base.html" %}
{% block title %}{{ project.title }} — Доска — Morozovka Portal{% endblock %}
{% block main_class %}container container-wide{% endblock %}
{% block head %}
<style>
.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; text-decoration: none; }
.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; text-decoration: none; }
.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>
{% endblock %}
{% block content %}
<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 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>
{% endblock %}
{% block scripts %}
<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();
});
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; }
}
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>
{% endblock %}