diff --git a/apps/api/app/Http/Controllers/ReportController.php b/apps/api/app/Http/Controllers/ReportController.php index 9326d7d..85c6396 100644 --- a/apps/api/app/Http/Controllers/ReportController.php +++ b/apps/api/app/Http/Controllers/ReportController.php @@ -191,6 +191,31 @@ public function previewPdf(Request $request) return $this->generatePdfResponse($reportName, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); } + /** + * Same document as the PDF export, served as HTML so the print preview can show + * and print exactly what the PDF contains. + */ + public function previewHtml(Request $request) + { + $validated = $request->validate([ + 'table_id' => 'required|uuid|exists:tables,id', + 'name' => 'sometimes|required|string|max:255', + 'query' => 'nullable|array', + 'layout' => 'nullable|array', + ]); + + $table = Table::findOrFail($validated['table_id']); + $reportName = $validated['name'] ?? 'Rapport temporaire'; + $ast = $validated['query'] ?? []; + $layout = $validated['layout'] ?? null; + + $result = $this->queryService->execute($table, $ast, null, null, $layout); + + $html = $this->renderReportHtml($reportName, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $layout); + + return response($html, 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + public function previewCsv(Request $request) { $validated = $request->validate([ @@ -209,14 +234,18 @@ public function previewCsv(Request $request) return $this->generateCsvResponse($result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); } - private function generatePdfResponse(string $reportName, array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null) + /** + * The single rendering of a report document. The PDF export and the on-screen + * print preview both go through here, so what is previewed is what is printed. + */ + private function renderReportHtml(string $reportName, array $columns, array $groups, ?string $groupBy, ?array $layout = null): string { $view = null; if ($layout && isset($layout['view_id'])) { $view = View::with('table.fields')->find($layout['view_id']); } - $html = view('reports.pdf', [ + return view('reports.pdf', [ 'reportName' => $reportName, 'columns' => $columns, 'groups' => $groups, @@ -224,6 +253,11 @@ private function generatePdfResponse(string $reportName, array $columns, array $ 'layout' => $layout, 'view' => $view, ])->render(); + } + + private function generatePdfResponse(string $reportName, array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null) + { + $html = $this->renderReportHtml($reportName, $columns, $groups, $groupBy, $layout); $dompdf = new Dompdf([ 'isHtml5ParserEnabled' => true, diff --git a/apps/api/routes/api.php b/apps/api/routes/api.php index ad9640c..f4827f6 100644 --- a/apps/api/routes/api.php +++ b/apps/api/routes/api.php @@ -34,6 +34,7 @@ Route::post('reports/preview', [ReportController::class, 'preview']); Route::post('reports/preview/pdf', [ReportController::class, 'previewPdf']); Route::post('reports/preview/csv', [ReportController::class, 'previewCsv']); + Route::post('reports/preview/html', [ReportController::class, 'previewHtml']); Route::get('reports/{report}/export/pdf', [ReportController::class, 'exportPdf']); Route::get('reports/{report}/export/csv', [ReportController::class, 'exportCsv']); Route::match(['get', 'post'], 'reports/{report}/execute', [ReportController::class, 'execute']); diff --git a/apps/api/tests/Feature/ReportFeatureTest.php b/apps/api/tests/Feature/ReportFeatureTest.php index 17ad79b..459f78d 100644 --- a/apps/api/tests/Feature/ReportFeatureTest.php +++ b/apps/api/tests/Feature/ReportFeatureTest.php @@ -432,3 +432,87 @@ function createAuthenticatedUser() expect($headerLine)->toBe(['Ville', 'Nom']); expect(array_count_values($headerLine)['Ville'])->toBe(1); }); + +test('print preview html is the same document the pdf export renders', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + Field::factory()->create(['table_id' => $table->id, 'name' => 'Ville', 'type' => 'text']); + Field::factory()->create(['table_id' => $table->id, 'name' => 'Nom', 'type' => 'text']); + + // More records than a preview page holds, so pagination would be visible. + foreach (range(1, 25) as $i) { + Record::create([ + 'table_id' => $table->id, + 'data' => ['Ville' => 'Québec', 'Nom' => "Tremblay {$i}"], + 'version' => 1, + ]); + } + + $payload = [ + 'table_id' => $table->id, + 'name' => 'Rapport imprimable', + 'query' => ['select' => ['Ville', 'Nom'], 'group_by' => 'Ville'], + 'layout' => ['fields' => [ + ['name' => 'Ville', 'visible' => true, 'order' => 1], + ['name' => 'Nom', 'visible' => true, 'order' => 2], + ]], + ]; + + $html = $this->actingAs($user)->postJson('/api/v1/reports/preview/html', $payload); + $html->assertStatus(200); + $html->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + + $body = $html->getContent(); + expect($body)->toContain('Rapport imprimable'); + // The document carries its own styles, so the iframe renders it standalone. + expect($body)->toContain('report-table'); + + // Not paginated: the preview shows every record the PDF would contain. The old + // JSX preview rendered only the current preview page, so the two disagreed. + expect($body)->toContain('Tremblay 1'); + expect($body)->toContain('Tremblay 25'); + + $pdf = $this->actingAs($user)->postJson('/api/v1/reports/preview/pdf', $payload); + $pdf->assertStatus(200); + expect($pdf->headers->get('Content-Type'))->toBe('application/pdf'); +}); + +test('print preview html honours the selected card view', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + $ville = Field::factory()->create(['table_id' => $table->id, 'name' => 'Ville', 'type' => 'text']); + $nom = Field::factory()->create(['table_id' => $table->id, 'name' => 'Nom', 'type' => 'text']); + + $view = View::factory()->create([ + 'table_id' => $table->id, + 'type' => 'card', + 'config' => ['columns' => [[$ville->id], [$nom->id]]], + ]); + + Record::create([ + 'table_id' => $table->id, + 'data' => ['Ville' => 'Québec', 'Nom' => 'Tremblay'], + 'version' => 1, + ]); + + $response = $this->actingAs($user)->postJson('/api/v1/reports/preview/html', [ + 'table_id' => $table->id, + 'name' => 'Rapport carte', + 'query' => ['select' => ['Ville', 'Nom']], + 'layout' => [ + 'fields' => [ + ['name' => 'Ville', 'visible' => true, 'order' => 1], + ['name' => 'Nom', 'visible' => true, 'order' => 2], + ], + 'view_id' => $view->id, + ], + ]); + + $response->assertStatus(200); + // Card markup, not the fallback table. + expect($response->getContent())->toContain('report-card'); +}); diff --git a/apps/client/src/components/ReportPrintPreview.tsx b/apps/client/src/components/ReportPrintPreview.tsx new file mode 100644 index 0000000..bca146f --- /dev/null +++ b/apps/client/src/components/ReportPrintPreview.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '../contexts/I18nContext'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface ReportPrintPreviewProps { + tableId: string; + reportName: string; + query: unknown; + layout: unknown; +} + +/** + * Shows the report document produced by the API — the very same HTML the PDF export + * is rendered from — inside an iframe, and prints that document rather than the + * surrounding page. Previously this screen re-implemented the layout in JSX, so the + * printed result and the exported PDF drifted apart (different markup, and the + * preview was paginated while the export was not). + */ +export function ReportPrintPreview({ + tableId, + reportName, + query, + layout, +}: ReportPrintPreviewProps) { + const { t } = useI18n(); + const frameRef = useRef(null); + const [html, setHtml] = useState(null); + const [error, setError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(false); + + fetch('/api/v1/reports/preview/html', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ table_id: tableId, name: reportName, query, layout }), + }) + .then((response) => { + if (!response.ok) throw new Error('Preview failed'); + return response.text(); + }) + .then((text) => { + if (!cancelled) { + setHtml(text); + setIsLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setError(true); + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [tableId, reportName, query, layout]); + + const handlePrint = () => { + const frame = frameRef.current?.contentWindow; + if (!frame) return; + frame.focus(); + frame.print(); + }; + + return ( +
+
+