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']);