Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import type { PacketPage } from "../api/types";
|
||
import { openPdf } from "../lib/pdf";
|
||
|
||
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||
|
||
/**
|
||
* The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet.
|
||
* `onPrintSheet` adds "Print this sheet" for the shown page (its packet page number).
|
||
*/
|
||
export function DrawingViewer({ pages, onPrintSheet }: { pages: PacketPage[]; onPrintSheet?: (n: number) => void }) {
|
||
const [sel, setSel] = useState(0);
|
||
const [zi, setZi] = useState(2);
|
||
const [width, setWidth] = useState(0);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const page = pages[Math.min(sel, pages.length - 1)];
|
||
const zoom = ZOOMS[zi] ?? 1;
|
||
const url = page?.url ?? "";
|
||
const pn = page?.pn ?? 1;
|
||
|
||
// Fit to width: follow the frame's width.
|
||
useEffect(() => {
|
||
const el = wrapRef.current;
|
||
if (!el) return;
|
||
const measure = () => setWidth(el.clientWidth);
|
||
measure();
|
||
if (typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!url || !width) return;
|
||
let cancelled = false;
|
||
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
|
||
setBusy(true);
|
||
setError(null);
|
||
openPdf(url)
|
||
.then((doc) => doc.getPage(Math.max(1, Math.min(pn || 1, doc.numPages))))
|
||
.then(async (pg) => {
|
||
if (cancelled) return;
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const base = pg.getViewport({ scale: 1 });
|
||
const scale = (Math.max(200, width - 2) / base.width) * zoom;
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const vp = pg.getViewport({ scale: scale * ratio });
|
||
canvas.width = Math.floor(vp.width);
|
||
canvas.height = Math.floor(vp.height);
|
||
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
|
||
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
|
||
task = pg.render({ canvas, viewport: vp });
|
||
await task.promise;
|
||
if (!cancelled) setBusy(false);
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return;
|
||
if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "RenderingCancelledException") return;
|
||
setBusy(false);
|
||
setError("The drawing could not be shown. Open it in a new tab instead.");
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
task?.cancel();
|
||
};
|
||
}, [url, pn, width, zoom]);
|
||
|
||
if (!page) return <div className="empty">No drawing attached yet.</div>;
|
||
|
||
return (
|
||
<div className="pdfv">
|
||
<div className="pdfv-bar">
|
||
<div className="seg" role="group" aria-label="Zoom">
|
||
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
|
||
−
|
||
</button>
|
||
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
|
||
Fit
|
||
</button>
|
||
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
|
||
<span className="grow" />
|
||
{onPrintSheet ? (
|
||
<button className="btn sm" type="button" onClick={() => onPrintSheet(page.n)}>
|
||
Print this sheet
|
||
</button>
|
||
) : null}
|
||
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
|
||
Open in a new tab
|
||
</a>
|
||
</div>
|
||
<div className="pdfv-body">
|
||
<ol className="pdfv-pages" aria-label="Pages">
|
||
{pages.map((p, i) => (
|
||
<li key={p.n}>
|
||
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
|
||
<span className="mono">p.{p.n}</span>
|
||
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
|
||
{p.void ? <span className="pill st-bad">Void</span> : null}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
<div className="pdfv-frame" ref={wrapRef}>
|
||
{error ? <div className="callout warn flush">{error}</div> : null}
|
||
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
|
||
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|