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 @@
@@ -65,6 +68,9 @@
= e($lane->name) ?>
+ showExportButton): ?>
+
+
slug) ?>",
cards: = $cardsJson ?>
};
-var KANBAN_COLS = = json_encode(array_map(fn($c) => ['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 = = json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>;
+var KANBAN_COLS = = json_encode(array_map(fn($c) => ['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 = = json_encode(array_map(fn($l) => ['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 @@
= e($lane->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']);