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