| @@ -173,6 +173,35 @@ class BoardsController extends Controller | |||
| return $this->redirect('/board/' . $board->slug); | |||
| } | |||
| public function updateCardAgeSettings(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $row = $this->boards()->find($id); | |||
| if ($row === null) { | |||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||
| } | |||
| $showCardAge = (string) $request->input('show_card_age', '0') === '1'; | |||
| $cardAgeWarningDays = max(0, (int) $request->input('card_age_warning_days', 0)); | |||
| $this->boards()->updateCardAgeSettings( | |||
| $id, | |||
| $showCardAge, | |||
| $cardAgeWarningDays, | |||
| date('Y-m-d H:i:s'), | |||
| AuthService::getCurrentUsername() | |||
| ); | |||
| return $this->json([ | |||
| 'ok' => true, | |||
| 'show_card_age' => $showCardAge, | |||
| 'card_age_warning_days' => $cardAgeWarningDays, | |||
| ]); | |||
| } | |||
| public function destroy(string $slug): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| @@ -47,6 +47,7 @@ class CardsController extends Controller | |||
| $card->notes = trim((string) $request->input('notes', '')); | |||
| $card->fullNote = (string) $request->input('full_note', ''); | |||
| $card->position = $nextPos; | |||
| $card->cellEnteredAt = $now; | |||
| $card->createdAt = $now; | |||
| $card->createdBy = $username; | |||
| $card->updatedAt = $now; | |||
| @@ -11,6 +11,8 @@ class Board | |||
| public string $slug = ''; | |||
| public bool $importFromPrintstream = false; | |||
| public string $printstreamJobName = ''; | |||
| public bool $showCardAge = false; | |||
| public int $cardAgeWarningDays = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| @@ -24,6 +26,8 @@ class Board | |||
| $model->slug = (string) ($row['slug'] ?? ''); | |||
| $model->importFromPrintstream = (bool) ($row['import_from_printstream'] ?? false); | |||
| $model->printstreamJobName = (string) ($row['printstream_job_name'] ?? ''); | |||
| $model->showCardAge = (bool) ($row['show_card_age'] ?? false); | |||
| $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| @@ -18,6 +18,7 @@ class Card | |||
| public string $notes = ''; | |||
| public string $fullNote = ''; | |||
| public int $position = 0; | |||
| public ?string $cellEnteredAt = null; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| @@ -38,6 +39,7 @@ class Card | |||
| $model->notes = (string) ($row['notes'] ?? ''); | |||
| $model->fullNote = (string) ($row['full_note'] ?? ''); | |||
| $model->position = (int) ($row['position'] ?? 0); | |||
| $model->cellEnteredAt = $row['cell_entered_at'] ?? null; | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| @@ -60,6 +62,7 @@ class Card | |||
| 'notes' => $this->notes, | |||
| 'full_note' => $this->fullNote, | |||
| 'position' => $this->position, | |||
| 'cell_entered_at' => $this->cellEnteredAt, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -63,14 +63,18 @@ class BoardRepository extends Repository | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO boards | |||
| (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) | |||
| (name, slug, import_from_printstream, printstream_job_name, show_card_age, card_age_warning_days, | |||
| created_at, created_by, updated_at, updated_by) | |||
| VALUES | |||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :created_at, :created_by, :updated_at, :updated_by)', | |||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :show_card_age, :card_age_warning_days, | |||
| :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'name' => $board->name, | |||
| 'slug' => $board->slug, | |||
| 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, | |||
| 'printstream_job_name' => $board->printstreamJobName, | |||
| 'show_card_age' => $board->showCardAge ? 1 : 0, | |||
| 'card_age_warning_days' => $board->cardAgeWarningDays, | |||
| 'created_at' => $board->createdAt, | |||
| 'created_by' => $board->createdBy, | |||
| 'updated_at' => $board->updatedAt, | |||
| @@ -102,4 +106,21 @@ class BoardRepository extends Repository | |||
| ] | |||
| ); | |||
| } | |||
| public function updateCardAgeSettings(int $id, bool $showCardAge, int $cardAgeWarningDays, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE boards | |||
| SET show_card_age = :show_card_age, card_age_warning_days = :card_age_warning_days, | |||
| updated_at = :updated_at, updated_by = :updated_by | |||
| WHERE id = :id', | |||
| [ | |||
| 'show_card_age' => $showCardAge ? 1 : 0, | |||
| 'card_age_warning_days' => $cardAgeWarningDays, | |||
| 'updated_at' => $updatedAt, | |||
| 'updated_by' => $updatedBy, | |||
| 'id' => $id, | |||
| ] | |||
| ); | |||
| } | |||
| } | |||
| @@ -44,10 +44,10 @@ class CardRepository extends Repository | |||
| $this->database->execute( | |||
| 'INSERT INTO cards | |||
| (board_id, column_id, swim_lane_id, job_number, job_name, customer_name, delivery_date, | |||
| quantity, notes, full_note, position, created_at, created_by, updated_at, updated_by) | |||
| quantity, notes, full_note, position, cell_entered_at, created_at, created_by, updated_at, updated_by) | |||
| VALUES | |||
| (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name, :delivery_date, | |||
| :quantity, :notes, :full_note, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||
| :quantity, :notes, :full_note, :position, :cell_entered_at, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'board_id' => $card->boardId, | |||
| 'column_id' => $card->columnId, | |||
| @@ -60,6 +60,7 @@ class CardRepository extends Repository | |||
| 'notes' => $card->notes, | |||
| 'full_note' => $card->fullNote, | |||
| 'position' => $card->position, | |||
| 'cell_entered_at' => $card->cellEnteredAt, | |||
| 'created_at' => $card->createdAt, | |||
| 'created_by' => $card->createdBy, | |||
| 'updated_at' => $card->updatedAt, | |||
| @@ -100,9 +101,18 @@ class CardRepository extends Repository | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE cards SET column_id = :column_id, swim_lane_id = :swim_lane_id, position = :position, | |||
| updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, | |||
| 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||
| updated_at = :updated_at, updated_by = :updated_by, | |||
| cell_entered_at = CASE | |||
| WHEN column_id != :check_column_id OR swim_lane_id != :check_swim_lane_id THEN :cell_entered_at | |||
| ELSE cell_entered_at | |||
| END | |||
| WHERE id = :id', | |||
| [ | |||
| 'column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, | |||
| 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, | |||
| 'check_column_id' => $columnId, 'check_swim_lane_id' => $swimLaneId, | |||
| 'cell_entered_at' => $updatedAt, 'id' => $id, | |||
| ] | |||
| ); | |||
| } | |||
| @@ -84,7 +84,9 @@ | |||
| var KANBAN = { | |||
| boardId: <?= (int) $board->id ?>, | |||
| boardSlug: "<?= e($board->slug) ?>", | |||
| cards: <?= $cardsJson ?> | |||
| cards: <?= $cardsJson ?>, | |||
| showCardAge: <?= $board->showCardAge ? 'true' : 'false' ?>, | |||
| cardAgeWarningDays: <?= (int) $board->cardAgeWarningDays ?> | |||
| }; | |||
| 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)) ?>; | |||
| @@ -79,5 +79,28 @@ | |||
| </ul> | |||
| </div> | |||
| <!-- Card age section --> | |||
| <div class="mb-2"> | |||
| <strong class="small d-block mb-2">Card Age</strong> | |||
| <div class="form-check form-switch mb-2"> | |||
| <input class="form-check-input" type="checkbox" role="switch" id="card-age-tooltip-toggle" | |||
| <?= $board->showCardAge ? 'checked' : '' ?>> | |||
| <label class="form-check-label small" for="card-age-tooltip-toggle"> | |||
| Show "time in column/lane" tooltip on cards | |||
| </label> | |||
| </div> | |||
| <label for="card-age-warning-days" class="form-label small mb-1"> | |||
| Mark cards as overdue after this many days in a column/lane | |||
| </label> | |||
| <div class="input-group input-group-sm" style="max-width: 160px;"> | |||
| <input type="number" min="0" step="1" class="form-control" id="card-age-warning-days" | |||
| value="<?= e((string) $board->cardAgeWarningDays) ?>" placeholder="0"> | |||
| <span class="input-group-text">days</span> | |||
| </div> | |||
| <div class="form-text">Set to 0 to disable.</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,24 @@ | |||
| <?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 boards ADD COLUMN show_card_age INTEGER NOT NULL DEFAULT 0' | |||
| ); | |||
| $database->execute( | |||
| 'ALTER TABLE boards ADD COLUMN card_age_warning_days INTEGER NOT NULL DEFAULT 0' | |||
| ); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| // SQLite cannot drop columns without recreating the table. | |||
| } | |||
| }; | |||
| @@ -0,0 +1,26 @@ | |||
| <?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 cards ADD COLUMN cell_entered_at DATETIME' | |||
| ); | |||
| // Backfill existing cards so the "time in column/lane" calculation has a starting point. | |||
| $database->execute( | |||
| 'UPDATE cards SET cell_entered_at = COALESCE(created_at, updated_at) WHERE cell_entered_at IS NULL' | |||
| ); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| // SQLite cannot drop columns without recreating the table. | |||
| } | |||
| }; | |||
| @@ -292,6 +292,22 @@ body.kanban-page .navbar .btn-outline-secondary:hover { | |||
| display: none !important; | |||
| } | |||
| .kanban-card-overdue { | |||
| border-color: #f1b3ab; | |||
| background: linear-gradient(180deg, #fff5f4 0%, #ffeceb 100%); | |||
| box-shadow: 0 4px 12px rgba(214, 62, 44, 0.12); | |||
| } | |||
| .kanban-card-overdue:hover { | |||
| border-color: #e2897e; | |||
| box-shadow: 0 10px 22px rgba(214, 62, 44, 0.18); | |||
| } | |||
| .kanban-card-overdue .card-job-number { | |||
| color: #b3261e; | |||
| background: #fde2df; | |||
| } | |||
| .card-headline { | |||
| display: flex; | |||
| align-items: center; | |||
| @@ -37,6 +37,62 @@ | |||
| .replace(/"/g, '"'); | |||
| } | |||
| function nowDbString() { | |||
| var d = new Date(); | |||
| function pad(n) { return n < 10 ? '0' + n : n; } | |||
| return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + | |||
| pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); | |||
| } | |||
| function ageInDays(cellEnteredAt) { | |||
| if (!cellEnteredAt) return 0; | |||
| var entered = new Date(String(cellEnteredAt).replace(' ', 'T')); | |||
| if (isNaN(entered.getTime())) return 0; | |||
| return (Date.now() - entered.getTime()) / (24 * 60 * 60 * 1000); | |||
| } | |||
| function formatAge(cellEnteredAt) { | |||
| var days = ageInDays(cellEnteredAt); | |||
| if (!cellEnteredAt || isNaN(days)) return null; | |||
| if (days < 0) days = 0; | |||
| var totalDays = Math.floor(days); | |||
| if (totalDays >= 1) { | |||
| return totalDays + ' day' + (totalDays === 1 ? '' : 's'); | |||
| } | |||
| var totalHours = Math.floor(days * 24); | |||
| if (totalHours >= 1) { | |||
| return totalHours + ' hour' + (totalHours === 1 ? '' : 's'); | |||
| } | |||
| return 'less than an hour'; | |||
| } | |||
| function applyCardAge(el, card) { | |||
| if (KANBAN.showCardAge) { | |||
| var age = formatAge(card.cell_entered_at); | |||
| if (age) { | |||
| el.title = 'In this column/lane for ' + age; | |||
| } else { | |||
| el.removeAttribute('title'); | |||
| } | |||
| } else { | |||
| el.removeAttribute('title'); | |||
| } | |||
| var isOverdue = KANBAN.cardAgeWarningDays > 0 && | |||
| ageInDays(card.cell_entered_at) >= KANBAN.cardAgeWarningDays; | |||
| el.classList.toggle('kanban-card-overdue', isOverdue); | |||
| } | |||
| function refreshAllCardAges() { | |||
| document.querySelectorAll('.kanban-card').forEach(function (el) { | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(el.dataset.id); }); | |||
| if (card) applyCardAge(el, card); | |||
| }); | |||
| } | |||
| function applyGridTemplate() { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var colHs = grid.querySelectorAll('.kanban-col-header'); | |||
| @@ -185,6 +241,7 @@ | |||
| div.dataset.laneId = card.swim_lane_id; | |||
| div.dataset.searchText = buildCardSearchText(card); | |||
| div.innerHTML = cardBodyHtml(card); | |||
| applyCardAge(div, card); | |||
| div.addEventListener('click', function () { | |||
| var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); }); | |||
| if (!c) return; | |||
| @@ -237,10 +294,15 @@ | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | |||
| var oldColId = card ? card.column_id : null; | |||
| var oldLaneId = card ? card.swim_lane_id : null; | |||
| if (card) { | |||
| if (String(oldColId) !== String(newColId) || String(oldLaneId) !== String(newLaneId)) { | |||
| card.cell_entered_at = nowDbString(); | |||
| } | |||
| card.column_id = parseInt(newColId, 10); | |||
| card.swim_lane_id = parseInt(newLaneId, 10); | |||
| card.position = newPos; | |||
| applyCardAge(evt.item, card); | |||
| } | |||
| evt.item.dataset.columnId = newColId; | |||
| @@ -480,6 +542,11 @@ | |||
| setColumnShowCount: function (colId, show) { | |||
| columnShowCount[String(colId)] = show; | |||
| refreshColumnCount(colId); | |||
| }, | |||
| setCardAgeSettings: function (showCardAge, cardAgeWarningDays) { | |||
| KANBAN.showCardAge = !!showCardAge; | |||
| KANBAN.cardAgeWarningDays = parseInt(cardAgeWarningDays, 10) || 0; | |||
| refreshAllCardAges(); | |||
| } | |||
| }; | |||
| @@ -212,6 +212,34 @@ | |||
| document.querySelectorAll('#lane-list li').forEach(bindLaneItem); | |||
| /* ═══════════════════════════════════════════════════════════ | |||
| CARD AGE | |||
| ═══════════════════════════════════════════════════════════ */ | |||
| var cardAgeTooltipToggle = document.getElementById('card-age-tooltip-toggle'); | |||
| var cardAgeWarningDays = document.getElementById('card-age-warning-days'); | |||
| function saveCardAgeSettings() { | |||
| var showCardAge = cardAgeTooltipToggle.checked; | |||
| var warningDays = parseInt(cardAgeWarningDays.value, 10); | |||
| if (isNaN(warningDays) || warningDays < 0) warningDays = 0; | |||
| cardAgeWarningDays.value = warningDays; | |||
| post('/boards/' + boardId + '/card-age-settings', { | |||
| show_card_age: showCardAge ? '1' : '0', | |||
| card_age_warning_days: warningDays | |||
| }, function (res) { | |||
| if (!res.ok) { | |||
| alert(res.error || 'Update failed'); | |||
| return; | |||
| } | |||
| window.KanbanBoard.setCardAgeSettings(res.show_card_age, res.card_age_warning_days); | |||
| }); | |||
| } | |||
| cardAgeTooltipToggle.addEventListener('change', saveCardAgeSettings); | |||
| cardAgeWarningDays.addEventListener('change', saveCardAgeSettings); | |||
| /* ── Inline rename helper ─────────────────────────────────── */ | |||
| function startRename(li, labelSel, saveCb) { | |||
| var span = li.querySelector(labelSel); | |||
| @@ -21,6 +21,7 @@ $router->get('/board/{slug}', [BoardsController::class, 'show']); | |||
| $router->get('/board/{slug}/edit', [BoardsController::class, 'edit']); | |||
| $router->post('/board/{slug}/update', [BoardsController::class, 'update']); | |||
| $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); | |||
| $router->post('/boards/{id}/card-age-settings', [BoardsController::class, 'updateCardAgeSettings']); | |||
| // Cards (JSON API) | |||
| $router->post('/cards', [CardsController::class, 'store']); | |||
Powered by TurnKey Linux.