Phase 7: close, file, PDFs and prints
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]>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Phase 7 helpers the close panel, the print menu and the tabs share: opening PDFs, the print rows, the kiosk
|
||||
// code, the VISUAL observed line.
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead, PrintKind, PrintRow, VcloseCheck, WorkOrder } from "../api/types";
|
||||
import { fmtTime } from "../lib/format";
|
||||
|
||||
/** The rule's signature meanings for FILE and ADMIN_REOPEN. */
|
||||
export const SIGN_FILE = "File in the data book";
|
||||
export const SIGN_REOPEN = "Reopen a filed packet";
|
||||
|
||||
/** Opens a PDF (or any server URL) in a new tab. Every PDF view and print in the packet goes through here. */
|
||||
export function openTab(url: string): void {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
/** Opens a printed copy of one kind. The server stamps it and records the print. */
|
||||
export function openPrint(id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}): void {
|
||||
openTab(packetsApi.printUrl(id, kind, opts));
|
||||
}
|
||||
|
||||
/** The asset id of a "/_blob/<id>" URL, for documents whose answer has no asset_id. */
|
||||
export function assetOfUrl(url: string): string | undefined {
|
||||
const m = /\/_blob\/([^/?#]+)/.exec(url);
|
||||
return m?.[1] ? decodeURIComponent(m[1]) : undefined;
|
||||
}
|
||||
|
||||
export interface PrintRowDef {
|
||||
kind: Exclude<PrintKind, "doc">;
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** The rows of the print menu for this packet, as the old openPrintMenu. */
|
||||
export function printRows(p: PacketRead): PrintRowDef[] {
|
||||
const drawings = p.pages.filter((x) => x.type === "drawing");
|
||||
const live = drawings.filter((x) => !x.void).length;
|
||||
const voids = drawings.filter((x) => !!x.void).length;
|
||||
const one = live === 1;
|
||||
const rows: PrintRowDef[] = [
|
||||
{
|
||||
kind: "traveler",
|
||||
label: "Job traveler",
|
||||
desc: "Job info, work order QR codes for clocking on, BOM and heat numbers, checklist with blanks to stamp, welds, sign-offs, notes",
|
||||
},
|
||||
{ kind: "map-h", label: "Drawing with heat map", desc: one ? "The drawing with its heat table" : `All ${live} drawing sheets with heat tables` },
|
||||
{ kind: "map-w", label: "Drawing with weld map", desc: one ? "The drawing with weld locations" : `All ${live} drawing sheets with weld locations` },
|
||||
{ kind: "clean", label: "Drawing (no markup)", desc: "The approved drawing without markup" },
|
||||
];
|
||||
if (voids) {
|
||||
rows.push({
|
||||
kind: "void",
|
||||
label: "Superseded drawings (VOID)",
|
||||
desc: `${voids} replaced drawing sheet${voids === 1 ? "" : "s"} with their weld marks, stamped VOID`,
|
||||
});
|
||||
}
|
||||
if (p.status === "complete" || p.status === "filed") {
|
||||
rows.push({ kind: "databook", label: "Data book", desc: "The full manufacturing data book (same as the PDF export)" });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The labor kiosk code of a work order, as the old qrText: `*%2{wo}${lot}${split}${sub}${seq}%*`. The columns stay
|
||||
* text (never cast); an empty split or sub is "0".
|
||||
*/
|
||||
export function kioskCode(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "seq">): string {
|
||||
const seq = w.seq === null || w.seq === undefined ? "" : String(w.seq);
|
||||
return `*%2${w.wo_base}$${w.lot}$${w.split || "0"}$${w.sub || "0"}$${seq}%*`;
|
||||
}
|
||||
|
||||
/** "{date} · {what} · {pages} pages · {name}". */
|
||||
export function printLineText(r: PrintRow): string {
|
||||
const pages = r.pages === null || r.pages === undefined ? null : `${r.pages} page${r.pages === 1 ? "" : "s"}`;
|
||||
return [fmtTime(r.at), r.what, pages, r.by?.display_name ?? null].filter((x): x is string => !!x).join(" · ");
|
||||
}
|
||||
|
||||
/** The VISUAL observed state as one line, or null when there is nothing to say (a hand packet). */
|
||||
export function visualLine(c: VcloseCheck | null | undefined): string | null {
|
||||
if (!c) return null;
|
||||
const open = c.open_wos ?? [];
|
||||
if (c.closed_at) return `VISUAL shows the job closed since ${fmtTime(c.closed_at)}.`;
|
||||
if (open.length) return `VISUAL still shows ${open.length} work order${open.length === 1 ? "" : "s"} open: ${open.join(", ")}.`;
|
||||
if (!c.observed_at) return "The feed has not checked VISUAL yet.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the close and file panel shows for this packet and user: filed (read-only for everyone), or complete for
|
||||
* whoever may close, file or reopen, and for everyone after a reopen (the broken seal line).
|
||||
*/
|
||||
export function showsCloseFile(p: PacketRead): boolean {
|
||||
if (p.removed) return false;
|
||||
if (p.status === "filed") return true;
|
||||
if (p.status !== "complete") return false;
|
||||
return !!p.seal?.broken_at || p.can.includes("set_vclosed") || p.can.includes("file") || !!p.blocked.file || p.can.includes("admin_reopen");
|
||||
}
|
||||
Reference in New Issue
Block a user