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: "= e($board->slug) ?>",
cards: = $cardsJson ?>
};
-var KANBAN_COLS = = json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position], $columns)) ?>;
+var KANBAN_COLS = = json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount], $columns)) ?>;
var KANBAN_LANES = = json_encode(array_map(fn($l) => ['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="= e((string) $col->id) ?>">
= e($col->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']);