| @@ -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()) { | |||
| @@ -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; | |||
| } | |||
| @@ -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( | |||
| @@ -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)) ?>; | |||
| </script> | |||
| @@ -32,6 +32,10 @@ | |||
| data-id="<?= e((string) $col->id) ?>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 col-label-text"><?= e($col->name) ?></span> | |||
| <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" | |||
| <?= $col->showCardCount ? 'checked' : '' ?>> | |||
| </div> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| @@ -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 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. | |||
| } | |||
| }; | |||
| @@ -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; | |||
| @@ -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 = '<span class="col-label">' + esc(col.name) + '</span>'; | |||
| 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(); | |||
| })(); | |||
| @@ -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 = | |||
| '<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>' : '') + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>'; | |||
| 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); | |||
| @@ -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']); | |||
Powered by TurnKey Linux.