@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,1998 @@
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
+ - **MUI-based report UI** — report list page, parameter forms (React Hook Form + Zod),
17
+ preview dialog, and format selection
18
+ - **Report HTML templates** use Tailwind CSS CDN for standalone rendering inside Puppeteer
19
+ (independent of the application's MUI build)
20
+ - All report layouts are programmatically developed by the AI coding agent — no manual
21
+ template authoring required
22
+
23
+ ---
24
+
25
+ ## Architecture Overview
26
+
27
+ ```
28
+ React SPA (Browser) Report Service (Node.js)
29
+ ┌──────────────────────────┐ ┌──────────────────────────┐
30
+ │ ReportListPage │ │ Express.js │
31
+ │ ReportParameterForm │ POST │ POST /api/reports/pdf │
32
+ │ ReportPreview │─────────►│ receives: HTML string │
33
+ │ useReportGeneration() │ │ renders via Puppeteer │
34
+ │ │◄─────────│ returns: PDF bytes │
35
+ │ Client-side export: │ binary │ │
36
+ │ • xlsx (XLSX) │ └──────────────────────────┘
37
+ │ • Papa Parse (CSV) │
38
+ └──────────────────────────┘
39
+ ```
40
+
41
+ **Why this architecture:**
42
+ - React is a frontend-only SPA — it cannot generate PDFs natively in the browser
43
+ - Puppeteer requires Node.js + headless Chrome (server-side only)
44
+ - The report "layout" is an HTML document that Puppeteer renders to PDF with full CSS support
45
+ - The React SPA builds report HTML from React layout components using
46
+ `ReactDOMServer.renderToStaticMarkup()`, then sends the HTML string to the report service
47
+ which returns PDF bytes as a binary download
48
+ - For CSV/XLSX, client-side libraries handle export directly in the browser with zero
49
+ server round-trip — the data is already available from TanStack Query cache
50
+
51
+ **Key design rule:** Report layout components are plain React components that use inline
52
+ styles or Tailwind CSS classes (loaded via CDN in the HTML wrapper). They do NOT use MUI
53
+ components or the application's Emotion styling — Puppeteer renders a standalone HTML
54
+ document that has no access to the application's runtime CSS.
55
+
56
+ ---
57
+
58
+ ## npm Dependencies
59
+
60
+ ### React SPA (main application package.json)
61
+
62
+ **Production:**
63
+
64
+ ```json
65
+ {
66
+ "dependencies": {
67
+ "xlsx": "^0.18.5",
68
+ "papaparse": "^5.4.1"
69
+ },
70
+ "devDependencies": {
71
+ "@types/papaparse": "^5.3.14"
72
+ }
73
+ }
74
+ ```
75
+
76
+ ### Report Service (separate package.json — companion service)
77
+
78
+ ```json
79
+ {
80
+ "name": "{{PROJECT_SLUG}}-report-service",
81
+ "version": "1.0.0",
82
+ "private": true,
83
+ "type": "module",
84
+ "scripts": {
85
+ "start": "node dist/server.js",
86
+ "dev": "tsx watch src/server.ts",
87
+ "build": "tsc"
88
+ },
89
+ "dependencies": {
90
+ "express": "^4.21.0",
91
+ "puppeteer": "^23.0.0",
92
+ "cors": "^2.8.5"
93
+ },
94
+ "devDependencies": {
95
+ "@types/express": "^5.0.0",
96
+ "@types/cors": "^2.8.17",
97
+ "typescript": "^5.0.0",
98
+ "tsx": "^4.0.0"
99
+ }
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Report Service (Node.js Express)
106
+
107
+ A lightweight Express server that accepts an HTML string and returns a rendered PDF.
108
+ This runs as a companion service alongside the Vite dev server during development, or
109
+ as a separate microservice (sidecar container) in production.
110
+
111
+ ### Server Implementation
112
+
113
+ ```typescript
114
+ // report-service/src/server.ts
115
+ import express, { type Request, type Response } from 'express';
116
+ import puppeteer, { type Browser } from 'puppeteer';
117
+ import cors from 'cors';
118
+
119
+ const app = express();
120
+ app.use(cors());
121
+ app.use(express.json({ limit: '10mb' }));
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Browser Singleton — reuse a single headless Chrome instance across requests
125
+ // ---------------------------------------------------------------------------
126
+ let browser: Browser | null = null;
127
+
128
+ async function getBrowser(): Promise<Browser> {
129
+ if (!browser || !browser.connected) {
130
+ browser = await puppeteer.launch({
131
+ headless: true,
132
+ args: [
133
+ '--no-sandbox',
134
+ '--disable-setuid-sandbox',
135
+ '--disable-dev-shm-usage',
136
+ '--disable-gpu',
137
+ ],
138
+ });
139
+ }
140
+ return browser;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // PDF Generation Endpoint
145
+ // ---------------------------------------------------------------------------
146
+ interface PdfRequestBody {
147
+ html: string;
148
+ landscape?: boolean;
149
+ format?: 'A4' | 'A3' | 'Letter' | 'Legal';
150
+ filename?: string;
151
+ }
152
+
153
+ app.post('/api/reports/pdf', async (req: Request<object, Buffer, PdfRequestBody>, res: Response): Promise<void> => {
154
+ const {
155
+ html,
156
+ landscape = false,
157
+ format = 'A4',
158
+ filename = 'report.pdf',
159
+ } = req.body;
160
+
161
+ if (!html || typeof html !== 'string') {
162
+ res.status(400).json({ error: 'Request body must include an "html" string.' });
163
+ return;
164
+ }
165
+
166
+ let page: Awaited<ReturnType<Browser['newPage']>> | null = null;
167
+
168
+ try {
169
+ const b = await getBrowser();
170
+ page = await b.newPage();
171
+
172
+ // Set content and wait for all network requests (e.g., Tailwind CDN) to finish
173
+ await page.setContent(html, { waitUntil: 'networkidle0' });
174
+
175
+ const pdf = await page.pdf({
176
+ format,
177
+ landscape,
178
+ printBackground: true,
179
+ margin: {
180
+ top: '10mm',
181
+ bottom: '10mm',
182
+ left: '10mm',
183
+ right: '10mm',
184
+ },
185
+ });
186
+
187
+ res.setHeader('Content-Type', 'application/pdf');
188
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
189
+ res.setHeader('Content-Length', String(pdf.byteLength));
190
+ res.send(pdf);
191
+ } catch (error) {
192
+ console.error('PDF generation failed:', error);
193
+ res.status(500).json({ error: 'PDF generation failed.' });
194
+ } finally {
195
+ if (page) {
196
+ await page.close();
197
+ }
198
+ }
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Health Check
203
+ // ---------------------------------------------------------------------------
204
+ app.get('/api/reports/health', (_req: Request, res: Response) => {
205
+ res.json({ status: 'ok' });
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Graceful Shutdown
210
+ // ---------------------------------------------------------------------------
211
+ const PORT = parseInt(process.env.REPORT_SERVICE_PORT ?? '3001', 10);
212
+
213
+ const server = app.listen(PORT, () => {
214
+ console.log(`Report service listening on port ${PORT}`);
215
+ });
216
+
217
+ async function gracefulShutdown(): Promise<void> {
218
+ console.log('Shutting down report service...');
219
+ server.close();
220
+ if (browser) {
221
+ await browser.close();
222
+ browser = null;
223
+ }
224
+ process.exit(0);
225
+ }
226
+
227
+ process.on('SIGTERM', gracefulShutdown);
228
+ process.on('SIGINT', gracefulShutdown);
229
+ ```
230
+
231
+ ### Vite Proxy Configuration (Development)
232
+
233
+ During development, Vite proxies report service requests to the companion Node.js server
234
+ so the React SPA can call `/api/reports/pdf` without CORS issues:
235
+
236
+ ```typescript
237
+ // vite.config.ts (add to existing server.proxy)
238
+ import { defineConfig } from 'vite';
239
+ import react from '@vitejs/plugin-react';
240
+ import path from 'path';
241
+
242
+ export default defineConfig({
243
+ plugins: [react()],
244
+ resolve: {
245
+ alias: {
246
+ '@': path.resolve(__dirname, './src'),
247
+ },
248
+ },
249
+ server: {
250
+ port: {{DEV_PORT}},
251
+ proxy: {
252
+ // Existing API proxy
253
+ '/api/v1': {
254
+ target: '{{API_BASE_URL_DEV}}',
255
+ changeOrigin: true,
256
+ },
257
+ // Report service proxy
258
+ '/api/reports': {
259
+ target: 'http://localhost:3001',
260
+ changeOrigin: true,
261
+ },
262
+ },
263
+ },
264
+ });
265
+ ```
266
+
267
+ ### Report Service Environment Variables
268
+
269
+ ```env
270
+ # report-service/.env
271
+ REPORT_SERVICE_PORT=3001
272
+ ```
273
+
274
+ ---
275
+
276
+ ## Report Types and Interfaces
277
+
278
+ ```typescript
279
+ // src/features/reports/reports.types.ts
280
+
281
+ /**
282
+ * Metadata definition for a single report.
283
+ * Report definitions are typically provided by the backend API or
284
+ * defined as constants in the frontend for static report catalogues.
285
+ */
286
+ export interface ReportDefinition {
287
+ /** Unique identifier (e.g., 'staff-list-report') */
288
+ reportId: string;
289
+ /** Human-readable display name */
290
+ name: string;
291
+ /** Description shown in the report catalogue */
292
+ description: string;
293
+ /** Domain/module grouping (e.g., 'Human Resources', 'Finance') */
294
+ domain: string;
295
+ /** Parameters the user must fill before generating */
296
+ parameters: ReportParameter[];
297
+ /** Supported output formats */
298
+ supportedFormats: ReportFormat[];
299
+ }
300
+
301
+ /**
302
+ * A single parameter definition for a report's input form.
303
+ */
304
+ export interface ReportParameter {
305
+ /** Field name used as the form field key */
306
+ name: string;
307
+ /** Display label */
308
+ label: string;
309
+ /** Input type — maps to MUI form controls */
310
+ type: 'text' | 'date' | 'number' | 'select' | 'boolean';
311
+ /** Whether the parameter is required */
312
+ required: boolean;
313
+ /** Options for 'select' type parameters */
314
+ options?: ReportParameterOption[];
315
+ /** Default value pre-filled in the form */
316
+ defaultValue?: string;
317
+ }
318
+
319
+ export interface ReportParameterOption {
320
+ label: string;
321
+ value: string;
322
+ }
323
+
324
+ /** Supported export formats */
325
+ export type ReportFormat = 'pdf' | 'xlsx' | 'csv';
326
+
327
+ /**
328
+ * Represents a row of tabular report data.
329
+ * Each key is a column identifier, value is the cell content.
330
+ */
331
+ export type ReportDataRow = Record<string, unknown>;
332
+
333
+ /**
334
+ * Column definition for tabular report layouts and client-side exports.
335
+ */
336
+ export interface ReportColumn {
337
+ /** Column identifier matching the key in ReportDataRow */
338
+ key: string;
339
+ /** Display header label */
340
+ header: string;
341
+ /** Column width hint (for PDF layout) */
342
+ width?: string;
343
+ /** Text alignment */
344
+ align?: 'left' | 'center' | 'right';
345
+ }
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Report API Hooks (TanStack Query)
351
+
352
+ ```typescript
353
+ // src/features/reports/api/reportApi.ts
354
+ import { axiosInstance } from '../../../lib/api/axiosInstance';
355
+ import type { ReportDefinition } from '../reports.types';
356
+
357
+ export const reportApi = {
358
+ /**
359
+ * Fetch all available report definitions.
360
+ */
361
+ list: async (): Promise<ReportDefinition[]> => {
362
+ const { data } = await axiosInstance.get<ReportDefinition[]>('/v1/reports');
363
+ return data;
364
+ },
365
+
366
+ /**
367
+ * Fetch a single report definition by ID.
368
+ */
369
+ getById: async (reportId: string): Promise<ReportDefinition> => {
370
+ const { data } = await axiosInstance.get<ReportDefinition>(`/v1/reports/${reportId}`);
371
+ return data;
372
+ },
373
+ };
374
+ ```
375
+
376
+ ```typescript
377
+ // src/features/reports/hooks/useReports.ts
378
+ import { useQuery } from '@tanstack/react-query';
379
+ import { reportApi } from '../api/reportApi';
380
+
381
+ export const reportKeys = {
382
+ all: ['reports'] as const,
383
+ lists: () => [...reportKeys.all, 'list'] as const,
384
+ list: () => [...reportKeys.lists()] as const,
385
+ details: () => [...reportKeys.all, 'detail'] as const,
386
+ detail: (reportId: string) => [...reportKeys.details(), reportId] as const,
387
+ };
388
+
389
+ /**
390
+ * Fetch all available report definitions.
391
+ */
392
+ export function useReportList() {
393
+ return useQuery({
394
+ queryKey: reportKeys.list(),
395
+ queryFn: () => reportApi.list(),
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Fetch a single report definition by ID.
401
+ */
402
+ export function useReportDetails(reportId: string) {
403
+ return useQuery({
404
+ queryKey: reportKeys.detail(reportId),
405
+ queryFn: () => reportApi.getById(reportId),
406
+ enabled: !!reportId,
407
+ });
408
+ }
409
+ ```
410
+
411
+ ---
412
+
413
+ ## useReportGeneration Hook
414
+
415
+ Custom hook that orchestrates report generation across all three formats. For PDF it
416
+ builds HTML from a React layout component and sends it to the report service. For XLSX
417
+ and CSV it generates the file entirely in the browser.
418
+
419
+ ```typescript
420
+ // src/features/reports/hooks/useReportGeneration.ts
421
+ import { useState, useCallback } from 'react';
422
+ import axios from 'axios';
423
+ import { exportToXlsx } from '../utils/exportXlsx';
424
+ import { exportToCsv } from '../utils/exportCsv';
425
+ import { useNotificationStore } from '../../../store/notification.store';
426
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
427
+
428
+ interface UseReportGenerationReturn {
429
+ /** Generate a PDF by sending HTML to the report service */
430
+ generatePdf: (
431
+ html: string,
432
+ filename: string,
433
+ options?: { landscape?: boolean; format?: 'A4' | 'A3' | 'Letter' | 'Legal' },
434
+ ) => Promise<void>;
435
+ /** Generate an XLSX file client-side */
436
+ generateXlsx: (
437
+ data: ReportDataRow[],
438
+ columns: ReportColumn[],
439
+ filename: string,
440
+ ) => void;
441
+ /** Generate a CSV file client-side */
442
+ generateCsv: (
443
+ data: ReportDataRow[],
444
+ columns: ReportColumn[],
445
+ filename: string,
446
+ ) => void;
447
+ /** Whether a PDF generation request is in flight */
448
+ isGenerating: boolean;
449
+ }
450
+
451
+ /**
452
+ * Hook providing report generation functions for all supported formats.
453
+ *
454
+ * Usage:
455
+ * const { generatePdf, generateXlsx, generateCsv, isGenerating } = useReportGeneration();
456
+ */
457
+ export function useReportGeneration(): UseReportGenerationReturn {
458
+ const [isGenerating, setIsGenerating] = useState(false);
459
+ const { push } = useNotificationStore();
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // PDF — send HTML to the Puppeteer report service, download the binary response
463
+ // ---------------------------------------------------------------------------
464
+ const generatePdf = useCallback(
465
+ async (
466
+ html: string,
467
+ filename: string,
468
+ options?: { landscape?: boolean; format?: 'A4' | 'A3' | 'Letter' | 'Legal' },
469
+ ): Promise<void> => {
470
+ setIsGenerating(true);
471
+ try {
472
+ const response = await axios.post(
473
+ '/api/reports/pdf',
474
+ {
475
+ html,
476
+ landscape: options?.landscape ?? false,
477
+ format: options?.format ?? 'A4',
478
+ filename,
479
+ },
480
+ { responseType: 'blob' },
481
+ );
482
+
483
+ // Create a temporary download link and trigger the browser download
484
+ const blob = new Blob([response.data], { type: 'application/pdf' });
485
+ const url = URL.createObjectURL(blob);
486
+ const link = document.createElement('a');
487
+ link.href = url;
488
+ link.download = filename;
489
+ document.body.appendChild(link);
490
+ link.click();
491
+ document.body.removeChild(link);
492
+ URL.revokeObjectURL(url);
493
+
494
+ push('success', `Report "${filename}" downloaded successfully.`);
495
+ } catch (error) {
496
+ const message =
497
+ error instanceof Error ? error.message : 'PDF generation failed.';
498
+ push('error', message);
499
+ throw error;
500
+ } finally {
501
+ setIsGenerating(false);
502
+ }
503
+ },
504
+ [push],
505
+ );
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // XLSX — generate entirely in the browser using the xlsx library
509
+ // ---------------------------------------------------------------------------
510
+ const generateXlsx = useCallback(
511
+ (data: ReportDataRow[], columns: ReportColumn[], filename: string): void => {
512
+ try {
513
+ exportToXlsx(data, columns, filename);
514
+ push('success', `Report "${filename}" downloaded successfully.`);
515
+ } catch (error) {
516
+ const message =
517
+ error instanceof Error ? error.message : 'XLSX export failed.';
518
+ push('error', message);
519
+ }
520
+ },
521
+ [push],
522
+ );
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // CSV — generate entirely in the browser using PapaParse
526
+ // ---------------------------------------------------------------------------
527
+ const generateCsv = useCallback(
528
+ (data: ReportDataRow[], columns: ReportColumn[], filename: string): void => {
529
+ try {
530
+ exportToCsv(data, columns, filename);
531
+ push('success', `Report "${filename}" downloaded successfully.`);
532
+ } catch (error) {
533
+ const message =
534
+ error instanceof Error ? error.message : 'CSV export failed.';
535
+ push('error', message);
536
+ }
537
+ },
538
+ [push],
539
+ );
540
+
541
+ return { generatePdf, generateXlsx, generateCsv, isGenerating };
542
+ }
543
+ ```
544
+
545
+ ---
546
+
547
+ ## Client-Side Export Utilities
548
+
549
+ ### XLSX Export
550
+
551
+ ```typescript
552
+ // src/features/reports/utils/exportXlsx.ts
553
+ import * as XLSX from 'xlsx';
554
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
555
+
556
+ /**
557
+ * Generate an XLSX file from tabular data and trigger a browser download.
558
+ *
559
+ * @param data - Array of data rows (each row is a Record<string, unknown>)
560
+ * @param columns - Column definitions providing key and header label
561
+ * @param filename - Download filename (should end with .xlsx)
562
+ */
563
+ export function exportToXlsx(
564
+ data: ReportDataRow[],
565
+ columns: ReportColumn[],
566
+ filename: string,
567
+ ): void {
568
+ // Build a 2D array: header row + data rows (column-ordered)
569
+ const headers = columns.map((col) => col.header);
570
+ const rows = data.map((row) =>
571
+ columns.map((col) => {
572
+ const value = row[col.key];
573
+ if (value === null || value === undefined) return '';
574
+ return String(value);
575
+ }),
576
+ );
577
+
578
+ const worksheetData = [headers, ...rows];
579
+ const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
580
+
581
+ // Auto-size columns based on content length
582
+ worksheet['!cols'] = columns.map((col, index) => {
583
+ const maxLength = Math.max(
584
+ col.header.length,
585
+ ...rows.map((row) => String(row[index] ?? '').length),
586
+ );
587
+ return { wch: Math.min(maxLength + 2, 50) };
588
+ });
589
+
590
+ // Bold header row
591
+ for (let colIdx = 0; colIdx < headers.length; colIdx++) {
592
+ const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx });
593
+ if (worksheet[cellRef]) {
594
+ worksheet[cellRef].s = { font: { bold: true } };
595
+ }
596
+ }
597
+
598
+ const workbook = XLSX.utils.book_new();
599
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Report');
600
+
601
+ // Trigger download
602
+ XLSX.writeFile(workbook, filename);
603
+ }
604
+ ```
605
+
606
+ ### CSV Export
607
+
608
+ ```typescript
609
+ // src/features/reports/utils/exportCsv.ts
610
+ import Papa from 'papaparse';
611
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
612
+
613
+ /**
614
+ * Generate a CSV file from tabular data and trigger a browser download.
615
+ *
616
+ * @param data - Array of data rows (each row is a Record<string, unknown>)
617
+ * @param columns - Column definitions providing key and header label
618
+ * @param filename - Download filename (should end with .csv)
619
+ */
620
+ export function exportToCsv(
621
+ data: ReportDataRow[],
622
+ columns: ReportColumn[],
623
+ filename: string,
624
+ ): void {
625
+ // Transform data to column-ordered objects with header labels as keys
626
+ const transformedData = data.map((row) => {
627
+ const ordered: Record<string, string> = {};
628
+ for (const col of columns) {
629
+ const value = row[col.key];
630
+ ordered[col.header] = value === null || value === undefined ? '' : String(value);
631
+ }
632
+ return ordered;
633
+ });
634
+
635
+ const csv = Papa.unparse(transformedData, {
636
+ quotes: true,
637
+ header: true,
638
+ });
639
+
640
+ // Create a Blob and trigger download
641
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
642
+ const url = URL.createObjectURL(blob);
643
+ const link = document.createElement('a');
644
+ link.href = url;
645
+ link.download = filename;
646
+ document.body.appendChild(link);
647
+ link.click();
648
+ document.body.removeChild(link);
649
+ URL.revokeObjectURL(url);
650
+ }
651
+ ```
652
+
653
+ ---
654
+
655
+ ## Report Layout Components
656
+
657
+ Report layouts are React components that render report content as plain HTML for Puppeteer.
658
+ They use inline styles or Tailwind CSS classes — NOT MUI components — because Puppeteer
659
+ renders a standalone HTML document without access to the application's Emotion runtime.
660
+
661
+ ### renderReportHtml Utility
662
+
663
+ This utility takes a React element, renders it to a static HTML string, and wraps it in
664
+ a complete HTML document with Tailwind CSS CDN for styling:
665
+
666
+ ```typescript
667
+ // src/features/reports/layouts/renderReportHtml.ts
668
+ import { renderToStaticMarkup } from 'react-dom/server';
669
+ import type { ReactElement } from 'react';
670
+
671
+ /**
672
+ * Render a React element to a complete HTML document string suitable for Puppeteer.
673
+ *
674
+ * The output HTML includes:
675
+ * - Tailwind CSS via CDN (for styling in Puppeteer's headless Chrome)
676
+ * - Print-optimized CSS (color-adjust, margin reset)
677
+ * - The rendered React component as the document body
678
+ *
679
+ * @param element - React element (report layout component) to render
680
+ * @param title - Document <title> for the PDF metadata
681
+ * @returns Complete HTML string ready to send to the report service
682
+ */
683
+ export function renderReportHtml(element: ReactElement, title: string): string {
684
+ const body = renderToStaticMarkup(element);
685
+
686
+ return `<!DOCTYPE html>
687
+ <html lang="en">
688
+ <head>
689
+ <meta charset="utf-8">
690
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
691
+ <title>${escapeHtml(title)}</title>
692
+ <script src="https://cdn.tailwindcss.com"></script>
693
+ <style>
694
+ @page { margin: 0; }
695
+ body {
696
+ -webkit-print-color-adjust: exact;
697
+ print-color-adjust: exact;
698
+ }
699
+ /* Ensure tables do not break across pages mid-row */
700
+ tr { page-break-inside: avoid; }
701
+ /* Force page breaks where explicitly requested */
702
+ .page-break { page-break-after: always; }
703
+ </style>
704
+ </head>
705
+ <body class="bg-white text-gray-900 text-sm p-8">
706
+ ${body}
707
+ </body>
708
+ </html>`;
709
+ }
710
+
711
+ /**
712
+ * Escape HTML special characters to prevent XSS in the document title.
713
+ */
714
+ function escapeHtml(text: string): string {
715
+ return text
716
+ .replace(/&/g, '&amp;')
717
+ .replace(/</g, '&lt;')
718
+ .replace(/>/g, '&gt;')
719
+ .replace(/"/g, '&quot;');
720
+ }
721
+ ```
722
+
723
+ ### Base Report Layout
724
+
725
+ A wrapper component that provides standard report chrome: company header, report title,
726
+ generation date, and page footer.
727
+
728
+ ```tsx
729
+ // src/features/reports/layouts/ReportLayout.tsx
730
+ import type { ReactNode } from 'react';
731
+
732
+ interface ReportLayoutProps {
733
+ /** Report title displayed at the top of every page */
734
+ title: string;
735
+ /** Optional subtitle (e.g., filter description) */
736
+ subtitle?: string;
737
+ /** ISO date string for the generation timestamp */
738
+ generatedAt: string;
739
+ /** Report body content */
740
+ children: ReactNode;
741
+ }
742
+
743
+ /**
744
+ * Base report layout with header, title, and footer.
745
+ * Uses Tailwind CSS classes (rendered via CDN in the HTML wrapper).
746
+ * Do NOT use MUI components here — this renders as standalone HTML for Puppeteer.
747
+ */
748
+ export function ReportLayout({ title, subtitle, generatedAt, children }: ReportLayoutProps) {
749
+ const formattedDate = new Date(generatedAt).toLocaleString('en-US', {
750
+ year: 'numeric',
751
+ month: 'long',
752
+ day: 'numeric',
753
+ hour: '2-digit',
754
+ minute: '2-digit',
755
+ });
756
+
757
+ return (
758
+ <div className="min-h-screen flex flex-col">
759
+ {/* Report Header */}
760
+ <header className="border-b border-gray-300 pb-4 mb-6">
761
+ <div className="flex justify-between items-start">
762
+ <div>
763
+ <h1 className="text-xl font-bold text-gray-900">{title}</h1>
764
+ {subtitle && (
765
+ <p className="text-sm text-gray-600 mt-1">{subtitle}</p>
766
+ )}
767
+ </div>
768
+ <div className="text-right text-xs text-gray-500">
769
+ <p>Generated: {formattedDate}</p>
770
+ </div>
771
+ </div>
772
+ </header>
773
+
774
+ {/* Report Body */}
775
+ <main className="flex-1">
776
+ {children}
777
+ </main>
778
+
779
+ {/* Report Footer */}
780
+ <footer className="border-t border-gray-300 pt-3 mt-8 text-xs text-gray-400 text-center">
781
+ <p>
782
+ {title} &mdash; Generated on {formattedDate}
783
+ </p>
784
+ </footer>
785
+ </div>
786
+ );
787
+ }
788
+ ```
789
+
790
+ ### Table Report Layout
791
+
792
+ A table-based report layout for standard tabular reports with column headers and data rows:
793
+
794
+ ```tsx
795
+ // src/features/reports/layouts/TableReportLayout.tsx
796
+ import { ReportLayout } from './ReportLayout';
797
+ import type { ReportDataRow, ReportColumn } from '../reports.types';
798
+
799
+ interface TableReportLayoutProps {
800
+ /** Report title */
801
+ title: string;
802
+ /** Optional subtitle */
803
+ subtitle?: string;
804
+ /** ISO date string for the generation timestamp */
805
+ generatedAt: string;
806
+ /** Column definitions */
807
+ columns: ReportColumn[];
808
+ /** Data rows */
809
+ data: ReportDataRow[];
810
+ /** Whether to show a total row count at the bottom */
811
+ showTotalCount?: boolean;
812
+ }
813
+
814
+ /**
815
+ * Tabular report layout with headers, data rows, and optional record count.
816
+ * Uses Tailwind CSS classes — do NOT use MUI components here.
817
+ */
818
+ export function TableReportLayout({
819
+ title,
820
+ subtitle,
821
+ generatedAt,
822
+ columns,
823
+ data,
824
+ showTotalCount = true,
825
+ }: TableReportLayoutProps) {
826
+ return (
827
+ <ReportLayout title={title} subtitle={subtitle} generatedAt={generatedAt}>
828
+ <table className="w-full border-collapse text-xs">
829
+ <thead>
830
+ <tr>
831
+ {columns.map((col) => (
832
+ <th
833
+ key={col.key}
834
+ className="border border-gray-300 bg-gray-100 px-3 py-2 font-semibold text-left"
835
+ style={{
836
+ width: col.width ?? 'auto',
837
+ textAlign: col.align ?? 'left',
838
+ }}
839
+ >
840
+ {col.header}
841
+ </th>
842
+ ))}
843
+ </tr>
844
+ </thead>
845
+ <tbody>
846
+ {data.length === 0 ? (
847
+ <tr>
848
+ <td
849
+ colSpan={columns.length}
850
+ className="border border-gray-300 px-3 py-8 text-center text-gray-500 italic"
851
+ >
852
+ No data available for the selected parameters.
853
+ </td>
854
+ </tr>
855
+ ) : (
856
+ data.map((row, rowIndex) => (
857
+ <tr key={rowIndex} className={rowIndex % 2 === 1 ? 'bg-gray-50' : ''}>
858
+ {columns.map((col) => (
859
+ <td
860
+ key={col.key}
861
+ className="border border-gray-300 px-3 py-1.5"
862
+ style={{ textAlign: col.align ?? 'left' }}
863
+ >
864
+ {row[col.key] !== null && row[col.key] !== undefined
865
+ ? String(row[col.key])
866
+ : ''}
867
+ </td>
868
+ ))}
869
+ </tr>
870
+ ))
871
+ )}
872
+ </tbody>
873
+ </table>
874
+
875
+ {showTotalCount && data.length > 0 && (
876
+ <p className="mt-3 text-xs text-gray-500">
877
+ Total records: {data.length}
878
+ </p>
879
+ )}
880
+ </ReportLayout>
881
+ );
882
+ }
883
+ ```
884
+
885
+ ---
886
+
887
+ ## Report Parameter Schema (Zod)
888
+
889
+ Dynamic Zod schema generation from report parameter definitions:
890
+
891
+ ```typescript
892
+ // src/features/reports/reports.schema.ts
893
+ import { z } from 'zod';
894
+ import type { ReportParameter, ReportFormat } from './reports.types';
895
+
896
+ /**
897
+ * Build a Zod schema dynamically from report parameter definitions.
898
+ * This allows each report to declare its own parameters and have them
899
+ * validated at form submission time.
900
+ */
901
+ export function buildReportParameterSchema(
902
+ parameters: ReportParameter[],
903
+ ): z.ZodObject<Record<string, z.ZodTypeAny>> {
904
+ const shape: Record<string, z.ZodTypeAny> = {};
905
+
906
+ for (const param of parameters) {
907
+ let fieldSchema: z.ZodTypeAny;
908
+
909
+ switch (param.type) {
910
+ case 'text':
911
+ fieldSchema = z.string();
912
+ break;
913
+ case 'date':
914
+ fieldSchema = z.string();
915
+ break;
916
+ case 'number':
917
+ fieldSchema = z.coerce.number();
918
+ break;
919
+ case 'select':
920
+ fieldSchema = z.string();
921
+ break;
922
+ case 'boolean':
923
+ fieldSchema = z.boolean();
924
+ break;
925
+ default:
926
+ fieldSchema = z.string();
927
+ }
928
+
929
+ if (param.required) {
930
+ if (param.type === 'boolean') {
931
+ // Booleans are always valid — no min check
932
+ shape[param.name] = fieldSchema;
933
+ } else {
934
+ shape[param.name] = (fieldSchema as z.ZodString).min(
935
+ 1,
936
+ `${param.label} is required`,
937
+ );
938
+ }
939
+ } else {
940
+ shape[param.name] = fieldSchema.optional();
941
+ }
942
+ }
943
+
944
+ return z.object(shape);
945
+ }
946
+
947
+ /**
948
+ * Schema for the report generation form (format selection).
949
+ */
950
+ export const reportGenerateSchema = z.object({
951
+ format: z.enum(['pdf', 'xlsx', 'csv'] as const satisfies readonly ReportFormat[]),
952
+ });
953
+
954
+ export type ReportGenerateFormValues = z.infer<typeof reportGenerateSchema>;
955
+ ```
956
+
957
+ ---
958
+
959
+ ## Report Pages (MUI Components)
960
+
961
+ ### Report List Page
962
+
963
+ ```tsx
964
+ // src/features/reports/pages/ReportListPage.tsx
965
+ import { useState, useMemo } from 'react';
966
+ import { useNavigate } from 'react-router-dom';
967
+ import {
968
+ Box,
969
+ Card,
970
+ CardContent,
971
+ CardActions,
972
+ Button,
973
+ Typography,
974
+ TextField,
975
+ InputAdornment,
976
+ Stack,
977
+ Chip,
978
+ MenuItem,
979
+ Select,
980
+ type SelectChangeEvent,
981
+ } from '@mui/material';
982
+ import SearchIcon from '@mui/icons-material/Search';
983
+ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
984
+ import TableChartIcon from '@mui/icons-material/TableChart';
985
+ import DescriptionIcon from '@mui/icons-material/Description';
986
+ import { PageHeader } from '../../../shared/components/PageHeader';
987
+ import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
988
+ import { EmptyState } from '../../../shared/components/EmptyState';
989
+ import { useReportList } from '../hooks/useReports';
990
+ import type { ReportFormat } from '../reports.types';
991
+
992
+ const FORMAT_ICONS: Record<ReportFormat, React.ReactNode> = {
993
+ pdf: <PictureAsPdfIcon fontSize="small" />,
994
+ xlsx: <TableChartIcon fontSize="small" />,
995
+ csv: <DescriptionIcon fontSize="small" />,
996
+ };
997
+
998
+ const FORMAT_COLORS: Record<ReportFormat, 'error' | 'success' | 'info'> = {
999
+ pdf: 'error',
1000
+ xlsx: 'success',
1001
+ csv: 'info',
1002
+ };
1003
+
1004
+ export function ReportListPage() {
1005
+ const navigate = useNavigate();
1006
+ const { data: reports, isLoading, isError } = useReportList();
1007
+
1008
+ const [search, setSearch] = useState('');
1009
+ const [domainFilter, setDomainFilter] = useState<string>('');
1010
+
1011
+ // Extract unique domains for the filter dropdown
1012
+ const domains = useMemo(() => {
1013
+ if (!reports) return [];
1014
+ return [...new Set(reports.map((r) => r.domain))].sort();
1015
+ }, [reports]);
1016
+
1017
+ // Filter reports by search text and domain
1018
+ const filteredReports = useMemo(() => {
1019
+ if (!reports) return [];
1020
+ return reports.filter((report) => {
1021
+ const matchesSearch =
1022
+ !search ||
1023
+ report.name.toLowerCase().includes(search.toLowerCase()) ||
1024
+ report.description.toLowerCase().includes(search.toLowerCase());
1025
+ const matchesDomain = !domainFilter || report.domain === domainFilter;
1026
+ return matchesSearch && matchesDomain;
1027
+ });
1028
+ }, [reports, search, domainFilter]);
1029
+
1030
+ // Group filtered reports by domain
1031
+ const groupedReports = useMemo(() => {
1032
+ const groups: Record<string, typeof filteredReports> = {};
1033
+ for (const report of filteredReports) {
1034
+ if (!groups[report.domain]) {
1035
+ groups[report.domain] = [];
1036
+ }
1037
+ groups[report.domain].push(report);
1038
+ }
1039
+ return groups;
1040
+ }, [filteredReports]);
1041
+
1042
+ if (isLoading) return <LoadingOverlay />;
1043
+ if (isError) return <div>Failed to load reports.</div>;
1044
+
1045
+ return (
1046
+ <Box>
1047
+ <PageHeader
1048
+ title="Reports"
1049
+ breadcrumbs={[
1050
+ { label: 'Dashboard', path: '/dashboard' },
1051
+ { label: 'Reports' },
1052
+ ]}
1053
+ />
1054
+
1055
+ {/* Filters */}
1056
+ <Stack direction="row" spacing={2} sx={{ mb: 3 }}>
1057
+ <TextField
1058
+ size="small"
1059
+ placeholder="Search reports..."
1060
+ value={search}
1061
+ onChange={(e) => setSearch(e.target.value)}
1062
+ slotProps={{
1063
+ input: {
1064
+ startAdornment: (
1065
+ <InputAdornment position="start">
1066
+ <SearchIcon fontSize="small" />
1067
+ </InputAdornment>
1068
+ ),
1069
+ },
1070
+ }}
1071
+ sx={{ minWidth: 300 }}
1072
+ />
1073
+ <Select
1074
+ size="small"
1075
+ value={domainFilter}
1076
+ onChange={(e: SelectChangeEvent) => setDomainFilter(e.target.value)}
1077
+ displayEmpty
1078
+ sx={{ minWidth: 200 }}
1079
+ >
1080
+ <MenuItem value="">All Domains</MenuItem>
1081
+ {domains.map((domain) => (
1082
+ <MenuItem key={domain} value={domain}>
1083
+ {domain}
1084
+ </MenuItem>
1085
+ ))}
1086
+ </Select>
1087
+ </Stack>
1088
+
1089
+ {/* Report Cards grouped by domain */}
1090
+ {filteredReports.length === 0 ? (
1091
+ <EmptyState message="No reports match your search criteria." />
1092
+ ) : (
1093
+ Object.entries(groupedReports).map(([domain, domainReports]) => (
1094
+ <Box key={domain} sx={{ mb: 4 }}>
1095
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
1096
+ {domain}
1097
+ </Typography>
1098
+ <Box
1099
+ sx={{
1100
+ display: 'grid',
1101
+ gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
1102
+ gap: 2,
1103
+ }}
1104
+ >
1105
+ {domainReports.map((report) => (
1106
+ <Card key={report.reportId} variant="outlined">
1107
+ <CardContent>
1108
+ <Typography variant="subtitle1" fontWeight={600}>
1109
+ {report.name}
1110
+ </Typography>
1111
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>
1112
+ {report.description}
1113
+ </Typography>
1114
+ <Stack direction="row" spacing={0.5}>
1115
+ {report.supportedFormats.map((format) => (
1116
+ <Chip
1117
+ key={format}
1118
+ icon={FORMAT_ICONS[format] as React.ReactElement}
1119
+ label={format.toUpperCase()}
1120
+ color={FORMAT_COLORS[format]}
1121
+ size="small"
1122
+ variant="outlined"
1123
+ />
1124
+ ))}
1125
+ </Stack>
1126
+ </CardContent>
1127
+ <CardActions>
1128
+ <Button
1129
+ size="small"
1130
+ variant="contained"
1131
+ onClick={() => navigate(`/reports/${report.reportId}`)}
1132
+ >
1133
+ Generate
1134
+ </Button>
1135
+ </CardActions>
1136
+ </Card>
1137
+ ))}
1138
+ </Box>
1139
+ </Box>
1140
+ ))
1141
+ )}
1142
+ </Box>
1143
+ );
1144
+ }
1145
+ ```
1146
+
1147
+ ### Report Generate Page
1148
+
1149
+ ```tsx
1150
+ // src/features/reports/pages/ReportGeneratePage.tsx
1151
+ import { useState, useMemo, useCallback } from 'react';
1152
+ import { useParams, useNavigate } from 'react-router-dom';
1153
+ import { useForm, Controller } from 'react-hook-form';
1154
+ import { zodResolver } from '@hookform/resolvers/zod';
1155
+ import {
1156
+ Box,
1157
+ Button,
1158
+ Card,
1159
+ CardContent,
1160
+ Divider,
1161
+ Stack,
1162
+ ToggleButton,
1163
+ ToggleButtonGroup,
1164
+ Typography,
1165
+ } from '@mui/material';
1166
+ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
1167
+ import TableChartIcon from '@mui/icons-material/TableChart';
1168
+ import DescriptionIcon from '@mui/icons-material/Description';
1169
+ import PreviewIcon from '@mui/icons-material/Preview';
1170
+ import DownloadIcon from '@mui/icons-material/Download';
1171
+ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
1172
+ import { PageHeader } from '../../../shared/components/PageHeader';
1173
+ import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
1174
+ import { TextFieldController } from '../../../shared/form/TextFieldController';
1175
+ import { SelectController } from '../../../shared/form/SelectController';
1176
+ import { useReportDetails } from '../hooks/useReports';
1177
+ import { useReportGeneration } from '../hooks/useReportGeneration';
1178
+ import { buildReportParameterSchema } from '../reports.schema';
1179
+ import { ReportPreviewDialog } from '../components/ReportPreviewDialog';
1180
+ import type { ReportFormat, ReportParameter } from '../reports.types';
1181
+
1182
+ export function ReportGeneratePage() {
1183
+ const { reportId } = useParams<{ reportId: string }>();
1184
+ const navigate = useNavigate();
1185
+ const { data: report, isLoading } = useReportDetails(reportId ?? '');
1186
+ const { generatePdf, generateXlsx, generateCsv, isGenerating } = useReportGeneration();
1187
+
1188
+ const [selectedFormat, setSelectedFormat] = useState<ReportFormat>('pdf');
1189
+ const [previewOpen, setPreviewOpen] = useState(false);
1190
+ const [previewHtml, setPreviewHtml] = useState('');
1191
+
1192
+ // Build dynamic Zod schema from report parameters
1193
+ const parameterSchema = useMemo(
1194
+ () => (report ? buildReportParameterSchema(report.parameters) : null),
1195
+ [report],
1196
+ );
1197
+
1198
+ const { control, handleSubmit, getValues } = useForm({
1199
+ resolver: parameterSchema ? zodResolver(parameterSchema) : undefined,
1200
+ defaultValues: useMemo(() => {
1201
+ if (!report) return {};
1202
+ const defaults: Record<string, string> = {};
1203
+ for (const param of report.parameters) {
1204
+ defaults[param.name] = param.defaultValue ?? '';
1205
+ }
1206
+ return defaults;
1207
+ }, [report]),
1208
+ });
1209
+
1210
+ /**
1211
+ * Build the report HTML for PDF generation or preview.
1212
+ * This function must be implemented per-report by the coding agent,
1213
+ * using the appropriate layout component and data fetched from the API.
1214
+ *
1215
+ * The pattern is:
1216
+ * 1. Call the module's report data API with the user-provided parameters
1217
+ * 2. Pass the data to a report layout component (e.g., TableReportLayout)
1218
+ * 3. Call renderReportHtml() to produce the full HTML string
1219
+ */
1220
+ const buildReportHtml = useCallback(
1221
+ async (_parameters: Record<string, unknown>): Promise<string> => {
1222
+ // This is a placeholder — each report implementation provides its own
1223
+ // buildReportHtml function that:
1224
+ // 1. Fetches data from the appropriate API endpoint with the parameters
1225
+ // 2. Renders a layout component (TableReportLayout, etc.) with the data
1226
+ // 3. Returns the HTML string via renderReportHtml()
1227
+ throw new Error(
1228
+ 'buildReportHtml must be implemented for each report. ' +
1229
+ 'See the Module Integration section for a complete example.',
1230
+ );
1231
+ },
1232
+ [],
1233
+ );
1234
+
1235
+ const handleGenerate = handleSubmit(async (values) => {
1236
+ if (!report) return;
1237
+
1238
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1239
+ const baseFilename = `${report.reportId}-${timestamp}`;
1240
+
1241
+ switch (selectedFormat) {
1242
+ case 'pdf': {
1243
+ const html = await buildReportHtml(values);
1244
+ await generatePdf(html, `${baseFilename}.pdf`);
1245
+ break;
1246
+ }
1247
+ case 'xlsx': {
1248
+ // For XLSX/CSV, the coding agent implements data fetching and passes
1249
+ // it to generateXlsx/generateCsv with the appropriate columns
1250
+ break;
1251
+ }
1252
+ case 'csv': {
1253
+ break;
1254
+ }
1255
+ }
1256
+ });
1257
+
1258
+ const handlePreview = handleSubmit(async (values) => {
1259
+ const html = await buildReportHtml(values);
1260
+ setPreviewHtml(html);
1261
+ setPreviewOpen(true);
1262
+ });
1263
+
1264
+ if (isLoading || !report) return <LoadingOverlay />;
1265
+
1266
+ return (
1267
+ <Box>
1268
+ <PageHeader
1269
+ title={report.name}
1270
+ breadcrumbs={[
1271
+ { label: 'Dashboard', path: '/dashboard' },
1272
+ { label: 'Reports', path: '/reports' },
1273
+ { label: report.name },
1274
+ ]}
1275
+ actions={
1276
+ <Button
1277
+ variant="outlined"
1278
+ startIcon={<ArrowBackIcon />}
1279
+ onClick={() => navigate('/reports')}
1280
+ >
1281
+ Back to Reports
1282
+ </Button>
1283
+ }
1284
+ />
1285
+
1286
+ <Card>
1287
+ <CardContent>
1288
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
1289
+ {report.description}
1290
+ </Typography>
1291
+
1292
+ {/* Parameter Form */}
1293
+ {report.parameters.length > 0 && (
1294
+ <>
1295
+ <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
1296
+ Report Parameters
1297
+ </Typography>
1298
+ <Stack spacing={2} sx={{ mb: 3, maxWidth: 600 }}>
1299
+ {report.parameters.map((param: ReportParameter) =>
1300
+ param.type === 'select' && param.options ? (
1301
+ <SelectController
1302
+ key={param.name}
1303
+ name={param.name}
1304
+ control={control}
1305
+ label={param.label}
1306
+ options={param.options.map((opt) => ({
1307
+ value: opt.value,
1308
+ label: opt.label,
1309
+ }))}
1310
+ />
1311
+ ) : (
1312
+ <TextFieldController
1313
+ key={param.name}
1314
+ name={param.name}
1315
+ control={control}
1316
+ label={param.label}
1317
+ required={param.required}
1318
+ type={param.type === 'date' ? 'date' : param.type === 'number' ? 'number' : 'text'}
1319
+ slotProps={{
1320
+ inputLabel: param.type === 'date' ? { shrink: true } : undefined,
1321
+ }}
1322
+ />
1323
+ ),
1324
+ )}
1325
+ </Stack>
1326
+ <Divider sx={{ mb: 3 }} />
1327
+ </>
1328
+ )}
1329
+
1330
+ {/* Format Selection */}
1331
+ <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
1332
+ Export Format
1333
+ </Typography>
1334
+ <ToggleButtonGroup
1335
+ value={selectedFormat}
1336
+ exclusive
1337
+ onChange={(_, value) => {
1338
+ if (value) setSelectedFormat(value as ReportFormat);
1339
+ }}
1340
+ size="small"
1341
+ sx={{ mb: 3 }}
1342
+ >
1343
+ {report.supportedFormats.includes('pdf') && (
1344
+ <ToggleButton value="pdf">
1345
+ <PictureAsPdfIcon sx={{ mr: 0.5 }} fontSize="small" />
1346
+ PDF
1347
+ </ToggleButton>
1348
+ )}
1349
+ {report.supportedFormats.includes('xlsx') && (
1350
+ <ToggleButton value="xlsx">
1351
+ <TableChartIcon sx={{ mr: 0.5 }} fontSize="small" />
1352
+ XLSX
1353
+ </ToggleButton>
1354
+ )}
1355
+ {report.supportedFormats.includes('csv') && (
1356
+ <ToggleButton value="csv">
1357
+ <DescriptionIcon sx={{ mr: 0.5 }} fontSize="small" />
1358
+ CSV
1359
+ </ToggleButton>
1360
+ )}
1361
+ </ToggleButtonGroup>
1362
+
1363
+ {/* Action Buttons */}
1364
+ <Stack direction="row" spacing={2}>
1365
+ {selectedFormat === 'pdf' && (
1366
+ <Button
1367
+ variant="outlined"
1368
+ startIcon={<PreviewIcon />}
1369
+ onClick={handlePreview}
1370
+ disabled={isGenerating}
1371
+ >
1372
+ Preview
1373
+ </Button>
1374
+ )}
1375
+ <Button
1376
+ variant="contained"
1377
+ startIcon={<DownloadIcon />}
1378
+ onClick={handleGenerate}
1379
+ loading={isGenerating}
1380
+ >
1381
+ Generate {selectedFormat.toUpperCase()}
1382
+ </Button>
1383
+ </Stack>
1384
+ </CardContent>
1385
+ </Card>
1386
+
1387
+ {/* Preview Dialog */}
1388
+ <ReportPreviewDialog
1389
+ open={previewOpen}
1390
+ onClose={() => setPreviewOpen(false)}
1391
+ html={previewHtml}
1392
+ title={report.name}
1393
+ />
1394
+ </Box>
1395
+ );
1396
+ }
1397
+ ```
1398
+
1399
+ ---
1400
+
1401
+ ## Report Preview Dialog
1402
+
1403
+ A MUI Dialog that renders the report layout as an HTML preview in an iframe:
1404
+
1405
+ ```tsx
1406
+ // src/features/reports/components/ReportPreviewDialog.tsx
1407
+ import {
1408
+ Dialog,
1409
+ DialogTitle,
1410
+ DialogContent,
1411
+ DialogActions,
1412
+ Button,
1413
+ IconButton,
1414
+ Box,
1415
+ } from '@mui/material';
1416
+ import CloseIcon from '@mui/icons-material/Close';
1417
+
1418
+ interface ReportPreviewDialogProps {
1419
+ /** Whether the dialog is open */
1420
+ open: boolean;
1421
+ /** Callback to close the dialog */
1422
+ onClose: () => void;
1423
+ /** The complete HTML string to preview */
1424
+ html: string;
1425
+ /** Report title shown in the dialog header */
1426
+ title: string;
1427
+ }
1428
+
1429
+ /**
1430
+ * Full-screen dialog that renders a report HTML preview inside an iframe.
1431
+ * The iframe uses a data URI to display the report without making a network request.
1432
+ */
1433
+ export function ReportPreviewDialog({
1434
+ open,
1435
+ onClose,
1436
+ html,
1437
+ title,
1438
+ }: ReportPreviewDialogProps) {
1439
+ // Encode the HTML as a data URI for the iframe src
1440
+ const dataUri = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
1441
+
1442
+ return (
1443
+ <Dialog open={open} onClose={onClose} fullScreen>
1444
+ <DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
1445
+ Preview: {title}
1446
+ <IconButton edge="end" onClick={onClose} aria-label="close">
1447
+ <CloseIcon />
1448
+ </IconButton>
1449
+ </DialogTitle>
1450
+ <DialogContent dividers sx={{ p: 0 }}>
1451
+ <Box
1452
+ component="iframe"
1453
+ src={dataUri}
1454
+ sx={{
1455
+ width: '100%',
1456
+ height: '100%',
1457
+ border: 'none',
1458
+ }}
1459
+ title={`Preview: ${title}`}
1460
+ />
1461
+ </DialogContent>
1462
+ <DialogActions>
1463
+ <Button onClick={onClose} variant="outlined">
1464
+ Close
1465
+ </Button>
1466
+ </DialogActions>
1467
+ </Dialog>
1468
+ );
1469
+ }
1470
+ ```
1471
+
1472
+ ---
1473
+
1474
+ ## Zustand Report Store (Optional)
1475
+
1476
+ If reports need client-side state beyond TanStack Query cache (e.g., persisting the last
1477
+ selected format or recent report history):
1478
+
1479
+ ```typescript
1480
+ // src/features/reports/store/report.store.ts
1481
+ import { create } from 'zustand';
1482
+ import { persist } from 'zustand/middleware';
1483
+ import type { ReportFormat } from '../reports.types';
1484
+
1485
+ interface ReportState {
1486
+ /** Last selected export format — remembered across page navigations */
1487
+ lastFormat: ReportFormat;
1488
+ /** Recently generated reports for quick access */
1489
+ recentReports: RecentReport[];
1490
+ setLastFormat: (format: ReportFormat) => void;
1491
+ addRecentReport: (report: RecentReport) => void;
1492
+ clearRecent: () => void;
1493
+ }
1494
+
1495
+ interface RecentReport {
1496
+ reportId: string;
1497
+ name: string;
1498
+ format: ReportFormat;
1499
+ generatedAt: string;
1500
+ }
1501
+
1502
+ const MAX_RECENT = 10;
1503
+
1504
+ export const useReportStore = create<ReportState>()(
1505
+ persist(
1506
+ (set) => ({
1507
+ lastFormat: 'pdf',
1508
+ recentReports: [],
1509
+
1510
+ setLastFormat: (format) => set({ lastFormat: format }),
1511
+
1512
+ addRecentReport: (report) =>
1513
+ set((state) => ({
1514
+ recentReports: [report, ...state.recentReports].slice(0, MAX_RECENT),
1515
+ })),
1516
+
1517
+ clearRecent: () => set({ recentReports: [] }),
1518
+ }),
1519
+ { name: 'report-preferences' },
1520
+ ),
1521
+ );
1522
+ ```
1523
+
1524
+ ---
1525
+
1526
+ ## React Router Integration
1527
+
1528
+ ```typescript
1529
+ // Add to src/router/index.tsx
1530
+
1531
+ import { lazy } from 'react';
1532
+
1533
+ const ReportListPage = lazy(() =>
1534
+ import('../features/reports/pages/ReportListPage').then((m) => ({
1535
+ default: m.ReportListPage,
1536
+ })),
1537
+ );
1538
+
1539
+ const ReportGeneratePage = lazy(() =>
1540
+ import('../features/reports/pages/ReportGeneratePage').then((m) => ({
1541
+ default: m.ReportGeneratePage,
1542
+ })),
1543
+ );
1544
+
1545
+ // Inside the protected dashboard routes children array:
1546
+ // {
1547
+ // path: '/reports',
1548
+ // element: (
1549
+ // <SuspenseWrapper>
1550
+ // <ReportListPage />
1551
+ // </SuspenseWrapper>
1552
+ // ),
1553
+ // },
1554
+ // {
1555
+ // path: '/reports/:reportId',
1556
+ // element: (
1557
+ // <SuspenseWrapper>
1558
+ // <ReportGeneratePage />
1559
+ // </SuspenseWrapper>
1560
+ // ),
1561
+ // },
1562
+ ```
1563
+
1564
+ Navigation item for the sidebar:
1565
+
1566
+ ```typescript
1567
+ // Add to src/shared/components/navigationConfig.ts
1568
+ import AssessmentIcon from '@mui/icons-material/Assessment';
1569
+
1570
+ // {
1571
+ // label: 'Reports',
1572
+ // path: '/reports',
1573
+ // icon: AssessmentIcon,
1574
+ // // Reports are typically available to all authenticated users
1575
+ // },
1576
+ ```
1577
+
1578
+ ---
1579
+
1580
+ ## Module Integration — Complete Sample Report
1581
+
1582
+ This section demonstrates how a module defines and registers a report. The example shows
1583
+ a Staff List Report within a hypothetical `staff` feature module.
1584
+
1585
+ ### Step 1: Report Data API
1586
+
1587
+ ```typescript
1588
+ // src/features/staff/api/staffApi.ts (add report data endpoint)
1589
+ import { axiosInstance } from '../../../lib/api/axiosInstance';
1590
+ import type { ReportDataRow } from '../../reports/reports.types';
1591
+
1592
+ export const staffApi = {
1593
+ // ... existing CRUD methods ...
1594
+
1595
+ /**
1596
+ * Fetch staff data formatted for report generation.
1597
+ * The backend returns a flat array suitable for tabular display.
1598
+ */
1599
+ getReportData: async (params: {
1600
+ department?: string;
1601
+ status?: string;
1602
+ }): Promise<ReportDataRow[]> => {
1603
+ const { data } = await axiosInstance.get<ReportDataRow[]>('/v1/staff/report', {
1604
+ params,
1605
+ });
1606
+ return data;
1607
+ },
1608
+ };
1609
+ ```
1610
+
1611
+ ### Step 2: Report Definition Constant
1612
+
1613
+ ```typescript
1614
+ // src/features/staff/reports/staffListReport.ts
1615
+ import type { ReportDefinition, ReportColumn } from '../../reports/reports.types';
1616
+
1617
+ export const STAFF_LIST_REPORT: ReportDefinition = {
1618
+ reportId: 'staff-list-report',
1619
+ name: 'Staff List Report',
1620
+ description: 'List all staff members with their department, position, and status.',
1621
+ domain: 'Human Resources',
1622
+ parameters: [
1623
+ {
1624
+ name: 'department',
1625
+ label: 'Department',
1626
+ type: 'select',
1627
+ required: false,
1628
+ options: [
1629
+ { label: 'Engineering', value: 'ENGINEERING' },
1630
+ { label: 'Marketing', value: 'MARKETING' },
1631
+ { label: 'Sales', value: 'SALES' },
1632
+ { label: 'Human Resources', value: 'HR' },
1633
+ ],
1634
+ },
1635
+ {
1636
+ name: 'status',
1637
+ label: 'Status',
1638
+ type: 'select',
1639
+ required: false,
1640
+ options: [
1641
+ { label: 'Active', value: 'ACTIVE' },
1642
+ { label: 'Inactive', value: 'INACTIVE' },
1643
+ { label: 'On Leave', value: 'ON_LEAVE' },
1644
+ ],
1645
+ },
1646
+ ],
1647
+ supportedFormats: ['pdf', 'xlsx', 'csv'],
1648
+ };
1649
+
1650
+ /**
1651
+ * Column definitions for the Staff List Report.
1652
+ * Used by both the PDF layout and client-side XLSX/CSV exports.
1653
+ */
1654
+ export const STAFF_LIST_COLUMNS: ReportColumn[] = [
1655
+ { key: 'name', header: 'Name', width: '20%' },
1656
+ { key: 'email', header: 'Email', width: '22%' },
1657
+ { key: 'department', header: 'Department', width: '15%' },
1658
+ { key: 'position', header: 'Position', width: '18%' },
1659
+ { key: 'status', header: 'Status', width: '10%' },
1660
+ { key: 'joinedDate', header: 'Joined Date', width: '15%', align: 'center' },
1661
+ ];
1662
+ ```
1663
+
1664
+ ### Step 3: Report Layout Component
1665
+
1666
+ ```tsx
1667
+ // src/features/staff/reports/StaffListReportLayout.tsx
1668
+ import { TableReportLayout } from '../../reports/layouts/TableReportLayout';
1669
+ import { STAFF_LIST_COLUMNS } from './staffListReport';
1670
+ import type { ReportDataRow } from '../../reports/reports.types';
1671
+
1672
+ interface StaffListReportLayoutProps {
1673
+ data: ReportDataRow[];
1674
+ parameters: { department?: string; status?: string };
1675
+ generatedAt: string;
1676
+ }
1677
+
1678
+ /**
1679
+ * Staff List Report layout component for PDF rendering.
1680
+ * This component is rendered to static HTML via renderReportHtml().
1681
+ */
1682
+ export function StaffListReportLayout({
1683
+ data,
1684
+ parameters,
1685
+ generatedAt,
1686
+ }: StaffListReportLayoutProps) {
1687
+ const subtitle = [
1688
+ parameters.department && `Department: ${parameters.department}`,
1689
+ parameters.status && `Status: ${parameters.status}`,
1690
+ ]
1691
+ .filter(Boolean)
1692
+ .join(' | ') || 'All Staff';
1693
+
1694
+ return (
1695
+ <TableReportLayout
1696
+ title="Staff List Report"
1697
+ subtitle={subtitle}
1698
+ generatedAt={generatedAt}
1699
+ columns={STAFF_LIST_COLUMNS}
1700
+ data={data}
1701
+ showTotalCount
1702
+ />
1703
+ );
1704
+ }
1705
+ ```
1706
+
1707
+ ### Step 4: Report Generation Hook (Module-Specific)
1708
+
1709
+ ```typescript
1710
+ // src/features/staff/reports/useStaffListReport.ts
1711
+ import { useCallback } from 'react';
1712
+ import { staffApi } from '../api/staffApi';
1713
+ import { useReportGeneration } from '../../reports/hooks/useReportGeneration';
1714
+ import { renderReportHtml } from '../../reports/layouts/renderReportHtml';
1715
+ import { StaffListReportLayout } from './StaffListReportLayout';
1716
+ import { STAFF_LIST_COLUMNS } from './staffListReport';
1717
+ import type { ReportFormat } from '../../reports/reports.types';
1718
+
1719
+ interface UseStaffListReportReturn {
1720
+ generate: (
1721
+ parameters: { department?: string; status?: string },
1722
+ format: ReportFormat,
1723
+ ) => Promise<void>;
1724
+ preview: (parameters: { department?: string; status?: string }) => Promise<string>;
1725
+ isGenerating: boolean;
1726
+ }
1727
+
1728
+ /**
1729
+ * Hook that encapsulates the complete staff list report generation flow.
1730
+ * Fetches data, builds the layout, and triggers the appropriate export.
1731
+ */
1732
+ export function useStaffListReport(): UseStaffListReportReturn {
1733
+ const { generatePdf, generateXlsx, generateCsv, isGenerating } = useReportGeneration();
1734
+
1735
+ const fetchData = useCallback(
1736
+ async (parameters: { department?: string; status?: string }) => {
1737
+ return staffApi.getReportData({
1738
+ department: parameters.department || undefined,
1739
+ status: parameters.status || undefined,
1740
+ });
1741
+ },
1742
+ [],
1743
+ );
1744
+
1745
+ const buildHtml = useCallback(
1746
+ async (parameters: { department?: string; status?: string }): Promise<string> => {
1747
+ const data = await fetchData(parameters);
1748
+ const element = (
1749
+ <StaffListReportLayout
1750
+ data={data}
1751
+ parameters={parameters}
1752
+ generatedAt={new Date().toISOString()}
1753
+ />
1754
+ );
1755
+ return renderReportHtml(element, 'Staff List Report');
1756
+ },
1757
+ [fetchData],
1758
+ );
1759
+
1760
+ const generate = useCallback(
1761
+ async (
1762
+ parameters: { department?: string; status?: string },
1763
+ format: ReportFormat,
1764
+ ): Promise<void> => {
1765
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1766
+ const baseFilename = `staff-list-report-${timestamp}`;
1767
+
1768
+ switch (format) {
1769
+ case 'pdf': {
1770
+ const html = await buildHtml(parameters);
1771
+ await generatePdf(html, `${baseFilename}.pdf`);
1772
+ break;
1773
+ }
1774
+ case 'xlsx': {
1775
+ const data = await fetchData(parameters);
1776
+ generateXlsx(data, STAFF_LIST_COLUMNS, `${baseFilename}.xlsx`);
1777
+ break;
1778
+ }
1779
+ case 'csv': {
1780
+ const data = await fetchData(parameters);
1781
+ generateCsv(data, STAFF_LIST_COLUMNS, `${baseFilename}.csv`);
1782
+ break;
1783
+ }
1784
+ }
1785
+ },
1786
+ [buildHtml, fetchData, generatePdf, generateXlsx, generateCsv],
1787
+ );
1788
+
1789
+ const preview = useCallback(
1790
+ async (parameters: { department?: string; status?: string }): Promise<string> => {
1791
+ return buildHtml(parameters);
1792
+ },
1793
+ [buildHtml],
1794
+ );
1795
+
1796
+ return { generate, preview, isGenerating };
1797
+ }
1798
+ ```
1799
+
1800
+ ---
1801
+
1802
+ ## Directory Structure (Report Feature)
1803
+
1804
+ ```
1805
+ src/features/
1806
+ ├── reports/ # Shared report infrastructure
1807
+ │ ├── api/
1808
+ │ │ └── reportApi.ts # Report catalogue API
1809
+ │ ├── components/
1810
+ │ │ └── ReportPreviewDialog.tsx # Full-screen preview dialog
1811
+ │ ├── hooks/
1812
+ │ │ ├── useReports.ts # TanStack Query hooks for report list
1813
+ │ │ └── useReportGeneration.ts # PDF/XLSX/CSV generation hook
1814
+ │ ├── layouts/
1815
+ │ │ ├── renderReportHtml.ts # ReactDOMServer HTML wrapper utility
1816
+ │ │ ├── ReportLayout.tsx # Base report layout (header + footer)
1817
+ │ │ └── TableReportLayout.tsx # Tabular report layout
1818
+ │ ├── pages/
1819
+ │ │ ├── ReportListPage.tsx # Report catalogue page
1820
+ │ │ └── ReportGeneratePage.tsx # Parameter form + generate page
1821
+ │ ├── store/
1822
+ │ │ └── report.store.ts # (Optional) Persisted report preferences
1823
+ │ ├── utils/
1824
+ │ │ ├── exportXlsx.ts # Client-side XLSX export
1825
+ │ │ └── exportCsv.ts # Client-side CSV export
1826
+ │ ├── reports.types.ts # Shared report type definitions
1827
+ │ ├── reports.schema.ts # Dynamic Zod schema builder
1828
+ │ └── index.ts # Public API re-exports
1829
+
1830
+ ├── staff/ # Example module with reports
1831
+ │ ├── api/
1832
+ │ │ └── staffApi.ts # Includes getReportData() method
1833
+ │ ├── reports/
1834
+ │ │ ├── staffListReport.ts # Report definition + column config
1835
+ │ │ ├── StaffListReportLayout.tsx # PDF layout component
1836
+ │ │ └── useStaffListReport.ts # Module-specific report generation hook
1837
+ │ └── ...
1838
+ ```
1839
+
1840
+ ---
1841
+
1842
+ ## Key Design Decisions
1843
+
1844
+ | Decision | Rationale |
1845
+ |---|---|
1846
+ | **Server-side Puppeteer for PDF** | Full CSS support (Tailwind, flexbox, grid), pixel-perfect rendering, support for page breaks, headers/footers, and complex layouts. Browser-based PDF libraries (jsPDF, html2canvas) produce inferior results. |
1847
+ | **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, well-maintained libraries. Instant export with no network latency. |
1848
+ | **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. |
1849
+ | **Tailwind CSS CDN in report HTML** | Puppeteer renders a standalone HTML document with no access to the application's Emotion/MUI runtime CSS. Tailwind CDN provides utility classes for styling without any build step. |
1850
+ | **Separate report service** | Puppeteer requires Node.js + headless Chrome which cannot run in the browser. A lightweight Express server keeps the concern isolated and can be independently scaled. |
1851
+ | **Dynamic Zod schemas** | Report parameters are defined as data (ReportDefinition), so the Zod schema is built dynamically at runtime. This allows new reports to be added without changing the form infrastructure. |
1852
+ | **Report layouts avoid MUI** | MUI relies on Emotion CSS-in-JS which requires a browser runtime. Puppeteer renders raw HTML — inline styles and Tailwind classes work reliably without a JS runtime. |
1853
+
1854
+ ---
1855
+
1856
+ ## Report Service Deployment
1857
+
1858
+ ### Development
1859
+
1860
+ - Start the Vite dev server: `npm run dev` (port {{DEV_PORT}})
1861
+ - Start the report service: `cd report-service && npm run dev` (port 3001)
1862
+ - Vite proxy forwards `/api/reports` to `http://localhost:3001`
1863
+
1864
+ ### Production
1865
+
1866
+ - Deploy the report service as a sidecar container or separate microservice
1867
+ - The React SPA connects to the report service via the `VITE_REPORT_SERVICE_URL`
1868
+ environment variable (or through an API gateway / ingress rule)
1869
+
1870
+ ### Environment Variables (Production)
1871
+
1872
+ ```env
1873
+ # React SPA (.env.production)
1874
+ VITE_REPORT_SERVICE_URL=${REPORT_SERVICE_URL}
1875
+
1876
+ # Report Service
1877
+ REPORT_SERVICE_PORT=3001
1878
+ ```
1879
+
1880
+ ### Dockerfile (Report Service)
1881
+
1882
+ ```dockerfile
1883
+ # report-service/Dockerfile
1884
+ FROM node:22-slim
1885
+
1886
+ # Install Chromium dependencies required by Puppeteer
1887
+ RUN apt-get update && apt-get install -y \
1888
+ chromium \
1889
+ fonts-liberation \
1890
+ libappindicator3-1 \
1891
+ libasound2 \
1892
+ libatk-bridge2.0-0 \
1893
+ libatk1.0-0 \
1894
+ libcups2 \
1895
+ libdbus-1-3 \
1896
+ libdrm2 \
1897
+ libgbm1 \
1898
+ libnspr4 \
1899
+ libnss3 \
1900
+ libxcomposite1 \
1901
+ libxdamage1 \
1902
+ libxrandr2 \
1903
+ xdg-utils \
1904
+ --no-install-recommends \
1905
+ && rm -rf /var/lib/apt/lists/*
1906
+
1907
+ # Tell Puppeteer to use the system-installed Chromium
1908
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
1909
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
1910
+
1911
+ WORKDIR /app
1912
+
1913
+ COPY package.json package-lock.json ./
1914
+ RUN npm ci --production
1915
+
1916
+ COPY dist/ ./dist/
1917
+
1918
+ EXPOSE 3001
1919
+
1920
+ USER node
1921
+
1922
+ CMD ["node", "dist/server.js"]
1923
+ ```
1924
+
1925
+ ---
1926
+
1927
+ ## Operational Notes
1928
+
1929
+ ### Chrome Memory Management
1930
+
1931
+ Puppeteer's headless Chrome can consume significant memory for large reports. Mitigations:
1932
+
1933
+ - **Browser singleton**: Reuse a single Chrome instance across requests (implemented in
1934
+ the server above). Each request opens a new page (tab) and closes it when done.
1935
+ - **Memory limit**: Set `--max-old-space-size` for the Node.js process and container
1936
+ memory limits in Kubernetes.
1937
+ - **Page cleanup**: Always close pages in a `finally` block to prevent memory leaks
1938
+ from failed requests.
1939
+
1940
+ ### Concurrent Report Generation
1941
+
1942
+ For production workloads with many concurrent report requests:
1943
+
1944
+ - **Semaphore pattern**: Limit the number of concurrent Puppeteer pages to prevent
1945
+ Chrome from running out of memory. Example: maximum 5 concurrent pages.
1946
+ - **Queue**: For high-volume scenarios, use a job queue (e.g., BullMQ with Redis) to
1947
+ serialize report generation and return results asynchronously.
1948
+
1949
+ ```typescript
1950
+ // Example: Simple concurrency limiter for the report service
1951
+ class Semaphore {
1952
+ private current = 0;
1953
+ private queue: (() => void)[] = [];
1954
+
1955
+ constructor(private readonly max: number) {}
1956
+
1957
+ async acquire(): Promise<void> {
1958
+ if (this.current < this.max) {
1959
+ this.current++;
1960
+ return;
1961
+ }
1962
+ return new Promise<void>((resolve) => {
1963
+ this.queue.push(resolve);
1964
+ });
1965
+ }
1966
+
1967
+ release(): void {
1968
+ this.current--;
1969
+ const next = this.queue.shift();
1970
+ if (next) {
1971
+ this.current++;
1972
+ next();
1973
+ }
1974
+ }
1975
+ }
1976
+
1977
+ // Usage in the report service:
1978
+ // const semaphore = new Semaphore(5);
1979
+ // app.post('/api/reports/pdf', async (req, res) => {
1980
+ // await semaphore.acquire();
1981
+ // try { /* generate PDF */ }
1982
+ // finally { semaphore.release(); }
1983
+ // });
1984
+ ```
1985
+
1986
+ ### Graceful Shutdown
1987
+
1988
+ The report service implements `SIGTERM` and `SIGINT` handlers that:
1989
+ 1. Stop accepting new HTTP connections
1990
+ 2. Close the Puppeteer browser instance (terminates headless Chrome)
1991
+ 3. Exit the process cleanly
1992
+
1993
+ This ensures Kubernetes can gracefully terminate the pod during deployments.
1994
+
1995
+ ### Health Check
1996
+
1997
+ The `/api/reports/health` endpoint returns `{ "status": "ok" }` and can be used as
1998
+ a Kubernetes liveness/readiness probe.