@rashidee/co2 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. package/static/index.html +24 -0
@@ -0,0 +1,1529 @@
1
+ # Reporting Patterns — Puppeteer PDF + Client-Side Export
2
+
3
+ This reference describes the reporting infrastructure for the spec. Include this content
4
+ in the Reporting section of the generated specification. All code samples must be reproduced
5
+ in the generated spec with full import statements and typed signatures.
6
+
7
+ The reporting feature provides:
8
+ - **Dual architecture** — server-side Puppeteer for pixel-perfect PDF generation, client-side
9
+ libraries for instant CSV/XLSX export
10
+ - **React layout components** rendered to static HTML via `ReactDOMServer.renderToStaticMarkup()`
11
+ and sent to a companion Node.js report service that uses Puppeteer (headless Chrome) to
12
+ produce PDF documents
13
+ - **Client-side CSV export** via PapaParse (no server round-trip)
14
+ - **Client-side XLSX export** via SheetJS `xlsx` library (no server round-trip)
15
+ - **Report definitions** — typed metadata describing each report's parameters and supported formats
16
+ - **Tailwind/Headless UI report UI** — report list page, parameter forms (React Hook Form + Zod),
17
+ preview dialog (Headless UI `Dialog`), and format selection
18
+ - **Report HTML templates** use Tailwind CSS CDN for standalone rendering inside Puppeteer
19
+ - All report layouts are programmatically developed by the AI coding agent — no manual
20
+ template authoring required
21
+
22
+ > **Note on consistency:** Unlike the MUI variant (where report layouts deliberately avoided
23
+ > MUI's Emotion runtime), this Tailwind variant uses the *same* utility-class vocabulary in
24
+ > both the app UI and the Puppeteer report HTML. The app's compiled Tailwind CSS does not reach
25
+ > the standalone Puppeteer document, so the report HTML loads Tailwind via CDN — but the classes
26
+ > you write are identical, which keeps report authoring intuitive.
27
+
28
+ ---
29
+
30
+ ## Architecture Overview
31
+
32
+ ```
33
+ React SPA (Browser) Report Service (Node.js)
34
+ ┌──────────────────────────┐ ┌──────────────────────────┐
35
+ │ ReportListPage │ │ Express.js │
36
+ │ ReportGeneratePage │ POST │ POST /api/reports/pdf │
37
+ │ ReportPreviewDialog │─────────►│ receives: HTML string │
38
+ │ useReportGeneration() │ │ renders via Puppeteer │
39
+ │ │◄─────────│ returns: PDF bytes │
40
+ │ Client-side export: │ binary │ │
41
+ │ • xlsx (XLSX) │ └──────────────────────────┘
42
+ │ • Papa Parse (CSV) │
43
+ └──────────────────────────┘
44
+ ```
45
+
46
+ **Why this architecture:**
47
+ - React is a frontend-only SPA — it cannot generate PDFs natively in the browser
48
+ - Puppeteer requires Node.js + headless Chrome (server-side only)
49
+ - The report "layout" is an HTML document that Puppeteer renders to PDF with full CSS support
50
+ - The React SPA builds report HTML from React layout components using
51
+ `ReactDOMServer.renderToStaticMarkup()`, then sends the HTML string to the report service
52
+ which returns PDF bytes as a binary download
53
+ - For CSV/XLSX, client-side libraries handle export directly in the browser with zero
54
+ server round-trip — the data is already available from TanStack Query cache
55
+
56
+ **Key design rule:** Report layout components are plain React components that use Tailwind
57
+ CSS classes (loaded via CDN in the HTML wrapper). They render as a standalone HTML document
58
+ inside Puppeteer that has no access to the application's compiled CSS bundle.
59
+
60
+ ---
61
+
62
+ ## npm Dependencies
63
+
64
+ ### React SPA (main application package.json)
65
+
66
+ ```json
67
+ {
68
+ "dependencies": {
69
+ "xlsx": "^0.18.5",
70
+ "papaparse": "^5.4.1"
71
+ },
72
+ "devDependencies": {
73
+ "@types/papaparse": "^5.3.14"
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### Report Service (separate package.json — companion service)
79
+
80
+ ```json
81
+ {
82
+ "name": "{{PROJECT_SLUG}}-report-service",
83
+ "version": "1.0.0",
84
+ "private": true,
85
+ "type": "module",
86
+ "scripts": {
87
+ "start": "node dist/server.js",
88
+ "dev": "tsx watch src/server.ts",
89
+ "build": "tsc"
90
+ },
91
+ "dependencies": {
92
+ "express": "^4.21.0",
93
+ "puppeteer": "^23.0.0",
94
+ "cors": "^2.8.5"
95
+ },
96
+ "devDependencies": {
97
+ "@types/express": "^5.0.0",
98
+ "@types/cors": "^2.8.17",
99
+ "typescript": "^5.0.0",
100
+ "tsx": "^4.0.0"
101
+ }
102
+ }
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Report Service (Node.js Express)
108
+
109
+ A lightweight Express server that accepts an HTML string and returns a rendered PDF.
110
+ This runs as a companion service alongside the Vite dev server during development, or
111
+ as a separate microservice (sidecar container) in production.
112
+
113
+ ### Server Implementation
114
+
115
+ ```typescript
116
+ // report-service/src/server.ts
117
+ import express, { type Request, type Response } from 'express';
118
+ import puppeteer, { type Browser } from 'puppeteer';
119
+ import cors from 'cors';
120
+
121
+ const app = express();
122
+ app.use(cors());
123
+ app.use(express.json({ limit: '10mb' }));
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Browser Singleton — reuse a single headless Chrome instance across requests
127
+ // ---------------------------------------------------------------------------
128
+ let browser: Browser | null = null;
129
+
130
+ async function getBrowser(): Promise<Browser> {
131
+ if (!browser || !browser.connected) {
132
+ browser = await puppeteer.launch({
133
+ headless: true,
134
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
135
+ });
136
+ }
137
+ return browser;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // PDF Generation Endpoint
142
+ // ---------------------------------------------------------------------------
143
+ interface PdfRequestBody {
144
+ html: string;
145
+ landscape?: boolean;
146
+ format?: 'A4' | 'A3' | 'Letter' | 'Legal';
147
+ filename?: string;
148
+ }
149
+
150
+ app.post('/api/reports/pdf', async (req: Request<object, Buffer, PdfRequestBody>, res: Response): Promise<void> => {
151
+ const { html, landscape = false, format = 'A4', filename = 'report.pdf' } = req.body;
152
+
153
+ if (!html || typeof html !== 'string') {
154
+ res.status(400).json({ error: 'Request body must include an "html" string.' });
155
+ return;
156
+ }
157
+
158
+ let page: Awaited<ReturnType<Browser['newPage']>> | null = null;
159
+
160
+ try {
161
+ const b = await getBrowser();
162
+ page = await b.newPage();
163
+
164
+ // Set content and wait for all network requests (e.g., Tailwind CDN) to finish
165
+ await page.setContent(html, { waitUntil: 'networkidle0' });
166
+
167
+ const pdf = await page.pdf({
168
+ format,
169
+ landscape,
170
+ printBackground: true,
171
+ margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' },
172
+ });
173
+
174
+ res.setHeader('Content-Type', 'application/pdf');
175
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
176
+ res.setHeader('Content-Length', String(pdf.byteLength));
177
+ res.send(pdf);
178
+ } catch (error) {
179
+ console.error('PDF generation failed:', error);
180
+ res.status(500).json({ error: 'PDF generation failed.' });
181
+ } finally {
182
+ if (page) await page.close();
183
+ }
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Health Check
188
+ // ---------------------------------------------------------------------------
189
+ app.get('/api/reports/health', (_req: Request, res: Response) => {
190
+ res.json({ status: 'ok' });
191
+ });
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Graceful Shutdown
195
+ // ---------------------------------------------------------------------------
196
+ const PORT = parseInt(process.env.REPORT_SERVICE_PORT ?? '3001', 10);
197
+
198
+ const server = app.listen(PORT, () => {
199
+ console.log(`Report service listening on port ${PORT}`);
200
+ });
201
+
202
+ async function gracefulShutdown(): Promise<void> {
203
+ console.log('Shutting down report service...');
204
+ server.close();
205
+ if (browser) {
206
+ await browser.close();
207
+ browser = null;
208
+ }
209
+ process.exit(0);
210
+ }
211
+
212
+ process.on('SIGTERM', gracefulShutdown);
213
+ process.on('SIGINT', gracefulShutdown);
214
+ ```
215
+
216
+ ### Vite Proxy Configuration (Development)
217
+
218
+ ```typescript
219
+ // vite.config.ts (add to existing server.proxy)
220
+ import { defineConfig } from 'vite';
221
+ import react from '@vitejs/plugin-react';
222
+ import path from 'path';
223
+
224
+ export default defineConfig({
225
+ plugins: [react()],
226
+ resolve: { alias: { '@': path.resolve(__dirname, './src') } },
227
+ server: {
228
+ port: {{DEV_PORT}},
229
+ proxy: {
230
+ '/api/v1': { target: '{{API_BASE_URL_DEV}}', changeOrigin: true },
231
+ '/api/reports': { target: 'http://localhost:3001', changeOrigin: true },
232
+ },
233
+ },
234
+ });
235
+ ```
236
+
237
+ ### Report Service Environment Variables
238
+
239
+ ```env
240
+ # report-service/.env
241
+ REPORT_SERVICE_PORT=3001
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Report Types and Interfaces
247
+
248
+ ```typescript
249
+ // src/features/reports/reports.types.ts
250
+
251
+ /**
252
+ * Metadata definition for a single report.
253
+ * Report definitions are typically provided by the backend API or
254
+ * defined as constants in the frontend for static report catalogues.
255
+ */
256
+ export interface ReportDefinition {
257
+ reportId: string;
258
+ name: string;
259
+ description: string;
260
+ domain: string;
261
+ parameters: ReportParameter[];
262
+ supportedFormats: ReportFormat[];
263
+ }
264
+
265
+ export interface ReportParameter {
266
+ name: string;
267
+ label: string;
268
+ /** Input type — maps to Tailwind/Headless UI form controls */
269
+ type: 'text' | 'date' | 'number' | 'select' | 'boolean';
270
+ required: boolean;
271
+ options?: ReportParameterOption[];
272
+ defaultValue?: string;
273
+ }
274
+
275
+ export interface ReportParameterOption {
276
+ label: string;
277
+ value: string;
278
+ }
279
+
280
+ export type ReportFormat = 'pdf' | 'xlsx' | 'csv';
281
+
282
+ export type ReportDataRow = Record<string, unknown>;
283
+
284
+ export interface ReportColumn {
285
+ key: string;
286
+ header: string;
287
+ width?: string;
288
+ align?: 'left' | 'center' | 'right';
289
+ }
290
+ ```
291
+
292
+ ---
293
+
294
+ ## Report API Hooks (TanStack Query)
295
+
296
+ ```typescript
297
+ // src/features/reports/api/reportApi.ts
298
+ import { axiosInstance } from '../../../lib/api/axiosInstance';
299
+ import type { ReportDefinition } from '../reports.types';
300
+
301
+ export const reportApi = {
302
+ list: async (): Promise<ReportDefinition[]> => {
303
+ const { data } = await axiosInstance.get<ReportDefinition[]>('/v1/reports');
304
+ return data;
305
+ },
306
+ getById: async (reportId: string): Promise<ReportDefinition> => {
307
+ const { data } = await axiosInstance.get<ReportDefinition>(`/v1/reports/${reportId}`);
308
+ return data;
309
+ },
310
+ };
311
+ ```
312
+
313
+ ```typescript
314
+ // src/features/reports/hooks/useReports.ts
315
+ import { useQuery } from '@tanstack/react-query';
316
+ import { reportApi } from '../api/reportApi';
317
+
318
+ export const reportKeys = {
319
+ all: ['reports'] as const,
320
+ lists: () => [...reportKeys.all, 'list'] as const,
321
+ list: () => [...reportKeys.lists()] as const,
322
+ details: () => [...reportKeys.all, 'detail'] as const,
323
+ detail: (reportId: string) => [...reportKeys.details(), reportId] as const,
324
+ };
325
+
326
+ export function useReportList() {
327
+ return useQuery({ queryKey: reportKeys.list(), queryFn: () => reportApi.list() });
328
+ }
329
+
330
+ export function useReportDetails(reportId: string) {
331
+ return useQuery({
332
+ queryKey: reportKeys.detail(reportId),
333
+ queryFn: () => reportApi.getById(reportId),
334
+ enabled: !!reportId,
335
+ });
336
+ }
337
+ ```
338
+
339
+ ---
340
+
341
+ ## useReportGeneration Hook
342
+
343
+ Custom hook that orchestrates report generation across all three formats. For PDF it
344
+ builds HTML from a React layout component and sends it to the report service. For XLSX
345
+ and CSV it generates the file entirely in the browser.
346
+
347
+ ```typescript
348
+ // src/features/reports/hooks/useReportGeneration.ts
349
+ import { useState, useCallback } from 'react';
350
+ import axios from 'axios';
351
+ import { exportToXlsx } from '../utils/exportXlsx';
352
+ import { exportToCsv } from '../utils/exportCsv';
353
+ import { useNotificationStore } from '../../../store/notification.store';
354
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
355
+
356
+ interface UseReportGenerationReturn {
357
+ generatePdf: (
358
+ html: string,
359
+ filename: string,
360
+ options?: { landscape?: boolean; format?: 'A4' | 'A3' | 'Letter' | 'Legal' },
361
+ ) => Promise<void>;
362
+ generateXlsx: (data: ReportDataRow[], columns: ReportColumn[], filename: string) => void;
363
+ generateCsv: (data: ReportDataRow[], columns: ReportColumn[], filename: string) => void;
364
+ isGenerating: boolean;
365
+ }
366
+
367
+ export function useReportGeneration(): UseReportGenerationReturn {
368
+ const [isGenerating, setIsGenerating] = useState(false);
369
+ const push = useNotificationStore((s) => s.push);
370
+
371
+ // PDF — send HTML to the Puppeteer report service, download the binary response
372
+ const generatePdf = useCallback(
373
+ async (
374
+ html: string,
375
+ filename: string,
376
+ options?: { landscape?: boolean; format?: 'A4' | 'A3' | 'Letter' | 'Legal' },
377
+ ): Promise<void> => {
378
+ setIsGenerating(true);
379
+ try {
380
+ const response = await axios.post(
381
+ '/api/reports/pdf',
382
+ { html, landscape: options?.landscape ?? false, format: options?.format ?? 'A4', filename },
383
+ { responseType: 'blob' },
384
+ );
385
+
386
+ const blob = new Blob([response.data], { type: 'application/pdf' });
387
+ const url = URL.createObjectURL(blob);
388
+ const link = document.createElement('a');
389
+ link.href = url;
390
+ link.download = filename;
391
+ document.body.appendChild(link);
392
+ link.click();
393
+ document.body.removeChild(link);
394
+ URL.revokeObjectURL(url);
395
+
396
+ push('success', `Report "${filename}" downloaded successfully.`);
397
+ } catch (error) {
398
+ const message = error instanceof Error ? error.message : 'PDF generation failed.';
399
+ push('error', message);
400
+ throw error;
401
+ } finally {
402
+ setIsGenerating(false);
403
+ }
404
+ },
405
+ [push],
406
+ );
407
+
408
+ // XLSX — generate entirely in the browser using the xlsx library
409
+ const generateXlsx = useCallback(
410
+ (data: ReportDataRow[], columns: ReportColumn[], filename: string): void => {
411
+ try {
412
+ exportToXlsx(data, columns, filename);
413
+ push('success', `Report "${filename}" downloaded successfully.`);
414
+ } catch (error) {
415
+ push('error', error instanceof Error ? error.message : 'XLSX export failed.');
416
+ }
417
+ },
418
+ [push],
419
+ );
420
+
421
+ // CSV — generate entirely in the browser using PapaParse
422
+ const generateCsv = useCallback(
423
+ (data: ReportDataRow[], columns: ReportColumn[], filename: string): void => {
424
+ try {
425
+ exportToCsv(data, columns, filename);
426
+ push('success', `Report "${filename}" downloaded successfully.`);
427
+ } catch (error) {
428
+ push('error', error instanceof Error ? error.message : 'CSV export failed.');
429
+ }
430
+ },
431
+ [push],
432
+ );
433
+
434
+ return { generatePdf, generateXlsx, generateCsv, isGenerating };
435
+ }
436
+ ```
437
+
438
+ ---
439
+
440
+ ## Client-Side Export Utilities
441
+
442
+ ### XLSX Export
443
+
444
+ ```typescript
445
+ // src/features/reports/utils/exportXlsx.ts
446
+ import * as XLSX from 'xlsx';
447
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
448
+
449
+ export function exportToXlsx(data: ReportDataRow[], columns: ReportColumn[], filename: string): void {
450
+ const headers = columns.map((col) => col.header);
451
+ const rows = data.map((row) =>
452
+ columns.map((col) => {
453
+ const value = row[col.key];
454
+ if (value === null || value === undefined) return '';
455
+ return String(value);
456
+ }),
457
+ );
458
+
459
+ const worksheetData = [headers, ...rows];
460
+ const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
461
+
462
+ worksheet['!cols'] = columns.map((col, index) => {
463
+ const maxLength = Math.max(col.header.length, ...rows.map((row) => String(row[index] ?? '').length));
464
+ return { wch: Math.min(maxLength + 2, 50) };
465
+ });
466
+
467
+ for (let colIdx = 0; colIdx < headers.length; colIdx++) {
468
+ const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx });
469
+ if (worksheet[cellRef]) worksheet[cellRef].s = { font: { bold: true } };
470
+ }
471
+
472
+ const workbook = XLSX.utils.book_new();
473
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Report');
474
+ XLSX.writeFile(workbook, filename);
475
+ }
476
+ ```
477
+
478
+ ### CSV Export
479
+
480
+ ```typescript
481
+ // src/features/reports/utils/exportCsv.ts
482
+ import Papa from 'papaparse';
483
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
484
+
485
+ export function exportToCsv(data: ReportDataRow[], columns: ReportColumn[], filename: string): void {
486
+ const transformedData = data.map((row) => {
487
+ const ordered: Record<string, string> = {};
488
+ for (const col of columns) {
489
+ const value = row[col.key];
490
+ ordered[col.header] = value === null || value === undefined ? '' : String(value);
491
+ }
492
+ return ordered;
493
+ });
494
+
495
+ const csv = Papa.unparse(transformedData, { quotes: true, header: true });
496
+
497
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
498
+ const url = URL.createObjectURL(blob);
499
+ const link = document.createElement('a');
500
+ link.href = url;
501
+ link.download = filename;
502
+ document.body.appendChild(link);
503
+ link.click();
504
+ document.body.removeChild(link);
505
+ URL.revokeObjectURL(url);
506
+ }
507
+ ```
508
+
509
+ ---
510
+
511
+ ## Report Layout Components
512
+
513
+ Report layouts are React components that render report content as plain HTML for Puppeteer.
514
+ They use Tailwind CSS classes (loaded via CDN in the HTML wrapper).
515
+
516
+ ### renderReportHtml Utility
517
+
518
+ ```typescript
519
+ // src/features/reports/layouts/renderReportHtml.ts
520
+ import { renderToStaticMarkup } from 'react-dom/server';
521
+ import type { ReactElement } from 'react';
522
+
523
+ /**
524
+ * Render a React element to a complete HTML document string suitable for Puppeteer.
525
+ * The output includes Tailwind CSS via CDN and print-optimized CSS.
526
+ */
527
+ export function renderReportHtml(element: ReactElement, title: string): string {
528
+ const body = renderToStaticMarkup(element);
529
+
530
+ return `<!DOCTYPE html>
531
+ <html lang="en">
532
+ <head>
533
+ <meta charset="utf-8">
534
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
535
+ <title>${escapeHtml(title)}</title>
536
+ <script src="https://cdn.tailwindcss.com"></script>
537
+ <style>
538
+ @page { margin: 0; }
539
+ body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
540
+ tr { page-break-inside: avoid; }
541
+ .page-break { page-break-after: always; }
542
+ </style>
543
+ </head>
544
+ <body class="bg-white text-gray-900 text-sm p-8">
545
+ ${body}
546
+ </body>
547
+ </html>`;
548
+ }
549
+
550
+ function escapeHtml(text: string): string {
551
+ return text
552
+ .replace(/&/g, '&amp;')
553
+ .replace(/</g, '&lt;')
554
+ .replace(/>/g, '&gt;')
555
+ .replace(/"/g, '&quot;');
556
+ }
557
+ ```
558
+
559
+ ### Base Report Layout
560
+
561
+ ```tsx
562
+ // src/features/reports/layouts/ReportLayout.tsx
563
+ import type { ReactNode } from 'react';
564
+
565
+ interface ReportLayoutProps {
566
+ title: string;
567
+ subtitle?: string;
568
+ generatedAt: string;
569
+ children: ReactNode;
570
+ }
571
+
572
+ /**
573
+ * Base report layout with header, title, and footer.
574
+ * Uses Tailwind CSS classes (rendered via CDN in the HTML wrapper).
575
+ */
576
+ export function ReportLayout({ title, subtitle, generatedAt, children }: ReportLayoutProps) {
577
+ const formattedDate = new Date(generatedAt).toLocaleString('en-US', {
578
+ year: 'numeric',
579
+ month: 'long',
580
+ day: 'numeric',
581
+ hour: '2-digit',
582
+ minute: '2-digit',
583
+ });
584
+
585
+ return (
586
+ <div className="min-h-screen flex flex-col">
587
+ <header className="border-b border-gray-300 pb-4 mb-6">
588
+ <div className="flex justify-between items-start">
589
+ <div>
590
+ <h1 className="text-xl font-bold text-gray-900">{title}</h1>
591
+ {subtitle && <p className="text-sm text-gray-600 mt-1">{subtitle}</p>}
592
+ </div>
593
+ <div className="text-right text-xs text-gray-500">
594
+ <p>Generated: {formattedDate}</p>
595
+ </div>
596
+ </div>
597
+ </header>
598
+
599
+ <main className="flex-1">{children}</main>
600
+
601
+ <footer className="border-t border-gray-300 pt-3 mt-8 text-xs text-gray-400 text-center">
602
+ <p>{title} &mdash; Generated on {formattedDate}</p>
603
+ </footer>
604
+ </div>
605
+ );
606
+ }
607
+ ```
608
+
609
+ ### Table Report Layout
610
+
611
+ ```tsx
612
+ // src/features/reports/layouts/TableReportLayout.tsx
613
+ import { ReportLayout } from './ReportLayout';
614
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
615
+
616
+ interface TableReportLayoutProps {
617
+ title: string;
618
+ subtitle?: string;
619
+ generatedAt: string;
620
+ columns: ReportColumn[];
621
+ data: ReportDataRow[];
622
+ showTotalCount?: boolean;
623
+ }
624
+
625
+ export function TableReportLayout({
626
+ title,
627
+ subtitle,
628
+ generatedAt,
629
+ columns,
630
+ data,
631
+ showTotalCount = true,
632
+ }: TableReportLayoutProps) {
633
+ return (
634
+ <ReportLayout title={title} subtitle={subtitle} generatedAt={generatedAt}>
635
+ <table className="w-full border-collapse text-xs">
636
+ <thead>
637
+ <tr>
638
+ {columns.map((col) => (
639
+ <th
640
+ key={col.key}
641
+ className="border border-gray-300 bg-gray-100 px-3 py-2 font-semibold text-left"
642
+ style={{ width: col.width ?? 'auto', textAlign: col.align ?? 'left' }}
643
+ >
644
+ {col.header}
645
+ </th>
646
+ ))}
647
+ </tr>
648
+ </thead>
649
+ <tbody>
650
+ {data.length === 0 ? (
651
+ <tr>
652
+ <td colSpan={columns.length} className="border border-gray-300 px-3 py-8 text-center text-gray-500 italic">
653
+ No data available for the selected parameters.
654
+ </td>
655
+ </tr>
656
+ ) : (
657
+ data.map((row, rowIndex) => (
658
+ <tr key={rowIndex} className={rowIndex % 2 === 1 ? 'bg-gray-50' : ''}>
659
+ {columns.map((col) => (
660
+ <td key={col.key} className="border border-gray-300 px-3 py-1.5" style={{ textAlign: col.align ?? 'left' }}>
661
+ {row[col.key] !== null && row[col.key] !== undefined ? String(row[col.key]) : ''}
662
+ </td>
663
+ ))}
664
+ </tr>
665
+ ))
666
+ )}
667
+ </tbody>
668
+ </table>
669
+
670
+ {showTotalCount && data.length > 0 && (
671
+ <p className="mt-3 text-xs text-gray-500">Total records: {data.length}</p>
672
+ )}
673
+ </ReportLayout>
674
+ );
675
+ }
676
+ ```
677
+
678
+ ---
679
+
680
+ ## Report Parameter Schema (Zod)
681
+
682
+ ```typescript
683
+ // src/features/reports/reports.schema.ts
684
+ import { z } from 'zod';
685
+ import type { ReportParameter, ReportFormat } from './reports.types';
686
+
687
+ export function buildReportParameterSchema(
688
+ parameters: ReportParameter[],
689
+ ): z.ZodObject<Record<string, z.ZodTypeAny>> {
690
+ const shape: Record<string, z.ZodTypeAny> = {};
691
+
692
+ for (const param of parameters) {
693
+ let fieldSchema: z.ZodTypeAny;
694
+
695
+ switch (param.type) {
696
+ case 'number':
697
+ fieldSchema = z.coerce.number();
698
+ break;
699
+ case 'boolean':
700
+ fieldSchema = z.boolean();
701
+ break;
702
+ case 'text':
703
+ case 'date':
704
+ case 'select':
705
+ default:
706
+ fieldSchema = z.string();
707
+ }
708
+
709
+ if (param.required) {
710
+ if (param.type === 'boolean') {
711
+ shape[param.name] = fieldSchema;
712
+ } else {
713
+ shape[param.name] = (fieldSchema as z.ZodString).min(1, `${param.label} is required`);
714
+ }
715
+ } else {
716
+ shape[param.name] = fieldSchema.optional();
717
+ }
718
+ }
719
+
720
+ return z.object(shape);
721
+ }
722
+
723
+ export const reportGenerateSchema = z.object({
724
+ format: z.enum(['pdf', 'xlsx', 'csv'] as const satisfies readonly ReportFormat[]),
725
+ });
726
+
727
+ export type ReportGenerateFormValues = z.infer<typeof reportGenerateSchema>;
728
+ ```
729
+
730
+ ---
731
+
732
+ ## Report Pages (Tailwind + Headless UI)
733
+
734
+ ### Report List Page
735
+
736
+ ```tsx
737
+ // src/features/reports/pages/ReportListPage.tsx
738
+ import { useState, useMemo } from 'react';
739
+ import { useNavigate } from 'react-router-dom';
740
+ import { MagnifyingGlassIcon, DocumentTextIcon, TableCellsIcon, DocumentIcon } from '@heroicons/react/24/outline';
741
+ import { Button } from '../../../shared/components/Button';
742
+ import { Card } from '../../../shared/components/Card';
743
+ import { PageHeader } from '../../../shared/components/PageHeader';
744
+ import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
745
+ import { EmptyState } from '../../../shared/components/EmptyState';
746
+ import { StatusBadge } from '../../../shared/components/StatusBadge';
747
+ import { useReportList } from '../hooks/useReports';
748
+ import type { ReportFormat } from '../reports.types';
749
+
750
+ const FORMAT_ICON: Record<ReportFormat, typeof DocumentTextIcon> = {
751
+ pdf: DocumentTextIcon,
752
+ xlsx: TableCellsIcon,
753
+ csv: DocumentIcon,
754
+ };
755
+
756
+ const FORMAT_VARIANT: Record<ReportFormat, 'danger' | 'success' | 'primary'> = {
757
+ pdf: 'danger',
758
+ xlsx: 'success',
759
+ csv: 'primary',
760
+ };
761
+
762
+ export function ReportListPage() {
763
+ const navigate = useNavigate();
764
+ const { data: reports, isLoading, isError } = useReportList();
765
+
766
+ const [search, setSearch] = useState('');
767
+ const [domainFilter, setDomainFilter] = useState('');
768
+
769
+ const domains = useMemo(() => {
770
+ if (!reports) return [];
771
+ return [...new Set(reports.map((r) => r.domain))].sort();
772
+ }, [reports]);
773
+
774
+ const filteredReports = useMemo(() => {
775
+ if (!reports) return [];
776
+ return reports.filter((report) => {
777
+ const matchesSearch =
778
+ !search ||
779
+ report.name.toLowerCase().includes(search.toLowerCase()) ||
780
+ report.description.toLowerCase().includes(search.toLowerCase());
781
+ const matchesDomain = !domainFilter || report.domain === domainFilter;
782
+ return matchesSearch && matchesDomain;
783
+ });
784
+ }, [reports, search, domainFilter]);
785
+
786
+ const groupedReports = useMemo(() => {
787
+ const groups: Record<string, typeof filteredReports> = {};
788
+ for (const report of filteredReports) {
789
+ (groups[report.domain] ??= []).push(report);
790
+ }
791
+ return groups;
792
+ }, [filteredReports]);
793
+
794
+ if (isLoading) return <LoadingOverlay />;
795
+ if (isError) return <p className="text-danger">Failed to load reports.</p>;
796
+
797
+ return (
798
+ <div>
799
+ <PageHeader title="Reports" breadcrumbs={[{ label: 'Dashboard', path: '/dashboard' }, { label: 'Reports' }]} />
800
+
801
+ {/* Filters */}
802
+ <div className="mb-6 flex flex-wrap gap-3">
803
+ <div className="relative">
804
+ <MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
805
+ <input
806
+ value={search}
807
+ onChange={(e) => setSearch(e.target.value)}
808
+ placeholder="Search reports…"
809
+ className="w-72 rounded border border-slate-300 bg-surface py-2 pl-9 pr-3 text-sm focus:border-primary focus:ring-1 focus:ring-primary dark:border-slate-600"
810
+ />
811
+ </div>
812
+ <select
813
+ value={domainFilter}
814
+ onChange={(e) => setDomainFilter(e.target.value)}
815
+ className="rounded border border-slate-300 bg-surface px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary dark:border-slate-600"
816
+ >
817
+ <option value="">All Domains</option>
818
+ {domains.map((domain) => (
819
+ <option key={domain} value={domain}>
820
+ {domain}
821
+ </option>
822
+ ))}
823
+ </select>
824
+ </div>
825
+
826
+ {filteredReports.length === 0 ? (
827
+ <EmptyState message="No reports match your search criteria." />
828
+ ) : (
829
+ Object.entries(groupedReports).map(([domain, domainReports]) => (
830
+ <section key={domain} className="mb-8">
831
+ <h2 className="mb-3 text-base font-semibold text-slate-800 dark:text-slate-100">{domain}</h2>
832
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
833
+ {domainReports.map((report) => (
834
+ <Card key={report.reportId} className="flex flex-col p-4">
835
+ <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{report.name}</h3>
836
+ <p className="mt-1 flex-1 text-sm text-slate-600 dark:text-slate-300">{report.description}</p>
837
+ <div className="mt-3 flex flex-wrap gap-1">
838
+ {report.supportedFormats.map((format) => {
839
+ const Icon = FORMAT_ICON[format];
840
+ return (
841
+ <span
842
+ key={format}
843
+ className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200"
844
+ >
845
+ <Icon className="h-3.5 w-3.5" />
846
+ {format.toUpperCase()}
847
+ </span>
848
+ );
849
+ })}
850
+ </div>
851
+ <div className="mt-4">
852
+ <Button size="sm" onClick={() => navigate(`/reports/${report.reportId}`)}>
853
+ Generate
854
+ </Button>
855
+ </div>
856
+ </Card>
857
+ ))}
858
+ </div>
859
+ </section>
860
+ ))
861
+ )}
862
+ </div>
863
+ );
864
+ }
865
+ ```
866
+
867
+ ### Report Generate Page
868
+
869
+ ```tsx
870
+ // src/features/reports/pages/ReportGeneratePage.tsx
871
+ import { useState, useMemo, useCallback } from 'react';
872
+ import { useParams, useNavigate } from 'react-router-dom';
873
+ import { useForm } from 'react-hook-form';
874
+ import { zodResolver } from '@hookform/resolvers/zod';
875
+ import { ArrowLeftIcon, EyeIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
876
+ import { Button } from '../../../shared/components/Button';
877
+ import { Card } from '../../../shared/components/Card';
878
+ import { PageHeader } from '../../../shared/components/PageHeader';
879
+ import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
880
+ import { TextFieldController } from '../../../shared/form/TextFieldController';
881
+ import { SelectController } from '../../../shared/form/SelectController';
882
+ import { useReportDetails } from '../hooks/useReports';
883
+ import { useReportGeneration } from '../hooks/useReportGeneration';
884
+ import { buildReportParameterSchema } from '../reports.schema';
885
+ import { ReportPreviewDialog } from '../components/ReportPreviewDialog';
886
+ import { cn } from '../../../lib/utils/cn';
887
+ import type { ReportFormat, ReportParameter } from '../reports.types';
888
+
889
+ export function ReportGeneratePage() {
890
+ const { reportId } = useParams<{ reportId: string }>();
891
+ const navigate = useNavigate();
892
+ const { data: report, isLoading } = useReportDetails(reportId ?? '');
893
+ const { generatePdf, isGenerating } = useReportGeneration();
894
+
895
+ const [selectedFormat, setSelectedFormat] = useState<ReportFormat>('pdf');
896
+ const [previewOpen, setPreviewOpen] = useState(false);
897
+ const [previewHtml, setPreviewHtml] = useState('');
898
+
899
+ const parameterSchema = useMemo(
900
+ () => (report ? buildReportParameterSchema(report.parameters) : null),
901
+ [report],
902
+ );
903
+
904
+ const { control, handleSubmit } = useForm({
905
+ resolver: parameterSchema ? zodResolver(parameterSchema) : undefined,
906
+ defaultValues: useMemo(() => {
907
+ if (!report) return {};
908
+ const defaults: Record<string, string> = {};
909
+ for (const param of report.parameters) defaults[param.name] = param.defaultValue ?? '';
910
+ return defaults;
911
+ }, [report]),
912
+ });
913
+
914
+ /**
915
+ * Build the report HTML for PDF generation or preview.
916
+ * Each report implementation provides its own buildReportHtml that:
917
+ * 1. Fetches data from the API with the parameters
918
+ * 2. Renders a layout component (TableReportLayout, etc.) with the data
919
+ * 3. Returns the HTML string via renderReportHtml()
920
+ */
921
+ const buildReportHtml = useCallback(async (_parameters: Record<string, unknown>): Promise<string> => {
922
+ throw new Error('buildReportHtml must be implemented for each report. See Module Integration.');
923
+ }, []);
924
+
925
+ const handleGenerate = handleSubmit(async (values) => {
926
+ if (!report) return;
927
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
928
+ const baseFilename = `${report.reportId}-${timestamp}`;
929
+ if (selectedFormat === 'pdf') {
930
+ const html = await buildReportHtml(values);
931
+ await generatePdf(html, `${baseFilename}.pdf`);
932
+ }
933
+ // xlsx/csv handled by the module-specific report hook (see Module Integration)
934
+ });
935
+
936
+ const handlePreview = handleSubmit(async (values) => {
937
+ const html = await buildReportHtml(values);
938
+ setPreviewHtml(html);
939
+ setPreviewOpen(true);
940
+ });
941
+
942
+ if (isLoading || !report) return <LoadingOverlay />;
943
+
944
+ return (
945
+ <div>
946
+ <PageHeader
947
+ title={report.name}
948
+ breadcrumbs={[
949
+ { label: 'Dashboard', path: '/dashboard' },
950
+ { label: 'Reports', path: '/reports' },
951
+ { label: report.name },
952
+ ]}
953
+ actions={
954
+ <Button variant="outline" onClick={() => navigate('/reports')}>
955
+ <ArrowLeftIcon className="h-4 w-4" />
956
+ Back to Reports
957
+ </Button>
958
+ }
959
+ />
960
+
961
+ <Card className="max-w-2xl p-6">
962
+ <p className="mb-6 text-sm text-slate-600 dark:text-slate-300">{report.description}</p>
963
+
964
+ {/* Parameter Form */}
965
+ {report.parameters.length > 0 && (
966
+ <>
967
+ <h3 className="mb-3 text-sm font-semibold text-slate-800 dark:text-slate-100">Report Parameters</h3>
968
+ <div className="mb-6 space-y-4">
969
+ {report.parameters.map((param: ReportParameter) =>
970
+ param.type === 'select' && param.options ? (
971
+ <SelectController
972
+ key={param.name}
973
+ name={param.name}
974
+ control={control}
975
+ label={param.label}
976
+ required={param.required}
977
+ options={param.options.map((o) => ({ value: o.value, label: o.label }))}
978
+ />
979
+ ) : (
980
+ <TextFieldController
981
+ key={param.name}
982
+ name={param.name}
983
+ control={control}
984
+ label={param.label}
985
+ required={param.required}
986
+ type={param.type === 'number' ? 'number' : 'text'}
987
+ />
988
+ ),
989
+ )}
990
+ </div>
991
+ <hr className="mb-6 border-slate-200 dark:border-slate-700" />
992
+ </>
993
+ )}
994
+
995
+ {/* Format Selection */}
996
+ <h3 className="mb-3 text-sm font-semibold text-slate-800 dark:text-slate-100">Export Format</h3>
997
+ <div className="mb-6 inline-flex rounded border border-slate-300 p-0.5 dark:border-slate-600">
998
+ {report.supportedFormats.map((format) => (
999
+ <button
1000
+ key={format}
1001
+ type="button"
1002
+ onClick={() => setSelectedFormat(format)}
1003
+ className={cn(
1004
+ 'rounded px-4 py-1.5 text-sm font-medium transition-colors',
1005
+ selectedFormat === format
1006
+ ? 'bg-primary text-white'
1007
+ : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700',
1008
+ )}
1009
+ >
1010
+ {format.toUpperCase()}
1011
+ </button>
1012
+ ))}
1013
+ </div>
1014
+
1015
+ {/* Action Buttons */}
1016
+ <div className="flex gap-3">
1017
+ {selectedFormat === 'pdf' && (
1018
+ <Button variant="outline" onClick={handlePreview} disabled={isGenerating}>
1019
+ <EyeIcon className="h-4 w-4" />
1020
+ Preview
1021
+ </Button>
1022
+ )}
1023
+ <Button onClick={handleGenerate} loading={isGenerating}>
1024
+ <ArrowDownTrayIcon className="h-4 w-4" />
1025
+ Generate {selectedFormat.toUpperCase()}
1026
+ </Button>
1027
+ </div>
1028
+ </Card>
1029
+
1030
+ <ReportPreviewDialog
1031
+ open={previewOpen}
1032
+ onClose={() => setPreviewOpen(false)}
1033
+ html={previewHtml}
1034
+ title={report.name}
1035
+ />
1036
+ </div>
1037
+ );
1038
+ }
1039
+ ```
1040
+
1041
+ ---
1042
+
1043
+ ## Report Preview Dialog (Headless UI Dialog)
1044
+
1045
+ ```tsx
1046
+ // src/features/reports/components/ReportPreviewDialog.tsx
1047
+ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
1048
+ import { XMarkIcon } from '@heroicons/react/24/outline';
1049
+ import { Button } from '../../../shared/components/Button';
1050
+
1051
+ interface ReportPreviewDialogProps {
1052
+ open: boolean;
1053
+ onClose: () => void;
1054
+ html: string;
1055
+ title: string;
1056
+ }
1057
+
1058
+ /**
1059
+ * Full-screen dialog that renders a report HTML preview inside an iframe.
1060
+ * The iframe uses a data URI to display the report without a network request.
1061
+ */
1062
+ export function ReportPreviewDialog({ open, onClose, html, title }: ReportPreviewDialogProps) {
1063
+ const dataUri = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
1064
+
1065
+ return (
1066
+ <Dialog open={open} onClose={onClose} className="relative z-[1000]">
1067
+ <DialogBackdrop className="fixed inset-0 bg-black/50" />
1068
+ <div className="fixed inset-0 flex flex-col">
1069
+ <DialogPanel className="flex h-full w-full flex-col bg-surface">
1070
+ <div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
1071
+ <DialogTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100">
1072
+ Preview: {title}
1073
+ </DialogTitle>
1074
+ <button
1075
+ type="button"
1076
+ onClick={onClose}
1077
+ aria-label="Close"
1078
+ className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
1079
+ >
1080
+ <XMarkIcon className="h-5 w-5" />
1081
+ </button>
1082
+ </div>
1083
+ <iframe src={dataUri} title={`Preview: ${title}`} className="flex-1 border-0 bg-white" />
1084
+ <div className="flex justify-end border-t border-slate-200 px-4 py-3 dark:border-slate-700">
1085
+ <Button variant="outline" onClick={onClose}>
1086
+ Close
1087
+ </Button>
1088
+ </div>
1089
+ </DialogPanel>
1090
+ </div>
1091
+ </Dialog>
1092
+ );
1093
+ }
1094
+ ```
1095
+
1096
+ ---
1097
+
1098
+ ## Zustand Report Store (Optional)
1099
+
1100
+ ```typescript
1101
+ // src/features/reports/store/report.store.ts
1102
+ import { create } from 'zustand';
1103
+ import { persist } from 'zustand/middleware';
1104
+ import type { ReportFormat } from '../reports.types';
1105
+
1106
+ interface RecentReport {
1107
+ reportId: string;
1108
+ name: string;
1109
+ format: ReportFormat;
1110
+ generatedAt: string;
1111
+ }
1112
+
1113
+ interface ReportState {
1114
+ lastFormat: ReportFormat;
1115
+ recentReports: RecentReport[];
1116
+ setLastFormat: (format: ReportFormat) => void;
1117
+ addRecentReport: (report: RecentReport) => void;
1118
+ clearRecent: () => void;
1119
+ }
1120
+
1121
+ const MAX_RECENT = 10;
1122
+
1123
+ export const useReportStore = create<ReportState>()(
1124
+ persist(
1125
+ (set) => ({
1126
+ lastFormat: 'pdf',
1127
+ recentReports: [],
1128
+ setLastFormat: (format) => set({ lastFormat: format }),
1129
+ addRecentReport: (report) =>
1130
+ set((state) => ({ recentReports: [report, ...state.recentReports].slice(0, MAX_RECENT) })),
1131
+ clearRecent: () => set({ recentReports: [] }),
1132
+ }),
1133
+ { name: 'report-preferences' },
1134
+ ),
1135
+ );
1136
+ ```
1137
+
1138
+ ---
1139
+
1140
+ ## React Router Integration
1141
+
1142
+ ```typescript
1143
+ // Add to src/router/index.tsx
1144
+ import { lazy } from 'react';
1145
+
1146
+ const ReportListPage = lazy(() =>
1147
+ import('../features/reports/pages/ReportListPage').then((m) => ({ default: m.ReportListPage })),
1148
+ );
1149
+ const ReportGeneratePage = lazy(() =>
1150
+ import('../features/reports/pages/ReportGeneratePage').then((m) => ({ default: m.ReportGeneratePage })),
1151
+ );
1152
+
1153
+ // Inside the protected dashboard routes children array:
1154
+ // { path: '/reports', element: <SuspenseWrapper><ReportListPage /></SuspenseWrapper> },
1155
+ // { path: '/reports/:reportId', element: <SuspenseWrapper><ReportGeneratePage /></SuspenseWrapper> },
1156
+ ```
1157
+
1158
+ Navigation item for the sidebar:
1159
+
1160
+ ```typescript
1161
+ // Add to src/shared/components/navigationConfig.ts
1162
+ import { ChartBarIcon } from '@heroicons/react/24/outline';
1163
+ // { label: 'Reports', path: '/reports', icon: ChartBarIcon },
1164
+ ```
1165
+
1166
+ ---
1167
+
1168
+ ## Module Integration — Complete Sample Report
1169
+
1170
+ This section demonstrates how a module defines and registers a report. The example shows
1171
+ a Staff List Report within a hypothetical `staff` feature module.
1172
+
1173
+ ### Step 1: Report Data API
1174
+
1175
+ ```typescript
1176
+ // src/features/staff/api/staffApi.ts (add report data endpoint)
1177
+ import { axiosInstance } from '../../../lib/api/axiosInstance';
1178
+ import type { ReportDataRow } from '../../reports/reports.types';
1179
+
1180
+ export const staffApi = {
1181
+ // ... existing CRUD methods ...
1182
+
1183
+ getReportData: async (params: { department?: string; status?: string }): Promise<ReportDataRow[]> => {
1184
+ const { data } = await axiosInstance.get<ReportDataRow[]>('/v1/staff/report', { params });
1185
+ return data;
1186
+ },
1187
+ };
1188
+ ```
1189
+
1190
+ ### Step 2: Report Definition Constant
1191
+
1192
+ ```typescript
1193
+ // src/features/staff/reports/staffListReport.ts
1194
+ import type { ReportDefinition, ReportColumn } from '../../reports/reports.types';
1195
+
1196
+ export const STAFF_LIST_REPORT: ReportDefinition = {
1197
+ reportId: 'staff-list-report',
1198
+ name: 'Staff List Report',
1199
+ description: 'List all staff members with their department, position, and status.',
1200
+ domain: 'Human Resources',
1201
+ parameters: [
1202
+ {
1203
+ name: 'department',
1204
+ label: 'Department',
1205
+ type: 'select',
1206
+ required: false,
1207
+ options: [
1208
+ { label: 'Engineering', value: 'ENGINEERING' },
1209
+ { label: 'Marketing', value: 'MARKETING' },
1210
+ { label: 'Sales', value: 'SALES' },
1211
+ { label: 'Human Resources', value: 'HR' },
1212
+ ],
1213
+ },
1214
+ {
1215
+ name: 'status',
1216
+ label: 'Status',
1217
+ type: 'select',
1218
+ required: false,
1219
+ options: [
1220
+ { label: 'Active', value: 'ACTIVE' },
1221
+ { label: 'Inactive', value: 'INACTIVE' },
1222
+ { label: 'On Leave', value: 'ON_LEAVE' },
1223
+ ],
1224
+ },
1225
+ ],
1226
+ supportedFormats: ['pdf', 'xlsx', 'csv'],
1227
+ };
1228
+
1229
+ export const STAFF_LIST_COLUMNS: ReportColumn[] = [
1230
+ { key: 'name', header: 'Name', width: '20%' },
1231
+ { key: 'email', header: 'Email', width: '22%' },
1232
+ { key: 'department', header: 'Department', width: '15%' },
1233
+ { key: 'position', header: 'Position', width: '18%' },
1234
+ { key: 'status', header: 'Status', width: '10%' },
1235
+ { key: 'joinedDate', header: 'Joined Date', width: '15%', align: 'center' },
1236
+ ];
1237
+ ```
1238
+
1239
+ ### Step 3: Report Layout Component
1240
+
1241
+ ```tsx
1242
+ // src/features/staff/reports/StaffListReportLayout.tsx
1243
+ import { TableReportLayout } from '../../reports/layouts/TableReportLayout';
1244
+ import { STAFF_LIST_COLUMNS } from './staffListReport';
1245
+ import type { ReportDataRow } from '../../reports/reports.types';
1246
+
1247
+ interface StaffListReportLayoutProps {
1248
+ data: ReportDataRow[];
1249
+ parameters: { department?: string; status?: string };
1250
+ generatedAt: string;
1251
+ }
1252
+
1253
+ export function StaffListReportLayout({ data, parameters, generatedAt }: StaffListReportLayoutProps) {
1254
+ const subtitle =
1255
+ [parameters.department && `Department: ${parameters.department}`, parameters.status && `Status: ${parameters.status}`]
1256
+ .filter(Boolean)
1257
+ .join(' | ') || 'All Staff';
1258
+
1259
+ return (
1260
+ <TableReportLayout
1261
+ title="Staff List Report"
1262
+ subtitle={subtitle}
1263
+ generatedAt={generatedAt}
1264
+ columns={STAFF_LIST_COLUMNS}
1265
+ data={data}
1266
+ showTotalCount
1267
+ />
1268
+ );
1269
+ }
1270
+ ```
1271
+
1272
+ ### Step 4: Report Generation Hook (Module-Specific)
1273
+
1274
+ ```typescript
1275
+ // src/features/staff/reports/useStaffListReport.ts
1276
+ import { useCallback } from 'react';
1277
+ import { staffApi } from '../api/staffApi';
1278
+ import { useReportGeneration } from '../../reports/hooks/useReportGeneration';
1279
+ import { renderReportHtml } from '../../reports/layouts/renderReportHtml';
1280
+ import { StaffListReportLayout } from './StaffListReportLayout';
1281
+ import { STAFF_LIST_COLUMNS } from './staffListReport';
1282
+ import type { ReportFormat } from '../../reports/reports.types';
1283
+
1284
+ interface UseStaffListReportReturn {
1285
+ generate: (parameters: { department?: string; status?: string }, format: ReportFormat) => Promise<void>;
1286
+ preview: (parameters: { department?: string; status?: string }) => Promise<string>;
1287
+ isGenerating: boolean;
1288
+ }
1289
+
1290
+ export function useStaffListReport(): UseStaffListReportReturn {
1291
+ const { generatePdf, generateXlsx, generateCsv, isGenerating } = useReportGeneration();
1292
+
1293
+ const fetchData = useCallback(
1294
+ (parameters: { department?: string; status?: string }) =>
1295
+ staffApi.getReportData({ department: parameters.department || undefined, status: parameters.status || undefined }),
1296
+ [],
1297
+ );
1298
+
1299
+ const buildHtml = useCallback(
1300
+ async (parameters: { department?: string; status?: string }): Promise<string> => {
1301
+ const data = await fetchData(parameters);
1302
+ const element = <StaffListReportLayout data={data} parameters={parameters} generatedAt={new Date().toISOString()} />;
1303
+ return renderReportHtml(element, 'Staff List Report');
1304
+ },
1305
+ [fetchData],
1306
+ );
1307
+
1308
+ const generate = useCallback(
1309
+ async (parameters: { department?: string; status?: string }, format: ReportFormat): Promise<void> => {
1310
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1311
+ const baseFilename = `staff-list-report-${timestamp}`;
1312
+
1313
+ switch (format) {
1314
+ case 'pdf': {
1315
+ const html = await buildHtml(parameters);
1316
+ await generatePdf(html, `${baseFilename}.pdf`);
1317
+ break;
1318
+ }
1319
+ case 'xlsx': {
1320
+ const data = await fetchData(parameters);
1321
+ generateXlsx(data, STAFF_LIST_COLUMNS, `${baseFilename}.xlsx`);
1322
+ break;
1323
+ }
1324
+ case 'csv': {
1325
+ const data = await fetchData(parameters);
1326
+ generateCsv(data, STAFF_LIST_COLUMNS, `${baseFilename}.csv`);
1327
+ break;
1328
+ }
1329
+ }
1330
+ },
1331
+ [buildHtml, fetchData, generatePdf, generateXlsx, generateCsv],
1332
+ );
1333
+
1334
+ const preview = useCallback(
1335
+ (parameters: { department?: string; status?: string }) => buildHtml(parameters),
1336
+ [buildHtml],
1337
+ );
1338
+
1339
+ return { generate, preview, isGenerating };
1340
+ }
1341
+ ```
1342
+
1343
+ ---
1344
+
1345
+ ## Directory Structure (Report Feature)
1346
+
1347
+ ```
1348
+ src/features/
1349
+ ├── reports/ # Shared report infrastructure
1350
+ │ ├── api/
1351
+ │ │ └── reportApi.ts # Report catalogue API
1352
+ │ ├── components/
1353
+ │ │ └── ReportPreviewDialog.tsx # Full-screen preview dialog (Headless UI)
1354
+ │ ├── hooks/
1355
+ │ │ ├── useReports.ts # TanStack Query hooks for report list
1356
+ │ │ └── useReportGeneration.ts # PDF/XLSX/CSV generation hook
1357
+ │ ├── layouts/
1358
+ │ │ ├── renderReportHtml.ts # ReactDOMServer HTML wrapper utility
1359
+ │ │ ├── ReportLayout.tsx # Base report layout (header + footer)
1360
+ │ │ └── TableReportLayout.tsx # Tabular report layout
1361
+ │ ├── pages/
1362
+ │ │ ├── ReportListPage.tsx # Report catalogue page
1363
+ │ │ └── ReportGeneratePage.tsx # Parameter form + generate page
1364
+ │ ├── store/
1365
+ │ │ └── report.store.ts # (Optional) Persisted report preferences
1366
+ │ ├── utils/
1367
+ │ │ ├── exportXlsx.ts # Client-side XLSX export
1368
+ │ │ └── exportCsv.ts # Client-side CSV export
1369
+ │ ├── reports.types.ts # Shared report type definitions
1370
+ │ ├── reports.schema.ts # Dynamic Zod schema builder
1371
+ │ └── index.ts # Public API re-exports
1372
+
1373
+ ├── staff/ # Example module with reports
1374
+ │ ├── api/
1375
+ │ │ └── staffApi.ts # Includes getReportData() method
1376
+ │ ├── reports/
1377
+ │ │ ├── staffListReport.ts # Report definition + column config
1378
+ │ │ ├── StaffListReportLayout.tsx # PDF layout component
1379
+ │ │ └── useStaffListReport.ts # Module-specific report generation hook
1380
+ │ └── ...
1381
+ ```
1382
+
1383
+ ---
1384
+
1385
+ ## Key Design Decisions
1386
+
1387
+ | Decision | Rationale |
1388
+ |---|---|
1389
+ | **Server-side Puppeteer for PDF** | Full CSS support (Tailwind, flexbox, grid), pixel-perfect rendering, page breaks, headers/footers. Browser-based PDF libraries (jsPDF, html2canvas) produce inferior results. |
1390
+ | **Client-side CSV/XLSX** | No server round-trip needed — data is already available from the API or TanStack Query cache. `xlsx` and `PapaParse` are mature libraries. Instant export with no network latency. |
1391
+ | **ReactDOMServer for HTML generation** | Consistent React component model for report layouts. Full TypeScript type safety. Report layouts are just React components — no separate template language. |
1392
+ | **Tailwind CSS CDN in report HTML** | Puppeteer renders a standalone HTML document with no access to the app's compiled CSS bundle. Tailwind CDN provides the same utility classes used elsewhere, with no build step. |
1393
+ | **Same Tailwind vocabulary everywhere** | The app UI and the report HTML both use Tailwind utility classes, so authoring a report layout is intuitive for anyone who built the app — no second styling system to learn. |
1394
+ | **Separate report service** | Puppeteer requires Node.js + headless Chrome which cannot run in the browser. A lightweight Express server isolates the concern and can be scaled independently. |
1395
+ | **Dynamic Zod schemas** | Report parameters are defined as data (ReportDefinition), so the Zod schema is built dynamically at runtime. New reports can be added without changing the form infrastructure. |
1396
+
1397
+ ---
1398
+
1399
+ ## Report Service Deployment
1400
+
1401
+ ### Development
1402
+
1403
+ - Start the Vite dev server: `npm run dev` (port {{DEV_PORT}})
1404
+ - Start the report service: `cd report-service && npm run dev` (port 3001)
1405
+ - Vite proxy forwards `/api/reports` to `http://localhost:3001`
1406
+
1407
+ ### Production
1408
+
1409
+ - Deploy the report service as a sidecar container or separate microservice
1410
+ - The React SPA connects via the `VITE_REPORT_SERVICE_URL` environment variable (or an API gateway / ingress rule)
1411
+
1412
+ ### Environment Variables (Production)
1413
+
1414
+ ```env
1415
+ # React SPA (.env.production)
1416
+ VITE_REPORT_SERVICE_URL=${REPORT_SERVICE_URL}
1417
+
1418
+ # Report Service
1419
+ REPORT_SERVICE_PORT=3001
1420
+ ```
1421
+
1422
+ ### Dockerfile (Report Service)
1423
+
1424
+ ```dockerfile
1425
+ # report-service/Dockerfile
1426
+ FROM node:22-slim
1427
+
1428
+ # Install Chromium dependencies required by Puppeteer
1429
+ RUN apt-get update && apt-get install -y \
1430
+ chromium \
1431
+ fonts-liberation \
1432
+ libappindicator3-1 \
1433
+ libasound2 \
1434
+ libatk-bridge2.0-0 \
1435
+ libatk1.0-0 \
1436
+ libcups2 \
1437
+ libdbus-1-3 \
1438
+ libdrm2 \
1439
+ libgbm1 \
1440
+ libnspr4 \
1441
+ libnss3 \
1442
+ libxcomposite1 \
1443
+ libxdamage1 \
1444
+ libxrandr2 \
1445
+ xdg-utils \
1446
+ --no-install-recommends \
1447
+ && rm -rf /var/lib/apt/lists/*
1448
+
1449
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
1450
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
1451
+
1452
+ WORKDIR /app
1453
+
1454
+ COPY package.json package-lock.json ./
1455
+ RUN npm ci --production
1456
+
1457
+ COPY dist/ ./dist/
1458
+
1459
+ EXPOSE 3001
1460
+
1461
+ USER node
1462
+
1463
+ CMD ["node", "dist/server.js"]
1464
+ ```
1465
+
1466
+ ---
1467
+
1468
+ ## Operational Notes
1469
+
1470
+ ### Chrome Memory Management
1471
+
1472
+ Puppeteer's headless Chrome can consume significant memory for large reports. Mitigations:
1473
+
1474
+ - **Browser singleton**: Reuse a single Chrome instance across requests (implemented above). Each request opens a new page (tab) and closes it when done.
1475
+ - **Memory limit**: Set `--max-old-space-size` for the Node.js process and container memory limits in Kubernetes.
1476
+ - **Page cleanup**: Always close pages in a `finally` block to prevent memory leaks from failed requests.
1477
+
1478
+ ### Concurrent Report Generation
1479
+
1480
+ For production workloads with many concurrent report requests, limit concurrent Puppeteer
1481
+ pages with a semaphore (e.g., max 5), or use a job queue (BullMQ + Redis) for high volume:
1482
+
1483
+ ```typescript
1484
+ // Example: Simple concurrency limiter for the report service
1485
+ class Semaphore {
1486
+ private current = 0;
1487
+ private queue: (() => void)[] = [];
1488
+
1489
+ constructor(private readonly max: number) {}
1490
+
1491
+ async acquire(): Promise<void> {
1492
+ if (this.current < this.max) {
1493
+ this.current++;
1494
+ return;
1495
+ }
1496
+ return new Promise<void>((resolve) => {
1497
+ this.queue.push(resolve);
1498
+ });
1499
+ }
1500
+
1501
+ release(): void {
1502
+ this.current--;
1503
+ const next = this.queue.shift();
1504
+ if (next) {
1505
+ this.current++;
1506
+ next();
1507
+ }
1508
+ }
1509
+ }
1510
+
1511
+ // Usage in the report service:
1512
+ // const semaphore = new Semaphore(5);
1513
+ // app.post('/api/reports/pdf', async (req, res) => {
1514
+ // await semaphore.acquire();
1515
+ // try { /* generate PDF */ }
1516
+ // finally { semaphore.release(); }
1517
+ // });
1518
+ ```
1519
+
1520
+ ### Graceful Shutdown
1521
+
1522
+ The report service implements `SIGTERM` and `SIGINT` handlers that stop accepting new
1523
+ connections, close the Puppeteer browser, and exit cleanly — ensuring Kubernetes can
1524
+ gracefully terminate the pod during deployments.
1525
+
1526
+ ### Health Check
1527
+
1528
+ The `/api/reports/health` endpoint returns `{ "status": "ok" }` and can be used as a
1529
+ Kubernetes liveness/readiness probe.