718 lines
36 KiB
PHP
718 lines
36 KiB
PHP
<?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>
|
||
|