Export_Persist във main преди 2 седмици
| @@ -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<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 | |||
| { | |||
| return new GoogleSheetImportService(); | |||
| @@ -14,6 +14,8 @@ window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON | |||
| <p><?= e($campaignTypeName) ?> #<?= e((string) $campaignId) ?></p> | |||
| </div> | |||
| <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-primary" href="/jobs/create">+ New Job</a> | |||
| </div> | |||
| @@ -141,11 +143,14 @@ window.__campaignJobTypes = <?= json_encode($jobTypes ?? [], JSON_HEX_TAG | JSON | |||
| <h2>Job Directory</h2> | |||
| <p>All jobs in this campaign with job fields and attribute fields.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| <div style="display:flex; gap:8px; flex-wrap:wrap;"> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| </div> | |||
| </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-success" x-cloak x-show="configMessage" x-text="configMessage"></div> | |||
| <div id="campaign-jobs-page-table" class="tabulator-host"></div> | |||
| </section> | |||
| @@ -26,7 +26,11 @@ | |||
| <h2>Job Directory</h2> | |||
| <p>All jobs with their campaign and job type.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| <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> | |||
| </div> | |||
| </div> | |||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | |||
| <div class="skeleton-row"></div> | |||
| @@ -36,6 +40,7 @@ | |||
| <div class="skeleton-row"></div> | |||
| </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> | |||
| </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 ───────────────────────────────────────────────────────────── | |||
| 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); | |||
| }, | |||
| }; | |||
| }; | |||
| @@ -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'); | |||
Powered by TurnKey Linux.