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

Tool tip for column and swim lane age

master
Daniel Covington 1 неделю назад
Родитель
Сommit
62194d0a32
14 измененных файлов: 263 добавлений и 8 удалений
  1. +29
    -0
      app/Controllers/BoardsController.php
  2. +1
    -0
      app/Controllers/CardsController.php
  3. +4
    -0
      app/Models/Board.php
  4. +3
    -0
      app/Models/Card.php
  5. +23
    -2
      app/Repositories/BoardRepository.php
  6. +15
    -5
      app/Repositories/CardRepository.php
  7. +3
    -1
      app/Views/boards/show.php
  8. +23
    -0
      app/Views/partials/settings-panel.php
  9. +24
    -0
      database/migrations/20260615_000002_add_card_age_settings_to_boards.php
  10. +26
    -0
      database/migrations/20260615_000003_add_cell_entered_at_to_cards.php
  11. +16
    -0
      public/css/kanban.css
  12. +67
    -0
      public/js/kanban-board.js
  13. +28
    -0
      public/js/kanban-settings.js
  14. +1
    -0
      routes/web.php

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

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


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

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


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

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


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

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

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

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

+ 15
- 5
app/Repositories/CardRepository.php Просмотреть файл

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



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

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


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

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

+ 24
- 0
database/migrations/20260615_000002_add_card_age_settings_to_boards.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 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.
}
};

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

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

+ 16
- 0
public/css/kanban.css Просмотреть файл

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


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

@@ -37,6 +37,62 @@
.replace(/"/g, '&quot;');
}

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



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

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


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

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