| @@ -16,6 +16,7 @@ use Core\Controller; | |||||
| use Core\Request; | use Core\Request; | ||||
| use Core\Response; | use Core\Response; | ||||
| use Core\Validator; | use Core\Validator; | ||||
| use Throwable; | |||||
| class JobController extends Controller | 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 | public function campaign(string $campaignId): Response | ||||
| { | { | ||||
| $campaign = $this->campaignRepo()->findWithType((int) $campaignId); | $campaign = $this->campaignRepo()->findWithType((int) $campaignId); | ||||
| @@ -365,6 +388,128 @@ class JobController extends Controller | |||||
| return trim(preg_replace('/\s+/', ' ', $value) ?? ''); | return trim(preg_replace('/\s+/', ' ', $value) ?? ''); | ||||
| } | } | ||||
| /** | |||||
| * @return list<array<string, mixed>> | |||||
| */ | |||||
| 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<array<string, mixed>> $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 | private function googleSheets(): GoogleSheetImportService | ||||
| { | { | ||||
| return new GoogleSheetImportService(); | return new GoogleSheetImportService(); | ||||
| @@ -14,6 +14,8 @@ window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON | |||||
| <p><?= e($campaignTypeName) ?> #<?= e((string) $campaignId) ?></p> | <p><?= e($campaignTypeName) ?> #<?= e((string) $campaignId) ?></p> | ||||
| </div> | </div> | ||||
| <div class="panel-actions"> | <div class="panel-actions"> | ||||
| <button class="button button-secondary" type="button" x-on:click="showConfiguration()">Configuration</button> | |||||
| <button class="button button-secondary" type="button" x-on:click="exportCsv()">Export CSV</button> | |||||
| <a class="button button-secondary" href="/campaigns">← Back to campaigns</a> | <a class="button button-secondary" href="/campaigns">← Back to campaigns</a> | ||||
| <a class="button button-primary" href="/jobs/create">+ New Job</a> | <a class="button button-primary" href="/jobs/create">+ New Job</a> | ||||
| </div> | </div> | ||||
| @@ -141,11 +143,14 @@ window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON | |||||
| <h2>Job Directory</h2> | <h2>Job Directory</h2> | ||||
| <p>All jobs in this campaign with job fields and attribute fields.</p> | <p>All jobs in this campaign with job fields and attribute fields.</p> | ||||
| </div> | </div> | ||||
| <div style="display:flex; gap:8px; flex-wrap:wrap;"> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | ||||
| </div> | </div> | ||||
| </div> | |||||
| <div class="inline-indicator" x-cloak x-show="isLoading">Loading jobs...</div> | <div class="inline-indicator" x-cloak x-show="isLoading">Loading jobs...</div> | ||||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | ||||
| <div class="alert alert-success" x-cloak x-show="configMessage" x-text="configMessage"></div> | |||||
| <div id="campaign-jobs-page-table" class="tabulator-host"></div> | <div id="campaign-jobs-page-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -26,8 +26,12 @@ | |||||
| <h2>Job Directory</h2> | <h2>Job Directory</h2> | ||||
| <p>All jobs with their campaign and job type.</p> | <p>All jobs with their campaign and job type.</p> | ||||
| </div> | </div> | ||||
| <div style="display:flex; gap:8px; flex-wrap:wrap;"> | |||||
| <button class="button button-secondary" type="button" x-on:click="showConfiguration()">Configuration</button> | |||||
| <button class="button button-secondary" type="button" x-on:click="exportCsv()">Export CSV</button> | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | ||||
| </div> | </div> | ||||
| </div> | |||||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | <div class="skeleton-rows" x-cloak x-show="isLoading"> | ||||
| <div class="skeleton-row"></div> | <div class="skeleton-row"></div> | ||||
| <div class="skeleton-row"></div> | <div class="skeleton-row"></div> | ||||
| @@ -36,6 +40,7 @@ | |||||
| <div class="skeleton-row"></div> | <div class="skeleton-row"></div> | ||||
| </div> | </div> | ||||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | ||||
| <div class="alert alert-success" x-cloak x-show="configMessage" x-text="configMessage"></div> | |||||
| <div id="job-table" class="tabulator-host"></div> | <div id="job-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -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 ───────────────────────────────────────────────────────────── | // ── Campaign Type ───────────────────────────────────────────────────────────── | ||||
| window.campaignTypeTable = function () { | window.campaignTypeTable = function () { | ||||
| @@ -418,6 +514,7 @@ window.campaignTable = function () { | |||||
| placeholder: 'No jobs found for this campaign.', | placeholder: 'No jobs found for this campaign.', | ||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | initialSort: [{ column: 'job_type_name', dir: 'asc' }], | ||||
| columns: columns, | columns: columns, | ||||
| ..._tabulatorBaseOptions(this.persistenceKey), | |||||
| }); | }); | ||||
| } else { | } else { | ||||
| this.jobsTable.setColumns(columns); | this.jobsTable.setColumns(columns); | ||||
| @@ -524,6 +621,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| table: null, | table: null, | ||||
| jobTypes: Array.isArray(jobTypes) ? jobTypes : [], | jobTypes: Array.isArray(jobTypes) ? jobTypes : [], | ||||
| isLoading: false, | isLoading: false, | ||||
| configMessage: '', | |||||
| isConnecting: false, | isConnecting: false, | ||||
| isImporting: false, | isImporting: false, | ||||
| errorMessage: '', | errorMessage: '', | ||||
| @@ -543,6 +641,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| selectedFileJobTypeId: '0', | selectedFileJobTypeId: '0', | ||||
| isLoadingFile: false, | isLoadingFile: false, | ||||
| isImportingFile: false, | isImportingFile: false, | ||||
| persistenceKey: 'campaign-jobs-page-' + campaignId, | |||||
| init() { | init() { | ||||
| this.loadTable(); | this.loadTable(); | ||||
| @@ -714,6 +813,14 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| this.loadTable(); | 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() { | async connectGoogleSheet() { | ||||
| this.isConnecting = true; | this.isConnecting = true; | ||||
| this.importMessage = ''; | this.importMessage = ''; | ||||
| @@ -1276,6 +1383,8 @@ window.jobTable = function () { | |||||
| table: null, | table: null, | ||||
| isLoading: false, | isLoading: false, | ||||
| errorMessage: '', | errorMessage: '', | ||||
| configMessage: '', | |||||
| persistenceKey: 'jobs-directory', | |||||
| init() { | init() { | ||||
| this.loadTable(); | this.loadTable(); | ||||
| @@ -1321,6 +1430,7 @@ window.jobTable = function () { | |||||
| placeholder: 'No jobs found.', | placeholder: 'No jobs found.', | ||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | initialSort: [{ column: 'job_type_name', dir: 'asc' }], | ||||
| columns: columns, | columns: columns, | ||||
| ..._tabulatorBaseOptions(this.persistenceKey), | |||||
| }); | }); | ||||
| } catch (error) { | } catch (error) { | ||||
| this.errorMessage = error.message || 'Unable to load jobs.'; | this.errorMessage = error.message || 'Unable to load jobs.'; | ||||
| @@ -1434,6 +1544,32 @@ window.jobTable = function () { | |||||
| reloadTable() { | reloadTable() { | ||||
| this.loadTable(); | 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); | |||||
| }, | |||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -41,6 +41,8 @@ $router->get('/campaigns/{id}/jobs', [JobController::class, 'campaign']) | |||||
| ->middleware('auth'); | ->middleware('auth'); | ||||
| $router->get('/campaigns/{id}/jobs/data', [JobController::class, 'dataForCampaign']) | $router->get('/campaigns/{id}/jobs/data', [JobController::class, 'dataForCampaign']) | ||||
| ->middleware('auth'); | ->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/sheets', [JobController::class, 'googleSheetsList'])->middleware('auth'); | ||||
| $router->post('/campaigns/{id}/jobs/import', [JobController::class, 'importGoogleSheet'])->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'); | $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 ────────────────────────────────────────────────────────────────────── | // ── Jobs ────────────────────────────────────────────────────────────────────── | ||||
| $router->get('/jobs', [JobController::class, 'index']) ->middleware('auth'); | $router->get('/jobs', [JobController::class, 'index']) ->middleware('auth'); | ||||
| $router->get('/jobs/data', [JobController::class, 'data']) ->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->get('/jobs/create', [JobController::class, 'create']) ->middleware('auth'); | ||||
| $router->post('/jobs', [JobController::class, 'store']) ->middleware('auth'); | $router->post('/jobs', [JobController::class, 'store']) ->middleware('auth'); | ||||
| $router->get('/jobs/{id}/edit', [JobController::class, 'edit']) ->middleware('auth'); | $router->get('/jobs/{id}/edit', [JobController::class, 'edit']) ->middleware('auth'); | ||||
Powered by TurnKey Linux.