diff --git a/app/Controllers/ColumnsController.php b/app/Controllers/ColumnsController.php index 5c0038c..7a9555e 100644 --- a/app/Controllers/ColumnsController.php +++ b/app/Controllers/ColumnsController.php @@ -126,6 +126,45 @@ class ColumnsController extends Controller ]); } + public function export(int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->redirect('/auth/login'); + } + + $row = $this->columns()->find($id); + if ($row === null) { + return new \Core\Response('Not found', 404); + } + + $col = \App\Models\BoardColumn::fromRow($row); + $cards = $this->cards()->findByColumnId($id); + + $out = fopen('php://memory', 'wb'); + fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes']); + foreach ($cards as $card) { + fputcsv($out, [ + $card->jobNumber, + $card->jobName, + $card->customerName, + $card->deliveryDate ?? '', + $card->quantity ?? '', + $card->notes, + ]); + } + rewind($out); + $csv = (string) stream_get_contents($out); + fclose($out); + + $slug = trim((string) preg_replace('/[^a-z0-9]+/i', '-', $col->name), '-'); + $filename = ($slug ?: 'column') . '-' . date('Y-m-d') . '.csv'; + + return new \Core\Response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + public function destroy(int $id): mixed { if (!AuthService::isLoggedIn()) { diff --git a/app/Controllers/SwimLanesController.php b/app/Controllers/SwimLanesController.php index e8f2c29..a5a4991 100644 --- a/app/Controllers/SwimLanesController.php +++ b/app/Controllers/SwimLanesController.php @@ -108,6 +108,45 @@ class SwimLanesController extends Controller ]); } + public function export(int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->redirect('/auth/login'); + } + + $row = $this->lanes()->find($id); + if ($row === null) { + return new \Core\Response('Not found', 404); + } + + $lane = \App\Models\SwimLane::fromRow($row); + $cards = $this->cards()->findBySwimLaneId($id); + + $out = fopen('php://memory', 'wb'); + fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes']); + foreach ($cards as $card) { + fputcsv($out, [ + $card->jobNumber, + $card->jobName, + $card->customerName, + $card->deliveryDate ?? '', + $card->quantity ?? '', + $card->notes, + ]); + } + rewind($out); + $csv = (string) stream_get_contents($out); + fclose($out); + + $slug = trim((string) preg_replace('/[^a-z0-9]+/i', '-', $lane->name), '-'); + $filename = ($slug ?: 'lane') . '-' . date('Y-m-d') . '.csv'; + + return new \Core\Response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + public function destroy(int $id): mixed { if (!AuthService::isLoggedIn()) { diff --git a/app/Repositories/CardRepository.php b/app/Repositories/CardRepository.php index c475962..8aae0c1 100644 --- a/app/Repositories/CardRepository.php +++ b/app/Repositories/CardRepository.php @@ -29,6 +29,28 @@ class CardRepository extends Repository return array_map(fn(array $r) => Card::fromRow($r), $rows); } + /** @return Card[] */ + public function findByColumnId(int $columnId): array + { + $rows = $this->database->query( + 'SELECT * FROM cards WHERE column_id = :column_id ORDER BY swim_lane_id ASC, position ASC', + ['column_id' => $columnId] + ); + + return array_map(fn(array $r) => Card::fromRow($r), $rows); + } + + /** @return Card[] */ + public function findBySwimLaneId(int $swimLaneId): array + { + $rows = $this->database->query( + 'SELECT * FROM cards WHERE swim_lane_id = :swim_lane_id ORDER BY column_id ASC, position ASC', + ['swim_lane_id' => $swimLaneId] + ); + + return array_map(fn(array $r) => Card::fromRow($r), $rows); + } + public function maxPosition(int $columnId, int $swimLaneId): int { $row = $this->database->first( diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php index bf6a47e..fe0271d 100644 --- a/app/Views/partials/settings-panel.php +++ b/app/Views/partials/settings-panel.php @@ -40,6 +40,9 @@ + @@ -93,6 +96,9 @@ + diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js index 0a9541c..e9573df 100644 --- a/public/js/kanban-settings.js +++ b/public/js/kanban-settings.js @@ -58,6 +58,7 @@ '' + '' : '') + '' + + '' + '' + '' + '' + @@ -201,6 +202,9 @@ }); }); } + li.querySelector('.btn-export-col').addEventListener('click', function () { + window.location.href = '/columns/' + li.dataset.id + '/export'; + }); bindAgeSettings(li, 'col', '/columns/', function (id, show, days) { window.KanbanBoard.setColumnCardAge(id, show, days); }); @@ -261,6 +265,9 @@ } }); }); + li.querySelector('.btn-export-lane').addEventListener('click', function () { + window.location.href = '/swimlanes/' + li.dataset.id + '/export'; + }); bindAgeSettings(li, 'lane', '/swimlanes/', function (id, show, days) { window.KanbanBoard.setLaneCardAge(id, show, days); }); diff --git a/routes/web.php b/routes/web.php index af2d9ba..95b799f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,7 @@ $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); $router->post('/cards/{id}', [CardsController::class, 'update']); // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} +$router->get('/columns/{id}/export', [ColumnsController::class, 'export']); $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']); @@ -37,6 +38,7 @@ $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->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); $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']);