| @@ -173,35 +173,6 @@ 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()) { | |||
| @@ -97,6 +97,35 @@ class ColumnsController extends Controller | |||
| return $this->json(['ok' => true, 'show_card_count' => $show]); | |||
| } | |||
| public function updateCardAgeSettings(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); | |||
| } | |||
| $showCardAge = (string) $request->input('show_card_age', '0') === '1'; | |||
| $cardAgeWarningDays = max(0, (int) $request->input('card_age_warning_days', 0)); | |||
| $this->columns()->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(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -79,6 +79,35 @@ class SwimLanesController extends Controller | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function updateCardAgeSettings(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $row = $this->lanes()->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->lanes()->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(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -11,8 +11,6 @@ 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; | |||
| @@ -26,8 +24,6 @@ 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; | |||
| @@ -11,6 +11,8 @@ class BoardColumn | |||
| public string $name = ''; | |||
| public int $position = 0; | |||
| public bool $showCardCount = false; | |||
| public bool $showCardAge = false; | |||
| public int $cardAgeWarningDays = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| @@ -19,15 +21,17 @@ 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->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'] ?? ''); | |||
| $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->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; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| @@ -10,6 +10,8 @@ class SwimLane | |||
| public int $boardId = 0; | |||
| public string $name = ''; | |||
| public int $position = 0; | |||
| public bool $showCardAge = false; | |||
| public int $cardAgeWarningDays = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| @@ -18,14 +20,16 @@ class SwimLane | |||
| 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->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; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| @@ -35,17 +35,19 @@ class BoardColumnRepository extends Repository | |||
| public function insert(BoardColumn $col): BoardColumn | |||
| { | |||
| $this->database->execute( | |||
| '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)', | |||
| 'INSERT INTO board_columns (board_id, name, position, show_card_count, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) | |||
| VALUES (:board_id, :name, :position, :show_card_count, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| '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, | |||
| 'board_id' => $col->boardId, | |||
| 'name' => $col->name, | |||
| 'position' => $col->position, | |||
| 'show_card_count' => $col->showCardCount ? 1 : 0, | |||
| 'show_card_age' => $col->showCardAge ? 1 : 0, | |||
| 'card_age_warning_days' => $col->cardAgeWarningDays, | |||
| 'created_at' => $col->createdAt, | |||
| 'created_by' => $col->createdBy, | |||
| 'updated_at' => $col->updatedAt, | |||
| 'updated_by' => $col->updatedBy, | |||
| ] | |||
| ); | |||
| @@ -71,6 +73,21 @@ class BoardColumnRepository extends Repository | |||
| ); | |||
| } | |||
| public function updateCardAgeSettings(int $id, bool $showCardAge, int $cardAgeWarningDays, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE board_columns 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, | |||
| ] | |||
| ); | |||
| } | |||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| @@ -63,18 +63,14 @@ class BoardRepository extends Repository | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO boards | |||
| (name, slug, import_from_printstream, printstream_job_name, show_card_age, card_age_warning_days, | |||
| created_at, created_by, updated_at, updated_by) | |||
| (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) | |||
| VALUES | |||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :show_card_age, :card_age_warning_days, | |||
| :created_at, :created_by, :updated_at, :updated_by)', | |||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :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, | |||
| @@ -106,21 +102,4 @@ 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, | |||
| ] | |||
| ); | |||
| } | |||
| } | |||
| @@ -35,16 +35,18 @@ class SwimLaneRepository extends Repository | |||
| public function insert(SwimLane $lane): SwimLane | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO swim_lanes (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 swim_lanes (board_id, name, position, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) | |||
| VALUES (:board_id, :name, :position, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'board_id' => $lane->boardId, | |||
| 'name' => $lane->name, | |||
| 'position' => $lane->position, | |||
| 'created_at' => $lane->createdAt, | |||
| 'created_by' => $lane->createdBy, | |||
| 'updated_at' => $lane->updatedAt, | |||
| 'updated_by' => $lane->updatedBy, | |||
| 'board_id' => $lane->boardId, | |||
| 'name' => $lane->name, | |||
| 'position' => $lane->position, | |||
| 'show_card_age' => $lane->showCardAge ? 1 : 0, | |||
| 'card_age_warning_days' => $lane->cardAgeWarningDays, | |||
| 'created_at' => $lane->createdAt, | |||
| 'created_by' => $lane->createdBy, | |||
| 'updated_at' => $lane->updatedAt, | |||
| 'updated_by' => $lane->updatedBy, | |||
| ] | |||
| ); | |||
| @@ -62,6 +64,21 @@ class SwimLaneRepository extends Repository | |||
| ); | |||
| } | |||
| public function updateCardAgeSettings(int $id, bool $showCardAge, int $cardAgeWarningDays, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE swim_lanes 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, | |||
| ] | |||
| ); | |||
| } | |||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| @@ -84,12 +84,10 @@ | |||
| var KANBAN = { | |||
| boardId: <?= (int) $board->id ?>, | |||
| boardSlug: "<?= e($board->slug) ?>", | |||
| cards: <?= $cardsJson ?>, | |||
| showCardAge: <?= $board->showCardAge ? 'true' : 'false' ?>, | |||
| cardAgeWarningDays: <?= (int) $board->cardAgeWarningDays ?> | |||
| cards: <?= $cardsJson ?> | |||
| }; | |||
| 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_COLS = <?= json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>; | |||
| var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>; | |||
| </script> | |||
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |||
| @@ -28,20 +28,41 @@ | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="col-list"> | |||
| <?php foreach ($columns as $col): ?> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| <li class="list-group-item py-2" | |||
| 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 class="d-flex align-items-center gap-2"> | |||
| <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-toggle-col-age" title="Card age settings"> | |||
| <i class="bi bi-clock-history"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-col" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </div> | |||
| <div class="col-age-settings d-none mt-2 ps-4"> | |||
| <div class="form-check form-switch mb-1"> | |||
| <input class="form-check-input col-age-toggle" type="checkbox" role="switch" | |||
| id="col-age-toggle-<?= e((string) $col->id) ?>" | |||
| <?= $col->showCardAge ? 'checked' : '' ?>> | |||
| <label class="form-check-label small" for="col-age-toggle-<?= e((string) $col->id) ?>"> | |||
| Show "time in cell" tooltip & mark overdue | |||
| </label> | |||
| </div> | |||
| <div class="input-group input-group-sm" style="max-width: 140px;"> | |||
| <input type="number" min="0" step="1" class="form-control col-age-days" | |||
| value="<?= e((string) $col->cardAgeWarningDays) ?>" placeholder="0"> | |||
| <span class="input-group-text">days</span> | |||
| </div> | |||
| <div class="form-text">Overdue after this many days. 0 = no overdue marking.</div> | |||
| </div> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-col" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </li> | |||
| <?php endforeach; ?> | |||
| </ul> | |||
| @@ -64,43 +85,41 @@ | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="lane-list"> | |||
| <?php foreach ($lanes as $lane): ?> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| <li class="list-group-item py-2" | |||
| data-id="<?= e((string) $lane->id) ?>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-lane" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| <div class="d-flex align-items-center gap-2"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-lane-age" title="Card age settings"> | |||
| <i class="bi bi-clock-history"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-lane" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </div> | |||
| <div class="lane-age-settings d-none mt-2 ps-4"> | |||
| <div class="form-check form-switch mb-1"> | |||
| <input class="form-check-input lane-age-toggle" type="checkbox" role="switch" | |||
| id="lane-age-toggle-<?= e((string) $lane->id) ?>" | |||
| <?= $lane->showCardAge ? 'checked' : '' ?>> | |||
| <label class="form-check-label small" for="lane-age-toggle-<?= e((string) $lane->id) ?>"> | |||
| Show "time in cell" tooltip & mark overdue | |||
| </label> | |||
| </div> | |||
| <div class="input-group input-group-sm" style="max-width: 140px;"> | |||
| <input type="number" min="0" step="1" class="form-control lane-age-days" | |||
| value="<?= e((string) $lane->cardAgeWarningDays) ?>" placeholder="0"> | |||
| <span class="input-group-text">days</span> | |||
| </div> | |||
| <div class="form-text">Overdue after this many days. 0 = no overdue marking.</div> | |||
| </div> | |||
| </li> | |||
| <?php endforeach; ?> | |||
| </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 board_columns ADD COLUMN show_card_age INTEGER NOT NULL DEFAULT 0' | |||
| ); | |||
| $database->execute( | |||
| 'ALTER TABLE board_columns 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,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 swim_lanes ADD COLUMN show_card_age INTEGER NOT NULL DEFAULT 0' | |||
| ); | |||
| $database->execute( | |||
| 'ALTER TABLE swim_lanes 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,25 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| /** | |||
| * Card age tooltip/overdue settings moved to board_columns and swim_lanes | |||
| * (configurable per column and per swim lane instead of per board). | |||
| */ | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute('ALTER TABLE boards DROP COLUMN show_card_age'); | |||
| $database->execute('ALTER TABLE boards DROP COLUMN card_age_warning_days'); | |||
| } | |||
| public function down(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'); | |||
| } | |||
| }; | |||
| @@ -6,6 +6,8 @@ | |||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | |||
| var collapsedLaneIds = loadCollapsedLaneIds(); | |||
| var columnShowCount = {}; | |||
| var columnAgeSettings = {}; | |||
| var laneAgeSettings = {}; | |||
| var searchState = { | |||
| query: '' | |||
| }; | |||
| @@ -69,8 +71,26 @@ | |||
| return 'less than an hour'; | |||
| } | |||
| function effectiveCardAgeSettings(card) { | |||
| var colSettings = columnAgeSettings[String(card.column_id)] || { showCardAge: false, cardAgeWarningDays: 0 }; | |||
| var laneSettings = laneAgeSettings[String(card.swim_lane_id)] || { showCardAge: false, cardAgeWarningDays: 0 }; | |||
| var showCardAge = colSettings.showCardAge || laneSettings.showCardAge; | |||
| var warningDays = 0; | |||
| if (colSettings.showCardAge) { | |||
| warningDays = colSettings.cardAgeWarningDays; | |||
| } else if (laneSettings.showCardAge) { | |||
| warningDays = laneSettings.cardAgeWarningDays; | |||
| } | |||
| return { showCardAge: showCardAge, cardAgeWarningDays: warningDays }; | |||
| } | |||
| function applyCardAge(el, card) { | |||
| if (KANBAN.showCardAge) { | |||
| var settings = effectiveCardAgeSettings(card); | |||
| if (settings.showCardAge) { | |||
| var age = formatAge(card.cell_entered_at); | |||
| if (age) { | |||
| el.title = 'In this column/lane for ' + age; | |||
| @@ -81,8 +101,8 @@ | |||
| el.removeAttribute('title'); | |||
| } | |||
| var isOverdue = KANBAN.cardAgeWarningDays > 0 && | |||
| ageInDays(card.cell_entered_at) >= KANBAN.cardAgeWarningDays; | |||
| var isOverdue = settings.cardAgeWarningDays > 0 && | |||
| ageInDays(card.cell_entered_at) >= settings.cardAgeWarningDays; | |||
| el.classList.toggle('kanban-card-overdue', isOverdue); | |||
| } | |||
| @@ -132,6 +152,21 @@ | |||
| }); | |||
| } | |||
| function initCardAgeSettings() { | |||
| KANBAN_COLS.forEach(function (col) { | |||
| columnAgeSettings[String(col.id)] = { | |||
| showCardAge: !!col.show_card_age, | |||
| cardAgeWarningDays: parseInt(col.card_age_warning_days, 10) || 0 | |||
| }; | |||
| }); | |||
| KANBAN_LANES.forEach(function (lane) { | |||
| laneAgeSettings[String(lane.id)] = { | |||
| showCardAge: !!lane.show_card_age, | |||
| cardAgeWarningDays: parseInt(lane.card_age_warning_days, 10) || 0 | |||
| }; | |||
| }); | |||
| } | |||
| function loadCollapsedLaneIds() { | |||
| var laneMap = {}; | |||
| try { | |||
| @@ -470,6 +505,7 @@ | |||
| grid.insertBefore(hdr, refNode); | |||
| columnShowCount[String(col.id)] = false; | |||
| columnAgeSettings[String(col.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; | |||
| var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | |||
| laneHeaders.forEach(function (lh) { | |||
| @@ -490,6 +526,7 @@ | |||
| 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)]; | |||
| delete columnAgeSettings[String(colId)]; | |||
| applyGridTemplate(); | |||
| }, | |||
| addLane: function (lane) { | |||
| @@ -507,6 +544,8 @@ | |||
| grid.appendChild(lh); | |||
| bindLaneHeaderToggle(lh); | |||
| laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; | |||
| colHeaders.forEach(function (ch) { | |||
| var cell = document.createElement('div'); | |||
| cell.className = 'kanban-cell'; | |||
| @@ -526,6 +565,7 @@ | |||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | |||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | |||
| delete laneAgeSettings[String(laneId)]; | |||
| if (collapsedLaneIds[String(laneId)]) { | |||
| delete collapsedLaneIds[String(laneId)]; | |||
| saveCollapsedLaneIds(); | |||
| @@ -543,14 +583,24 @@ | |||
| columnShowCount[String(colId)] = show; | |||
| refreshColumnCount(colId); | |||
| }, | |||
| setCardAgeSettings: function (showCardAge, cardAgeWarningDays) { | |||
| KANBAN.showCardAge = !!showCardAge; | |||
| KANBAN.cardAgeWarningDays = parseInt(cardAgeWarningDays, 10) || 0; | |||
| setColumnCardAge: function (colId, showCardAge, cardAgeWarningDays) { | |||
| columnAgeSettings[String(colId)] = { | |||
| showCardAge: !!showCardAge, | |||
| cardAgeWarningDays: parseInt(cardAgeWarningDays, 10) || 0 | |||
| }; | |||
| refreshAllCardAges(); | |||
| }, | |||
| setLaneCardAge: function (laneId, showCardAge, cardAgeWarningDays) { | |||
| laneAgeSettings[String(laneId)] = { | |||
| showCardAge: !!showCardAge, | |||
| cardAgeWarningDays: parseInt(cardAgeWarningDays, 10) || 0 | |||
| }; | |||
| refreshAllCardAges(); | |||
| } | |||
| }; | |||
| applyGridTemplate(); | |||
| initCardAgeSettings(); | |||
| renderCards(); | |||
| initSortables(); | |||
| initJobSearch(); | |||
| @@ -45,22 +45,72 @@ | |||
| }); | |||
| } | |||
| function buildListItem(id, name, editClass, deleteClass, labelClass, countToggle) { | |||
| function buildListItem(id, name, editClass, deleteClass, labelClass, countToggle, agePrefix) { | |||
| var li = document.createElement('li'); | |||
| li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | |||
| li.className = 'list-group-item 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>'; | |||
| '<div class="d-flex align-items-center gap-2">' + | |||
| '<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 btn-toggle-' + agePrefix + '-age" title="Card age settings"><i class="bi bi-clock-history"></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>' + | |||
| '</div>' + | |||
| '<div class="' + agePrefix + '-age-settings d-none mt-2 ps-4">' + | |||
| '<div class="form-check form-switch mb-1">' + | |||
| '<input class="form-check-input ' + agePrefix + '-age-toggle" type="checkbox" role="switch">' + | |||
| '<label class="form-check-label small">Show "time in cell" tooltip & mark overdue</label>' + | |||
| '</div>' + | |||
| '<div class="input-group input-group-sm" style="max-width: 140px;">' + | |||
| '<input type="number" min="0" step="1" class="form-control ' + agePrefix + '-age-days" value="0" placeholder="0">' + | |||
| '<span class="input-group-text">days</span>' + | |||
| '</div>' + | |||
| '<div class="form-text">Overdue after this many days. 0 = no overdue marking.</div>' + | |||
| '</div>'; | |||
| return li; | |||
| } | |||
| /* ── Card age settings (per column / per swim lane) ────────── */ | |||
| function bindAgeSettings(li, prefix, urlBase, setter) { | |||
| var toggleBtn = li.querySelector('.btn-toggle-' + prefix + '-age'); | |||
| var settingsDiv = li.querySelector('.' + prefix + '-age-settings'); | |||
| if (toggleBtn && settingsDiv) { | |||
| toggleBtn.addEventListener('click', function () { | |||
| settingsDiv.classList.toggle('d-none'); | |||
| }); | |||
| } | |||
| var ageToggle = li.querySelector('.' + prefix + '-age-toggle'); | |||
| var ageDays = li.querySelector('.' + prefix + '-age-days'); | |||
| if (!ageToggle || !ageDays) return; | |||
| function save() { | |||
| var show = ageToggle.checked; | |||
| var days = parseInt(ageDays.value, 10); | |||
| if (isNaN(days) || days < 0) days = 0; | |||
| ageDays.value = days; | |||
| post(urlBase + li.dataset.id + '/card-age-settings', { | |||
| show_card_age: show ? '1' : '0', | |||
| card_age_warning_days: days | |||
| }, function (res) { | |||
| if (!res.ok) { | |||
| alert(res.error || 'Update failed'); | |||
| return; | |||
| } | |||
| setter(li.dataset.id, res.show_card_age, res.card_age_warning_days); | |||
| }); | |||
| } | |||
| ageToggle.addEventListener('change', save); | |||
| ageDays.addEventListener('change', save); | |||
| } | |||
| function esc(s) { | |||
| return String(s) | |||
| .replace(/&/g, '&').replace(/</g, '<') | |||
| @@ -104,7 +154,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', true); | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text', true, 'col'); | |||
| document.getElementById('col-list').appendChild(li); | |||
| bindColItem(li); | |||
| window.KanbanBoard.addColumn(res); | |||
| @@ -151,6 +201,7 @@ | |||
| }); | |||
| }); | |||
| } | |||
| bindAgeSettings(li, 'col', '/columns/', window.KanbanBoard.setColumnCardAge); | |||
| } | |||
| document.querySelectorAll('#col-list li').forEach(bindColItem); | |||
| @@ -175,7 +226,7 @@ | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| document.getElementById('lane-add-input').value = ''; | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text'); | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', false, 'lane'); | |||
| document.getElementById('lane-list').appendChild(li); | |||
| bindLaneItem(li); | |||
| window.KanbanBoard.addLane(res); | |||
| @@ -208,38 +259,11 @@ | |||
| } | |||
| }); | |||
| }); | |||
| bindAgeSettings(li, 'lane', '/swimlanes/', window.KanbanBoard.setLaneCardAge); | |||
| } | |||
| 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,7 +21,6 @@ $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']); | |||
| @@ -32,12 +31,14 @@ $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}/card-age-settings', [ColumnsController::class, 'updateCardAgeSettings']); | |||
| $router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); | |||
| $router->post('/columns/{id}', [ColumnsController::class, 'update']); | |||
| $router->post('/columns', [ColumnsController::class, 'store']); | |||
| // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | |||
| $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); | |||
| $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | |||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | |||
| $router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']); | |||
| $router->post('/swimlanes', [SwimLanesController::class, 'store']); | |||
Powered by TurnKey Linux.