diff --git a/app/Controllers/BoardsController.php b/app/Controllers/BoardsController.php index f21535d..c897dad 100644 --- a/app/Controllers/BoardsController.php +++ b/app/Controllers/BoardsController.php @@ -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); diff --git a/app/Controllers/CardsController.php b/app/Controllers/CardsController.php index 8c97b71..e062aa2 100644 --- a/app/Controllers/CardsController.php +++ b/app/Controllers/CardsController.php @@ -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]); + } } diff --git a/app/Controllers/ColumnsController.php b/app/Controllers/ColumnsController.php index c411cce..5ee4b5b 100644 --- a/app/Controllers/ColumnsController.php +++ b/app/Controllers/ColumnsController.php @@ -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); diff --git a/app/Controllers/SwimLanesController.php b/app/Controllers/SwimLanesController.php index 8f1d50a..ff3f579 100644 --- a/app/Controllers/SwimLanesController.php +++ b/app/Controllers/SwimLanesController.php @@ -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); diff --git a/app/Models/CardMovement.php b/app/Models/CardMovement.php new file mode 100644 index 0000000..d1e60b4 --- /dev/null +++ b/app/Models/CardMovement.php @@ -0,0 +1,57 @@ +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, + ]; + } +} diff --git a/app/Repositories/CardMovementRepository.php b/app/Repositories/CardMovementRepository.php new file mode 100644 index 0000000..8c22d8c --- /dev/null +++ b/app/Repositories/CardMovementRepository.php @@ -0,0 +1,85 @@ +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] + ); + } +} diff --git a/app/Views/partials/card-modal.php b/app/Views/partials/card-modal.php index 609bbc2..f7589cf 100644 --- a/app/Views/partials/card-modal.php +++ b/app/Views/partials/card-modal.php @@ -43,6 +43,11 @@
+ +
+ + +