| @@ -79,6 +79,24 @@ class SwimLanesController extends Controller | |||||
| return $this->json(['ok' => true]); | return $this->json(['ok' => true]); | ||||
| } | } | ||||
| public function toggleCount(Request $request, int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $row = $this->lanes()->find($id); | |||||
| if ($row === null) { | |||||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| } | |||||
| $show = (string) $request->input('show_card_count', '0') === '1'; | |||||
| $this->lanes()->updateShowCardCount($id, $show, date('Y-m-d H:i:s'), AuthService::getCurrentUsername()); | |||||
| return $this->json(['ok' => true, 'show_card_count' => $show]); | |||||
| } | |||||
| public function toggleExport(Request $request, int $id): mixed | public function toggleExport(Request $request, int $id): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -10,6 +10,7 @@ class SwimLane | |||||
| public int $boardId = 0; | public int $boardId = 0; | ||||
| public string $name = ''; | public string $name = ''; | ||||
| public int $position = 0; | public int $position = 0; | ||||
| public bool $showCardCount = false; | |||||
| public bool $showExportButton = false; | public bool $showExportButton = false; | ||||
| public bool $showCardAge = false; | public bool $showCardAge = false; | ||||
| public int $cardAgeWarningDays = 0; | public int $cardAgeWarningDays = 0; | ||||
| @@ -25,6 +26,7 @@ class SwimLane | |||||
| $model->boardId = (int) ($row['board_id'] ?? 0); | $model->boardId = (int) ($row['board_id'] ?? 0); | ||||
| $model->name = (string) ($row['name'] ?? ''); | $model->name = (string) ($row['name'] ?? ''); | ||||
| $model->position = (int) ($row['position'] ?? 0); | $model->position = (int) ($row['position'] ?? 0); | ||||
| $model->showCardCount = (bool) ($row['show_card_count'] ?? false); | |||||
| $model->showExportButton = (bool) ($row['show_export_button'] ?? false); | $model->showExportButton = (bool) ($row['show_export_button'] ?? false); | ||||
| $model->showCardAge = (bool) ($row['show_card_age'] ?? false); | $model->showCardAge = (bool) ($row['show_card_age'] ?? false); | ||||
| $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); | $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); | ||||
| @@ -35,12 +35,13 @@ class SwimLaneRepository extends Repository | |||||
| public function insert(SwimLane $lane): SwimLane | public function insert(SwimLane $lane): SwimLane | ||||
| { | { | ||||
| $this->database->execute( | $this->database->execute( | ||||
| 'INSERT INTO swim_lanes (board_id, name, position, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) | |||||
| VALUES (:board_id, :name, :position, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', | |||||
| 'INSERT INTO swim_lanes (board_id, name, position, show_card_count, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) | |||||
| VALUES (:board_id, :name, :position, :show_card_count, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', | |||||
| [ | [ | ||||
| 'board_id' => $lane->boardId, | 'board_id' => $lane->boardId, | ||||
| 'name' => $lane->name, | 'name' => $lane->name, | ||||
| 'position' => $lane->position, | 'position' => $lane->position, | ||||
| 'show_card_count' => $lane->showCardCount ? 1 : 0, | |||||
| 'show_export_button' => $lane->showExportButton ? 1 : 0, | 'show_export_button' => $lane->showExportButton ? 1 : 0, | ||||
| 'show_card_age' => $lane->showCardAge ? 1 : 0, | 'show_card_age' => $lane->showCardAge ? 1 : 0, | ||||
| 'card_age_warning_days' => $lane->cardAgeWarningDays, | 'card_age_warning_days' => $lane->cardAgeWarningDays, | ||||
| @@ -65,6 +66,14 @@ class SwimLaneRepository extends Repository | |||||
| ); | ); | ||||
| } | } | ||||
| public function updateShowCardCount(int $id, bool $show, string $updatedAt, string $updatedBy): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE swim_lanes SET show_card_count = :show_card_count, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['show_card_count' => $show ? 1 : 0, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||||
| ); | |||||
| } | |||||
| public function updateShowExportButton(int $id, bool $show, string $updatedAt, string $updatedBy): void | public function updateShowExportButton(int $id, bool $show, string $updatedAt, string $updatedBy): void | ||||
| { | { | ||||
| $this->database->execute( | $this->database->execute( | ||||
| @@ -93,7 +93,7 @@ var KANBAN = { | |||||
| cards: <?= $cardsJson ?> | cards: <?= $cardsJson ?> | ||||
| }; | }; | ||||
| var KANBAN_COLS = <?= json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_export_button' => $c->showExportButton, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>; | var KANBAN_COLS = <?= json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_export_button' => $c->showExportButton, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>; | ||||
| var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_export_button' => $l->showExportButton, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>; | |||||
| var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_card_count' => $l->showCardCount, 'show_export_button' => $l->showExportButton, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>; | |||||
| </script> | </script> | ||||
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | ||||
| @@ -94,6 +94,10 @@ | |||||
| <div class="d-flex align-items-center gap-2"> | <div class="d-flex align-items-center gap-2"> | ||||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | ||||
| <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | ||||
| <div class="form-check form-switch m-0" title="Show card count in swim lane header"> | |||||
| <input class="form-check-input lane-count-toggle" type="checkbox" role="switch" | |||||
| <?= $lane->showCardCount ? 'checked' : '' ?>> | |||||
| </div> | |||||
| <div class="form-check form-switch m-0" title="Show export button on board"> | <div class="form-check form-switch m-0" title="Show export button on board"> | ||||
| <input class="form-check-input lane-export-toggle" type="checkbox" role="switch" | <input class="form-check-input lane-export-toggle" type="checkbox" role="switch" | ||||
| <?= $lane->showExportButton ? 'checked' : '' ?>> | <?= $lane->showExportButton ? 'checked' : '' ?>> | ||||
| @@ -0,0 +1,21 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'ALTER TABLE swim_lanes ADD COLUMN show_card_count INTEGER NOT NULL DEFAULT 0' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| // SQLite cannot drop columns without recreating the table. | |||||
| } | |||||
| }; | |||||
| @@ -173,6 +173,21 @@ body.kanban-page .navbar .btn-outline-secondary:hover { | |||||
| background: #eff5ff; | background: #eff5ff; | ||||
| } | } | ||||
| .kanban-lane-header .lane-count-badge { | |||||
| flex: 0 0 auto; | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| min-width: 1.5rem; | |||||
| height: 1.5rem; | |||||
| padding: 0 0.4rem; | |||||
| border-radius: 999px; | |||||
| font-size: 0.68rem; | |||||
| font-weight: 800; | |||||
| color: #fff; | |||||
| background: #1e4d8f; | |||||
| } | |||||
| .kanban-lane-header { | .kanban-lane-header { | ||||
| position: sticky; | position: sticky; | ||||
| left: 0; | left: 0; | ||||
| @@ -6,6 +6,7 @@ | |||||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | ||||
| var collapsedLaneIds = loadCollapsedLaneIds(); | var collapsedLaneIds = loadCollapsedLaneIds(); | ||||
| var columnShowCount = {}; | var columnShowCount = {}; | ||||
| var laneShowCount = {}; | |||||
| var columnShowExport = {}; | var columnShowExport = {}; | ||||
| var laneShowExport = {}; | var laneShowExport = {}; | ||||
| var columnAgeSettings = {}; | var columnAgeSettings = {}; | ||||
| @@ -154,6 +155,37 @@ | |||||
| }); | }); | ||||
| } | } | ||||
| function countCardsInLane(laneId) { | |||||
| var count = 0; | |||||
| KANBAN.cards.forEach(function (c) { | |||||
| if (String(c.swim_lane_id) === String(laneId)) count++; | |||||
| }); | |||||
| return count; | |||||
| } | |||||
| function refreshLaneCount(laneId) { | |||||
| var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]'); | |||||
| if (!header) return; | |||||
| var badge = header.querySelector('.lane-count-badge'); | |||||
| if (!laneShowCount[String(laneId)]) { | |||||
| if (badge) badge.remove(); | |||||
| return; | |||||
| } | |||||
| if (!badge) { | |||||
| badge = document.createElement('span'); | |||||
| badge.className = 'lane-count-badge'; | |||||
| header.appendChild(badge); | |||||
| } | |||||
| badge.textContent = countCardsInLane(laneId); | |||||
| } | |||||
| function initLaneCounts() { | |||||
| KANBAN_LANES.forEach(function (lane) { | |||||
| laneShowCount[String(lane.id)] = !!lane.show_card_count; | |||||
| refreshLaneCount(lane.id); | |||||
| }); | |||||
| } | |||||
| function refreshColumnExport(colId) { | function refreshColumnExport(colId) { | ||||
| var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]'); | var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]'); | ||||
| if (!header) return; | if (!header) return; | ||||
| @@ -397,6 +429,11 @@ | |||||
| } | } | ||||
| refreshColumnCount(newColId); | refreshColumnCount(newColId); | ||||
| if (oldLaneId !== null && String(oldLaneId) !== String(newLaneId)) { | |||||
| refreshLaneCount(oldLaneId); | |||||
| } | |||||
| refreshLaneCount(newLaneId); | |||||
| post('/cards/' + cardId + '/move', { | post('/cards/' + cardId + '/move', { | ||||
| column_id: newColId, | column_id: newColId, | ||||
| swim_lane_id: newLaneId, | swim_lane_id: newLaneId, | ||||
| @@ -513,6 +550,7 @@ | |||||
| } | } | ||||
| applyCardFilter(); | applyCardFilter(); | ||||
| refreshColumnCount(card.column_id); | refreshColumnCount(card.column_id); | ||||
| refreshLaneCount(card.swim_lane_id); | |||||
| }, | }, | ||||
| onCardUpdated: function (id, data) { | onCardUpdated: function (id, data) { | ||||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | ||||
| @@ -535,11 +573,13 @@ | |||||
| onCardDeleted: function (id) { | onCardDeleted: function (id) { | ||||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | ||||
| var colId = card ? card.column_id : null; | var colId = card ? card.column_id : null; | ||||
| var laneId = card ? card.swim_lane_id : null; | |||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | ||||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | ||||
| if (el) el.remove(); | if (el) el.remove(); | ||||
| applyCardFilter(); | applyCardFilter(); | ||||
| if (colId !== null) refreshColumnCount(colId); | if (colId !== null) refreshColumnCount(colId); | ||||
| if (laneId !== null) refreshLaneCount(laneId); | |||||
| }, | }, | ||||
| addColumn: function (col) { | addColumn: function (col) { | ||||
| var grid = document.getElementById('kanban-grid'); | var grid = document.getElementById('kanban-grid'); | ||||
| @@ -595,6 +635,7 @@ | |||||
| grid.appendChild(lh); | grid.appendChild(lh); | ||||
| bindLaneHeaderToggle(lh); | bindLaneHeaderToggle(lh); | ||||
| laneShowCount[String(lane.id)] = false; | |||||
| laneShowExport[String(lane.id)] = false; | laneShowExport[String(lane.id)] = false; | ||||
| laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; | laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; | ||||
| @@ -617,6 +658,7 @@ | |||||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | ||||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | ||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | ||||
| delete laneShowCount[String(laneId)]; | |||||
| delete laneShowExport[String(laneId)]; | delete laneShowExport[String(laneId)]; | ||||
| delete laneAgeSettings[String(laneId)]; | delete laneAgeSettings[String(laneId)]; | ||||
| if (collapsedLaneIds[String(laneId)]) { | if (collapsedLaneIds[String(laneId)]) { | ||||
| @@ -636,6 +678,10 @@ | |||||
| columnShowCount[String(colId)] = show; | columnShowCount[String(colId)] = show; | ||||
| refreshColumnCount(colId); | refreshColumnCount(colId); | ||||
| }, | }, | ||||
| setLaneShowCount: function (laneId, show) { | |||||
| laneShowCount[String(laneId)] = show; | |||||
| refreshLaneCount(laneId); | |||||
| }, | |||||
| setColumnShowExport: function (colId, show) { | setColumnShowExport: function (colId, show) { | ||||
| columnShowExport[String(colId)] = !!show; | columnShowExport[String(colId)] = !!show; | ||||
| refreshColumnExport(colId); | refreshColumnExport(colId); | ||||
| @@ -668,4 +714,5 @@ | |||||
| initJobSearch(); | initJobSearch(); | ||||
| initLaneHeaderToggles(); | initLaneHeaderToggles(); | ||||
| initColumnCounts(); | initColumnCounts(); | ||||
| initLaneCounts(); | |||||
| })(); | })(); | ||||
| @@ -54,8 +54,8 @@ | |||||
| '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | ||||
| '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | ||||
| (countToggle ? | (countToggle ? | ||||
| '<div class="form-check form-switch m-0" title="Show card count in column header">' + | |||||
| '<input class="form-check-input col-count-toggle" type="checkbox" role="switch">' + | |||||
| '<div class="form-check form-switch m-0" title="Show card count in header">' + | |||||
| '<input class="form-check-input ' + agePrefix + '-count-toggle" type="checkbox" role="switch">' + | |||||
| '</div>' : '') + | '</div>' : '') + | ||||
| '<div class="form-check form-switch m-0" title="Show export button on board">' + | '<div class="form-check form-switch m-0" title="Show export button on board">' + | ||||
| '<input class="form-check-input ' + agePrefix + '-export-toggle" type="checkbox" role="switch">' + | '<input class="form-check-input ' + agePrefix + '-export-toggle" type="checkbox" role="switch">' + | ||||
| @@ -245,7 +245,7 @@ | |||||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | if (!res.ok) { alert(res.error || 'Failed'); return; } | ||||
| document.getElementById('lane-add-form').classList.add('d-none'); | document.getElementById('lane-add-form').classList.add('d-none'); | ||||
| document.getElementById('lane-add-input').value = ''; | document.getElementById('lane-add-input').value = ''; | ||||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', false, 'lane'); | |||||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', true, 'lane'); | |||||
| document.getElementById('lane-list').appendChild(li); | document.getElementById('lane-list').appendChild(li); | ||||
| bindLaneItem(li); | bindLaneItem(li); | ||||
| window.KanbanBoard.addLane(res); | window.KanbanBoard.addLane(res); | ||||
| @@ -278,6 +278,20 @@ | |||||
| } | } | ||||
| }); | }); | ||||
| }); | }); | ||||
| var laneCountToggle = li.querySelector('.lane-count-toggle'); | |||||
| if (laneCountToggle) { | |||||
| laneCountToggle.addEventListener('change', function () { | |||||
| var show = laneCountToggle.checked; | |||||
| post('/swimlanes/' + li.dataset.id + '/toggle-count', { show_card_count: show ? '1' : '0' }, function (res) { | |||||
| if (res.ok) { | |||||
| window.KanbanBoard.setLaneShowCount(li.dataset.id, show); | |||||
| } else { | |||||
| laneCountToggle.checked = !show; | |||||
| alert(res.error || 'Update failed'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| var laneExportToggle = li.querySelector('.lane-export-toggle'); | var laneExportToggle = li.querySelector('.lane-export-toggle'); | ||||
| if (laneExportToggle) { | if (laneExportToggle) { | ||||
| laneExportToggle.addEventListener('change', function () { | laneExportToggle.addEventListener('change', function () { | ||||
| @@ -41,6 +41,7 @@ $router->post('/columns', [ColumnsController::class, 'store']); | |||||
| // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | ||||
| $router->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); | $router->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); | ||||
| $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); | $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); | ||||
| $router->post('/swimlanes/{id}/toggle-count', [SwimLanesController::class, 'toggleCount']); | |||||
| $router->post('/swimlanes/{id}/toggle-export', [SwimLanesController::class, 'toggleExport']); | $router->post('/swimlanes/{id}/toggle-export', [SwimLanesController::class, 'toggleExport']); | ||||
| $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | ||||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | ||||
Powered by TurnKey Linux.