| @@ -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 | public function destroy(int $id): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | 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 | public function destroy(int $id): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -29,6 +29,28 @@ class CardRepository extends Repository | |||||
| return array_map(fn(array $r) => Card::fromRow($r), $rows); | 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 | public function maxPosition(int $columnId, int $swimLaneId): int | ||||
| { | { | ||||
| $row = $this->database->first( | $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"> | <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> | <i class="bi bi-clock-history"></i> | ||||
| </button> | </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"> | <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | ||||
| <i class="bi bi-pencil"></i> | <i class="bi bi-pencil"></i> | ||||
| </button> | </button> | ||||
| @@ -93,6 +96,9 @@ | |||||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-lane-age" title="Card age settings"> | <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> | <i class="bi bi-clock-history"></i> | ||||
| </button> | </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"> | <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | ||||
| <i class="bi bi-pencil"></i> | <i class="bi bi-pencil"></i> | ||||
| </button> | </button> | ||||
| @@ -58,6 +58,7 @@ | |||||
| '<input class="form-check-input col-count-toggle" type="checkbox" role="switch">' + | '<input class="form-check-input col-count-toggle" type="checkbox" role="switch">' + | ||||
| '</div>' : '') + | '</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-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-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>' + | '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>' + | ||||
| '</div>' + | '</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) { | bindAgeSettings(li, 'col', '/columns/', function (id, show, days) { | ||||
| window.KanbanBoard.setColumnCardAge(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) { | bindAgeSettings(li, 'lane', '/swimlanes/', function (id, show, days) { | ||||
| window.KanbanBoard.setLaneCardAge(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']); | $router->post('/cards/{id}', [CardsController::class, 'update']); | ||||
| // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} | // 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/reorder', [ColumnsController::class, 'reorder']); | ||||
| $router->post('/columns/{id}/toggle-count', [ColumnsController::class, 'toggleCount']); | $router->post('/columns/{id}/toggle-count', [ColumnsController::class, 'toggleCount']); | ||||
| $router->post('/columns/{id}/card-age-settings', [ColumnsController::class, 'updateCardAgeSettings']); | $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']); | $router->post('/columns', [ColumnsController::class, 'store']); | ||||
| // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | // 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/reorder', [SwimLanesController::class, 'reorder']); | ||||
| $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); | ||||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | ||||
Powered by TurnKey Linux.