@tonzxz/government-forms 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/FORM_CATALOG.md +4 -0
  2. package/README.md +52 -2
  3. package/dist/document-preview.d.ts +32 -0
  4. package/dist/document-preview.js +55 -0
  5. package/dist/gam/utils/export-preview-pdf.js +54 -43
  6. package/dist/gam/utils/print-element.js +41 -61
  7. package/dist/government-form.d.ts +6 -1
  8. package/dist/government-form.js +4 -2
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +1 -0
  11. package/dist/inventory/iar.d.ts +5 -0
  12. package/dist/inventory/iar.js +5 -0
  13. package/dist/inventory/ics.d.ts +5 -0
  14. package/dist/inventory/ics.js +5 -0
  15. package/dist/inventory/iirup.d.ts +5 -0
  16. package/dist/inventory/iirup.js +5 -0
  17. package/dist/inventory/iirusp.d.ts +5 -0
  18. package/dist/inventory/iirusp.js +5 -0
  19. package/dist/inventory/item.d.ts +5 -0
  20. package/dist/inventory/item.js +5 -0
  21. package/dist/inventory/itr.d.ts +5 -0
  22. package/dist/inventory/itr.js +5 -0
  23. package/dist/inventory/par.d.ts +5 -0
  24. package/dist/inventory/par.js +5 -0
  25. package/dist/inventory/po.d.ts +5 -0
  26. package/dist/inventory/po.js +5 -0
  27. package/dist/inventory/ppelc.d.ts +5 -0
  28. package/dist/inventory/ppelc.js +5 -0
  29. package/dist/inventory/pr.d.ts +5 -0
  30. package/dist/inventory/pr.js +6 -0
  31. package/dist/inventory/property-card.d.ts +5 -0
  32. package/dist/inventory/property-card.js +5 -0
  33. package/dist/inventory/ptr.d.ts +5 -0
  34. package/dist/inventory/ptr.js +5 -0
  35. package/dist/inventory/regspi.d.ts +5 -0
  36. package/dist/inventory/regspi.js +5 -0
  37. package/dist/inventory/ris.d.ts +5 -0
  38. package/dist/inventory/ris.js +5 -0
  39. package/dist/inventory/rlsddp.d.ts +5 -0
  40. package/dist/inventory/rlsddp.js +5 -0
  41. package/dist/inventory/rlsddsp.d.ts +5 -0
  42. package/dist/inventory/rlsddsp.js +5 -0
  43. package/dist/inventory/rpci.d.ts +5 -0
  44. package/dist/inventory/rpci.js +5 -0
  45. package/dist/inventory/rpcppe.d.ts +5 -0
  46. package/dist/inventory/rpcppe.js +5 -0
  47. package/dist/inventory/rpcsp.d.ts +5 -0
  48. package/dist/inventory/rpcsp.js +5 -0
  49. package/dist/inventory/rrsp.d.ts +5 -0
  50. package/dist/inventory/rrsp.js +5 -0
  51. package/dist/inventory/rsmi.d.ts +5 -0
  52. package/dist/inventory/rsmi.js +5 -0
  53. package/dist/inventory/rsmp.d.ts +5 -0
  54. package/dist/inventory/rsmp.js +5 -0
  55. package/dist/inventory/rspi.d.ts +5 -0
  56. package/dist/inventory/rspi.js +5 -0
  57. package/dist/inventory/spc.d.ts +5 -0
  58. package/dist/inventory/spc.js +5 -0
  59. package/dist/inventory/splc.d.ts +5 -0
  60. package/dist/inventory/splc.js +5 -0
  61. package/dist/inventory/stock-card.d.ts +5 -0
  62. package/dist/inventory/stock-card.js +5 -0
  63. package/dist/inventory/wmr.d.ts +5 -0
  64. package/dist/inventory/wmr.js +5 -0
  65. package/dist/reports/pr/_components/document-preview.js +5 -2
  66. package/dist/styles.css +148 -0
  67. package/package.json +5 -1
package/FORM_CATALOG.md CHANGED
@@ -42,3 +42,7 @@ Additional extracted components outside the reports directory:
42
42
  Purchase Order receives eligible purchase requests, approver display data and the generated PO number from the host. It does not query or authorize those records itself. Inventory item types are exported as `Inventory` and `InventoryStatus`.
43
43
 
44
44
  `extraction-manifest.json` records original source paths and SHA-256 hashes for traceability. Most source components are copied with import rewrites; the PO and RSMP forms were separated from their page/modal shells. Agency branding and remote font loading were removed where found. These are extracted application templates, not a guarantee that every agency accepts an identical document layout.
45
+
46
+ ## Document viewers
47
+
48
+ Every inventory entry exports `PreviewDialog` and `PreviewPanel` in addition to the existing `Preview`. Pass that preview’s typed props in `previewProps`. Both include PDF download and print controls; PR includes its XLSX exporter automatically. Other forms accept an optional `onExportXlsx` callback. Import `inventory/styles.css` once. See README for integration.
package/README.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  An installable React 19 library extracted from BCS IMS. The npm package is `@tonzxz/government-forms`; its inventory entry point is `@tonzxz/government-forms/inventory`. The package is configured for public npm distribution.
4
4
 
5
+ ## Document preview with PDF, XLSX, and print (0.2.0)
6
+
7
+ `Preview` is the printable document layout. `PreviewDialog` adds the scrollable modal and export toolbar shown in the original system. `PreviewPanel` embeds the same viewer directly on a page. All `/inventory/<code>` paths expose these components, including `/inventory/pplc`.
8
+
9
+ Import the package CSS once as described below. In Next.js, use the viewer from a client component:
10
+
11
+ ```tsx
12
+ "use client";
13
+ import { useState } from "react";
14
+ import {
15
+ PreviewDialog,
16
+ type PrRecord,
17
+ type PrSignatories,
18
+ } from "@tonzxz/government-forms/inventory/pr";
19
+
20
+ export function PurchaseRequestDetails({
21
+ record,
22
+ signatories,
23
+ }: {
24
+ record: PrRecord;
25
+ signatories?: PrSignatories;
26
+ }) {
27
+ const [open, setOpen] = useState(false);
28
+ return (
29
+ <>
30
+ <button type="button" onClick={() => setOpen(true)}>
31
+ View purchase request
32
+ </button>
33
+ <PreviewDialog
34
+ open={open}
35
+ onOpenChange={setOpen}
36
+ filename={record.prNo}
37
+ previewProps={{ record, signatories }}
38
+ />
39
+ </>
40
+ );
41
+ }
42
+ ```
43
+
44
+ Pass your API record to `record`; document numbers, agency details, line items, and signatories come from your ERP. The viewer does not fetch or save data. PR includes **Download XLSX**, **Download PDF**, **Print**, and **Close**. Other viewers include PDF and print; supply `onExportXlsx` to enable your spreadsheet exporter. The on-screen preview is the original HTML paper layout; PDF download renders that layout in the browser. PR documents automatically split after 12 items per page so later items remain visible and exportable.
45
+
46
+ For an embedded viewer, use `PreviewPanel` from the same import path with `previewProps={{ record, signatories }}`. Each form retains its own preview props; TypeScript shows the required fields. `pplc` uses its ledger preview props, not `PrRecord`.
47
+
48
+ To show a **Preview document** button beside the generic form's submit button, pass `preview={<Preview record={record} signatories={signatories} />}` and `previewFilename={record.prNo}` to `PRForm`, importing `Preview` from the PR path. Update that record from your controlled form values for a live draft preview. Supply `onExportXlsx` on `PRForm` if you also want its spreadsheet button; the PR `PreviewDialog` and `PreviewPanel` wire the exporter automatically.
49
+
50
+ The generic six-field PR payload is not a complete `PrRecord`. Map your draft or saved ERP data explicitly, or use the dedicated `PurchaseRequestCreateForm` for the fuller entry layout. Installing the update alone does not add a preview to an existing ERP page: render one of these viewer components.
51
+
52
+ Custom document layouts can use `DocumentPreviewDialog` or `DocumentPreviewPanel` from `@tonzxz/government-forms/inventory/preview` and pass the printable layout as `children`.
53
+
54
+
5
55
  ## Included
6
56
 
7
57
  - 25 configurable report definitions, available through `GovernmentForm`.
@@ -22,7 +72,7 @@ npm install @tonzxz/government-forms react@^19 react-dom@^19
22
72
  Before publishing, install the generated archive in a different repository:
23
73
 
24
74
  ```sh
25
- npm install /absolute/path/to/tonzxz-government-forms-0.1.0.tgz
75
+ npm install /absolute/path/to/tonzxz-government-forms-0.2.0.tgz
26
76
  ```
27
77
 
28
78
  The public package can be installed without npm authentication. Consumers use the exact same package version independently; deployments and databases stay separate.
@@ -43,7 +93,7 @@ const [value, setValue] = useState(() => createFormValues(definition));
43
93
  <PRForm value={value} onChange={setValue} onSubmit={savePurchaseRequest} />;
44
94
  ```
45
95
 
46
- Every report code in the catalog has an `/inventory/<code>` entry. Each exports its configurable form as both `default` and `Form`, plus `definition`, `Preview`, and the original named preview. Dedicated entry components are also exported where available. `Preview` and dedicated entry components retain their own typed props; their data models differ from the generic form payload.
96
+ Every report code in the catalog has an `/inventory/<code>` entry. Each exports its configurable form as both `default` and `Form`, plus `definition`, `Preview`, `PreviewDialog`, `PreviewPanel`, and the original named preview. Dedicated entry components are also exported where available. `Preview` and dedicated entry components retain their own typed props; their data models differ from the generic form payload.
47
97
 
48
98
  `/inventory/pplc` is an alias for the existing PPELC form (`/inventory/ppelc`); its document code stays `ppelc`. Additional paths `/inventory/po` and `/inventory/item` export their dedicated entry components as `default`/`Form` and their previews as `Preview`; these two do not have a generic `definition`. Existing root and `/forms/<code>` imports remain supported.
49
99
 
@@ -0,0 +1,32 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ export type DocumentPreviewProps = {
3
+ children: ReactNode;
4
+ title?: string;
5
+ description?: string;
6
+ filename?: string;
7
+ /** Optional spreadsheet exporter supplied by the form or ERP integration. */
8
+ onExportXlsx?: () => void | Promise<void>;
9
+ className?: string;
10
+ };
11
+ export type DocumentPreviewDialogProps = DocumentPreviewProps & {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ };
15
+ /** Embedded paper preview with PDF/print actions. This displays the document layout, not a PDF iframe. */
16
+ export declare function DocumentPreviewPanel({ title, description, className, ...props }: DocumentPreviewProps): import("react/jsx-runtime").JSX.Element;
17
+ /** Accessible modal equivalent of the application's document-details viewer. */
18
+ export declare function DocumentPreviewDialog({ open, onOpenChange, title, description, className, ...props }: DocumentPreviewDialogProps): import("react/jsx-runtime").JSX.Element;
19
+ export type FormPreviewPanelProps<P extends object> = Omit<DocumentPreviewProps, 'children'> & {
20
+ previewProps: P;
21
+ };
22
+ export type FormPreviewDialogProps<P extends object> = Omit<DocumentPreviewDialogProps, 'children'> & {
23
+ previewProps: P;
24
+ };
25
+ /** Bind a viewer to each existing document's exact typed props. No record data is fabricated. */
26
+ export declare function createDocumentPreviewComponents<P extends object>(Preview: ComponentType<P>, defaults: {
27
+ title: string;
28
+ filename: string;
29
+ }, exportXlsx?: (props: P) => void | Promise<void>): {
30
+ PreviewPanel: ({ previewProps, ...props }: FormPreviewPanelProps<P>) => import("react/jsx-runtime").JSX.Element;
31
+ PreviewDialog: ({ previewProps, ...props }: FormPreviewDialogProps<P>) => import("react/jsx-runtime").JSX.Element;
32
+ };
@@ -0,0 +1,55 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useRef, useState } from 'react';
4
+ import { Dialog } from '@base-ui/react/dialog';
5
+ function PreviewBody({ children, filename = 'document', onExportXlsx, close }) {
6
+ const paper = useRef(null);
7
+ const lock = useRef(false);
8
+ const [busy, setBusy] = useState(null);
9
+ const [error, setError] = useState('');
10
+ async function perform(action) {
11
+ if (lock.current || !paper.current)
12
+ return;
13
+ lock.current = true;
14
+ setBusy(action);
15
+ setError('');
16
+ try {
17
+ if (action === 'xlsx')
18
+ await onExportXlsx?.();
19
+ else if (action === 'pdf') {
20
+ const { exportPreviewElementToPdf } = await import('./gam/utils/export-preview-pdf.js');
21
+ await exportPreviewElementToPdf(paper.current, filename.replace(/\.pdf$/i, '') + '.pdf');
22
+ }
23
+ else {
24
+ const { printElement } = await import('./gam/utils/print-element.js');
25
+ await printElement(paper.current, filename);
26
+ }
27
+ }
28
+ catch (cause) {
29
+ setError(cause instanceof Error ? cause.message : 'Unable to prepare the document. Please try again.');
30
+ }
31
+ finally {
32
+ lock.current = false;
33
+ setBusy(null);
34
+ }
35
+ }
36
+ return _jsxs(_Fragment, { children: [_jsx("div", { className: "qforms-preview-viewport", tabIndex: 0, "aria-label": "Document pages", children: _jsx("div", { ref: paper, className: "qforms-preview-paper", children: children }) }), error && _jsx("p", { role: "alert", className: "qforms-preview-error", children: error }), _jsxs("footer", { className: "qforms-preview-actions", "aria-busy": busy !== null, children: [_jsx("span", { role: "status", className: "qforms-preview-status", children: busy ? 'Preparing document…' : '' }), onExportXlsx && _jsx("button", { type: "button", disabled: busy !== null, onClick: () => void perform('xlsx'), children: "Download XLSX" }), _jsx("button", { type: "button", disabled: busy !== null, onClick: () => void perform('pdf'), children: "Download PDF" }), _jsx("button", { type: "button", disabled: busy !== null, onClick: () => void perform('print'), children: "Print" }), close] })] });
37
+ }
38
+ /** Embedded paper preview with PDF/print actions. This displays the document layout, not a PDF iframe. */
39
+ export function DocumentPreviewPanel({ title = 'Document preview', description = 'Preview the document before downloading or printing.', className = '', ...props }) {
40
+ return _jsxs("section", { className: `qforms-preview-panel ${className}`, "aria-label": title, children: [_jsxs("header", { className: "qforms-preview-header", children: [_jsx("h2", { children: title }), _jsx("p", { children: description })] }), _jsx(PreviewBody, { ...props })] });
41
+ }
42
+ /** Accessible modal equivalent of the application's document-details viewer. */
43
+ export function DocumentPreviewDialog({ open, onOpenChange, title = 'Document preview', description = 'Read-only view of the document.', className = '', ...props }) {
44
+ return _jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(Dialog.Portal, { children: [_jsx(Dialog.Backdrop, { className: "qforms-preview-backdrop" }), _jsxs(Dialog.Popup, { className: `qforms-preview-dialog ${className}`, children: [_jsxs("header", { className: "qforms-preview-header", children: [_jsx(Dialog.Title, { children: title }), _jsx(Dialog.Description, { children: description }), _jsx(Dialog.Close, { className: "qforms-preview-dismiss", "aria-label": "Close document preview", children: "\u00D7" })] }), _jsx(PreviewBody, { ...props, close: _jsx(Dialog.Close, { className: "qforms-preview-close", children: "Close" }) })] })] }) });
45
+ }
46
+ /** Bind a viewer to each existing document's exact typed props. No record data is fabricated. */
47
+ export function createDocumentPreviewComponents(Preview, defaults, exportXlsx) {
48
+ function PreviewPanel({ previewProps, ...props }) {
49
+ return _jsx(DocumentPreviewPanel, { ...defaults, onExportXlsx: exportXlsx ? () => exportXlsx(previewProps) : undefined, ...props, children: _jsx(Preview, { ...previewProps }) });
50
+ }
51
+ function PreviewDialog({ previewProps, ...props }) {
52
+ return _jsx(DocumentPreviewDialog, { ...defaults, onExportXlsx: exportXlsx ? () => exportXlsx(previewProps) : undefined, ...props, children: _jsx(Preview, { ...previewProps }) });
53
+ }
54
+ return { PreviewPanel, PreviewDialog };
55
+ }
@@ -129,46 +129,53 @@ export async function renderPreviewToCanvas(root) {
129
129
  // html2canvas's cloned iframe and can crop wrapped table text.
130
130
  const pageWidth = Math.ceil(Math.max(pageRect.width, page.scrollWidth));
131
131
  const pageHeight = Math.ceil(Math.max(pageRect.height, page.scrollHeight));
132
- const canvas = await html2canvas(page, {
133
- backgroundColor: "#fff",
134
- scale: Math.min(Math.max(window.devicePixelRatio || 2, 2), 3),
135
- useCORS: true,
136
- scrollX: 0,
137
- scrollY: -window.scrollY,
138
- width: pageWidth,
139
- height: pageHeight,
140
- windowWidth: pageWidth,
141
- windowHeight: pageHeight,
142
- onclone: (clonedDocument, clonedPage) => {
143
- // Preview ancestors carry screen-only scaling and clipping. Remove those
144
- // from the capture so text and borders use the same natural coordinates.
145
- let ancestor = clonedPage;
146
- while (ancestor) {
147
- ancestor.style.setProperty("transform", "none", "important");
148
- ancestor.style.setProperty("zoom", "1", "important");
149
- ancestor.style.setProperty("overflow", "visible", "important");
150
- ancestor = ancestor.parentElement;
151
- }
152
- // html2canvas 1.4.1 can't parse lab()/oklch()/lch() — Tailwind v4 uses them
153
- // inside generated stylesheets. Resolve them to rgb() in the clone before
154
- // html2canvas reads computed styles.
155
- sanitizeUnsupportedColorFunctions(clonedDocument, createUnsupportedColorNormalizer());
156
- // ICS uses the page itself as its white paper background. Clear every
157
- // structural table fill in the capture clone so html2canvas cannot paint
158
- // an opaque header or cell layer over labels and values. Both PDF export
159
- // and printing use this capture path.
160
- const clonedIcsPage = clonedPage.matches('[data-ics-preview="true"]')
161
- ? clonedPage
162
- : clonedPage.querySelector('[data-ics-preview="true"]');
163
- if (clonedIcsPage) {
164
- const tableLayers = clonedIcsPage.querySelectorAll("table, thead, tbody, tr, th, td");
165
- for (const layer of tableLayers) {
166
- layer.style.setProperty("background", "transparent", "important");
167
- layer.style.setProperty("background-color", "transparent", "important");
132
+ // html2canvas measures font baselines with a hidden 1px image in the live
133
+ // document. Tailwind's block-image reset breaks that inline baseline probe.
134
+ // Restrict this temporary override to the probe, preserving document images.
135
+ const metricsStyle = document.createElement('style');
136
+ metricsStyle.textContent = 'body > div[style*="visibility: hidden"] > img[width="1"][height="1"] { display: inline !important; }';
137
+ document.head.appendChild(metricsStyle);
138
+ try {
139
+ const canvas = await html2canvas(page, {
140
+ backgroundColor: "#fff",
141
+ scale: Math.min(Math.max(window.devicePixelRatio || 2, 2), 3),
142
+ useCORS: true,
143
+ scrollX: 0,
144
+ scrollY: -window.scrollY,
145
+ width: pageWidth,
146
+ height: pageHeight,
147
+ windowWidth: pageWidth,
148
+ windowHeight: pageHeight,
149
+ onclone: (clonedDocument, clonedPage) => {
150
+ // Preview ancestors carry screen-only scaling and clipping. Remove those
151
+ // from the capture so text and borders use the same natural coordinates.
152
+ let ancestor = clonedPage;
153
+ while (ancestor) {
154
+ ancestor.style.setProperty("transform", "none", "important");
155
+ ancestor.style.setProperty("zoom", "1", "important");
156
+ ancestor.style.setProperty("overflow", "visible", "important");
157
+ ancestor = ancestor.parentElement;
168
158
  }
169
- }
170
- const style = clonedDocument.createElement("style");
171
- style.textContent = `
159
+ // html2canvas 1.4.1 can't parse lab()/oklch()/lch() — Tailwind v4 uses them
160
+ // inside generated stylesheets. Resolve them to rgb() in the clone before
161
+ // html2canvas reads computed styles.
162
+ sanitizeUnsupportedColorFunctions(clonedDocument, createUnsupportedColorNormalizer());
163
+ // ICS uses the page itself as its white paper background. Clear every
164
+ // structural table fill in the capture clone so html2canvas cannot paint
165
+ // an opaque header or cell layer over labels and values. Both PDF export
166
+ // and printing use this capture path.
167
+ const clonedIcsPage = clonedPage.matches('[data-ics-preview="true"]')
168
+ ? clonedPage
169
+ : clonedPage.querySelector('[data-ics-preview="true"]');
170
+ if (clonedIcsPage) {
171
+ const tableLayers = clonedIcsPage.querySelectorAll("table, thead, tbody, tr, th, td");
172
+ for (const layer of tableLayers) {
173
+ layer.style.setProperty("background", "transparent", "important");
174
+ layer.style.setProperty("background-color", "transparent", "important");
175
+ }
176
+ }
177
+ const style = clonedDocument.createElement("style");
178
+ style.textContent = `
172
179
  html, body {
173
180
  background: #fff !important;
174
181
  }
@@ -183,10 +190,14 @@ export async function renderPreviewToCanvas(root) {
183
190
  transform: none !important;
184
191
  }
185
192
  `;
186
- clonedDocument.head.appendChild(style);
187
- },
188
- });
189
- return { canvas, orientation, fitOnePage };
193
+ clonedDocument.head.appendChild(style);
194
+ },
195
+ });
196
+ return { canvas, orientation, fitOnePage };
197
+ }
198
+ finally {
199
+ metricsStyle.remove();
200
+ }
190
201
  }
191
202
  export async function exportPreviewElementToPdf(root, filename) {
192
203
  const pages = Array.from(root.querySelectorAll(PDF_PAGE_SELECTOR));
@@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client";
6
6
  * Opens the browser print dialog so the DOCUMENT FORM itself is printed —
7
7
  * never the screen, the modal, or anything else on the page.
8
8
  *
9
- * Why this approach can't fail:
9
+ * Rendering approach:
10
10
  * - The preview is rendered with the SAME html2canvas pipeline the
11
11
  * "Download PDF" buttons use. html2canvas captures ONLY the target element,
12
12
  * so the modal/backdrop/app chrome are never part of the rendered output.
@@ -20,65 +20,45 @@ import { createRoot } from "react-dom/client";
20
20
  * size or a letterboxed/whitespace layout.
21
21
  */
22
22
  import { renderPreviewToCanvas } from "./export-preview-pdf.js";
23
- // A4 dimensions in millimeters — portrait 210×297, landscape 297×210.
24
- const A4_PORTRAIT_MM = { width: 210, height: 297 };
25
- const A4_LANDSCAPE_MM = { width: 297, height: 210 };
26
- function buildPrintHtml({ imageSrc, title, landscape, }) {
23
+ function buildPrintHtml({ pages, title }) {
27
24
  const safeTitle = title.replace(/[<>&"']/g, "");
28
- const pageMm = landscape ? A4_LANDSCAPE_MM : A4_PORTRAIT_MM;
29
- return `<!doctype html>
30
- <html lang="en">
31
- <head>
32
- <meta charset="utf-8" />
33
- <title>${safeTitle}</title>
34
- <style>
35
- @page {
36
- size: A4 ${landscape ? "landscape" : "portrait"};
37
- margin: 0;
38
- }
39
- html, body {
40
- margin: 0;
41
- padding: 0;
42
- background: #fff;
43
- }
44
- /* The print sheet is exactly A4, so the form fills the sheet with no
45
- browser-default paper size (e.g. Letter) or letterboxing involved. */
46
- .print-stage {
47
- display: flex;
48
- align-items: center;
49
- justify-content: center;
50
- width: ${pageMm.width}mm;
51
- height: ${pageMm.height}mm;
52
- background: #fff;
53
- }
54
- .print-stage img {
55
- display: block;
56
- width: 100%;
57
- height: 100%;
58
- object-fit: contain;
59
- }
60
- </style>
61
- </head>
62
- <body>
63
- <div class="print-stage">
64
- <img src="${imageSrc}" alt="" />
65
- </div>
66
- </body>
67
- </html>`;
25
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${safeTitle}</title><style>
26
+ @page portrait { size: A4 portrait; margin: 0; }
27
+ @page landscape { size: A4 landscape; margin: 0; }
28
+ html, body { margin: 0; padding: 0; background: white; }
29
+ .print-stage { width: 210mm; height: 297mm; page: portrait; break-after: page; }
30
+ .print-stage.landscape { width: 297mm; height: 210mm; page: landscape; }
31
+ .print-stage:last-child { break-after: auto; }
32
+ img { display: block; width: 100%; height: 100%; object-fit: contain; }
33
+ </style></head><body>${pages.map(page => `<div class="print-stage${page.landscape ? ' landscape' : ''}"><img src="${page.imageSrc}" alt=""></div>`).join('')}</body></html>`;
68
34
  }
69
35
  export async function printElement(root, title = "Document") {
70
36
  const cleanTitle = title.replace(/\.pdf$/i, "").trim() || "Document";
71
- // Render the form exactly like the Download PDF buttons do. html2canvas
72
- // only captures `root` (the document preview), so the modal can never
73
- // appear in the printed output.
74
- const { canvas, orientation } = await renderPreviewToCanvas(root);
75
- const imageSrc = canvas.toDataURL("image/png");
76
- const landscape = orientation === "landscape";
37
+ const explicitPages = Array.from(root.querySelectorAll('[data-pdf-page="true"], .iar-a4-page, .itr-a4-page, .regspi-a4-page, .splc-a4-page, .spc-a4-page'));
38
+ const pages = [];
39
+ for (const element of explicitPages.length > 0 ? explicitPages : [root]) {
40
+ const { canvas, orientation, fitOnePage } = await renderPreviewToCanvas(element);
41
+ const landscape = orientation === 'landscape';
42
+ const pageHeight = fitOnePage ? canvas.height : canvas.width * (landscape ? 210 / 297 : 297 / 210);
43
+ for (let y = 0; y < canvas.height; y += pageHeight) {
44
+ const slice = document.createElement('canvas');
45
+ slice.width = canvas.width;
46
+ slice.height = Math.ceil(fitOnePage ? canvas.height : pageHeight);
47
+ const ctx = slice.getContext('2d');
48
+ if (!ctx)
49
+ throw new Error('Unable to prepare the print page.');
50
+ ctx.fillStyle = '#fff';
51
+ ctx.fillRect(0, 0, slice.width, slice.height);
52
+ const height = Math.min(pageHeight, canvas.height - y);
53
+ ctx.drawImage(canvas, 0, y, canvas.width, height, 0, 0, canvas.width, height);
54
+ pages.push({ imageSrc: slice.toDataURL('image/png'), landscape });
55
+ }
56
+ }
77
57
  const iframe = document.createElement("iframe");
78
58
  // Off-screen but not display:none — some browsers refuse to print
79
59
  // display:none iframes. Sized to A4 proportions so layout is computed.
80
60
  iframe.style.cssText = "position:fixed;top:0;left:-10000px;width:794px;height:1123px;border:0;";
81
- iframe.srcdoc = buildPrintHtml({ imageSrc, title: cleanTitle, landscape });
61
+ iframe.srcdoc = buildPrintHtml({ pages, title: cleanTitle });
82
62
  iframe.setAttribute("aria-hidden", "true");
83
63
  document.body.appendChild(iframe);
84
64
  return new Promise(resolve => {
@@ -104,26 +84,26 @@ export async function printElement(root, title = "Document") {
104
84
  cleanup();
105
85
  return;
106
86
  }
107
- const image = frameDocument.querySelector("img");
87
+ const images = Array.from(frameDocument.querySelectorAll("img"));
108
88
  const firePrint = () => {
109
89
  if (printTriggered)
110
90
  return;
111
91
  printTriggered = true;
112
92
  frameWindow.focus();
93
+ frameWindow.addEventListener("afterprint", () => cleanup(), { once: true });
113
94
  frameWindow.print();
114
95
  // Chrome fires "afterprint" on the printed window once the dialog
115
96
  // closes. Listen there so cleanup happens right after.
116
- frameWindow.addEventListener("afterprint", () => cleanup());
117
97
  };
118
- if (image && !image.complete) {
119
- image.addEventListener("load", () => firePrint(), { once: true });
120
- image.addEventListener("error", () => firePrint(), { once: true });
121
- // Fallback: if the image stalls, still open the dialog.
122
- fallbackTimer = window.setTimeout(() => firePrint(), 3000);
98
+ if (images.some(image => !image.complete)) {
99
+ void Promise.all(images.map(image => image.complete ? Promise.resolve() : new Promise(resolve => {
100
+ image.addEventListener('load', () => resolve(), { once: true });
101
+ image.addEventListener('error', () => resolve(), { once: true });
102
+ }))).then(firePrint);
103
+ fallbackTimer = window.setTimeout(firePrint, 3000);
123
104
  }
124
- else {
105
+ else
125
106
  firePrint();
126
- }
127
107
  });
128
108
  iframe.addEventListener("error", () => cleanup());
129
109
  });
@@ -11,6 +11,11 @@ export type GovernmentFormProps = {
11
11
  submitLabel?: string;
12
12
  disabled?: boolean;
13
13
  children?: ReactNode;
14
+ /** Supply the actual document preview and map the form data to its typed props. */
15
+ preview?: ReactNode;
16
+ previewTitle?: string;
17
+ previewFilename?: string;
18
+ onExportXlsx?: () => void | Promise<void>;
14
19
  };
15
20
  /** A controlled entry form for the repository's configurable report definitions. */
16
- export declare function GovernmentForm({ code, definition, value, onChange, onSubmit, validate, submitLabel, disabled, children }: GovernmentFormProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function GovernmentForm({ code, definition, value, onChange, onSubmit, validate, submitLabel, disabled, children, preview, previewTitle, previewFilename, onExportXlsx }: GovernmentFormProps): import("react/jsx-runtime").JSX.Element;
@@ -1,10 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
+ import { DocumentPreviewDialog } from './document-preview.js';
4
5
  import { formDefinitions, validateFormValues } from './definitions.js';
5
6
  /** A controlled entry form for the repository's configurable report definitions. */
6
- export function GovernmentForm({ code, definition = formDefinitions[code], value, onChange, onSubmit, validate, submitLabel = 'Save document', disabled = false, children }) {
7
+ export function GovernmentForm({ code, definition = formDefinitions[code], value, onChange, onSubmit, validate, submitLabel = 'Save document', disabled = false, children, preview, previewTitle, previewFilename, onExportXlsx }) {
7
8
  const prefix = useId();
9
+ const [previewOpen, setPreviewOpen] = useState(false);
8
10
  const saving = useRef(false);
9
11
  const [pending, setPending] = useState(false);
10
12
  const [errors, setErrors] = useState({});
@@ -41,5 +43,5 @@ export function GovernmentForm({ code, definition = formDefinitions[code], value
41
43
  saving.current = false;
42
44
  setPending(false);
43
45
  }
44
- }, children: [_jsxs("header", { className: "qforms-header", children: [_jsx("p", { className: "qforms-code", children: definition.reportCode }), _jsx("h2", { children: definition.title }), _jsx("p", { children: definition.description })] }), Object.keys(errors).length > 0 && _jsxs("div", { ref: summaryRef, tabIndex: -1, role: "alert", className: "qforms-errors", children: [_jsx("strong", { children: "Review the form before saving." }), _jsx("ul", { children: Object.entries(errors).map(([key, error]) => _jsx("li", { children: _jsx("a", { href: `#${prefix}-${key}`, children: error }) }, key)) })] }), _jsx("div", { className: "qforms-grid", children: definition.fields.map(field => input(field, value.values[field.id] ?? '', `values.${field.id}`, next => onChange({ ...value, values: { ...value.values, [field.id]: next } }))) }), !!definition.tableFields?.length && _jsxs("section", { className: "qforms-lines", "aria-label": "Line items", children: [_jsxs("div", { className: "qforms-line-heading", children: [_jsx("h3", { children: "Line items" }), _jsx("button", { type: "button", disabled: locked, onClick: () => onChange({ ...value, rows: [...value.rows, Object.fromEntries(definition.tableFields.map(field => [field.id, '']))] }), children: "Add item" })] }), value.rows.map((row, index) => _jsxs("fieldset", { className: "qforms-row", children: [_jsxs("legend", { children: ["Item ", index + 1] }), _jsx("div", { className: "qforms-grid", children: definition.tableFields.map(field => input(field, row[field.id] ?? '', `rows.${index}.${field.id}`, next => onChange({ ...value, rows: value.rows.map((item, i) => i === index ? { ...item, [field.id]: next } : item) }))) }), _jsx("button", { type: "button", disabled: locked, "aria-label": `Remove item ${index + 1}`, onClick: () => { setErrors({}); onChange({ ...value, rows: value.rows.filter((_, i) => i !== index) }); }, children: "Remove item" })] }, index))] }), children, saveError && _jsx("p", { role: "alert", className: "qforms-error", children: saveError }), _jsx("footer", { children: _jsx("button", { type: "submit", disabled: locked, children: pending ? 'Saving…' : submitLabel }) })] });
46
+ }, children: [_jsxs("header", { className: "qforms-header", children: [_jsx("p", { className: "qforms-code", children: definition.reportCode }), _jsx("h2", { children: definition.title }), _jsx("p", { children: definition.description })] }), Object.keys(errors).length > 0 && _jsxs("div", { ref: summaryRef, tabIndex: -1, role: "alert", className: "qforms-errors", children: [_jsx("strong", { children: "Review the form before saving." }), _jsx("ul", { children: Object.entries(errors).map(([key, error]) => _jsx("li", { children: _jsx("a", { href: `#${prefix}-${key}`, children: error }) }, key)) })] }), _jsx("div", { className: "qforms-grid", children: definition.fields.map(field => input(field, value.values[field.id] ?? '', `values.${field.id}`, next => onChange({ ...value, values: { ...value.values, [field.id]: next } }))) }), !!definition.tableFields?.length && _jsxs("section", { className: "qforms-lines", "aria-label": "Line items", children: [_jsxs("div", { className: "qforms-line-heading", children: [_jsx("h3", { children: "Line items" }), _jsx("button", { type: "button", disabled: locked, onClick: () => onChange({ ...value, rows: [...value.rows, Object.fromEntries(definition.tableFields.map(field => [field.id, '']))] }), children: "Add item" })] }), value.rows.map((row, index) => _jsxs("fieldset", { className: "qforms-row", children: [_jsxs("legend", { children: ["Item ", index + 1] }), _jsx("div", { className: "qforms-grid", children: definition.tableFields.map(field => input(field, row[field.id] ?? '', `rows.${index}.${field.id}`, next => onChange({ ...value, rows: value.rows.map((item, i) => i === index ? { ...item, [field.id]: next } : item) }))) }), _jsx("button", { type: "button", disabled: locked, "aria-label": `Remove item ${index + 1}`, onClick: () => { setErrors({}); onChange({ ...value, rows: value.rows.filter((_, i) => i !== index) }); }, children: "Remove item" })] }, index))] }), children, saveError && _jsx("p", { role: "alert", className: "qforms-error", children: saveError }), _jsxs("footer", { children: [preview != null && _jsx("button", { type: "button", onClick: () => setPreviewOpen(true), children: "Preview document" }), _jsx("button", { type: "submit", disabled: locked, children: pending ? 'Saving…' : submitLabel })] }), preview != null && _jsx(DocumentPreviewDialog, { open: previewOpen, onOpenChange: setPreviewOpen, title: previewTitle ?? `${definition.title} — Document preview`, filename: previewFilename ?? definition.reportCode, onExportXlsx: onExportXlsx, children: preview })] });
45
47
  }
package/dist/index.d.ts CHANGED
@@ -47,3 +47,5 @@ export * from './inventory/components/inventory-item-form.js';
47
47
  export * from './inventory/components/inventory-item-preview.js';
48
48
  export * from './inventory/types.js';
49
49
  export * from './gam/components/purchase-order-page-client.js';
50
+ export { DocumentPreviewDialog, DocumentPreviewPanel } from './document-preview.js';
51
+ export type { DocumentPreviewProps, DocumentPreviewDialogProps, FormPreviewDialogProps, FormPreviewPanelProps } from './document-preview.js';
package/dist/index.js CHANGED
@@ -48,3 +48,4 @@ export * from './inventory/components/inventory-item-form.js';
48
48
  export * from './inventory/components/inventory-item-preview.js';
49
49
  export * from './inventory/types.js';
50
50
  export * from './gam/components/purchase-order-page-client.js';
51
+ export { DocumentPreviewDialog, DocumentPreviewPanel } from './document-preview.js';
@@ -2,3 +2,8 @@ export { Form, Form as default, definition } from '../forms/iar.js';
2
2
  export { IarDocumentPreview, IarDocumentPreview as Preview } from '../reports/iar/_components/document-preview.js';
3
3
  export { IarCreateForm } from '../reports/iar/_components/create-form.js';
4
4
  export type * from '../reports/iar/types.js';
5
+ import { IarDocumentPreview as PreviewComponent } from '../reports/iar/_components/document-preview.js';
6
+ import type { ComponentType, ComponentProps } from 'react';
7
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
8
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
9
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -2,3 +2,8 @@
2
2
  export { Form, Form as default, definition } from '../forms/iar.js';
3
3
  export { IarDocumentPreview, IarDocumentPreview as Preview } from '../reports/iar/_components/document-preview.js';
4
4
  export { IarCreateForm } from '../reports/iar/_components/create-form.js';
5
+ import { IarDocumentPreview as PreviewComponent } from '../reports/iar/_components/document-preview.js';
6
+ import { createDocumentPreviewComponents } from '../document-preview.js';
7
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'IAR document preview', filename: 'iar' });
8
+ export const PreviewDialog = previewComponents.PreviewDialog;
9
+ export const PreviewPanel = previewComponents.PreviewPanel;
@@ -1,2 +1,7 @@
1
1
  export { Form, Form as default, definition } from '../forms/ics.js';
2
2
  export { IcsPreview, IcsPreview as Preview } from '../gam/components/ics-preview.js';
3
+ import { IcsPreview as PreviewComponent } from '../gam/components/ics-preview.js';
4
+ import type { ComponentType, ComponentProps } from 'react';
5
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
6
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
7
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -1,3 +1,8 @@
1
1
  "use client";
2
2
  export { Form, Form as default, definition } from '../forms/ics.js';
3
3
  export { IcsPreview, IcsPreview as Preview } from '../gam/components/ics-preview.js';
4
+ import { IcsPreview as PreviewComponent } from '../gam/components/ics-preview.js';
5
+ import { createDocumentPreviewComponents } from '../document-preview.js';
6
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'ICS document preview', filename: 'ics' });
7
+ export const PreviewDialog = previewComponents.PreviewDialog;
8
+ export const PreviewPanel = previewComponents.PreviewPanel;
@@ -1,2 +1,7 @@
1
1
  export { Form, Form as default, definition } from '../forms/iirup.js';
2
2
  export { IirupPreview, IirupPreview as Preview } from '../gam/components/iirup-preview.js';
3
+ import { IirupPreview as PreviewComponent } from '../gam/components/iirup-preview.js';
4
+ import type { ComponentType, ComponentProps } from 'react';
5
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
6
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
7
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -1,3 +1,8 @@
1
1
  "use client";
2
2
  export { Form, Form as default, definition } from '../forms/iirup.js';
3
3
  export { IirupPreview, IirupPreview as Preview } from '../gam/components/iirup-preview.js';
4
+ import { IirupPreview as PreviewComponent } from '../gam/components/iirup-preview.js';
5
+ import { createDocumentPreviewComponents } from '../document-preview.js';
6
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'IIRUP document preview', filename: 'iirup' });
7
+ export const PreviewDialog = previewComponents.PreviewDialog;
8
+ export const PreviewPanel = previewComponents.PreviewPanel;
@@ -2,3 +2,8 @@ export { Form, Form as default, definition } from '../forms/iirusp.js';
2
2
  export { IiruspDocumentPreview, IiruspDocumentPreview as Preview } from '../reports/iirusp/_components/document-preview.js';
3
3
  export { IiruspCreateForm } from '../reports/iirusp/_components/create-form.js';
4
4
  export type * from '../reports/iirusp/types.js';
5
+ import { IiruspDocumentPreview as PreviewComponent } from '../reports/iirusp/_components/document-preview.js';
6
+ import type { ComponentType, ComponentProps } from 'react';
7
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
8
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
9
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -2,3 +2,8 @@
2
2
  export { Form, Form as default, definition } from '../forms/iirusp.js';
3
3
  export { IiruspDocumentPreview, IiruspDocumentPreview as Preview } from '../reports/iirusp/_components/document-preview.js';
4
4
  export { IiruspCreateForm } from '../reports/iirusp/_components/create-form.js';
5
+ import { IiruspDocumentPreview as PreviewComponent } from '../reports/iirusp/_components/document-preview.js';
6
+ import { createDocumentPreviewComponents } from '../document-preview.js';
7
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'IIRUSP document preview', filename: 'iirusp' });
8
+ export const PreviewDialog = previewComponents.PreviewDialog;
9
+ export const PreviewPanel = previewComponents.PreviewPanel;
@@ -1,3 +1,8 @@
1
1
  export { InventoryItemForm as default, InventoryItemForm as Form, InventoryItemForm } from './components/inventory-item-form.js';
2
2
  export { InventoryItemPreview as Preview, InventoryItemPreview } from './components/inventory-item-preview.js';
3
3
  export type * from './types.js';
4
+ import { InventoryItemPreview as PreviewComponent } from './components/inventory-item-preview.js';
5
+ import type { ComponentType, ComponentProps } from 'react';
6
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
7
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
8
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -1,3 +1,8 @@
1
1
  "use client";
2
2
  export { InventoryItemForm as default, InventoryItemForm as Form, InventoryItemForm } from './components/inventory-item-form.js';
3
3
  export { InventoryItemPreview as Preview, InventoryItemPreview } from './components/inventory-item-preview.js';
4
+ import { InventoryItemPreview as PreviewComponent } from './components/inventory-item-preview.js';
5
+ import { createDocumentPreviewComponents } from '../document-preview.js';
6
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'ITEM document preview', filename: 'item' });
7
+ export const PreviewDialog = previewComponents.PreviewDialog;
8
+ export const PreviewPanel = previewComponents.PreviewPanel;
@@ -2,3 +2,8 @@ export { Form, Form as default, definition } from '../forms/itr.js';
2
2
  export { ItrPreview, ItrPreview as Preview } from '../gam/components/itr-preview.js';
3
3
  export { CreateForm as ItrCreateForm } from '../reports/itr/_components/create-form.js';
4
4
  export type * from '../reports/itr/types.js';
5
+ import { ItrPreview as PreviewComponent } from '../gam/components/itr-preview.js';
6
+ import type { ComponentType, ComponentProps } from 'react';
7
+ import type { FormPreviewDialogProps, FormPreviewPanelProps } from '../document-preview.js';
8
+ export declare const PreviewDialog: ComponentType<FormPreviewDialogProps<ComponentProps<typeof PreviewComponent>>>;
9
+ export declare const PreviewPanel: ComponentType<FormPreviewPanelProps<ComponentProps<typeof PreviewComponent>>>;
@@ -2,3 +2,8 @@
2
2
  export { Form, Form as default, definition } from '../forms/itr.js';
3
3
  export { ItrPreview, ItrPreview as Preview } from '../gam/components/itr-preview.js';
4
4
  export { CreateForm as ItrCreateForm } from '../reports/itr/_components/create-form.js';
5
+ import { ItrPreview as PreviewComponent } from '../gam/components/itr-preview.js';
6
+ import { createDocumentPreviewComponents } from '../document-preview.js';
7
+ const previewComponents = createDocumentPreviewComponents(PreviewComponent, { title: 'ITR document preview', filename: 'itr' });
8
+ export const PreviewDialog = previewComponents.PreviewDialog;
9
+ export const PreviewPanel = previewComponents.PreviewPanel;