385 lines
15 KiB
PHP
385 lines
15 KiB
PHP
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||
<h1 class="h3 mb-0">Канбан-доска</h1>
|
||
|
||
<div class="d-flex gap-2 flex-wrap">
|
||
<a href="/tasks" class="btn btn-outline-primary">Таблица</a>
|
||
<a href="/tasks/kanban" class="btn btn-outline-secondary">Канбан</a>
|
||
<a href="/tasks/create" class="btn btn-primary">+ Новая задача</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-body">
|
||
<form method="get" action="/tasks/kanban" class="row g-2 align-items-end">
|
||
<div class="col-12 col-md-4">
|
||
<label class="form-label">Фильтр по статусу</label>
|
||
<select name="status" class="form-select">
|
||
<option value="">Все статусы</option>
|
||
<option value="NEW" <?= ($filterStatus ?? '') === 'NEW' ? 'selected' : '' ?>>Новая</option>
|
||
<option value="IN_PROGRESS" <?= ($filterStatus ?? '') === 'IN_PROGRESS' ? 'selected' : '' ?>>В работе</option>
|
||
<option value="REVIEW" <?= ($filterStatus ?? '') === 'REVIEW' ? 'selected' : '' ?>>На проверке</option>
|
||
<option value="DONE" <?= ($filterStatus ?? '') === 'DONE' ? 'selected' : '' ?>>Закрыта</option>
|
||
<option value="CANCELED" <?= ($filterStatus ?? '') === 'CANCELED' ? 'selected' : '' ?>>Отменена</option>
|
||
<option value="OVERDUE" <?= ($filterStatus ?? '') === 'OVERDUE' ? 'selected' : '' ?>>Просрочена</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="col-12 col-md-auto">
|
||
<button type="submit" class="btn btn-primary">Применить</button>
|
||
<a href="/tasks/kanban" class="btn btn-outline-secondary">Сбросить</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kanban-wrapper">
|
||
<div class="kanban-board">
|
||
<?php foreach ($columns as $statusCode => $column): ?>
|
||
<div class="kanban-column" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||
<div class="kanban-column-header <?= htmlspecialchars($column['header_class']) ?>">
|
||
<div class="fw-semibold">
|
||
<?= htmlspecialchars($column['title']) ?>
|
||
</div>
|
||
<span class="badge text-bg-light kanban-count">
|
||
<?= count($column['tasks']) ?>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="kanban-column-body dropzone" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||
<?php if (empty($column['tasks'])): ?>
|
||
<div class="kanban-empty">
|
||
Нет задач
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($column['tasks'] as $task): ?>
|
||
<?php
|
||
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||
echo '<pre style="background:#000;color:#0f0;font-size:11px">';
|
||
echo 'ID: ' . ($task['id'] ?? '') . PHP_EOL;
|
||
echo 'planned_at: ' . ($task['planned_at'] ?? 'EMPTY') . PHP_EOL;
|
||
echo 'deadline: ' . print_r($deadline, true);
|
||
echo '</pre>';
|
||
|
||
|
||
$commentCount = (int)($task['comment_count'] ?? 0);
|
||
$lastCommentId = (int)($task['last_comment_id'] ?? 0);
|
||
$lastReadCommentId = (int)($task['last_read_comment_id'] ?? 0);
|
||
$isUnread = $commentCount > 0 && $lastCommentId > $lastReadCommentId;
|
||
$badgeClass = $isUnread ? 'bg-primary' : 'bg-secondary';
|
||
|
||
$priorityClass = match ($task['priority']) {
|
||
'LOW' => 'text-bg-light',
|
||
'MEDIUM' => 'text-bg-secondary',
|
||
'HIGH' => 'text-bg-warning',
|
||
'CRITICAL' => 'text-bg-danger',
|
||
default => 'text-bg-secondary',
|
||
};
|
||
?>
|
||
<div class="kanban-card <?= htmlspecialchars($deadline['class'] ?? '') ?>"
|
||
draggable="true"
|
||
data-task-id="<?= (int)$task['id'] ?>"
|
||
data-current-status="<?= htmlspecialchars((string)$task['status']) ?>">
|
||
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
||
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
|
||
data-task-modal
|
||
data-task-id="<?= (int)$task['id'] ?>"
|
||
class="kanban-card-title text-decoration-none">
|
||
<?= htmlspecialchars((string)$task['name']) ?>
|
||
</a>
|
||
|
||
<span class="badge <?= $priorityClass ?>">
|
||
<?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||
</span>
|
||
</div>
|
||
|
||
<?php if (!empty($task['description'])): ?>
|
||
<div class="kanban-card-desc mb-2">
|
||
<?= htmlspecialchars(mb_strimwidth((string)$task['description'], 0, 140, '...')) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="small text-muted mb-2">
|
||
<div><strong>CRM:</strong> <?= htmlspecialchars((string)$task['crm_id']) ?></div>
|
||
<?php if (!empty($task['creator_name'])): ?>
|
||
<div><strong>Постановщик:</strong> <?= htmlspecialchars((string)$task['creator_name']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($task['assignee_name'])): ?>
|
||
<div><strong>Ответственный:</strong> <?= htmlspecialchars((string)$task['assignee_name']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($task['supplier'])): ?>
|
||
<div><strong>Поставщик:</strong> <?= htmlspecialchars((string)$task['supplier']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($task['delivery_date'])): ?>
|
||
<div><strong>Срок:</strong> <?= htmlspecialchars((string)$task['delivery_date']) ?></div>
|
||
<?php endif; ?>
|
||
<?php if (!empty($task['planned_at'])): ?>
|
||
<div>
|
||
<strong>Дата план:</strong>
|
||
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) ?>
|
||
|
||
<?php if ($deadline): ?>
|
||
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||
<?= htmlspecialchars($deadline['text']) ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<span
|
||
id="comment-badge-<?= (int)$task['id'] ?>"
|
||
class="badge <?= $badgeClass ?>"
|
||
data-task-id="<?= (int)$task['id'] ?>"
|
||
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||
data-last-comment-id="<?= $lastCommentId ?>"
|
||
>
|
||
Комментарии: <span class="comment-count"><?= $commentCount ?></span>
|
||
</span>
|
||
|
||
<?php
|
||
$isDone = (($task['status'] ?? '') === 'DONE');
|
||
?>
|
||
<span class="badge <?= $isDone ? 'text-bg-success' : 'text-bg-light' ?>">
|
||
<?= $isDone ? 'Выполнено' : 'Не выполнено' ?>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.kanban-wrapper {
|
||
overflow-x: auto;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.kanban-board {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
min-width: max-content;
|
||
}
|
||
|
||
.kanban-column {
|
||
width: 320px;
|
||
background: #f8f9fa;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: calc(100vh - 220px);
|
||
}
|
||
|
||
.kanban-column-header {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(255,255,255,.15);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-top-left-radius: 14px;
|
||
border-top-right-radius: 14px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
color: #fff;
|
||
}
|
||
|
||
.kanban-header-new {
|
||
background: #6c757d;
|
||
}
|
||
|
||
.kanban-header-progress {
|
||
background: #0d6efd;
|
||
}
|
||
|
||
.kanban-header-review {
|
||
background: #fd7e14;
|
||
}
|
||
|
||
.kanban-header-done {
|
||
background: #198754;
|
||
}
|
||
|
||
.kanban-header-canceled {
|
||
background: #495057;
|
||
}
|
||
|
||
.kanban-header-overdue {
|
||
background: #dc3545;
|
||
}
|
||
|
||
.kanban-column-body {
|
||
padding: 12px;
|
||
overflow-y: auto;
|
||
min-height: 120px;
|
||
transition: background-color .2s ease;
|
||
}
|
||
|
||
.kanban-column-body.drag-over {
|
||
background: #e9f2ff;
|
||
}
|
||
|
||
.kanban-card {
|
||
background: #fff;
|
||
border: 1px solid #e9ecef;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||
margin-bottom: 12px;
|
||
cursor: grab;
|
||
}
|
||
|
||
.kanban-card:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.kanban-card.dragging {
|
||
opacity: .55;
|
||
transform: rotate(1deg);
|
||
}
|
||
|
||
.kanban-card-title {
|
||
font-weight: 600;
|
||
color: #212529;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.kanban-card-title:hover {
|
||
color: #0d6efd;
|
||
}
|
||
|
||
.kanban-card-desc {
|
||
color: #6c757d;
|
||
font-size: 0.92rem;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.kanban-empty {
|
||
color: #6c757d;
|
||
text-align: center;
|
||
padding: 20px 10px;
|
||
border: 1px dashed #ced4da;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.kanban-column {
|
||
width: 280px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
let draggedCard = null;
|
||
let originalStatus = null;
|
||
|
||
document.querySelectorAll('.kanban-card').forEach(card => {
|
||
card.addEventListener('dragstart', function () {
|
||
draggedCard = this;
|
||
originalStatus = this.getAttribute('data-current-status');
|
||
this.classList.add('dragging');
|
||
});
|
||
|
||
card.addEventListener('dragend', function () {
|
||
this.classList.remove('dragging');
|
||
document.querySelectorAll('.dropzone').forEach(zone => zone.classList.remove('drag-over'));
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.dropzone').forEach(zone => {
|
||
zone.addEventListener('dragover', function (e) {
|
||
e.preventDefault();
|
||
this.classList.add('drag-over');
|
||
});
|
||
|
||
zone.addEventListener('dragleave', function () {
|
||
this.classList.remove('drag-over');
|
||
});
|
||
|
||
zone.addEventListener('drop', async function (e) {
|
||
e.preventDefault();
|
||
this.classList.remove('drag-over');
|
||
|
||
if (!draggedCard) return;
|
||
|
||
const newStatus = this.getAttribute('data-status');
|
||
const taskId = draggedCard.getAttribute('data-task-id');
|
||
|
||
if (!taskId || !newStatus || originalStatus === newStatus) {
|
||
return;
|
||
}
|
||
|
||
const oldZone = document.querySelector('.dropzone[data-status="' + originalStatus + '"]');
|
||
|
||
this.appendChild(draggedCard);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('task_id', taskId);
|
||
formData.append('status', newStatus);
|
||
|
||
const response = await fetch('/tasks/change-status', {
|
||
method: 'POST',
|
||
body: formData,
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (!result.success) {
|
||
throw new Error(result.message || 'Ошибка смены статуса');
|
||
}
|
||
|
||
draggedCard.setAttribute('data-current-status', newStatus);
|
||
updateColumnCounts();
|
||
removeEmptyBlocks();
|
||
} catch (error) {
|
||
if (oldZone) {
|
||
oldZone.appendChild(draggedCard);
|
||
}
|
||
updateColumnCounts();
|
||
removeEmptyBlocks();
|
||
alert('Не удалось изменить статус');
|
||
}
|
||
});
|
||
});
|
||
|
||
function updateColumnCounts() {
|
||
document.querySelectorAll('.kanban-column').forEach(column => {
|
||
const cards = column.querySelectorAll('.kanban-card');
|
||
const countBadge = column.querySelector('.kanban-count');
|
||
if (countBadge) {
|
||
countBadge.textContent = cards.length;
|
||
}
|
||
});
|
||
}
|
||
|
||
function removeEmptyBlocks() {
|
||
document.querySelectorAll('.dropzone').forEach(zone => {
|
||
const cards = zone.querySelectorAll('.kanban-card');
|
||
const empty = zone.querySelector('.kanban-empty');
|
||
|
||
if (cards.length === 0) {
|
||
if (!empty) {
|
||
const div = document.createElement('div');
|
||
div.className = 'kanban-empty';
|
||
div.textContent = 'Нет задач';
|
||
zone.appendChild(div);
|
||
}
|
||
} else if (empty) {
|
||
empty.remove();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
</script>
|