Daniel Covington 5 дней назад
Родитель
Сommit
1be468666d
13 измененных файлов: 224 добавлений и 28 удалений
  1. +20
    -2
      app/Controllers/ColumnsController.php
  2. +20
    -2
      app/Controllers/SwimLanesController.php
  3. +2
    -0
      app/Models/BoardColumn.php
  4. +2
    -0
      app/Models/SwimLane.php
  5. +11
    -2
      app/Repositories/BoardColumnRepository.php
  6. +11
    -2
      app/Repositories/SwimLaneRepository.php
  7. +8
    -2
      app/Views/boards/show.php
  8. +8
    -6
      app/Views/partials/settings-panel.php
  9. +21
    -0
      database/migrations/20260617_000001_add_show_export_button_to_board_columns.php
  10. +21
    -0
      database/migrations/20260617_000002_add_show_export_button_to_swim_lanes.php
  11. +62
    -0
      public/js/kanban-board.js
  12. +31
    -7
      public/js/kanban-settings.js
  13. +7
    -5
      routes/web.php

+ 20
- 2
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);


+ 20
- 2
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);


+ 2
- 0
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;


+ 2
- 0
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;


+ 11
- 2
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(


+ 11
- 2
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(


+ 8
- 2
app/Views/boards/show.php Просмотреть файл

@@ -53,6 +53,9 @@
<?php foreach ($columns as $col): ?>
<div class="kanban-col-header" data-col-id="<?= e((string) $col->id) ?>">
<span class="col-label"><?= e($col->name) ?></span>
<?php if ($col->showExportButton): ?>
<a href="/columns/<?= e((string) $col->id) ?>/export" class="col-export-btn" title="Export column to CSV"><i class="bi bi-download"></i></a>
<?php endif; ?>
</div>
<?php endforeach; ?>

@@ -65,6 +68,9 @@
<i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<span class="lane-label"><?= e($lane->name) ?></span>
<?php if ($lane->showExportButton): ?>
<a href="/swimlanes/<?= e((string) $lane->id) ?>/export" class="lane-export-btn" title="Export swim lane to CSV"><i class="bi bi-download"></i></a>
<?php endif; ?>
</div>
<?php foreach ($columns as $col): ?>
<div class="kanban-cell"
@@ -86,8 +92,8 @@ var KANBAN = {
boardSlug: "<?= e($board->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)) ?>;
</script>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>


+ 8
- 6
app/Views/partials/settings-panel.php Просмотреть файл

@@ -37,12 +37,13 @@
<input class="form-check-input col-count-toggle" type="checkbox" role="switch"
<?= $col->showCardCount ? 'checked' : '' ?>>
</div>
<div class="form-check form-switch m-0" title="Show export button on board">
<input class="form-check-input col-export-toggle" type="checkbox" role="switch"
<?= $col->showExportButton ? 'checked' : '' ?>>
</div>
<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,12 +94,13 @@
<div class="d-flex align-items-center gap-2">
<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>
<span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span>
<div class="form-check form-switch m-0" title="Show export button on board">
<input class="form-check-input lane-export-toggle" type="checkbox" role="switch"
<?= $lane->showExportButton ? 'checked' : '' ?>>
</div>
<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>


+ 21
- 0
database/migrations/20260617_000001_add_show_export_button_to_board_columns.php Просмотреть файл

@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->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.
}
};

+ 21
- 0
database/migrations/20260617_000002_add_show_export_button_to_swim_lanes.php Просмотреть файл

@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->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.
}
};

+ 62
- 0
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 = '<i class="bi bi-download"></i>';
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 = '<i class="bi bi-download"></i>';
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();


+ 31
- 7
public/js/kanban-settings.js Просмотреть файл

@@ -57,8 +57,10 @@
'<div class="form-check form-switch m-0" title="Show card count in column header">' +
'<input class="form-check-input col-count-toggle" type="checkbox" role="switch">' +
'</div>' : '') +
'<div class="form-check form-switch m-0" title="Show export button on board">' +
'<input class="form-check-input ' + agePrefix + '-export-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>' +
@@ -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);
});


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


Загрузка…
Отмена
Сохранить

Powered by TurnKey Linux.