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
+717
View File
@@ -0,0 +1,717 @@
<?php
$baseUrl = '/boards/' . urlencode((string)$board['code']);
$queryBase = [
'status' => $filters['status'] ?? '',
'priority' => $filters['priority'] ?? '',
'assignee_id' => $filters['assignee_id'] ?? '',
'search' => $filters['search'] ?? '',
];
$tableUrl = $baseUrl . '?view=table';
$kanbanUrl = $baseUrl . '?view=kanban';
$cardsUrl = $baseUrl . '?view=cards';
$deadlineRank = function (array $task): int {
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
return match ($deadline['code'] ?? '') {
'red' => 1,
'yellow' => 2,
'green' => 3,
default => 4,
};
};
usort($tasks, function (array $a, array $b) use ($deadlineRank): int {
$rankA = $deadlineRank($a);
$rankB = $deadlineRank($b);
if ($rankA !== $rankB) {
return $rankA <=> $rankB;
}
return strtotime((string)($a['planned_at'] ?? '9999-12-31')) <=> strtotime((string)($b['planned_at'] ?? '9999-12-31'));
});
if (!empty($columns)) {
foreach ($columns as $statusCode => $column) {
usort($columns[$statusCode]['tasks'], function (array $a, array $b) use ($deadlineRank): int {
$rankA = $deadlineRank($a);
$rankB = $deadlineRank($b);
if ($rankA !== $rankB) {
return $rankA <=> $rankB;
}
return strtotime((string)($a['planned_at'] ?? '9999-12-31')) <=> strtotime((string)($b['planned_at'] ?? '9999-12-31'));
});
}
}
?>
<!-- <div class="alert alert-info mb-3">-->
<!-- view = --><?php //= htmlspecialchars((string)$view) ?>
<!-- </div>-->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
<div>
<h1 class="h3 mb-0"><?= htmlspecialchars((string)$board['name']) ?></h1>
<div class="d-flex flex-wrap gap-2 mt-2">
<span class="badge text-bg-secondary">
Всего: <?= (int)$stats['TOTAL'] ?>
</span>
<span class="badge text-bg-secondary">
Новые: <?= (int)$stats['NEW'] ?>
</span>
<span class="badge text-bg-primary">
В работе: <?= (int)$stats['IN_PROGRESS'] ?>
</span>
<span class="badge text-bg-warning text-dark">
На проверке: <?= (int)$stats['REVIEW'] ?>
</span>
<span class="badge text-bg-success">
Завершено: <?= (int)$stats['DONE'] ?>
</span>
<span class="badge text-bg-danger">
Просрочено: <?= (int)$stats['OVERDUE'] ?>
</span>
</div>
<div class="text-muted small">Код: <?= htmlspecialchars((string)$board['code']) ?></div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="<?= htmlspecialchars($tableUrl) ?>" class="btn <?= $view === 'table' ? 'btn-primary' : 'btn-outline-primary' ?>">Таблица</a>
<a href="<?= htmlspecialchars($kanbanUrl) ?>" class="btn <?= $view === 'kanban' ? 'btn-primary' : 'btn-outline-primary' ?>">Канбан</a>
<a href="<?= htmlspecialchars($cardsUrl) ?>" class="btn <?= $view === 'cards' ? 'btn-primary' : 'btn-outline-primary' ?>">Плитки</a>
<a href="/tasks/create?board_code=<?= urlencode((string)$board['code']) ?>" class="btn btn-success">+ Новая задача</a>
</div>
<?php if (!empty($canManageBoard)): ?>
<button class="btn btn-primary"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#boardSettingsCanvas"
aria-controls="boardSettingsCanvas">
Редактировать доску
</button>
<?php endif; ?>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body">
<form method="get" action="<?= htmlspecialchars($baseUrl) ?>" class="row g-2 align-items-end">
<input type="hidden" name="view" value="<?= htmlspecialchars((string)$view) ?>">
<div class="col-12 col-md-3">
<label class="form-label">Статус</label>
<select name="status" class="form-select">
<option value="">Все</option>
<option value="NEW" <?= ($filters['status'] ?? '') === 'NEW' ? 'selected' : '' ?>>Новая</option>
<option value="IN_PROGRESS" <?= ($filters['status'] ?? '') === 'IN_PROGRESS' ? 'selected' : '' ?>>В работе</option>
<option value="REVIEW" <?= ($filters['status'] ?? '') === 'REVIEW' ? 'selected' : '' ?>>На проверке</option>
<option value="DONE" <?= ($filters['status'] ?? '') === 'DONE' ? 'selected' : '' ?>>Закрыта</option>
<option value="CANCELED" <?= ($filters['status'] ?? '') === 'CANCELED' ? 'selected' : '' ?>>Отменена</option>
<option value="OVERDUE" <?= ($filters['status'] ?? '') === 'OVERDUE' ? 'selected' : '' ?>>Просрочена</option>
</select>
</div>
<div class="col-12 col-md-2">
<label class="form-label">Приоритет</label>
<select name="priority" class="form-select">
<option value="">Все</option>
<option value="LOW" <?= ($filters['priority'] ?? '') === 'LOW' ? 'selected' : '' ?>>Низкий</option>
<option value="MEDIUM" <?= ($filters['priority'] ?? '') === 'MEDIUM' ? 'selected' : '' ?>>Средний</option>
<option value="HIGH" <?= ($filters['priority'] ?? '') === 'HIGH' ? 'selected' : '' ?>>Высокий</option>
<option value="CRITICAL" <?= ($filters['priority'] ?? '') === 'CRITICAL' ? 'selected' : '' ?>>Критический</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Ответственный</label>
<select name="assignee_id" class="form-select">
<option value="">Все</option>
<?php foreach ($users as $u): ?>
<option value="<?= (int)$u['id'] ?>" <?= (int)($filters['assignee_id'] ?? 0) === (int)$u['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)($u['display_name'] ?: $u['login'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Поиск</label>
<input type="text" name="search" value="<?= htmlspecialchars((string)($filters['search'] ?? '')) ?>" class="form-control" placeholder="Наименование, заказ, заявитель...">
</div>
<div class="col-12 col-md-auto">
<button type="submit" class="btn btn-primary">Применить</button>
<a href="<?= htmlspecialchars($baseUrl . '?view=' . urlencode((string)$view)) ?>" class="btn btn-outline-secondary">Сбросить</a>
</div>
</form>
</div>
</div>
<?php if ($view === 'table'): ?>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>CRM ID</th>
<th>Постановщик</th>
<th>Ответственный</th>
<th>Наименование</th>
<th>Статус</th>
<th>Приоритет</th>
<th>Комментарии</th>
<th>Дата план</th>
</tr>
</thead>
<tbody>
<?php foreach ($tasks as $task): ?>
<?php
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
$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';
$statusClass = match ($task['status']) {
'NEW' => 'bg-secondary',
'IN_PROGRESS' => 'bg-primary',
'REVIEW' => 'bg-warning text-dark',
'DONE' => 'bg-success',
'CANCELED' => 'bg-dark',
'OVERDUE' => 'bg-danger',
default => 'bg-secondary',
};
$priorityClass = match ($task['priority']) {
'LOW' => 'bg-light text-dark',
'MEDIUM' => 'bg-secondary',
'HIGH' => 'bg-warning text-dark',
'CRITICAL' => 'bg-danger',
default => 'bg-secondary',
};
?>
<tr class="<?= htmlspecialchars($deadline['class'] ?? '') ?>">
<td><?= (int)$task['id'] ?></td>
<td><?= htmlspecialchars((string)$task['crm_id']) ?></td>
<td><?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?></td>
<td><?= htmlspecialchars((string)($task['assignee_name'] ?? '')) ?></td>
<td>
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
data-task-modal
data-task-id="<?= (int)$task['id'] ?>"
class="text-decoration-none">
<?= htmlspecialchars((string)$task['name']) ?>
</a>
</td>
<td>
<span class="badge <?= $statusClass ?>">
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
</span>
</td>
<td>
<span class="badge <?= $priorityClass ?>">
<?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
</span>
</td>
<td>
<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 ?>"
>
<?= $commentCount ?>
</span>
</td>
<td>
<?php if (!empty($task['planned_at'])): ?>
<div class="small text-muted mt-1">
<?= 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; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($tasks)): ?>
<tr>
<td colspan="8" class="text-center text-muted py-4">Нет задач</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php elseif ($view === 'cards'): ?>
<div class="row" id="cardsContainer">
<?php foreach ($tasks as $task): ?>
<?php
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
$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;
$statusBadgeClass = match ($task['status']) {
'NEW' => 'bg-secondary text-white',
'IN_PROGRESS' => 'bg-info text-white',
'REVIEW' => 'bg-warning text-dark',
'DONE' => 'bg-success text-white',
'CANCELED' => 'bg-dark text-white',
'OVERDUE' => 'bg-danger text-white',
default => 'bg-secondary text-white',
};
$cardBorderClass = match ($task['status']) {
'NEW' => 'border-secondary',
'IN_PROGRESS' => 'border-info',
'REVIEW' => 'border-warning',
'DONE' => 'border-success',
'CANCELED' => 'border-dark',
'OVERDUE' => 'border-danger',
default => 'border-secondary',
};
$priorityClass = match ($task['priority']) {
'LOW' => 'bg-light text-dark',
'MEDIUM' => 'bg-primary text-white',
'HIGH' => 'bg-warning text-dark',
'CRITICAL' => 'bg-danger text-white',
default => 'bg-primary text-white',
};
?>
<div class="col-12 col-md-6 col-xl-3 my-3 task-card"
data-task-id="<?= (int)$task['id'] ?>"
data-status="<?= htmlspecialchars((string)$task['status']) ?>">
<div class="card h-100 shadow-sm <?= $cardBorderClass ?> <?= htmlspecialchars($deadline['class'] ?? '') ?>"
data-task-modal
data-task-id="<?= (int)$task['id'] ?>"
style="cursor: pointer; border-width: 2px;">
<div class="card-body">
<p class="card-title fw-bold mb-2">
<?= htmlspecialchars((string)($task['crm_id'] ?: ('Заявка #' . $task['id']))) ?>
</p>
<p class="card-text mb-3">
<?= htmlspecialchars((string)$task['name']) ?>
</p>
<div class="fw-bold mb-1 small">
Постановщик: <?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?>
</div>
<div class="fw-bold mb-1 small">
Ответственный:
<?= htmlspecialchars((string)($task['assignee_name'] ?? 'Не назначен')) ?>
</div>
<?php if (!empty($task['supplier'])): ?>
<div class="small text-muted mb-1">
Поставщик: <?= htmlspecialchars((string)$task['supplier']) ?>
</div>
<?php endif; ?>
<div class="plane mb-1 small text-muted">
<strong>Дата план:</strong>
<?= !empty($task['planned_at']) ? 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>
<div class="fact mb-2 small text-muted">
<strong>Дата факт:</strong> <?= htmlspecialchars((string)($task['completed_at'] ?? '')) ?>
</div>
<div class="fact mb-2 small text-muted">
<div><strong>Дата постановки:</strong> <?= htmlspecialchars((string)($task['task_created_at'] ?? '')) ?></div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top flex-wrap gap-2">
<span class="badge <?= $priorityClass ?> rounded-pill">
Приоритет: <?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
</span>
<span
id="comment-badge-<?= (int)$task['id'] ?>"
class="badge <?= $isUnread ? 'bg-primary' : 'bg-secondary' ?>"
data-task-id="<?= (int)$task['id'] ?>"
data-last-read-comment-id="<?= $lastReadCommentId ?>"
data-last-comment-id="<?= $lastCommentId ?>"
>
<span class="comment-count"><?= $commentCount ?></span>
</span>
<span class="badge <?= $statusBadgeClass ?>">
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
</span>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($tasks)): ?>
<div class="col-12">
<div class="alert alert-light border text-muted">
Нет задач
</div>
</div>
<?php endif; ?>
</div>
<style>
#cardsContainer .task-card .card {
transition: transform .15s ease, box-shadow .15s ease;
}
#cardsContainer .task-card .card:hover {
transform: translateY(-2px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.12) !important;
}
#cardsContainer .card-title {
font-size: .95rem;
}
#cardsContainer .card-text {
min-height: 48px;
font-size: .95rem;
}
</style>
<?php else: ?>
<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);
?>
<?php
$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',
};
$isDone = (($task['status'] ?? '') === 'DONE');
?>
<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['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>
<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 - 260px); }
.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.deadline-green {
background: #d1e7dd !important;
border: 2px solid #198754 !important;
}
.kanban-card.deadline-yellow {
background: #fff3cd !important;
border: 2px solid #ffc107 !important;
}
.kanban-card.deadline-red {
background: #f8d7da !important;
border: 2px solid #dc3545 !important;
}
.deadline-badge-green {
background: #198754;
}
.deadline-badge-yellow {
background: #ffc107;
color: #000;
}
.deadline-badge-red {
background: #dc3545;
}
.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; }
.offcanvas{--bs-offcanvas-width: 1000px;};
@media (max-width: 768px) { .kanban-column { width: 280px; } }
</style>
<?php endif; ?>
<?php if (!empty($canManageBoard)): ?>
<div class="offcanvas offcanvas-end" tabindex="-1" id="boardSettingsCanvas" style="width: 720px;">
<div class="offcanvas-header">
<h5 class="offcanvas-title">Редактирование доски: <?= htmlspecialchars($board['name']) ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<form method="post" action="/admin/boards/save-all">
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
<div class="accordion" id="boardSettingsAccordion">
<!-- ОСНОВНОЕ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" data-bs-toggle="collapse" data-bs-target="#mainBlock">
Основное
</button>
</h2>
<div id="mainBlock" class="accordion-collapse collapse show">
<div class="accordion-body">
<input type="text" name="name" class="form-control mb-2"
value="<?= htmlspecialchars($board['name']) ?>" placeholder="Название">
<input type="text" name="code" class="form-control mb-2"
value="<?= htmlspecialchars($board['code']) ?>" placeholder="Код">
<textarea name="description" class="form-control mb-2"><?= htmlspecialchars($board['description'] ?? '') ?></textarea>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active"
<?= !empty($board['is_active']) ? 'checked' : '' ?>>
<label class="form-check-label">Активна</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_on_home"
<?= !empty($board['show_on_home']) ? 'checked' : '' ?>>
<label class="form-check-label">Показывать на главной</label>
</div>
</div>
</div>
</div>
<!-- GOOGLE -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#googleBlock" type="button">
Google Sheets
</button>
</h2>
<div id="googleBlock" class="accordion-collapse collapse">
<div class="accordion-body">
<input type="text"
name="sheet_url"
class="form-control mb-2"
value="<?= htmlspecialchars((string)($boardSource['spreadsheet_url'] ?? '')) ?>"
placeholder="Ссылка на таблицу">
<input type="text"
name="sheet_name"
class="form-control mb-2"
value="<?= htmlspecialchars((string)($boardSource['sheet_name'] ?? '')) ?>"
placeholder="Название листа">
<select name="sync_mode" class="form-control">
<option value="import" <?= (($boardSource['sync_mode'] ?? '') === 'import') ? 'selected' : '' ?>>Импорт</option>
<option value="export" <?= (($boardSource['sync_mode'] ?? '') === 'export') ? 'selected' : '' ?>>Экспорт</option>
<option value="import_export" <?= (($boardSource['sync_mode'] ?? 'import_export') === 'import_export') ? 'selected' : '' ?>>Импорт + экспорт</option>
</select>
<div class="form-check mt-2">
<input class="form-check-input"
type="checkbox"
name="sheet_is_active"
id="sheet_is_active"
<?= !empty($boardSource['is_active']) ? 'checked' : '' ?>>
<label class="form-check-label" for="sheet_is_active">Интеграция активна</label>
</div>
</div>
</div>
</div>
<!-- КАСТОМНЫЕ ПОЛЯ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#fieldsBlock" type="button">
Кастомные поля
</button>
</h2>
<div id="fieldsBlock" class="accordion-collapse collapse">
<div class="accordion-body">
<?php if (!empty($boardFields)): ?>
<?php foreach ($boardFields as $field): ?>
<div class="border p-2 mb-2">
<input type="hidden" name="fields[<?= $field['id'] ?>][id]" value="<?= $field['id'] ?>">
<input type="text"
name="fields[<?= $field['id'] ?>][name]"
value="<?= htmlspecialchars($field['name']) ?>"
class="form-control mb-1">
<select name="fields[<?= $field['id'] ?>][type]" class="form-control mb-1">
<option value="text" <?= $field['field_type']=='text'?'selected':'' ?>>Текст</option>
<option value="date" <?= $field['field_type']=='date'?'selected':'' ?>>Дата</option>
</select>
<div class="form-check">
<input type="checkbox"
name="fields[<?= $field['id'] ?>][delete]"
class="form-check-input">
<label class="form-check-label text-danger">Удалить</label>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="text-muted">Кастомных полей пока нет</div>
<?php endif; ?>
<hr>
<!-- ДОБАВИТЬ НОВОЕ -->
<input type="text" name="new_fields[][name]" class="form-control mb-2" placeholder="Новое поле">
</div>
</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-success w-100">
💾 Сохранить всё
</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
console.log('bootstrap =', window.bootstrap);
console.log('offcanvas el =', document.getElementById('boardSettingsCanvas'));
});
</script>
+158
View File
@@ -0,0 +1,158 @@
<h1 class="h3 mb-3">Создание задачи</h1>
<?php if (!empty($_SESSION['error'])): ?>
<div class="alert alert-danger">
<?= htmlspecialchars($_SESSION['error']) ?>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/tasks/store">
<div class="mb-3">
<label class="form-label">Доска</label>
<select name="board_id" class="form-select" required>
<option value="">Выберите доску</option>
<?php foreach ($boards as $board): ?>
<option value="<?= (int)$board['id'] ?>" <?= (($board_code ?? '') === ($board['code'] ?? '')) ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$board['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Постановщик</label>
<select name="creator_id" class="form-select" required>
<?php foreach ($users as $item): ?>
<option value="<?= (int)$item['id'] ?>" <?= (int)$item['id'] === (int)$user['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)($item['display_name'] ?: $item['login'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Ответственный</label>
<select name="assignee_id" class="form-select">
<option value="">Не выбран</option>
<?php foreach ($users as $item): ?>
<option value="<?= (int)$item['id'] ?>">
<?= htmlspecialchars((string)($item['display_name'] ?: $item['login'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Наименование</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Описание задачи</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Дата постановки</label>
<input type="datetime-local" name="task_created_at" class="form-control">
</div>
<div class="col-md-6">
<label class="form-label">Дата план</label>
<input type="datetime-local" name="planned_at" class="form-control">
</div>
</div>
<?php foreach ($boardFields as $field): ?>
<div class="mb-3">
<label class="form-label">
<?= htmlspecialchars((string)$field['name']) ?>
</label>
<?php
$inputName = 'custom_field_' . (int)$field['id'];
$required = (int)$field['is_required'] === 1 ? 'required' : '';
?>
<?php if ($field['field_type'] === 'textarea'): ?>
<textarea
name="<?= $inputName ?>"
class="form-control"
<?= $required ?>
></textarea>
<?php elseif ($field['field_type'] === 'date'): ?>
<input
type="date"
name="<?= $inputName ?>"
class="form-control"
<?= $required ?>
>
<?php elseif ($field['field_type'] === 'number'): ?>
<input
type="number"
name="<?= $inputName ?>"
class="form-control"
<?= $required ?>
>
<?php elseif ($field['field_type'] === 'checkbox'): ?>
<div class="form-check">
<input
type="checkbox"
name="<?= $inputName ?>"
value="1"
class="form-check-input"
id="field_<?= (int)$field['id'] ?>"
>
<label class="form-check-label" for="field_<?= (int)$field['id'] ?>">
Да
</label>
</div>
<?php else: ?>
<input
type="text"
name="<?= $inputName ?>"
class="form-control"
<?= $required ?>
>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div class="mb-3">
<label class="form-label">Статус</label>
<select name="status" class="form-select">
<option value="NEW">Новая</option>
<option value="IN_PROGRESS">В работе</option>
<option value="REVIEW">На проверке</option>
<option value="DONE">Закрыта</option>
<option value="CANCELED">Отменена</option>
<option value="OVERDUE">Просрочена</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Приоритет</label>
<select name="priority" class="form-select">
<option value="LOW">Низкий</option>
<option value="MEDIUM" selected>Средний</option>
<option value="HIGH">Высокий</option>
<option value="CRITICAL">Критический</option>
</select>
</div>
<input type="hidden" name="board_code" value="<?= htmlspecialchars((string)($board_code ?? '')) ?>">
<?php
$backUrl = !empty($board_code) ? '/boards/' . urlencode((string)$board_code) : '/tasks';
?>
<input type="hidden" name="board_code" value="<?= htmlspecialchars((string)($board_code ?? '')) ?>">
<button type="submit" class="btn btn-success">Создать</button>
<a href="<?= htmlspecialchars($backUrl) ?>" class="btn btn-secondary">Назад</a>
</form>
</div>
</div>
+170
View File
@@ -0,0 +1,170 @@
<?php
$deadlineRank = function (array $task): int {
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
return match ($deadline['code'] ?? '') {
'red' => 1,
'yellow' => 2,
'green' => 3,
default => 4,
};
};
foreach ($boards as &$board) {
if (empty($board['tasks']) || !is_array($board['tasks'])) {
continue;
}
usort($board['tasks'], function (array $a, array $b) use ($deadlineRank): int {
$rankA = $deadlineRank($a);
$rankB = $deadlineRank($b);
if ($rankA !== $rankB) {
return $rankA <=> $rankB;
}
return strtotime((string)($a['planned_at'] ?? '9999-12-31'))
<=> strtotime((string)($b['planned_at'] ?? '9999-12-31'));
});
}
unset($board);
?>
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Главная</h1>
</div>
<?php if (empty($boards)): ?>
<div class="alert alert-light border text-muted">
Нет досок для отображения на главной.
</div>
<?php else: ?>
<div class="row g-3">
<?php foreach ($boards as $board): ?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-white">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<a href="/boards/<?= htmlspecialchars((string)$board['code']) ?>"
class="text-decoration-none fw-semibold fs-5">
<?= htmlspecialchars((string)$board['name']) ?>
</a>
<?php if (!empty($board['description'])): ?>
<div class="text-muted small mt-1">
<?= htmlspecialchars((string)$board['description']) ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge text-bg-secondary">
Всего: <?= (int)$board['stats']['total_count'] ?>
</span>
<span class="badge text-bg-secondary">
Новые: <?= (int)$board['stats']['new_count'] ?>
</span>
<span class="badge text-bg-primary">
В работе: <?= (int)$board['stats']['in_progress_count'] ?>
</span>
<span class="badge text-bg-success">
Завершено: <?= (int)$board['stats']['done_count'] ?>
</span>
</div>
<div class="list-group list-group-flush">
<?php if (empty($board['tasks'])): ?>
<div class="text-muted small">
В этой доске пока нет задач.
</div>
<?php else: ?>
<?php foreach ($board['tasks'] as $task): ?>
<?php
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
$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';
$statusClass = match ($task['status']) {
'NEW' => 'bg-secondary',
'IN_PROGRESS' => 'bg-primary',
'REVIEW' => 'bg-warning text-dark',
'DONE' => 'bg-success',
'CANCELED' => 'bg-dark',
'OVERDUE' => 'bg-danger',
default => 'bg-secondary',
};
?>
<div class="list-group-item px-2 <?= $deadline['class'] ?? '' ?>">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
data-task-modal
data-task-id="<?= (int)$task['id'] ?>"
class="text-decoration-none fw-semibold">
<?= htmlspecialchars((string)$task['name']) ?>
</a>
<div class="small text-muted">
<?= htmlspecialchars((string)$task['crm_id']) ?>
</div>
<?php if (!empty($task['assignee_name'])): ?>
<div class="small text-muted">
Ответственный: <?= htmlspecialchars((string)$task['assignee_name']) ?>
</div>
<?php endif; ?>
<?php if (!empty($task['planned_at'])): ?>
<div class="small mt-1">
<span class="text-muted">План:</span>
<?= 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 flex-column align-items-end gap-1">
<span class="badge <?= $statusClass ?>">
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
</span>
<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 ?>"
>
<?= $commentCount ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<div class="card-footer bg-white">
<a href="/boards/<?= htmlspecialchars((string)$board['code']) ?>" class="btn btn-sm btn-outline-primary">
Открыть доску
</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
+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>
+367
View File
@@ -0,0 +1,367 @@
<?php
function crm_format_datetime(?string $value): string
{
$value = trim((string)$value);
if ($value === '') return '—';
$timestamp = strtotime($value);
return $timestamp === false ? htmlspecialchars($value) : date('d.m.Y H:i', $timestamp);
}
function crm_datetime_local(?string $value): string
{
$value = trim((string)$value);
if ($value === '') return '';
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m-d\TH:i', $timestamp);
}
function crm_hide_custom_field(array $field): bool
{
$fieldName = mb_strtolower(trim((string)($field['name'] ?? '')));
$fieldCode = mb_strtolower(trim((string)($field['code'] ?? '')));
return in_array($fieldName, [
'дата создания',
'дата постановки',
'дата план',
'дата факта',
'дата факт',
], true) || in_array($fieldCode, [
'task_created_at',
'planned_at',
'completed_at',
'date_created',
'created_date',
], true);
}
?>
<div class="modal-header row">
<h5 class="modal-title fs-5 col-9">
<?= htmlspecialchars((string)$task['name']) ?>
</h5>
<div class="col-3 d-flex gap-2 align-items-center">
<?php if (!empty($canEditTask)): ?>
<button type="button" class="btn btn-outline-primary btn-sm" id="taskEditBtn">
Редактировать задачу
</button>
<?php endif; ?>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
</div>
<div class="modal-body">
<form id="taskEditForm" method="post" action="/tasks/update">
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
<div class="row g-3 mb-3">
<div class="col-md-6">
<strong>CRM ID:</strong> <?= htmlspecialchars((string)$task['crm_id']) ?>
</div>
<div class="col-md-6">
<strong>Постановщик:</strong> <?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?>
</div>
<div class="col-md-6">
<strong>Ответственный:</strong> <?= htmlspecialchars((string)($task['assignee_name'] ?? '')) ?>
</div>
<div class="col-md-6">
<strong>Статус:</strong> <?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
</div>
<div class="col-md-6">
<strong>Приоритет:</strong> <?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
</div>
<div class="col-md-6">
<strong>Дата постановки:</strong>
<span class="task-view-value"><?= crm_format_datetime($task['task_created_at'] ?? null) ?></span>
<input
type="datetime-local"
name="task_created_at"
class="form-control task-edit-field d-none mt-1"
value="<?= htmlspecialchars(crm_datetime_local($task['task_created_at'] ?? null)) ?>"
<?= empty($canEditTask) ? 'disabled' : '' ?>
>
</div>
<div class="col-md-6">
<strong>Дата план:</strong>
<span class="task-view-value"><?= crm_format_datetime($task['planned_at'] ?? null) ?></span>
<input
type="datetime-local"
name="planned_at"
class="form-control task-edit-field d-none mt-1"
value="<?= htmlspecialchars(crm_datetime_local($task['planned_at'] ?? null)) ?>"
<?= empty($canEditTask) ? 'disabled' : '' ?>
>
</div>
<div class="col-md-6">
<strong>Дата факт:</strong>
<?= crm_format_datetime($task['completed_at'] ?? null) ?>
</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">Наименование</label>
<div class="task-view-value border rounded p-3 bg-light">
<?= htmlspecialchars((string)$task['name']) ?>
</div>
<input
type="text"
name="name"
class="form-control task-edit-field d-none"
value="<?= htmlspecialchars((string)$task['name']) ?>"
<?= empty($canEditTask) ? 'disabled' : '' ?>
>
</div>
<div class="mb-4">
<label class="form-label fw-bold">Описание задачи</label>
<div class="task-view-value border rounded p-3 bg-light">
<?= nl2br(htmlspecialchars((string)($task['description'] ?? ''))) ?>
</div>
<textarea
name="description"
class="form-control task-edit-field d-none"
rows="4"
<?= empty($canEditTask) ? 'disabled' : '' ?>
><?= htmlspecialchars((string)($task['description'] ?? '')) ?></textarea>
</div>
<?php if (!empty($customFields)): ?>
<div class="mb-4">
<label class="form-label fw-bold">Поля доски</label>
<div class="row g-3">
<?php foreach ($customFields as $field): ?>
<?php if (crm_hide_custom_field($field)) continue; ?>
<div class="col-md-6">
<div class="border rounded p-3 bg-light h-100">
<label class="fw-semibold mb-1">
<?= htmlspecialchars((string)$field['name']) ?>
</label>
<div class="task-view-value">
<?= trim((string)($field['value'] ?? '')) !== ''
? nl2br(htmlspecialchars((string)$field['value']))
: '<span class="text-muted">—</span>' ?>
</div>
<input
type="text"
name="custom_field_<?= (int)$field['id'] ?>"
class="form-control task-edit-field d-none"
value="<?= htmlspecialchars((string)($field['value'] ?? '')) ?>"
<?= empty($canEditTask) ? 'disabled' : '' ?>
>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($canEditTask)): ?>
<div class="mt-4 mb-4 d-none" id="taskEditActions">
<button type="submit" class="btn btn-success">
Сохранить
</button>
<button type="button" class="btn btn-secondary" id="taskCancelEditBtn">
Отмена
</button>
</div>
<?php endif; ?>
</form>
<div class="mb-4">
<label class="form-label fw-bold">Доска</label>
<div class="border rounded p-3 bg-light">
<div><strong>Название:</strong> <?= htmlspecialchars((string)($task['board_name'] ?? '')) ?></div>
<div><strong>Код:</strong> <?= htmlspecialchars((string)($task['board_code'] ?? '')) ?></div>
<?php if (!empty($task['board_description'])): ?>
<div class="mt-2">
<strong>Описание:</strong><br>
<?= nl2br(htmlspecialchars((string)$task['board_description'])) ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($canEditTask)): ?>
<hr>
<h6 class="mb-3">Управление статусом</h6>
<div class="d-flex flex-wrap gap-2 mb-4">
<?php
$statuses = [
'NEW' => ['Новая', 'btn-outline-secondary'],
'IN_PROGRESS' => ['В работу', 'btn-outline-primary'],
'REVIEW' => ['На проверку', 'btn-outline-warning'],
'CANCELED' => ['Отменить', 'btn-outline-dark'],
'DONE' => ['Завершить', 'btn-success fw-semibold'],
];
?>
<?php foreach ($statuses as $statusCode => [$statusLabel, $btnClass]): ?>
<form method="post" action="/tasks/update-status" class="d-inline js-task-status-form" data-task-id="<?= (int)$task['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
<input type="hidden" name="status" value="<?= htmlspecialchars($statusCode) ?>">
<button type="submit" class="btn <?= htmlspecialchars($btnClass) ?> btn-sm">
<?= htmlspecialchars($statusLabel) ?>
</button>
</form>
<?php endforeach; ?>
</div>
<hr>
<h6 class="mb-3">Файлы</h6>
<form method="post" action="/tasks/files/upload" enctype="multipart/form-data" class="mb-4 js-task-file-upload-form" data-task-id="<?= (int)$task['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label">Загрузить файл</label>
<input type="file" name="file" class="form-control" required>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100">Загрузить файл</button>
</div>
</div>
</form>
<?php endif; ?>
<div class="files-list mb-4">
<?php if (empty($files)): ?>
<div class="text-muted">Файлов пока нет.</div>
<?php else: ?>
<div class="row g-3">
<?php foreach ($files as $file): ?>
<div class="col-12 col-md-6">
<div class="border rounded p-3 h-100">
<?php if (!empty($file['is_image'])): ?>
<div class="mb-3 text-center">
<img
src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
alt="<?= htmlspecialchars((string)$file['original_name']) ?>"
class="img-fluid rounded border file-preview-image"
style="max-height: 180px; object-fit: cover; cursor: pointer;"
data-image-modal
data-image-src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
data-image-title="<?= htmlspecialchars((string)$file['original_name']) ?>"
>
</div>
<?php endif; ?>
<div class="fw-semibold mb-1">
<?= htmlspecialchars((string)$file['original_name']) ?>
</div>
<div class="small text-muted">
Размер: <?= number_format(((int)$file['file_size']) / 1024, 1, '.', ' ') ?> KB
</div>
<div class="small text-muted mb-3">
Загрузил:
<?= htmlspecialchars((string)($file['display_name'] ?: $file['login'] ?: 'Пользователь')) ?>
— <?= htmlspecialchars((string)$file['created_at']) ?>
</div>
<div class="d-flex gap-2 flex-wrap">
<?php if (!empty($file['is_image'])): ?>
<button
type="button"
class="btn btn-sm btn-outline-primary"
data-image-modal
data-image-src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
data-image-title="<?= htmlspecialchars((string)$file['original_name']) ?>"
>
Открыть
</button>
<?php endif; ?>
<a href="/tasks/files/download?id=<?= (int)$file['id'] ?>" class="btn btn-sm btn-outline-secondary">
Скачать
</a>
<?php if (!empty($canEditTask)): ?>
<form method="post"
action="/tasks/files/delete"
class="d-inline js-task-file-delete-form"
data-task-id="<?= (int)$task['id'] ?>"
onsubmit="return confirm('Удалить файл?');">
<input type="hidden" name="id" value="<?= (int)$file['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
</form>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<hr>
<h6 class="mb-3">Комментарии</h6>
<form method="post" action="/tasks/comment/store" class="mb-4 js-task-comment-form" data-task-id="<?= (int)$task['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
<div class="mb-3">
<textarea name="text" class="form-control" rows="3" placeholder="Введите комментарий..." required></textarea>
</div>
<button type="submit" class="btn btn-primary">Добавить комментарий</button>
</form>
<div class="comments-list">
<?php if (empty($comments)): ?>
<div class="text-muted">Комментариев пока нет.</div>
<?php else: ?>
<?php foreach ($comments as $comment): ?>
<div class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<strong>
<?= htmlspecialchars((string)($comment['display_name'] ?: $comment['login'] ?: 'Пользователь')) ?>
</strong>
<small class="text-muted">
<?= htmlspecialchars((string)$comment['created_at']) ?>
</small>
</div>
<div>
<?= nl2br(htmlspecialchars((string)$comment['text'])) ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>