diff --git a/assets/controllers/project_bom_export_controller.js b/assets/controllers/project_bom_export_controller.js new file mode 100644 index 00000000..0668d327 --- /dev/null +++ b/assets/controllers/project_bom_export_controller.js @@ -0,0 +1,176 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + url: String, + }; + + async export(event) { + event.preventDefault(); + + const tableElement = this.element + .closest('#bom-tab-pane') + ?.querySelector('table'); + + if (!tableElement) { + throw new Error('Could not find the project BOM table.'); + } + + if ( + typeof window.jQuery === 'undefined' + || !window.jQuery.fn.DataTable.isDataTable(tableElement) + ) { + throw new Error('The project BOM DataTable is not initialized.'); + } + + const dataTable = window.jQuery(tableElement).DataTable(); + const ajaxParameters = dataTable.ajax.params(); + + const parameters = this.toSearchParameters(ajaxParameters); + + /* + * Export the currently visible columns in their current display order. + * Exclude the picture column because it has no useful CSV value. + */ + dataTable + .columns() + .every(function () { + /* + * column.visible() returns DataTables' configured visibility. + * + * Responsive may hide a column visually and move it into a child + * row, but it does not change this configured visibility state. + */ + if (!this.visible()) { + return; + } + + const index = this.index(); + const settings = dataTable.settings()[0]; + const columnSettings = settings.aoColumns[index]; + const name = columnSettings.data; + + /* + * The picture column is not useful in CSV. + */ + if (!name || name === 'picture') { + return; + } + + const heading = this.header().textContent.trim(); + + parameters.append('exportColumns[]', name); + parameters.append( + 'exportLabels[]', + heading || name + ); + }); + + await this.downloadExport(parameters); + } + + async downloadExport(parameters) { + const response = await fetch(this.urlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: parameters.toString(), + credentials: 'same-origin', + }); + + if (!response.ok) { + const errorBody = await response.text(); + + console.error('BOM CSV export failed:', { + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + + throw new Error( + `BOM CSV export failed with HTTP ${response.status}` + ); + } + + const blob = await response.blob(); + const filename = this.getFilename(response) + ?? 'project_bom.csv'; + + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = downloadUrl; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(downloadUrl); + } + + getFilename(response) { + const disposition = response.headers.get('Content-Disposition'); + + if (!disposition) { + return null; + } + + /* + * Prefer RFC 5987 filename*=UTF-8''... when present. + */ + const encodedMatch = disposition.match( + /filename\*=UTF-8''([^;]+)/i + ); + + if (encodedMatch) { + return decodeURIComponent(encodedMatch[1]); + } + + const filenameMatch = disposition.match( + /filename="?([^";]+)"?/i + ); + + return filenameMatch ? filenameMatch[1] : null; + } + + /** + * Convert DataTables' nested AJAX object into query-string parameters. + */ + toSearchParameters(object) { + const parameters = new URLSearchParams(); + + const append = (key, value) => { + if (Array.isArray(value)) { + value.forEach((item, index) => { + append(`${key}[${index}]`, item); + }); + + return; + } + + if (value !== null && typeof value === 'object') { + Object.entries(value).forEach(([childKey, childValue]) => { + const fullKey = key + ? `${key}[${childKey}]` + : childKey; + + append(fullKey, childValue); + }); + + return; + } + + parameters.append(key, String(value ?? '')); + }; + + Object.entries(object).forEach(([key, value]) => { + append(key, value); + }); + + return parameters; + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 531deb3f..de16eb6e 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -46,6 +46,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; +use App\Helpers\FilenameSanatizer; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\Serializer\SerializerInterface; +use App\Services\ImportExportSystem\ProjectBomExporter; use function Symfony\Component\Translation\t; @@ -128,6 +132,148 @@ class ProjectController extends AbstractController ]); } + #[Route( + path: '/{id}/bom/export', + name: 'project_bom_export', + requirements: ['id' => '\d+'], + methods: ['POST'] + )] + public function exportBOM( + Project $project, + Request $request, + ProjectBomExporter $projectBomExporter, + SerializerInterface $serializer, + ): Response { + $this->denyAccessUnlessGranted('read', $project); + + /* + * First run the normal BOM DataTable callback. This applies exactly the + * same project restriction, search criteria and active column ordering + * as the displayed table. + * + * We only use its hidden ID column. Rendered cell contents are discarded. + */ + $table = $this->dataTableFactory->createFromType( + ProjectBomEntriesDataTable::class, + ['project' => $project], + ); + + $request->request->set('_dt', $table->getName()); + /* + * Export every row matching the current search/filter. + * Ignore the page currently displayed in the browser. + */ + $request->request->set('start', 0); + $request->request->set('length', -1); + + $table->handleRequest($request); + + if (!$table->isCallback()) { + throw new \RuntimeException( + 'The BOM export request was not recognised as a DataTable callback.' + ); + } + + $tableResponse = $table->getResponse(); + + /** @var array{ + * data?: list> + * } $payload + */ + $payload = json_decode( + $tableResponse->getContent() ?: '{}', + true, + 512, + JSON_THROW_ON_ERROR, + ); + + /* + * The DataTable already contains a hidden ID column. Collect those IDs in + * their returned order. That order is the currently selected table order. + */ + $orderedIds = []; + + foreach ($payload['data'] ?? [] as $tableRow) { + $id = filter_var( + $tableRow['id'] ?? null, + FILTER_VALIDATE_INT, + ); + + if ($id !== false) { + $orderedIds[] = $id; + } + } + + $columns = array_values( + array_filter( + $request->request->all('exportColumns'), + static fn(mixed $column): bool => is_string($column), + ) + ); + + $labels = array_values( + array_filter( + $request->request->all('exportLabels'), + static fn(mixed $label): bool => is_string($label), + ) + ); + + if ($columns === []) { + throw new \InvalidArgumentException( + 'No columns were specified for BOM export.' + ); + } + + /* + * Reload raw entities from Doctrine. The exporter verifies that every + * entry belongs to this project and restores the DataTable's ID order. + */ + $entries = $projectBomExporter->getOrderedEntries( + $project, + $orderedIds, + ); + + $rows = []; + + foreach ($entries as $entry) { + $rows[] = $projectBomExporter->createRow( + $entry, + $columns, + $labels, + ); + } + + $csv = $serializer->serialize($rows, 'csv', [ + 'as_collection' => true, + 'csv_delimiter' => ';', + ]); + + $filename = FilenameSanatizer::sanitizeFilename( + sprintf( + 'project_%s_bom.csv', + $project->getName(), + ) + ); + + $response = new Response($csv); + + $response->headers->set( + 'Content-Type', + 'text/csv; charset=UTF-8', + ); + + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename, + 'project_bom.csv', + ) + ); + + return $response; + } + #[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])] public function importBOM( Request $request,