From 26cef101d06d5118e02c40bffe7d4c6063b8a349 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 15 Jun 2026 12:53:35 -0400 Subject: [PATCH] added column counts --- app/Controllers/ColumnsController.php | 18 +++++++ app/Models/BoardColumn.php | 18 ++++--- app/Repositories/BoardColumnRepository.php | 27 ++++++---- app/Views/boards/show.php | 2 +- app/Views/partials/settings-panel.php | 4 ++ ...1_add_show_card_count_to_board_columns.php | 21 ++++++++ public/css/kanban.css | 25 ++++++++++ public/js/kanban-board.js | 50 +++++++++++++++++++ public/js/kanban-settings.js | 22 +++++++- routes/web.php | 1 + 10 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 database/migrations/20260615_000001_add_show_card_count_to_board_columns.php diff --git a/app/Controllers/ColumnsController.php b/app/Controllers/ColumnsController.php index f807d94..de0641e 100644 --- a/app/Controllers/ColumnsController.php +++ b/app/Controllers/ColumnsController.php @@ -79,6 +79,24 @@ class ColumnsController 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->columns()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $show = (string) $request->input('show_card_count', '0') === '1'; + + $this->columns()->updateShowCardCount($id, $show, date('Y-m-d H:i:s'), AuthService::getCurrentUsername()); + + return $this->json(['ok' => true, 'show_card_count' => $show]); + } + public function destroy(int $id): mixed { if (!AuthService::isLoggedIn()) { diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 8604de7..181b2a7 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -10,6 +10,7 @@ class BoardColumn public int $boardId = 0; public string $name = ''; public int $position = 0; + public bool $showCardCount = false; public ?string $createdAt = null; public string $createdBy = ''; public ?string $updatedAt = null; @@ -18,14 +19,15 @@ class BoardColumn public static function fromRow(array $row): self { $model = new self(); - $model->id = (int) ($row['id'] ?? 0); - $model->boardId = (int) ($row['board_id'] ?? 0); - $model->name = (string) ($row['name'] ?? ''); - $model->position = (int) ($row['position'] ?? 0); - $model->createdAt = $row['created_at'] ?? null; - $model->createdBy = (string) ($row['created_by'] ?? ''); - $model->updatedAt = $row['updated_at'] ?? null; - $model->updatedBy = (string) ($row['updated_by'] ?? ''); + $model->id = (int) ($row['id'] ?? 0); + $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->createdAt = $row['created_at'] ?? null; + $model->createdBy = (string) ($row['created_by'] ?? ''); + $model->updatedAt = $row['updated_at'] ?? null; + $model->updatedBy = (string) ($row['updated_by'] ?? ''); return $model; } diff --git a/app/Repositories/BoardColumnRepository.php b/app/Repositories/BoardColumnRepository.php index fe5404e..5dd5e53 100644 --- a/app/Repositories/BoardColumnRepository.php +++ b/app/Repositories/BoardColumnRepository.php @@ -35,16 +35,17 @@ class BoardColumnRepository extends Repository public function insert(BoardColumn $col): BoardColumn { $this->database->execute( - 'INSERT INTO board_columns (board_id, name, position, created_at, created_by, updated_at, updated_by) - VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', + 'INSERT INTO board_columns (board_id, name, position, show_card_count, created_at, created_by, updated_at, updated_by) + VALUES (:board_id, :name, :position, :show_card_count, :created_at, :created_by, :updated_at, :updated_by)', [ - 'board_id' => $col->boardId, - 'name' => $col->name, - 'position' => $col->position, - 'created_at' => $col->createdAt, - 'created_by' => $col->createdBy, - 'updated_at' => $col->updatedAt, - 'updated_by' => $col->updatedBy, + 'board_id' => $col->boardId, + 'name' => $col->name, + 'position' => $col->position, + 'show_card_count' => $col->showCardCount ? 1 : 0, + 'created_at' => $col->createdAt, + 'created_by' => $col->createdBy, + 'updated_at' => $col->updatedAt, + 'updated_by' => $col->updatedBy, ] ); @@ -62,6 +63,14 @@ class BoardColumnRepository extends Repository ); } + public function updateShowCardCount(int $id, bool $show, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE board_columns 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 updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void { $this->database->execute( diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php index 74c1f1a..4db7a29 100644 --- a/app/Views/boards/show.php +++ b/app/Views/boards/show.php @@ -86,7 +86,7 @@ var KANBAN = { boardSlug: "slug) ?>", cards: }; -var KANBAN_COLS = ['id' => $c->id, 'name' => $c->name, 'position' => $c->position], $columns)) ?>; +var KANBAN_COLS = ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount], $columns)) ?>; var KANBAN_LANES = ['id' => $l->id, 'name' => $l->name, 'position' => $l->position], $lanes)) ?>; diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php index 7925bd5..d5cea0a 100644 --- a/app/Views/partials/settings-panel.php +++ b/app/Views/partials/settings-panel.php @@ -32,6 +32,10 @@ data-id="id) ?>"> name) ?> +
+ showCardCount ? 'checked' : '' ?>> +
diff --git a/database/migrations/20260615_000001_add_show_card_count_to_board_columns.php b/database/migrations/20260615_000001_add_show_card_count_to_board_columns.php new file mode 100644 index 0000000..533e022 --- /dev/null +++ b/database/migrations/20260615_000001_add_show_card_count_to_board_columns.php @@ -0,0 +1,21 @@ +execute( + 'ALTER TABLE board_columns ADD COLUMN show_card_count INTEGER NOT NULL DEFAULT 0' + ); + } + + public function down(Database $database): void + { + // SQLite cannot drop columns without recreating the table. + } +}; diff --git a/public/css/kanban.css b/public/css/kanban.css index 1603cc5..4abbd21 100644 --- a/public/css/kanban.css +++ b/public/css/kanban.css @@ -135,6 +135,10 @@ body.kanban-page .navbar .btn-outline-secondary:hover { z-index: 30; min-width: 230px; padding: 0.75rem 0.88rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.4rem; color: #eff5ff; font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); font-weight: 700; @@ -148,6 +152,27 @@ body.kanban-page .navbar .btn-outline-secondary:hover { border-left: 1px solid rgba(255, 255, 255, 0.16); } +.kanban-col-header .col-label { + min-width: 0; +} + +.kanban-col-header .col-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; + letter-spacing: 0; + text-transform: none; + color: #173864; + background: #eff5ff; +} + .kanban-lane-header { position: sticky; left: 0; diff --git a/public/js/kanban-board.js b/public/js/kanban-board.js index 64f4a67..1026793 100644 --- a/public/js/kanban-board.js +++ b/public/js/kanban-board.js @@ -5,6 +5,7 @@ var boardId = KANBAN.boardId; var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); var collapsedLaneIds = loadCollapsedLaneIds(); + var columnShowCount = {}; var searchState = { query: '' }; @@ -44,6 +45,37 @@ grid.style.gridTemplateColumns = cols; } + function countCardsInColumn(colId) { + var count = 0; + KANBAN.cards.forEach(function (c) { + if (String(c.column_id) === String(colId)) count++; + }); + return count; + } + + function refreshColumnCount(colId) { + var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]'); + if (!header) return; + var badge = header.querySelector('.col-count-badge'); + if (!columnShowCount[String(colId)]) { + if (badge) badge.remove(); + return; + } + if (!badge) { + badge = document.createElement('span'); + badge.className = 'col-count-badge'; + header.appendChild(badge); + } + badge.textContent = countCardsInColumn(colId); + } + + function initColumnCounts() { + KANBAN_COLS.forEach(function (col) { + columnShowCount[String(col.id)] = !!col.show_card_count; + refreshColumnCount(col.id); + }); + } + function loadCollapsedLaneIds() { var laneMap = {}; try { @@ -204,6 +236,7 @@ }); var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); + var oldColId = card ? card.column_id : null; if (card) { card.column_id = parseInt(newColId, 10); card.swim_lane_id = parseInt(newLaneId, 10); @@ -213,6 +246,11 @@ evt.item.dataset.columnId = newColId; evt.item.dataset.laneId = newLaneId; + if (oldColId !== null && String(oldColId) !== String(newColId)) { + refreshColumnCount(oldColId); + } + refreshColumnCount(newColId); + post('/cards/' + cardId + '/move', { column_id: newColId, swim_lane_id: newLaneId, @@ -328,6 +366,7 @@ cell.appendChild(buildCardEl(card)); } applyCardFilter(); + refreshColumnCount(card.column_id); }, onCardUpdated: function (id, data) { var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); @@ -348,10 +387,13 @@ applyCardFilter(); }, onCardDeleted: function (id) { + var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); + var colId = card ? card.column_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); }, addColumn: function (col) { var grid = document.getElementById('kanban-grid'); @@ -365,6 +407,8 @@ hdr.innerHTML = '' + esc(col.name) + ''; grid.insertBefore(hdr, refNode); + columnShowCount[String(col.id)] = false; + var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); laneHeaders.forEach(function (lh) { var laneId = lh.dataset.laneId; @@ -383,6 +427,7 @@ document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); + delete columnShowCount[String(colId)]; applyGridTemplate(); }, addLane: function (lane) { @@ -431,6 +476,10 @@ renameLane: function (laneId, name) { var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); if (hdr) hdr.textContent = name; + }, + setColumnShowCount: function (colId, show) { + columnShowCount[String(colId)] = show; + refreshColumnCount(colId); } }; @@ -439,4 +488,5 @@ initSortables(); initJobSearch(); initLaneHeaderToggles(); + initColumnCounts(); })(); diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js index bbd2f6c..40e6924 100644 --- a/public/js/kanban-settings.js +++ b/public/js/kanban-settings.js @@ -45,13 +45,17 @@ }); } - function buildListItem(id, name, editClass, deleteClass, labelClass) { + function buildListItem(id, name, editClass, deleteClass, labelClass, countToggle) { var li = document.createElement('li'); li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; li.dataset.id = id; li.innerHTML = '' + '' + esc(name) + '' + + (countToggle ? + '
' + + '' + + '
' : '') + '' + ''; return li; @@ -100,7 +104,7 @@ if (!res.ok) { alert(res.error || 'Failed'); return; } document.getElementById('col-add-form').classList.add('d-none'); document.getElementById('col-add-input').value = ''; - var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text'); + var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text', true); document.getElementById('col-list').appendChild(li); bindColItem(li); window.KanbanBoard.addColumn(res); @@ -133,6 +137,20 @@ } }); }); + var countToggle = li.querySelector('.col-count-toggle'); + if (countToggle) { + countToggle.addEventListener('change', function () { + var show = countToggle.checked; + post('/columns/' + li.dataset.id + '/toggle-count', { show_card_count: show ? '1' : '0' }, function (res) { + if (res.ok) { + window.KanbanBoard.setColumnShowCount(li.dataset.id, show); + } else { + countToggle.checked = !show; + alert(res.error || 'Update failed'); + } + }); + }); + } } document.querySelectorAll('#col-list li').forEach(bindColItem); diff --git a/routes/web.php b/routes/web.php index a4ea117..5398aed 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,7 @@ $router->post('/cards/{id}', [CardsController::class, 'update']); // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} $router->post('/columns/reorder', [ColumnsController::class, 'reorder']); +$router->post('/columns/{id}/toggle-count', [ColumnsController::class, 'toggleCount']); $router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); $router->post('/columns/{id}', [ColumnsController::class, 'update']); $router->post('/columns', [ColumnsController::class, 'store']);