diff --git a/app/Controllers/JobController.php b/app/Controllers/JobController.php index d860d11..23bb5fb 100644 --- a/app/Controllers/JobController.php +++ b/app/Controllers/JobController.php @@ -16,6 +16,7 @@ use Core\Controller; use Core\Request; use Core\Response; use Core\Validator; +use Throwable; class JobController extends Controller { @@ -44,6 +45,28 @@ class JobController extends Controller )); } + public function export(): Response + { + return $this->csvResponse( + $this->exportRowsFromRequest(), + 'jobs-' . date('Y-m-d-His') . '.csv' + ); + } + + public function exportForCampaign(string $campaignId): Response + { + $campaign = $this->campaignRepo()->findWithType((int) $campaignId); + + if ($campaign === null) { + return $this->redirect('/campaigns'); + } + + return $this->csvResponse( + $this->exportRowsFromRequest(), + 'campaign-' . (int) $campaignId . '-jobs-' . date('Y-m-d-His') . '.csv' + ); + } + public function campaign(string $campaignId): Response { $campaign = $this->campaignRepo()->findWithType((int) $campaignId); @@ -365,6 +388,128 @@ class JobController extends Controller return trim(preg_replace('/\s+/', ' ', $value) ?? ''); } + /** + * @return list> + */ + private function exportRowsFromRequest(): array + { + $token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); + + if (!verify_csrf_token($token)) { + return []; + } + + $rawBody = file_get_contents('php://input'); + if (!is_string($rawBody) || trim($rawBody) === '') { + return []; + } + + try { + $payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + + $rows = $payload['rows'] ?? []; + $columns = $payload['columns'] ?? []; + + if (!is_array($rows) || !is_array($columns)) { + return []; + } + + $visibleColumns = []; + foreach ($columns as $column) { + if (!is_array($column)) { + continue; + } + + $field = trim((string) ($column['field'] ?? '')); + if ($field === '' || $field === 'edit_url') { + continue; + } + + $visibleColumns[] = [ + 'field' => $field, + 'title' => trim((string) ($column['title'] ?? $field)), + ]; + } + + if ($visibleColumns === []) { + return []; + } + + $exportRows = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $exportRow = []; + foreach ($visibleColumns as $column) { + $exportRow[$column['title']] = $this->csvCellValue($row[$column['field']] ?? ''); + } + + $exportRows[] = $exportRow; + } + + return $exportRows; + } + + /** + * @param list> $rows + */ + private function csvResponse(array $rows, string $filename): Response + { + $handle = fopen('php://temp', 'r+'); + if ($handle === false) { + return Response::make('Unable to create CSV export.', 500); + } + + try { + fwrite($handle, "\xEF\xBB\xBF"); + + if ($rows !== []) { + fputcsv($handle, array_keys($rows[0])); + + foreach ($rows as $row) { + fputcsv($handle, array_values($row)); + } + } + + rewind($handle); + $csv = stream_get_contents($handle); + } finally { + fclose($handle); + } + + if ($csv === false) { + return Response::make('Unable to create CSV export.', 500); + } + + return new Response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + ]); + } + + private function csvCellValue(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_array($value) || is_object($value)) { + try { + return json_encode($value, JSON_THROW_ON_ERROR) ?: ''; + } catch (Throwable) { + return ''; + } + } + + return (string) $value; + } + private function googleSheets(): GoogleSheetImportService { return new GoogleSheetImportService(); diff --git a/app/Views/jobs/campaign.php b/app/Views/jobs/campaign.php index 25764ef..8d8c167 100644 --- a/app/Views/jobs/campaign.php +++ b/app/Views/jobs/campaign.php @@ -14,6 +14,8 @@ window.__campaignJobTypes = #

+ + ← Back to campaigns + New Job
@@ -141,11 +143,14 @@ window.__campaignJobTypes = Job Directory

All jobs in this campaign with job fields and attribute fields.

- +
+ +
Loading jobs...
+
diff --git a/app/Views/jobs/index.php b/app/Views/jobs/index.php index 1ffc05e..6b723da 100644 --- a/app/Views/jobs/index.php +++ b/app/Views/jobs/index.php @@ -26,7 +26,11 @@

Job Directory

All jobs with their campaign and job type.

- +
+ + + +
@@ -36,6 +40,7 @@
+
diff --git a/public/js/app.js b/public/js/app.js index 5239621..8659bcc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -51,6 +51,102 @@ function _escapeHtml(value) { }); } +function _tabulatorPersistenceKey(key) { + return 'ct.tabulator.' + key; +} + +function _tabulatorBaseOptions(persistenceKey) { + return { + persistence: { + columns: true, + sort: true, + filter: true, + page: { + size: true, + page: true, + }, + }, + persistenceID: persistenceKey, + }; +} + +function _clearTabulatorPersistence(persistenceKey) { + try { + window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-columns')); + window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-sort')); + window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-filter')); + window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-page')); + } catch (error) { + } +} + +function _tabulatorVisibleColumns(table) { + if (!table || typeof table.getColumns !== 'function') { + return []; + } + + return table.getColumns() + .map((column) => { + const definition = typeof column.getDefinition === 'function' ? column.getDefinition() : null; + if (!definition || !definition.field || definition.field === 'edit_url') { + return null; + } + + const element = typeof column.getElement === 'function' ? column.getElement() : null; + const isVisible = !element || element.offsetParent !== null; + if (!isVisible) { + return null; + } + + return { + field: definition.field, + title: definition.title || definition.field, + }; + }) + .filter((column) => column !== null); +} + +function _downloadBlob(blob, filename) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => window.URL.revokeObjectURL(url), 0); +} + +async function _exportTabulatorCsv(url, table, filename) { + if (!table) { + throw new Error('The table is not ready yet.'); + } + + const payload = { + columns: _tabulatorVisibleColumns(table), + rows: typeof table.getData === 'function' ? table.getData('active') : [], + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'text/csv', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': window.__csrf || '', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error('Unable to export CSV.'); + } + + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="?([^";]+)"?/i); + _downloadBlob(blob, match ? match[1] : filename); +} + // ── Campaign Type ───────────────────────────────────────────────────────────── window.campaignTypeTable = function () { @@ -418,6 +514,7 @@ window.campaignTable = function () { placeholder: 'No jobs found for this campaign.', initialSort: [{ column: 'job_type_name', dir: 'asc' }], columns: columns, + ..._tabulatorBaseOptions(this.persistenceKey), }); } else { this.jobsTable.setColumns(columns); @@ -524,6 +621,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { table: null, jobTypes: Array.isArray(jobTypes) ? jobTypes : [], isLoading: false, + configMessage: '', isConnecting: false, isImporting: false, errorMessage: '', @@ -543,6 +641,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { selectedFileJobTypeId: '0', isLoadingFile: false, isImportingFile: false, + persistenceKey: 'campaign-jobs-page-' + campaignId, init() { this.loadTable(); @@ -714,6 +813,14 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { this.loadTable(); }, + async exportCsv() { + try { + await _exportTabulatorCsv('/campaigns/' + encodeURIComponent(campaignId) + '/jobs/export', this.table, 'campaign-jobs-export.csv'); + } catch (error) { + this.errorMessage = error.message || 'Unable to export CSV.'; + } + }, + async connectGoogleSheet() { this.isConnecting = true; this.importMessage = ''; @@ -1276,6 +1383,8 @@ window.jobTable = function () { table: null, isLoading: false, errorMessage: '', + configMessage: '', + persistenceKey: 'jobs-directory', init() { this.loadTable(); @@ -1321,6 +1430,7 @@ window.jobTable = function () { placeholder: 'No jobs found.', initialSort: [{ column: 'job_type_name', dir: 'asc' }], columns: columns, + ..._tabulatorBaseOptions(this.persistenceKey), }); } catch (error) { this.errorMessage = error.message || 'Unable to load jobs.'; @@ -1434,6 +1544,32 @@ window.jobTable = function () { reloadTable() { this.loadTable(); }, + + async exportCsv() { + try { + await _exportTabulatorCsv('/jobs/export', this.table, 'jobs-export.csv'); + } catch (error) { + this.errorMessage = error.message || 'Unable to export CSV.'; + } + }, + + showConfiguration() { + if (!this.table) { + return; + } + + const shouldReset = window.confirm('Reset this table configuration? This clears saved columns, filters, sorting, and page size.'); + if (!shouldReset) { + return; + } + + _clearTabulatorPersistence(this.persistenceKey); + this.configMessage = 'Table configuration reset.'; + this.loadTable(); + window.setTimeout(() => { + this.configMessage = ''; + }, 3000); + }, }; }; diff --git a/routes/web.php b/routes/web.php index 77f2002..4c164a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,6 +41,8 @@ $router->get('/campaigns/{id}/jobs', [JobController::class, 'campaign']) ->middleware('auth'); $router->get('/campaigns/{id}/jobs/data', [JobController::class, 'dataForCampaign']) ->middleware('auth'); +$router->post('/campaigns/{id}/jobs/export', [JobController::class, 'exportForCampaign']) + ->middleware('auth'); $router->post('/campaigns/{id}/jobs/import/sheets', [JobController::class, 'googleSheetsList'])->middleware('auth'); $router->post('/campaigns/{id}/jobs/import', [JobController::class, 'importGoogleSheet'])->middleware('auth'); $router->post('/campaigns/{id}/jobs/import/file/sheets', [JobController::class, 'fileSheetsList']) ->middleware('auth'); @@ -61,6 +63,7 @@ $router->post('/campaign-types/{id}/delete', [CampaignTypeController::class, 'de // ── Jobs ────────────────────────────────────────────────────────────────────── $router->get('/jobs', [JobController::class, 'index']) ->middleware('auth'); $router->get('/jobs/data', [JobController::class, 'data']) ->middleware('auth'); +$router->post('/jobs/export', [JobController::class, 'export']) ->middleware('auth'); $router->get('/jobs/create', [JobController::class, 'create']) ->middleware('auth'); $router->post('/jobs', [JobController::class, 'store']) ->middleware('auth'); $router->get('/jobs/{id}/edit', [JobController::class, 'edit']) ->middleware('auth');