first commit

This commit is contained in:
exercict
2026-07-14 08:10:11 +04:00
commit fdfb0dd62e
60 changed files with 9930 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
<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>