| @@ -79,6 +79,24 @@ class ColumnsController 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->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 | public function destroy(int $id): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -10,6 +10,7 @@ class BoardColumn | |||||
| 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 ?string $createdAt = null; | public ?string $createdAt = null; | ||||
| public string $createdBy = ''; | public string $createdBy = ''; | ||||
| public ?string $updatedAt = null; | public ?string $updatedAt = null; | ||||
| @@ -18,14 +19,15 @@ class BoardColumn | |||||
| public static function fromRow(array $row): self | public static function fromRow(array $row): self | ||||
| { | { | ||||
| $model = new 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; | return $model; | ||||
| } | } | ||||
| @@ -35,16 +35,17 @@ class BoardColumnRepository extends Repository | |||||
| public function insert(BoardColumn $col): BoardColumn | public function insert(BoardColumn $col): BoardColumn | ||||
| { | { | ||||
| $this->database->execute( | $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 | public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | ||||
| { | { | ||||
| $this->database->execute( | $this->database->execute( | ||||
| @@ -86,7 +86,7 @@ var KANBAN = { | |||||
| boardSlug: "<?= e($board->slug) ?>", | boardSlug: "<?= e($board->slug) ?>", | ||||
| cards: <?= $cardsJson ?> | 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)) ?>; | var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position], $lanes)) ?>; | ||||
| </script> | </script> | ||||
| @@ -32,6 +32,10 @@ | |||||
| data-id="<?= e((string) $col->id) ?>"> | data-id="<?= e((string) $col->id) ?>"> | ||||
| <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 col-label-text"><?= e($col->name) ?></span> | <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"> | <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | ||||
| <i class="bi bi-pencil"></i> | <i class="bi bi-pencil"></i> | ||||
| </button> | </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; | z-index: 30; | ||||
| min-width: 230px; | min-width: 230px; | ||||
| padding: 0.75rem 0.88rem; | padding: 0.75rem 0.88rem; | ||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 0.4rem; | |||||
| color: #eff5ff; | color: #eff5ff; | ||||
| font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); | font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); | ||||
| font-weight: 700; | 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); | 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 { | .kanban-lane-header { | ||||
| position: sticky; | position: sticky; | ||||
| left: 0; | left: 0; | ||||
| @@ -5,6 +5,7 @@ | |||||
| var boardId = KANBAN.boardId; | var boardId = KANBAN.boardId; | ||||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | ||||
| var collapsedLaneIds = loadCollapsedLaneIds(); | var collapsedLaneIds = loadCollapsedLaneIds(); | ||||
| var columnShowCount = {}; | |||||
| var searchState = { | var searchState = { | ||||
| query: '' | query: '' | ||||
| }; | }; | ||||
| @@ -44,6 +45,37 @@ | |||||
| grid.style.gridTemplateColumns = cols; | 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() { | function loadCollapsedLaneIds() { | ||||
| var laneMap = {}; | var laneMap = {}; | ||||
| try { | try { | ||||
| @@ -204,6 +236,7 @@ | |||||
| }); | }); | ||||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | ||||
| var oldColId = card ? card.column_id : null; | |||||
| if (card) { | if (card) { | ||||
| card.column_id = parseInt(newColId, 10); | card.column_id = parseInt(newColId, 10); | ||||
| card.swim_lane_id = parseInt(newLaneId, 10); | card.swim_lane_id = parseInt(newLaneId, 10); | ||||
| @@ -213,6 +246,11 @@ | |||||
| evt.item.dataset.columnId = newColId; | evt.item.dataset.columnId = newColId; | ||||
| evt.item.dataset.laneId = newLaneId; | evt.item.dataset.laneId = newLaneId; | ||||
| if (oldColId !== null && String(oldColId) !== String(newColId)) { | |||||
| refreshColumnCount(oldColId); | |||||
| } | |||||
| refreshColumnCount(newColId); | |||||
| post('/cards/' + cardId + '/move', { | post('/cards/' + cardId + '/move', { | ||||
| column_id: newColId, | column_id: newColId, | ||||
| swim_lane_id: newLaneId, | swim_lane_id: newLaneId, | ||||
| @@ -328,6 +366,7 @@ | |||||
| cell.appendChild(buildCardEl(card)); | cell.appendChild(buildCardEl(card)); | ||||
| } | } | ||||
| applyCardFilter(); | applyCardFilter(); | ||||
| refreshColumnCount(card.column_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); }); | ||||
| @@ -348,10 +387,13 @@ | |||||
| applyCardFilter(); | applyCardFilter(); | ||||
| }, | }, | ||||
| onCardDeleted: function (id) { | 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); }); | 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); | |||||
| }, | }, | ||||
| addColumn: function (col) { | addColumn: function (col) { | ||||
| var grid = document.getElementById('kanban-grid'); | var grid = document.getElementById('kanban-grid'); | ||||
| @@ -365,6 +407,8 @@ | |||||
| hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>'; | hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>'; | ||||
| grid.insertBefore(hdr, refNode); | grid.insertBefore(hdr, refNode); | ||||
| columnShowCount[String(col.id)] = false; | |||||
| var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | ||||
| laneHeaders.forEach(function (lh) { | laneHeaders.forEach(function (lh) { | ||||
| var laneId = lh.dataset.laneId; | var laneId = lh.dataset.laneId; | ||||
| @@ -383,6 +427,7 @@ | |||||
| document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | ||||
| document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.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); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | ||||
| delete columnShowCount[String(colId)]; | |||||
| applyGridTemplate(); | applyGridTemplate(); | ||||
| }, | }, | ||||
| addLane: function (lane) { | addLane: function (lane) { | ||||
| @@ -431,6 +476,10 @@ | |||||
| renameLane: function (laneId, name) { | renameLane: function (laneId, name) { | ||||
| var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); | var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); | ||||
| if (hdr) hdr.textContent = name; | if (hdr) hdr.textContent = name; | ||||
| }, | |||||
| setColumnShowCount: function (colId, show) { | |||||
| columnShowCount[String(colId)] = show; | |||||
| refreshColumnCount(colId); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -439,4 +488,5 @@ | |||||
| initSortables(); | initSortables(); | ||||
| initJobSearch(); | initJobSearch(); | ||||
| initLaneHeaderToggles(); | 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'); | var li = document.createElement('li'); | ||||
| li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | ||||
| li.dataset.id = id; | li.dataset.id = id; | ||||
| li.innerHTML = | li.innerHTML = | ||||
| '<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 ? | |||||
| '<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-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>'; | '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>'; | ||||
| return li; | return li; | ||||
| @@ -100,7 +104,7 @@ | |||||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | if (!res.ok) { alert(res.error || 'Failed'); return; } | ||||
| document.getElementById('col-add-form').classList.add('d-none'); | document.getElementById('col-add-form').classList.add('d-none'); | ||||
| document.getElementById('col-add-input').value = ''; | 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); | document.getElementById('col-list').appendChild(li); | ||||
| bindColItem(li); | bindColItem(li); | ||||
| window.KanbanBoard.addColumn(res); | 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); | 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} | // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} | ||||
| $router->post('/columns/reorder', [ColumnsController::class, 'reorder']); | $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}/delete', [ColumnsController::class, 'destroy']); | ||||
| $router->post('/columns/{id}', [ColumnsController::class, 'update']); | $router->post('/columns/{id}', [ColumnsController::class, 'update']); | ||||
| $router->post('/columns', [ColumnsController::class, 'store']); | $router->post('/columns', [ColumnsController::class, 'store']); | ||||
Powered by TurnKey Linux.