Przeglądaj źródła

Add card move history log

Track when a card moves between columns/swim lanes: who moved it, when,
and where from/to. Logged on every column/lane change via CardsController::move(),
surfaced in the card modal via GET /cards/{id}/history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 2 tygodni temu
rodzic
commit
355bbbc1a1
10 zmienionych plików z 310 dodań i 0 usunięć
  1. +7
    -0
      app/Controllers/BoardsController.php
  2. +62
    -0
      app/Controllers/CardsController.php
  3. +7
    -0
      app/Controllers/ColumnsController.php
  4. +7
    -0
      app/Controllers/SwimLanesController.php
  5. +57
    -0
      app/Models/CardMovement.php
  6. +85
    -0
      app/Repositories/CardMovementRepository.php
  7. +5
    -0
      app/Views/partials/card-modal.php
  8. +37
    -0
      database/migrations/20260917_000001_create_card_movements_table.php
  9. +42
    -0
      public/js/kanban-modal.js
  10. +1
    -0
      routes/web.php

+ 7
- 0
app/Controllers/BoardsController.php Wyświetl plik

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Models\Board;
use App\Repositories\BoardColumnRepository;
use App\Repositories\BoardRepository;
use App\Repositories\CardMovementRepository;
use App\Repositories\CardRepository;
use App\Repositories\SwimLaneRepository;
use App\Services\AuthService;
@@ -35,6 +36,11 @@ class BoardsController extends Controller
return new CardRepository(database());
}

private function cardMovements(): CardMovementRepository
{
return new CardMovementRepository(database());
}

public function index(): mixed
{
if ($guard = AuthService::requireLogin()) {
@@ -185,6 +191,7 @@ class BoardsController extends Controller
return $this->redirect('/boards');
}

$this->cardMovements()->deleteByBoardId($board->id);
$this->cards()->deleteByBoardId($board->id);
$this->columns()->deleteByBoardId($board->id);
$this->lanes()->deleteByBoardId($board->id);


+ 62
- 0
app/Controllers/CardsController.php Wyświetl plik

@@ -5,7 +5,11 @@ declare(strict_types=1);
namespace App\Controllers;

use App\Models\Card;
use App\Models\CardMovement;
use App\Repositories\BoardColumnRepository;
use App\Repositories\CardMovementRepository;
use App\Repositories\CardRepository;
use App\Repositories\SwimLaneRepository;
use App\Services\AuthService;
use Core\Controller;
use Core\Request;
@@ -17,6 +21,21 @@ class CardsController extends Controller
return new CardRepository(database());
}

private function cardMovements(): CardMovementRepository
{
return new CardMovementRepository(database());
}

private function columns(): BoardColumnRepository
{
return new BoardColumnRepository(database());
}

private function lanes(): SwimLaneRepository
{
return new SwimLaneRepository(database());
}

public function store(Request $request): mixed
{
if (!AuthService::isLoggedIn()) {
@@ -99,6 +118,12 @@ class CardsController extends Controller
return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
}

$card = $this->cards()->findById($id);

if ($card === null) {
return $this->json(['ok' => false, 'error' => 'Not found'], 404);
}

$columnId = (int) $request->input('column_id', 0);
$swimLaneId = (int) $request->input('swim_lane_id', 0);
$position = (int) $request->input('position', 0);
@@ -107,6 +132,28 @@ class CardsController extends Controller

$this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username);

if ($columnId !== $card->columnId || $swimLaneId !== $card->swimLaneId) {
$fromColumn = $this->columns()->find($card->columnId);
$toColumn = $this->columns()->find($columnId);
$fromLane = $this->lanes()->find($card->swimLaneId);
$toLane = $this->lanes()->find($swimLaneId);

$movement = new CardMovement();
$movement->cardId = $id;
$movement->fromColumnId = $card->columnId;
$movement->fromColumnName = $fromColumn['name'] ?? null;
$movement->toColumnId = $columnId;
$movement->toColumnName = $toColumn['name'] ?? null;
$movement->fromSwimLaneId = $card->swimLaneId;
$movement->fromSwimLaneName = $fromLane['name'] ?? null;
$movement->toSwimLaneId = $swimLaneId;
$movement->toSwimLaneName = $toLane['name'] ?? null;
$movement->movedAt = $now;
$movement->movedBy = $username;

$this->cardMovements()->insert($movement);
}

$siblings = trim((string) $request->input('sibling_ids', ''));
if ($siblings !== '') {
foreach (explode(',', $siblings) as $idx => $sibId) {
@@ -126,8 +173,23 @@ class CardsController extends Controller
return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
}

$this->cardMovements()->deleteByCardId($id);
$this->cards()->delete($id);

return $this->json(['ok' => true]);
}

public function history(int $id): mixed
{
if (!AuthService::isLoggedIn()) {
return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
}

$movements = array_map(
fn($m) => $m->toJsonArray(),
$this->cardMovements()->findByCardId($id)
);

return $this->json(['ok' => true, 'movements' => $movements]);
}
}

+ 7
- 0
app/Controllers/ColumnsController.php Wyświetl plik

@@ -6,6 +6,7 @@ namespace App\Controllers;

use App\Models\BoardColumn;
use App\Repositories\BoardColumnRepository;
use App\Repositories\CardMovementRepository;
use App\Repositories\CardRepository;
use App\Services\AuthService;
use Core\Controller;
@@ -23,6 +24,11 @@ class ColumnsController extends Controller
return new CardRepository(database());
}

private function cardMovements(): CardMovementRepository
{
return new CardMovementRepository(database());
}

public function store(Request $request): mixed
{
if (!AuthService::isLoggedIn()) {
@@ -189,6 +195,7 @@ class ColumnsController extends Controller
return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
}

$this->cardMovements()->deleteByColumnId($id);
$this->cards()->deleteByColumnId($id);
$this->columns()->delete($id);



+ 7
- 0
app/Controllers/SwimLanesController.php Wyświetl plik

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;

use App\Models\SwimLane;
use App\Repositories\CardMovementRepository;
use App\Repositories\CardRepository;
use App\Repositories\SwimLaneRepository;
use App\Services\AuthService;
@@ -23,6 +24,11 @@ class SwimLanesController extends Controller
return new CardRepository(database());
}

private function cardMovements(): CardMovementRepository
{
return new CardMovementRepository(database());
}

public function store(Request $request): mixed
{
if (!AuthService::isLoggedIn()) {
@@ -189,6 +195,7 @@ class SwimLanesController extends Controller
return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
}

$this->cardMovements()->deleteBySwimLaneId($id);
$this->cards()->deleteBySwimLaneId($id);
$this->lanes()->delete($id);



+ 57
- 0
app/Models/CardMovement.php Wyświetl plik

@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Models;

class CardMovement
{
public int $id = 0;
public int $cardId = 0;
public ?int $fromColumnId = null;
public ?string $fromColumnName = null;
public int $toColumnId = 0;
public ?string $toColumnName = null;
public ?int $fromSwimLaneId = null;
public ?string $fromSwimLaneName = null;
public int $toSwimLaneId = 0;
public ?string $toSwimLaneName = null;
public string $movedAt = '';
public string $movedBy = '';

public static function fromRow(array $row): self
{
$model = new self();
$model->id = (int) ($row['id'] ?? 0);
$model->cardId = (int) ($row['card_id'] ?? 0);
$model->fromColumnId = isset($row['from_column_id']) ? (int) $row['from_column_id'] : null;
$model->fromColumnName = $row['from_column_name'] ?? null;
$model->toColumnId = (int) ($row['to_column_id'] ?? 0);
$model->toColumnName = $row['to_column_name'] ?? null;
$model->fromSwimLaneId = isset($row['from_swim_lane_id']) ? (int) $row['from_swim_lane_id'] : null;
$model->fromSwimLaneName = $row['from_swim_lane_name'] ?? null;
$model->toSwimLaneId = (int) ($row['to_swim_lane_id'] ?? 0);
$model->toSwimLaneName = $row['to_swim_lane_name'] ?? null;
$model->movedAt = (string) ($row['moved_at'] ?? '');
$model->movedBy = (string) ($row['moved_by'] ?? '');

return $model;
}

public function toJsonArray(): array
{
return [
'id' => $this->id,
'from_column_id' => $this->fromColumnId,
'from_column_name' => $this->fromColumnName,
'to_column_id' => $this->toColumnId,
'to_column_name' => $this->toColumnName,
'from_swim_lane_id' => $this->fromSwimLaneId,
'from_swim_lane_name' => $this->fromSwimLaneName,
'to_swim_lane_id' => $this->toSwimLaneId,
'to_swim_lane_name' => $this->toSwimLaneName,
'moved_at' => $this->movedAt,
'moved_by' => $this->movedBy,
];
}
}

+ 85
- 0
app/Repositories/CardMovementRepository.php Wyświetl plik

@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\CardMovement;
use Core\Repository;

class CardMovementRepository extends Repository
{
protected string $table = 'card_movements';

public function insert(CardMovement $movement): CardMovement
{
$this->database->execute(
'INSERT INTO card_movements
(card_id, from_column_id, from_column_name, to_column_id, to_column_name,
from_swim_lane_id, from_swim_lane_name, to_swim_lane_id, to_swim_lane_name,
moved_at, moved_by)
VALUES
(:card_id, :from_column_id, :from_column_name, :to_column_id, :to_column_name,
:from_swim_lane_id, :from_swim_lane_name, :to_swim_lane_id, :to_swim_lane_name,
:moved_at, :moved_by)',
[
'card_id' => $movement->cardId,
'from_column_id' => $movement->fromColumnId,
'from_column_name' => $movement->fromColumnName,
'to_column_id' => $movement->toColumnId,
'to_column_name' => $movement->toColumnName,
'from_swim_lane_id' => $movement->fromSwimLaneId,
'from_swim_lane_name' => $movement->fromSwimLaneName,
'to_swim_lane_id' => $movement->toSwimLaneId,
'to_swim_lane_name' => $movement->toSwimLaneName,
'moved_at' => $movement->movedAt,
'moved_by' => $movement->movedBy,
]
);

$row = $this->database->first('SELECT last_insert_rowid() AS id');
$movement->id = (int) ($row['id'] ?? 0);

return $movement;
}

/** @return CardMovement[] */
public function findByCardId(int $cardId): array
{
$rows = $this->database->query(
'SELECT * FROM card_movements WHERE card_id = :card_id ORDER BY moved_at DESC, id DESC',
['card_id' => $cardId]
);

return array_map(fn(array $r) => CardMovement::fromRow($r), $rows);
}

public function deleteByCardId(int $cardId): void
{
$this->database->execute('DELETE FROM card_movements WHERE card_id = :card_id', ['card_id' => $cardId]);
}

public function deleteByBoardId(int $boardId): void
{
$this->database->execute(
'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE board_id = :board_id)',
['board_id' => $boardId]
);
}

public function deleteByColumnId(int $columnId): void
{
$this->database->execute(
'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE column_id = :column_id)',
['column_id' => $columnId]
);
}

public function deleteBySwimLaneId(int $swimLaneId): void
{
$this->database->execute(
'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE swim_lane_id = :swim_lane_id)',
['swim_lane_id' => $swimLaneId]
);
}
}

+ 5
- 0
app/Views/partials/card-modal.php Wyświetl plik

@@ -43,6 +43,11 @@
</div>

<div id="card-modal-error" class="alert alert-danger d-none"></div>

<div class="mb-3 d-none" id="card-history-wrap">
<label class="form-label">Move History</label>
<ul class="list-group list-group-flush small" id="card-history-list" style="max-height: 200px; overflow-y: auto;"></ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>


+ 37
- 0
database/migrations/20260917_000001_create_card_movements_table.php Wyświetl plik

@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->execute(
'CREATE TABLE IF NOT EXISTS card_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL,
from_column_id INTEGER,
from_column_name VARCHAR(255),
to_column_id INTEGER NOT NULL,
to_column_name VARCHAR(255),
from_swim_lane_id INTEGER,
from_swim_lane_name VARCHAR(255),
to_swim_lane_id INTEGER NOT NULL,
to_swim_lane_name VARCHAR(255),
moved_at DATETIME NOT NULL,
moved_by VARCHAR(255),
FOREIGN KEY (card_id) REFERENCES cards(id)
)'
);

$database->execute('CREATE INDEX IF NOT EXISTS idx_card_movements_card_id ON card_movements (card_id)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS card_movements');
}
};

+ 42
- 0
public/js/kanban-modal.js Wyświetl plik

@@ -20,6 +20,9 @@
var notesEl = document.getElementById('card-notes');
var fullNoteEl = document.getElementById('card-full-note');

var historyWrap = document.getElementById('card-history-wrap');
var historyList = document.getElementById('card-history-list');

var boardId = KANBAN.boardId;

/* ── Helpers ─────────────────────────────────────────────── */
@@ -42,6 +45,42 @@
errEl.classList.add('d-none');
}

function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}

function formatMovedAt(s) {
if (!s) return '';
var d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d.getTime())) return s;
return d.toLocaleString();
}

function loadHistory(id) {
historyList.innerHTML = '';
fetch('/cards/' + id + '/history')
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.ok || !res.movements || !res.movements.length) {
historyWrap.classList.add('d-none');
return;
}
historyWrap.classList.remove('d-none');
res.movements.forEach(function (m) {
var li = document.createElement('li');
li.className = 'list-group-item px-0 py-1';
li.innerHTML =
'<strong>' + escapeHtml(m.to_column_name || 'Unknown column') + '</strong>' +
' &mdash; ' + escapeHtml(m.moved_by || 'Unknown user') +
' <span class="text-muted">(' + escapeHtml(formatMovedAt(m.moved_at)) + ')</span>';
historyList.appendChild(li);
});
})
.catch(function () { historyWrap.classList.add('d-none'); });
}

/* ── Open for create ──────────────────────────────────────── */
function openCreate(bId, colId, laneId) {
titleEl.textContent = 'Add Card';
@@ -56,6 +95,8 @@
notesEl.value = '';
fullNoteEl.value = '';
btnDelete.classList.add('d-none');
historyWrap.classList.add('d-none');
historyList.innerHTML = '';
clearError();
bsModal.show();
jobNumEl.focus();
@@ -75,6 +116,7 @@
notesEl.value = notes || '';
fullNoteEl.value = fullNote || '';
btnDelete.classList.remove('d-none');
loadHistory(id);
clearError();
bsModal.show();
jobNumEl.focus();


+ 1
- 0
routes/web.php Wyświetl plik

@@ -24,6 +24,7 @@ $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']);

// Cards (JSON API)
$router->post('/cards', [CardsController::class, 'store']);
$router->get('/cards/{id}/history', [CardsController::class, 'history']);
$router->post('/cards/{id}/move', [CardsController::class, 'move']);
$router->post('/cards/{id}/delete', [CardsController::class, 'destroy']);
$router->post('/cards/{id}', [CardsController::class, 'update']);


Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.