| @@ -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()) { | |||
| @@ -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()) { | |||
| @@ -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( | |||
| @@ -40,6 +40,9 @@ | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-col-age" title="Card age settings"> | |||
| <i class="bi bi-clock-history"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-export-col" title="Export to CSV"> | |||
| <i class="bi bi-download"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| @@ -93,6 +96,9 @@ | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-lane-age" title="Card age settings"> | |||
| <i class="bi bi-clock-history"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-export-lane" title="Export to CSV"> | |||
| <i class="bi bi-download"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| @@ -58,6 +58,7 @@ | |||
| '<input class="form-check-input col-count-toggle" type="checkbox" role="switch">' + | |||
| '</div>' : '') + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-' + agePrefix + '-age" title="Card age settings"><i class="bi bi-clock-history"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary btn-export-' + agePrefix + '" title="Export to CSV"><i class="bi bi-download"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>' + | |||
| '</div>' + | |||
| @@ -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); | |||
| }); | |||
| @@ -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']); | |||
Powered by TurnKey Linux.