Просмотр исходного кода

Set aging per column and swim lane

master
Daniel Covington 1 неделю назад
Родитель
Сommit
9b87affbbc
17 измененных файлов: 399 добавлений и 188 удалений
  1. +0
    -29
      app/Controllers/BoardsController.php
  2. +29
    -0
      app/Controllers/ColumnsController.php
  3. +29
    -0
      app/Controllers/SwimLanesController.php
  4. +0
    -4
      app/Models/Board.php
  5. +13
    -9
      app/Models/BoardColumn.php
  6. +12
    -8
      app/Models/SwimLane.php
  7. +27
    -10
      app/Repositories/BoardColumnRepository.php
  8. +2
    -23
      app/Repositories/BoardRepository.php
  9. +26
    -9
      app/Repositories/SwimLaneRepository.php
  10. +3
    -5
      app/Views/boards/show.php
  11. +63
    -44
      app/Views/partials/settings-panel.php
  12. +24
    -0
      database/migrations/20260615_000004_add_card_age_settings_to_board_columns.php
  13. +24
    -0
      database/migrations/20260615_000005_add_card_age_settings_to_swim_lanes.php
  14. +25
    -0
      database/migrations/20260615_000006_remove_card_age_settings_from_boards.php
  15. +56
    -6
      public/js/kanban-board.js
  16. +64
    -40
      public/js/kanban-settings.js
  17. +2
    -1
      routes/web.php

+ 0
- 29
app/Controllers/BoardsController.php Просмотреть файл

@@ -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()) {


+ 29
- 0
app/Controllers/ColumnsController.php Просмотреть файл

@@ -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()) {


+ 29
- 0
app/Controllers/SwimLanesController.php Просмотреть файл

@@ -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()) {


+ 0
- 4
app/Models/Board.php Просмотреть файл

@@ -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;


+ 13
- 9
app/Models/BoardColumn.php Просмотреть файл

@@ -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;
}


+ 12
- 8
app/Models/SwimLane.php Просмотреть файл

@@ -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;
}


+ 27
- 10
app/Repositories/BoardColumnRepository.php Просмотреть файл

@@ -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(


+ 2
- 23
app/Repositories/BoardRepository.php Просмотреть файл

@@ -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,
]
);
}
}

+ 26
- 9
app/Repositories/SwimLaneRepository.php Просмотреть файл

@@ -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(


+ 3
- 5
app/Views/boards/show.php Просмотреть файл

@@ -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>


+ 63
- 44
app/Views/partials/settings-panel.php Просмотреть файл

@@ -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 &amp; 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 &amp; 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>

+ 24
- 0
database/migrations/20260615_000004_add_card_age_settings_to_board_columns.php Просмотреть файл

@@ -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.
}
};

+ 24
- 0
database/migrations/20260615_000005_add_card_age_settings_to_swim_lanes.php Просмотреть файл

@@ -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.
}
};

+ 25
- 0
database/migrations/20260615_000006_remove_card_age_settings_from_boards.php Просмотреть файл

@@ -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');
}
};

+ 56
- 6
public/js/kanban-board.js Просмотреть файл

@@ -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();


+ 64
- 40
public/js/kanban-settings.js Просмотреть файл

@@ -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 &amp; 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, '&amp;').replace(/</g, '&lt;')
@@ -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);


+ 2
- 1
routes/web.php Просмотреть файл

@@ -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.