266 lines
13 KiB
HTML
266 lines
13 KiB
HTML
{% 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 %}
|