@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.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- 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, '&')
|
|
717
|
+
.replace(/</g, '<')
|
|
718
|
+
.replace(/>/g, '>')
|
|
719
|
+
.replace(/"/g, '"');
|
|
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} — 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.
|