| @@ -79,6 +79,24 @@ class SwimLanesController extends Controller | |||
| 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 | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -10,6 +10,7 @@ class SwimLane | |||
| public int $boardId = 0; | |||
| public string $name = ''; | |||
| public int $position = 0; | |||
| public bool $showCardCount = false; | |||
| public bool $showExportButton = false; | |||
| public bool $showCardAge = false; | |||
| public int $cardAgeWarningDays = 0; | |||
| @@ -25,6 +26,7 @@ class SwimLane | |||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||
| $model->name = (string) ($row['name'] ?? ''); | |||
| $model->position = (int) ($row['position'] ?? 0); | |||
| $model->showCardCount = (bool) ($row['show_card_count'] ?? false); | |||
| $model->showExportButton = (bool) ($row['show_export_button'] ?? false); | |||
| $model->showCardAge = (bool) ($row['show_card_age'] ?? false); | |||
| $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); | |||
| @@ -35,12 +35,13 @@ class SwimLaneRepository extends Repository | |||
| public function insert(SwimLane $lane): SwimLane | |||
| { | |||
| $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, | |||
| 'name' => $lane->name, | |||
| 'position' => $lane->position, | |||
| 'show_card_count' => $lane->showCardCount ? 1 : 0, | |||
| 'show_export_button' => $lane->showExportButton ? 1 : 0, | |||
| 'show_card_age' => $lane->showCardAge ? 1 : 0, | |||
| '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 | |||
| { | |||
| $this->database->execute( | |||
| @@ -93,7 +93,7 @@ var KANBAN = { | |||
| 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_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 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"> | |||
| <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> | |||
| <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"> | |||
| <input class="form-check-input lane-export-toggle" type="checkbox" role="switch" | |||
| <?= $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; | |||
| } | |||
| .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 { | |||
| position: sticky; | |||
| left: 0; | |||
| @@ -6,6 +6,7 @@ | |||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | |||
| var collapsedLaneIds = loadCollapsedLaneIds(); | |||
| var columnShowCount = {}; | |||
| var laneShowCount = {}; | |||
| var columnShowExport = {}; | |||
| var laneShowExport = {}; | |||
| 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) { | |||
| var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]'); | |||
| if (!header) return; | |||
| @@ -397,6 +429,11 @@ | |||
| } | |||
| refreshColumnCount(newColId); | |||
| if (oldLaneId !== null && String(oldLaneId) !== String(newLaneId)) { | |||
| refreshLaneCount(oldLaneId); | |||
| } | |||
| refreshLaneCount(newLaneId); | |||
| post('/cards/' + cardId + '/move', { | |||
| column_id: newColId, | |||
| swim_lane_id: newLaneId, | |||
| @@ -513,6 +550,7 @@ | |||
| } | |||
| applyCardFilter(); | |||
| refreshColumnCount(card.column_id); | |||
| refreshLaneCount(card.swim_lane_id); | |||
| }, | |||
| onCardUpdated: function (id, data) { | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | |||
| @@ -535,11 +573,13 @@ | |||
| onCardDeleted: function (id) { | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | |||
| 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); }); | |||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||
| if (el) el.remove(); | |||
| applyCardFilter(); | |||
| if (colId !== null) refreshColumnCount(colId); | |||
| if (laneId !== null) refreshLaneCount(laneId); | |||
| }, | |||
| addColumn: function (col) { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| @@ -595,6 +635,7 @@ | |||
| grid.appendChild(lh); | |||
| bindLaneHeaderToggle(lh); | |||
| laneShowCount[String(lane.id)] = false; | |||
| laneShowExport[String(lane.id)] = false; | |||
| laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; | |||
| @@ -617,6 +658,7 @@ | |||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').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); }); | |||
| delete laneShowCount[String(laneId)]; | |||
| delete laneShowExport[String(laneId)]; | |||
| delete laneAgeSettings[String(laneId)]; | |||
| if (collapsedLaneIds[String(laneId)]) { | |||
| @@ -636,6 +678,10 @@ | |||
| columnShowCount[String(colId)] = show; | |||
| refreshColumnCount(colId); | |||
| }, | |||
| setLaneShowCount: function (laneId, show) { | |||
| laneShowCount[String(laneId)] = show; | |||
| refreshLaneCount(laneId); | |||
| }, | |||
| setColumnShowExport: function (colId, show) { | |||
| columnShowExport[String(colId)] = !!show; | |||
| refreshColumnExport(colId); | |||
| @@ -668,4 +714,5 @@ | |||
| initJobSearch(); | |||
| initLaneHeaderToggles(); | |||
| initColumnCounts(); | |||
| initLaneCounts(); | |||
| })(); | |||
| @@ -54,8 +54,8 @@ | |||
| '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | |||
| '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | |||
| (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 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">' + | |||
| @@ -245,7 +245,7 @@ | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| 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); | |||
| bindLaneItem(li); | |||
| 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'); | |||
| if (laneExportToggle) { | |||
| 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} | |||
| $router->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); | |||
| $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}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | |||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | |||
Powered by TurnKey Linux.