diff --git a/app/Controllers/ColumnsController.php b/app/Controllers/ColumnsController.php index 7a9555e..c411cce 100644 --- a/app/Controllers/ColumnsController.php +++ b/app/Controllers/ColumnsController.php @@ -79,6 +79,24 @@ class ColumnsController extends Controller return $this->json(['ok' => true]); } + public function toggleExport(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $row = $this->columns()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $show = (string) $request->input('show_export_button', '0') === '1'; + + $this->columns()->updateShowExportButton($id, $show, date('Y-m-d H:i:s'), AuthService::getCurrentUsername()); + + return $this->json(['ok' => true, 'show_export_button' => $show]); + } + public function toggleCount(Request $request, int $id): mixed { if (!AuthService::isLoggedIn()) { @@ -141,7 +159,7 @@ class ColumnsController extends Controller $cards = $this->cards()->findByColumnId($id); $out = fopen('php://memory', 'wb'); - fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes']); + fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes'], ',', '"', '\\'); foreach ($cards as $card) { fputcsv($out, [ $card->jobNumber, @@ -150,7 +168,7 @@ class ColumnsController extends Controller $card->deliveryDate ?? '', $card->quantity ?? '', $card->notes, - ]); + ], ',', '"', '\\'); } rewind($out); $csv = (string) stream_get_contents($out); diff --git a/app/Controllers/SwimLanesController.php b/app/Controllers/SwimLanesController.php index a5a4991..5b5ff3a 100644 --- a/app/Controllers/SwimLanesController.php +++ b/app/Controllers/SwimLanesController.php @@ -79,6 +79,24 @@ class SwimLanesController extends Controller return $this->json(['ok' => true]); } + public function toggleExport(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $row = $this->lanes()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $show = (string) $request->input('show_export_button', '0') === '1'; + + $this->lanes()->updateShowExportButton($id, $show, date('Y-m-d H:i:s'), AuthService::getCurrentUsername()); + + return $this->json(['ok' => true, 'show_export_button' => $show]); + } + public function updateCardAgeSettings(Request $request, int $id): mixed { if (!AuthService::isLoggedIn()) { @@ -123,7 +141,7 @@ class SwimLanesController extends Controller $cards = $this->cards()->findBySwimLaneId($id); $out = fopen('php://memory', 'wb'); - fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes']); + fputcsv($out, ['Job #', 'Job Name', 'Customer', 'Delivery Date', 'Quantity', 'Notes'], ',', '"', '\\'); foreach ($cards as $card) { fputcsv($out, [ $card->jobNumber, @@ -132,7 +150,7 @@ class SwimLanesController extends Controller $card->deliveryDate ?? '', $card->quantity ?? '', $card->notes, - ]); + ], ',', '"', '\\'); } rewind($out); $csv = (string) stream_get_contents($out); diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 295b1c8..6479885 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -11,6 +11,7 @@ class BoardColumn public string $name = ''; public int $position = 0; public bool $showCardCount = false; + public bool $showExportButton = false; public bool $showCardAge = false; public int $cardAgeWarningDays = 0; public ?string $createdAt = null; @@ -26,6 +27,7 @@ class BoardColumn $model->name = (string) ($row['name'] ?? ''); $model->position = (int) ($row['position'] ?? 0); $model->showCardCount = (bool) ($row['show_card_count'] ?? false); + $model->showExportButton = (bool) ($row['show_export_button'] ?? false); $model->showCardAge = (bool) ($row['show_card_age'] ?? false); $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); $model->createdAt = $row['created_at'] ?? null; diff --git a/app/Models/SwimLane.php b/app/Models/SwimLane.php index 4910aa7..1fa7d3a 100644 --- a/app/Models/SwimLane.php +++ b/app/Models/SwimLane.php @@ -10,6 +10,7 @@ class SwimLane public int $boardId = 0; public string $name = ''; public int $position = 0; + public bool $showExportButton = false; public bool $showCardAge = false; public int $cardAgeWarningDays = 0; public ?string $createdAt = null; @@ -24,6 +25,7 @@ class SwimLane $model->boardId = (int) ($row['board_id'] ?? 0); $model->name = (string) ($row['name'] ?? ''); $model->position = (int) ($row['position'] ?? 0); + $model->showExportButton = (bool) ($row['show_export_button'] ?? false); $model->showCardAge = (bool) ($row['show_card_age'] ?? false); $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); $model->createdAt = $row['created_at'] ?? null; diff --git a/app/Repositories/BoardColumnRepository.php b/app/Repositories/BoardColumnRepository.php index 00e06e5..f6929c7 100644 --- a/app/Repositories/BoardColumnRepository.php +++ b/app/Repositories/BoardColumnRepository.php @@ -35,13 +35,14 @@ class BoardColumnRepository extends Repository public function insert(BoardColumn $col): BoardColumn { $this->database->execute( - 'INSERT INTO board_columns (board_id, name, position, show_card_count, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) - VALUES (:board_id, :name, :position, :show_card_count, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', + 'INSERT INTO board_columns (board_id, name, position, show_card_count, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) + VALUES (:board_id, :name, :position, :show_card_count, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', [ 'board_id' => $col->boardId, 'name' => $col->name, 'position' => $col->position, 'show_card_count' => $col->showCardCount ? 1 : 0, + 'show_export_button' => $col->showExportButton ? 1 : 0, 'show_card_age' => $col->showCardAge ? 1 : 0, 'card_age_warning_days' => $col->cardAgeWarningDays, 'created_at' => $col->createdAt, @@ -65,6 +66,14 @@ class BoardColumnRepository extends Repository ); } + public function updateShowExportButton(int $id, bool $show, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE board_columns SET show_export_button = :show_export_button, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['show_export_button' => $show ? 1 : 0, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + public function updateShowCardCount(int $id, bool $show, string $updatedAt, string $updatedBy): void { $this->database->execute( diff --git a/app/Repositories/SwimLaneRepository.php b/app/Repositories/SwimLaneRepository.php index 0c1ec71..d09d431 100644 --- a/app/Repositories/SwimLaneRepository.php +++ b/app/Repositories/SwimLaneRepository.php @@ -35,12 +35,13 @@ class SwimLaneRepository extends Repository public function insert(SwimLane $lane): SwimLane { $this->database->execute( - 'INSERT INTO swim_lanes (board_id, name, position, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) - VALUES (:board_id, :name, :position, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', + 'INSERT INTO swim_lanes (board_id, name, position, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by) + VALUES (:board_id, :name, :position, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)', [ 'board_id' => $lane->boardId, 'name' => $lane->name, 'position' => $lane->position, + 'show_export_button' => $lane->showExportButton ? 1 : 0, 'show_card_age' => $lane->showCardAge ? 1 : 0, 'card_age_warning_days' => $lane->cardAgeWarningDays, 'created_at' => $lane->createdAt, @@ -64,6 +65,14 @@ class SwimLaneRepository extends Repository ); } + public function updateShowExportButton(int $id, bool $show, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE swim_lanes SET show_export_button = :show_export_button, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', + ['show_export_button' => $show ? 1 : 0, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + ); + } + public function updateCardAgeSettings(int $id, bool $showCardAge, int $cardAgeWarningDays, string $updatedAt, string $updatedBy): void { $this->database->execute( diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php index 156b8c3..35545fe 100644 --- a/app/Views/boards/show.php +++ b/app/Views/boards/show.php @@ -53,6 +53,9 @@
name) ?> + showExportButton): ?> + +
@@ -65,6 +68,9 @@ name) ?> + showExportButton): ?> + +
slug) ?>", cards: }; -var KANBAN_COLS = ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>; -var KANBAN_LANES = ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>; +var KANBAN_COLS = ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_export_button' => $c->showExportButton, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>; +var KANBAN_LANES = ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_export_button' => $l->showExportButton, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>; diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php index fe0271d..c4f4525 100644 --- a/app/Views/partials/settings-panel.php +++ b/app/Views/partials/settings-panel.php @@ -37,12 +37,13 @@ showCardCount ? 'checked' : '' ?>>
+
+ showExportButton ? 'checked' : '' ?>> +
- @@ -93,12 +94,13 @@
name) ?> +
+ showExportButton ? 'checked' : '' ?>> +
- diff --git a/database/migrations/20260617_000001_add_show_export_button_to_board_columns.php b/database/migrations/20260617_000001_add_show_export_button_to_board_columns.php new file mode 100644 index 0000000..e7db223 --- /dev/null +++ b/database/migrations/20260617_000001_add_show_export_button_to_board_columns.php @@ -0,0 +1,21 @@ +execute( + 'ALTER TABLE board_columns ADD COLUMN show_export_button INTEGER NOT NULL DEFAULT 0' + ); + } + + public function down(Database $database): void + { + // SQLite cannot drop columns without recreating the table. + } +}; diff --git a/database/migrations/20260617_000002_add_show_export_button_to_swim_lanes.php b/database/migrations/20260617_000002_add_show_export_button_to_swim_lanes.php new file mode 100644 index 0000000..a7e021a --- /dev/null +++ b/database/migrations/20260617_000002_add_show_export_button_to_swim_lanes.php @@ -0,0 +1,21 @@ +execute( + 'ALTER TABLE swim_lanes ADD COLUMN show_export_button INTEGER NOT NULL DEFAULT 0' + ); + } + + public function down(Database $database): void + { + // SQLite cannot drop columns without recreating the table. + } +}; diff --git a/public/js/kanban-board.js b/public/js/kanban-board.js index 833a1e2..abf1ac0 100644 --- a/public/js/kanban-board.js +++ b/public/js/kanban-board.js @@ -6,6 +6,8 @@ var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); var collapsedLaneIds = loadCollapsedLaneIds(); var columnShowCount = {}; + var columnShowExport = {}; + var laneShowExport = {}; var columnAgeSettings = {}; var laneAgeSettings = {}; var searchState = { @@ -152,6 +154,53 @@ }); } + function refreshColumnExport(colId) { + var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]'); + if (!header) return; + var btn = header.querySelector('.col-export-btn'); + if (columnShowExport[String(colId)]) { + if (!btn) { + btn = document.createElement('a'); + btn.className = 'col-export-btn'; + btn.href = '/columns/' + colId + '/export'; + btn.title = 'Export column to CSV'; + btn.innerHTML = ''; + header.appendChild(btn); + } + } else { + if (btn) btn.remove(); + } + } + + function refreshLaneExport(laneId) { + var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]'); + if (!header) return; + var btn = header.querySelector('.lane-export-btn'); + if (laneShowExport[String(laneId)]) { + if (!btn) { + btn = document.createElement('a'); + btn.className = 'lane-export-btn'; + btn.href = '/swimlanes/' + laneId + '/export'; + btn.title = 'Export swim lane to CSV'; + btn.innerHTML = ''; + header.appendChild(btn); + } + } else { + if (btn) btn.remove(); + } + } + + function initExportButtons() { + KANBAN_COLS.forEach(function (col) { + columnShowExport[String(col.id)] = !!col.show_export_button; + refreshColumnExport(col.id); + }); + KANBAN_LANES.forEach(function (lane) { + laneShowExport[String(lane.id)] = !!lane.show_export_button; + refreshLaneExport(lane.id); + }); + } + function initCardAgeSettings() { KANBAN_COLS.forEach(function (col) { columnAgeSettings[String(col.id)] = { @@ -505,6 +554,7 @@ grid.insertBefore(hdr, refNode); columnShowCount[String(col.id)] = false; + columnShowExport[String(col.id)] = false; columnAgeSettings[String(col.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); @@ -526,6 +576,7 @@ document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); delete columnShowCount[String(colId)]; + delete columnShowExport[String(colId)]; delete columnAgeSettings[String(colId)]; applyGridTemplate(); }, @@ -544,6 +595,7 @@ grid.appendChild(lh); bindLaneHeaderToggle(lh); + laneShowExport[String(lane.id)] = false; laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 }; colHeaders.forEach(function (ch) { @@ -565,6 +617,7 @@ document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); + delete laneShowExport[String(laneId)]; delete laneAgeSettings[String(laneId)]; if (collapsedLaneIds[String(laneId)]) { delete collapsedLaneIds[String(laneId)]; @@ -583,6 +636,14 @@ columnShowCount[String(colId)] = show; refreshColumnCount(colId); }, + setColumnShowExport: function (colId, show) { + columnShowExport[String(colId)] = !!show; + refreshColumnExport(colId); + }, + setLaneShowExport: function (laneId, show) { + laneShowExport[String(laneId)] = !!show; + refreshLaneExport(laneId); + }, setColumnCardAge: function (colId, showCardAge, cardAgeWarningDays) { columnAgeSettings[String(colId)] = { showCardAge: !!showCardAge, @@ -601,6 +662,7 @@ applyGridTemplate(); initCardAgeSettings(); + initExportButtons(); renderCards(); initSortables(); initJobSearch(); diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js index e9573df..6183e84 100644 --- a/public/js/kanban-settings.js +++ b/public/js/kanban-settings.js @@ -57,8 +57,10 @@ '
' + '' + '
' : '') + + '
' + + '' + + '
' + '' + - '' + '' + '' + '
' + @@ -202,9 +204,20 @@ }); }); } - li.querySelector('.btn-export-col').addEventListener('click', function () { - window.location.href = '/columns/' + li.dataset.id + '/export'; - }); + var colExportToggle = li.querySelector('.col-export-toggle'); + if (colExportToggle) { + colExportToggle.addEventListener('change', function () { + var show = colExportToggle.checked; + post('/columns/' + li.dataset.id + '/toggle-export', { show_export_button: show ? '1' : '0' }, function (res) { + if (res.ok) { + window.KanbanBoard.setColumnShowExport(li.dataset.id, show); + } else { + colExportToggle.checked = !show; + alert(res.error || 'Update failed'); + } + }); + }); + } bindAgeSettings(li, 'col', '/columns/', function (id, show, days) { window.KanbanBoard.setColumnCardAge(id, show, days); }); @@ -265,9 +278,20 @@ } }); }); - li.querySelector('.btn-export-lane').addEventListener('click', function () { - window.location.href = '/swimlanes/' + li.dataset.id + '/export'; - }); + var laneExportToggle = li.querySelector('.lane-export-toggle'); + if (laneExportToggle) { + laneExportToggle.addEventListener('change', function () { + var show = laneExportToggle.checked; + post('/swimlanes/' + li.dataset.id + '/toggle-export', { show_export_button: show ? '1' : '0' }, function (res) { + if (res.ok) { + window.KanbanBoard.setLaneShowExport(li.dataset.id, show); + } else { + laneExportToggle.checked = !show; + alert(res.error || 'Update failed'); + } + }); + }); + } 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 95b799f..1d4d2a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,17 +29,19 @@ $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->get('/columns/{id}/export', [ColumnsController::class, 'export']); +$router->post('/columns/reorder', [ColumnsController::class, 'reorder']); +$router->post('/columns/{id}/toggle-export', [ColumnsController::class, 'toggleExport']); +$router->post('/columns/{id}/toggle-count', [ColumnsController::class, 'toggleCount']); $router->post('/columns/{id}/card-age-settings', [ColumnsController::class, 'updateCardAgeSettings']); $router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); $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->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); +$router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); +$router->post('/swimlanes/{id}/toggle-export', [SwimLanesController::class, 'toggleExport']); $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); $router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']);