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:
@@ -16,8 +16,11 @@ import type {
|
||||
PacketHeader,
|
||||
PacketListQuery,
|
||||
PacketRead,
|
||||
PrintKind,
|
||||
PrintRow,
|
||||
QcFields,
|
||||
ReceiptBody,
|
||||
SealView,
|
||||
ShortBody,
|
||||
StageFields,
|
||||
TransitionBody,
|
||||
@@ -127,6 +130,25 @@ export const packetsApi = {
|
||||
api.put<PacketRead>(`${base(id)}/qc/welds/${no}`, { ...body, ...g }),
|
||||
/** The scalar inspection fields with a field-level base: 409 field_conflict when someone changed one first. */
|
||||
qcPatch: (id: number, body: FieldCas<QcFields>) => api.patch<PacketRead>(`${base(id)}/qc`, body),
|
||||
|
||||
/* Phase 7: close, file, PDFs and prints. */
|
||||
/** ADMIN_REOPEN: filed back to complete. The reason and the signature are required. */
|
||||
reopen: (id: number, body: { reason: string } & Guarded) => api.post<PacketRead>(`${base(id)}/reopen`, body),
|
||||
/** The newest seal, whether it still matches the record (null when the key is not available), and every seal row. */
|
||||
seal: async (id: number, signal?: AbortSignal): Promise<SealView> => {
|
||||
const raw = await api.get<Partial<SealView> | null>(`${base(id)}/seal`, signal);
|
||||
return { seal: raw?.seal ?? null, valid: raw?.valid ?? null, seals: Array.isArray(raw?.seals) ? raw.seals : [] };
|
||||
},
|
||||
/** Printed copies and exports of this packet, newest first. */
|
||||
prints: async (id: number, signal?: AbortSignal): Promise<PrintRow[]> =>
|
||||
asList<PrintRow>(await api.get<unknown>(`${base(id)}/prints`, signal), "prints"),
|
||||
/** The data book or the job traveler as a PDF. `download` asks for an attachment. */
|
||||
pdfUrl: (id: number, kind: "databook" | "traveler", download = false) => `${base(id)}/pdf/${kind}${download ? "?download=1" : ""}`,
|
||||
/** The traceability CSV. */
|
||||
csvUrl: (id: number) => `${base(id)}/export/traceability.csv`,
|
||||
/** A printed copy: the stamped PDF of one print kind. `sheet` limits a map or clean print to one page; `asset` names the document for kind doc. */
|
||||
printUrl: (id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}) =>
|
||||
`${base(id)}/print/${kind}${qs({ sheet: opts.sheet, asset: opts.asset })}`,
|
||||
};
|
||||
|
||||
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
|
||||
|
||||
@@ -413,6 +413,12 @@ export const signOff = (id: number, sign: SignBody | undefined, g: Guarded = {})
|
||||
|
||||
export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body));
|
||||
|
||||
/** Phase 7: the ITP as a PDF built now. `download` asks for an attachment. */
|
||||
export const itpPdfUrl = (id: number, download = false) => `/api/itps/${id}/pdf${download ? "?download=1" : ""}`;
|
||||
|
||||
/** Phase 7: the ITP PDF stamped PRINTED COPY. */
|
||||
export const itpPrintUrl = (id: number) => `/api/itps/${id}/print`;
|
||||
|
||||
export const qcApi = {
|
||||
getQcHome,
|
||||
getFolders,
|
||||
@@ -433,4 +439,6 @@ export const qcApi = {
|
||||
unsignBlock,
|
||||
signOff,
|
||||
reopenItp,
|
||||
itpPdfUrl,
|
||||
itpPrintUrl,
|
||||
};
|
||||
|
||||
@@ -384,6 +384,8 @@ export interface QcFolderDoc {
|
||||
url: string;
|
||||
scope: "unit" | "project";
|
||||
added_at: string | null;
|
||||
/** Not sent today; the asset id is read from `url` when it is missing. */
|
||||
asset_id?: string;
|
||||
}
|
||||
|
||||
/** The unit's ITP summary. */
|
||||
@@ -480,7 +482,12 @@ export interface PacketRead {
|
||||
open_cos: number[];
|
||||
open_shorts: number;
|
||||
docs: PacketDoc[];
|
||||
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null;
|
||||
/** Phase 7: the newest seal row, broken or not, with the data book filed on it. */
|
||||
seal: PacketSeal | null;
|
||||
/** Phase 7: how many seal rows the packet has (2 after a reopen and a refile). */
|
||||
seals?: number;
|
||||
/** Phase 7: what the feed saw in VISUAL against the Clerical close. Null for a hand packet. */
|
||||
vclose_check?: VcloseCheck | null;
|
||||
can: string[];
|
||||
blocked: Record<string, string[]>;
|
||||
holder: string;
|
||||
@@ -505,6 +512,65 @@ export interface PacketRead {
|
||||
itp_hold?: ItpHold | null;
|
||||
}
|
||||
|
||||
/* ---------- Phase 7: close, file, PDFs and prints ---------- */
|
||||
|
||||
/** The data book stored on a seal. */
|
||||
export interface SealDatabook {
|
||||
asset_id: string;
|
||||
/** "/_blob/<id>", inline. */
|
||||
url: string;
|
||||
/** "/_blob/<id>?download=1", as an attachment. */
|
||||
download_url: string;
|
||||
pages: number | null;
|
||||
size: number | null;
|
||||
file_name: string | null;
|
||||
}
|
||||
|
||||
/** PacketRead.seal and the rows of GET /api/packets/{id}/seal. */
|
||||
export interface PacketSeal {
|
||||
id: number;
|
||||
sealed_at: string;
|
||||
sealed_by: WhoRef | null;
|
||||
key_id: string;
|
||||
signed: boolean;
|
||||
broken_at: string | null;
|
||||
broken_by: WhoRef | null;
|
||||
broken_reason: string | null;
|
||||
/** Work orders the feed showed open in VISUAL at filing, or null. */
|
||||
open_wos: string[] | null;
|
||||
databook: SealDatabook | null;
|
||||
}
|
||||
|
||||
/** PacketRead.vclose_check. */
|
||||
export interface VcloseCheck {
|
||||
observed_at: string | null;
|
||||
closed_at: string | null;
|
||||
/** "wo_base/lot" of every leg not closed and not cancelled. Text, never numbers. */
|
||||
open_wos: string[];
|
||||
/** True when the Clerical close and the feed's closed_at say the same. */
|
||||
agrees: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/packets/{id}/seal. `valid` is null when the seal key is not available. */
|
||||
export interface SealView {
|
||||
seal: PacketSeal | null;
|
||||
valid: boolean | null;
|
||||
seals: PacketSeal[];
|
||||
}
|
||||
|
||||
/** The print kinds of GET /api/packets/{id}/print/{kind}. */
|
||||
export type PrintKind = "traveler" | "map-h" | "map-w" | "clean" | "void" | "databook" | "doc";
|
||||
|
||||
/** One row of GET /api/packets/{id}/prints. */
|
||||
export interface PrintRow {
|
||||
id: number;
|
||||
at: string;
|
||||
by: WhoRef | null;
|
||||
kind: string;
|
||||
what: string;
|
||||
pages: number | null;
|
||||
}
|
||||
|
||||
/* ---------- Phase 5: the shop on the packet ---------- */
|
||||
|
||||
export interface Weld {
|
||||
@@ -726,6 +792,8 @@ export interface TransitionBody {
|
||||
for_sup_id?: string;
|
||||
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
|
||||
ncr?: string;
|
||||
/** Phase 7: FILE. Files although the feed still shows work orders open in VISUAL. */
|
||||
confirm_open_wos?: boolean;
|
||||
sign?: SignBody;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { PacketRead } from "../api/types";
|
||||
|
||||
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
|
||||
export function Act({
|
||||
p,
|
||||
action,
|
||||
busy,
|
||||
onClick,
|
||||
className = "btn",
|
||||
children,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
action: string;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const can = p.can.includes(action);
|
||||
const why = p.blocked[action];
|
||||
if (!can && !why) return null;
|
||||
const reason = why?.join(" ") ?? "";
|
||||
return (
|
||||
<div className="act">
|
||||
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
{!can && reason ? <div className="why small">{reason}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import { errorCode } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import { useLoad } from "../lib/useLoad";
|
||||
import { Act } from "./Act";
|
||||
import { openTab, showsCloseFile, SIGN_REOPEN, visualLine } from "./closeUtil";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
/** The seal line of a filed packet, from GET /seal. */
|
||||
function SealLine({ p }: { p: PacketRead }) {
|
||||
const [load] = useLoad((s) => packetsApi.seal(p.id, s), `${p.id}:${p.version}`);
|
||||
if (load.state === "loading") return <div className="small muted">Checking the seal</div>;
|
||||
if (load.state === "error") return <div className="small muted">The seal could not be checked. {load.message}</div>;
|
||||
const key = load.data.seal?.key_id ?? p.seal?.key_id ?? "";
|
||||
if (load.data.valid === true) {
|
||||
return (
|
||||
<div>
|
||||
<span className="pill st-ok" data-testid="seal-state">
|
||||
Seal intact (key {key})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (load.data.valid === false) {
|
||||
return (
|
||||
<div className="callout warn flush" role="alert" data-testid="seal-state">
|
||||
<b>Seal does not match the record.</b> The filed record changed after it was sealed. Tell IT.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="small muted" data-testid="seal-state">
|
||||
Seal unknown (key not available)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Clerical close and file panel, from status complete on. Ready to close (databook route, not confirmed closed),
|
||||
* ready for the data book, and filed (read-only for everyone but the IT reopen).
|
||||
*/
|
||||
export function CloseFile({
|
||||
p,
|
||||
busy,
|
||||
mutate,
|
||||
askReason,
|
||||
sub,
|
||||
onPrint,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
busy: boolean;
|
||||
mutate: Mutate;
|
||||
askReason: (opts: ReasonOptions) => Promise<string | null>;
|
||||
sub: string;
|
||||
onPrint: () => void;
|
||||
}) {
|
||||
const [confirm, setConfirm] = useState<string | null>(null);
|
||||
if (!showsCloseFile(p)) return null;
|
||||
|
||||
const filed = p.status === "filed";
|
||||
const databookRoute = p.routed_to === "databook";
|
||||
const readyToClose = !filed && databookRoute && !p.vclosed;
|
||||
const check = p.vclose_check ?? null;
|
||||
const vline = visualLine(check);
|
||||
const seal = p.seal;
|
||||
const book = filed && seal?.databook ? seal.databook : null;
|
||||
const viewUrl = book?.url ?? packetsApi.pdfUrl(p.id, "databook");
|
||||
const downloadUrl = book?.download_url ?? packetsApi.pdfUrl(p.id, "databook", true);
|
||||
|
||||
/** FILE. A 409 open_work_orders opens the confirm; File anyway sends it again with confirm_open_wos. */
|
||||
const fileIt = async (confirmed: boolean) => {
|
||||
const hit: { message?: string } = {};
|
||||
await mutate(
|
||||
async (g) => {
|
||||
try {
|
||||
return await packetsApi.transition(p.id, {
|
||||
action: "file",
|
||||
expect: { status: p.status, dept: p.dept.code },
|
||||
...(confirmed ? { confirm_open_wos: true } : {}),
|
||||
...g,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!confirmed && errorCode(err) === "open_work_orders") {
|
||||
hit.message = errorText(err);
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{ ok: "Filed in the data book." },
|
||||
);
|
||||
if (hit.message !== undefined) setConfirm(hit.message);
|
||||
};
|
||||
|
||||
const reopen = async () => {
|
||||
const reason = await askReason({
|
||||
title: "Reopen a filed packet",
|
||||
sub,
|
||||
note: "The packet goes back to complete. The seal is kept as broken, with your reason.",
|
||||
min: 8,
|
||||
max: 300,
|
||||
danger: true,
|
||||
submitLabel: "Reopen",
|
||||
});
|
||||
if (reason === null) return;
|
||||
await mutate((g) => packetsApi.reopen(p.id, { reason, ...g }), { sign: SIGN_REOPEN, ok: "Reopened. The earlier seal is kept as broken." });
|
||||
};
|
||||
|
||||
const exports = (
|
||||
<>
|
||||
<div className="act">
|
||||
<button className={filed ? "btn pri" : "btn"} type="button" onClick={() => openTab(viewUrl)}>
|
||||
Data book (PDF)
|
||||
</button>
|
||||
</div>
|
||||
<div className="act">
|
||||
<a className="btn sm ghost" href={downloadUrl} download>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<div className="act">
|
||||
<a className="btn" href={packetsApi.csvUrl(p.id)} download>
|
||||
Traceability (CSV)
|
||||
</a>
|
||||
</div>
|
||||
<div className="act">
|
||||
<button className="btn" type="button" onClick={onPrint}>
|
||||
Print…
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const reopened =
|
||||
!filed && seal?.broken_at ? (
|
||||
<div className="small" data-testid="reopened">
|
||||
Reopened by {seal.broken_by?.display_name ?? "IT"} {fmtTime(seal.broken_at)}: {seal.broken_reason ?? ""}. The earlier seal is kept as broken.
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section className="panel actbox" aria-label="Close and file">
|
||||
{filed ? (
|
||||
<>
|
||||
<div>
|
||||
<b>Filed in the data book</b> {fmtTime(seal?.sealed_at ?? p.databook_at ?? p.status_at)} by {seal?.sealed_by?.display_name ?? "Clerical"}.
|
||||
{book?.pages ? ` The data book has ${book.pages} page${book.pages === 1 ? "" : "s"}.` : ""}
|
||||
</div>
|
||||
<SealLine p={p} />
|
||||
{seal?.open_wos?.length ? <div className="small due soon">Filed with work orders open in VISUAL: {seal.open_wos.join(", ")}.</div> : null}
|
||||
<div className="acts">
|
||||
{exports}
|
||||
<Act p={p} action="admin_reopen" busy={busy} className="btn ghost" onClick={() => void reopen()}>
|
||||
Reopen (IT)
|
||||
</Act>
|
||||
</div>
|
||||
</>
|
||||
) : readyToClose ? (
|
||||
<>
|
||||
<div>
|
||||
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
|
||||
</div>
|
||||
{vline ? (
|
||||
<div className="small" data-testid="visual-line">
|
||||
{vline}
|
||||
</div>
|
||||
) : null}
|
||||
{reopened}
|
||||
<div className="acts">
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
||||
Job closed in VISUAL
|
||||
</Act>
|
||||
<Act p={p} action="file" busy={busy} onClick={() => void fileIt(false)}>
|
||||
File in the data book
|
||||
</Act>
|
||||
{exports}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<b>Ready for the data book.</b>
|
||||
{databookRoute && p.vclosed ? ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by?.display_name ?? "Clerical"}.` : ""} Check the data book, then file it.
|
||||
</div>
|
||||
{(check?.open_wos ?? []).length > 0 && vline ? (
|
||||
<div className="callout warn flush" data-testid="visual-line">
|
||||
{vline}
|
||||
</div>
|
||||
) : null}
|
||||
{reopened}
|
||||
<div className="acts">
|
||||
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt(false)}>
|
||||
File in the data book
|
||||
</Act>
|
||||
{exports}
|
||||
{databookRoute ? (
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
||||
Not closed in VISUAL, move back
|
||||
</Act>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{confirm !== null ? (
|
||||
<Sheet title="File with open work orders?" sub={sub} onClose={() => setConfirm(null)}>
|
||||
<p>{confirm}</p>
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn pri"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setConfirm(null);
|
||||
void fileIt(true);
|
||||
}}
|
||||
>
|
||||
File anyway
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,11 @@ 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. */
|
||||
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
|
||||
/**
|
||||
* 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);
|
||||
@@ -84,6 +87,11 @@ export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
|
||||
</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>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { errorText } from "../lib/format";
|
||||
import { voidText } from "../lib/voidStamp";
|
||||
import type { Write, WriteResult } from "../warehouse/PullTable";
|
||||
import { MapViewer } from "./MapViewer";
|
||||
import { openPrint } from "./closeUtil";
|
||||
import {
|
||||
HeatTableInner,
|
||||
HeatTableOnDrawing,
|
||||
@@ -327,6 +328,11 @@ export function HeatMapTab({
|
||||
: "No heat table on this sheet"}
|
||||
</span>
|
||||
) : null}
|
||||
{!p.removed ? (
|
||||
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "map-h", { sheet: page.n })}>
|
||||
Print this sheet
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dv">
|
||||
<MapViewer
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
@@ -11,7 +11,11 @@ import { useGuard } from "../components/useGuard";
|
||||
import { useChanged } from "../lib/changes";
|
||||
import { errorText } from "../lib/format";
|
||||
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
|
||||
import { Act } from "./Act";
|
||||
import { CloseFile } from "./CloseFile";
|
||||
import { DrawingViewer } from "./DrawingViewer";
|
||||
import { openPrint } from "./closeUtil";
|
||||
import { PrintMenu } from "./PrintMenu";
|
||||
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||
import { coSummary, coToReview } from "../lib/changeOrders";
|
||||
@@ -40,37 +44,6 @@ const TOAST_MS = 5000;
|
||||
|
||||
/** The rule's signature meaning for attaching a drawing by hand. */
|
||||
const SIGN_ATTACH = "Attach a drawing by hand";
|
||||
const SIGN_FILE = "File in the data book";
|
||||
|
||||
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
|
||||
function Act({
|
||||
p,
|
||||
action,
|
||||
busy,
|
||||
onClick,
|
||||
className = "btn",
|
||||
children,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
action: string;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const can = p.can.includes(action);
|
||||
const why = p.blocked[action];
|
||||
if (!can && !why) return null;
|
||||
const reason = why?.join(" ") ?? "";
|
||||
return (
|
||||
<div className="act">
|
||||
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
{!can && reason ? <div className="why small">{reason}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
|
||||
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
|
||||
@@ -139,7 +112,7 @@ export function PacketPage() {
|
||||
const [search] = useSearchParams();
|
||||
const wantTab = search.get("tab");
|
||||
const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null);
|
||||
const [sheet, setSheet] = useState<"dept" | "move" | null>(null);
|
||||
const [sheet, setSheet] = useState<"dept" | "move" | "print" | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
||||
@@ -318,8 +291,6 @@ export function PacketPage() {
|
||||
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
|
||||
};
|
||||
|
||||
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
|
||||
|
||||
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||
const review = coToReview(p);
|
||||
const hasQc = !!p.qc || p.qc_history.length > 0;
|
||||
@@ -461,6 +432,11 @@ export function PacketPage() {
|
||||
Move
|
||||
</button>
|
||||
) : null}
|
||||
{!p.removed ? (
|
||||
<button className="btn sm" type="button" onClick={() => setSheet("print")}>
|
||||
Print…
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,35 +606,7 @@ export function PacketPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
|
||||
<section className="panel actbox" aria-label="Close and file">
|
||||
<div>
|
||||
{p.vclosed ? (
|
||||
<>
|
||||
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="acts">
|
||||
{!p.vclosed ? (
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
||||
Job closed in VISUAL
|
||||
</Act>
|
||||
) : (
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
||||
Not closed in VISUAL, move back
|
||||
</Act>
|
||||
)}
|
||||
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
|
||||
Mark filed
|
||||
</Act>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<CloseFile p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} onPrint={() => setSheet("print")} />
|
||||
|
||||
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
|
||||
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
|
||||
@@ -694,7 +642,7 @@ export function PacketPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<DrawingViewer pages={drawingPages} />
|
||||
<DrawingViewer pages={drawingPages} {...(p.removed ? {} : { onPrintSheet: (n: number) => openPrint(p.id, "clean", { sheet: n }) })} />
|
||||
</div>
|
||||
) : null}
|
||||
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
|
||||
@@ -732,6 +680,7 @@ export function PacketPage() {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "print" ? <PrintMenu p={p} sub={job} onClose={() => setSheet(null)} /> : null}
|
||||
{wh.sheet}
|
||||
{guardSheet}
|
||||
{toast ? (
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PacketPage, PacketRead } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { mockFetch, type Route } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
import { kioskCode, printRows } from "./closeUtil";
|
||||
|
||||
function page(n: number, over: Partial<PacketPage> = {}): PacketPage {
|
||||
return { n, type: "drawing", asset_id: `as-${n}`, pn: n, dwg: "CI-DWG-1", rev: "0", void: null, url: `/_blob/as-${n}`, ...over };
|
||||
}
|
||||
|
||||
const VOID = { co_id: 1, co_no: 1, at: "2026-10-16T08:00:00Z", new_n: 3 };
|
||||
|
||||
function base(packet: PacketRead, extra: Record<string, Route> = {}) {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe() },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const rowNames = () =>
|
||||
within(screen.getByRole("dialog", { name: "Print" }))
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("printRows", () => {
|
||||
it("one sheet, a draft: four rows, no void, no data book", () => {
|
||||
const rows = printRows(makePacket({ pages: [page(1)] }));
|
||||
expect(rows.map((r) => r.label)).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)"]);
|
||||
expect(rows[1]?.desc).toBe("The drawing with its heat table");
|
||||
});
|
||||
|
||||
it("two live sheets and a void one, complete: the void and data book rows", () => {
|
||||
const rows = printRows(makePacket({ status: "complete", pages: [page(1, { void: VOID }), page(2), page(3)] }));
|
||||
expect(rows.map((r) => r.kind)).toEqual(["traveler", "map-h", "map-w", "clean", "void", "databook"]);
|
||||
expect(rows[1]?.desc).toBe("All 2 drawing sheets with heat tables");
|
||||
expect(rows[4]?.desc).toBe("1 replaced drawing sheet with their weld marks, stamped VOID");
|
||||
expect(rows[5]?.desc).toBe("The full manufacturing data book (same as the PDF export)");
|
||||
});
|
||||
|
||||
it("filed keeps the data book row", () => {
|
||||
expect(printRows(makePacket({ status: "filed" })).map((r) => r.kind)).toContain("databook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Print menu", () => {
|
||||
it("opens from the header, lists the rows, and a row opens its print in a new tab", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makePacket({ pages: [page(1)] })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Print" });
|
||||
expect(within(sheet).getByText("L999001 · WELD-PART")).toBeInTheDocument();
|
||||
expect(rowNames()).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Cancel"]);
|
||||
expect(within(sheet).getByRole("button", { name: "Job traveler" })).toHaveAccessibleDescription(
|
||||
"Job info, work order QR codes for clocking on, BOM and heat numbers, checklist with blanks to stamp, welds, sign-offs, notes",
|
||||
);
|
||||
expect(sheet).toHaveTextContent('Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded.');
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Drawing with weld map" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/map-w", "_blank", "noopener");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows the void and data book rows on a complete packet and opens the data book print", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makePacket({ status: "complete", routed_to: "shipping", can: [], blocked: {}, pages: [page(1, { void: VOID }), page(3)] })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
|
||||
await screen.findByRole("dialog", { name: "Print" });
|
||||
expect(rowNames()).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Superseded drawings (VOID)", "Data book", "Cancel"]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Data book" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/databook", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("Cancel closes it without opening anything", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makePacket()));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not offered on a removed packet", async () => {
|
||||
mockFetch(base(makePacket({ removed: { at: "2026-10-16T08:00:00Z", by: null, reason: "duplicate" }, can: [], blocked: {} })));
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("tab", { name: /^Materials/ });
|
||||
expect(screen.queryByRole("button", { name: "Print…" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Print entry points on the tabs", () => {
|
||||
it("the Work orders tab shows the kiosk codes, Print traveler and the traveler PDF", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
// jsdom has no canvas; the QR falls back to "No QR" and the code text stays.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
const wo = { id: 1, wo_base: "L999001", lot: "7A", split: "", sub: "0", seq: 10, op: "WELD", qty: 2, target_hrs: 3, row_version: 1 };
|
||||
expect(kioskCode(wo)).toBe("*%2L999001$7A$0$0$10%*");
|
||||
mockFetch(base(makePacket({ work_orders: [wo] })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Work orders/ }));
|
||||
expect(screen.getByText(/^Scan a code at the labor kiosk to clock on in Visual\./)).toHaveTextContent(
|
||||
"Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.",
|
||||
);
|
||||
expect(screen.getByTestId("kiosk-code")).toHaveTextContent("*%2L999001$7A$0$0$10%*");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Print traveler" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/traveler", "_blank", "noopener");
|
||||
const pdf = screen.getByRole("link", { name: "Traveler (PDF)" });
|
||||
expect(pdf).toHaveAttribute("href", "/api/packets/1/pdf/traveler");
|
||||
expect(pdf).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("the Materials tab prints an MTR", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const line = { item: 3, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H123", mtr_asset_id: "mtr-3" };
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, bom: [line] })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("the Documents tab prints a packet document and a folder document", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const docs = [
|
||||
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report", title: "RT report", added_at: null, added_by: null, url: "/_blob/a4", removed: null },
|
||||
];
|
||||
const folder = [{ id: 1, type: "ITP", title: "L999001-ITP", rev: null, url: "/_blob/f1", scope: "unit" as const, added_at: null }];
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, docs, qc_folder_docs: folder })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
||||
fireEvent.click(within(screen.getByRole("region", { name: "Documents for the data book" })).getByRole("button", { name: "Print" }));
|
||||
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=a4", "_blank", "noopener");
|
||||
fireEvent.click(within(screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" })).getByRole("button", { name: "Print" }));
|
||||
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=f1", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("the Drawing tab prints the shown sheet", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, pages: [page(1), page(2)] })));
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("tab", { name: /^Drawing/ });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^p\.2/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Weld map", "map-w"],
|
||||
["Heat map", "map-h"],
|
||||
])("the %s tab prints the shown sheet", async (tab, kind) => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, pages: [page(1), page(2)] })));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: new RegExp(`^${tab}`) }));
|
||||
fireEvent.click(within(screen.getByRole("group", { name: "Sheets" })).getAllByRole("button")[1]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
||||
expect(open).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`, "_blank", "noopener");
|
||||
});
|
||||
});
|
||||
|
||||
describe("History tab", () => {
|
||||
it("lists the printed copies and exports", async () => {
|
||||
const prints = [
|
||||
{ id: 2, at: "2026-10-17T10:00:00Z", by: { id: "u1", display_name: "Clara Clerk" }, kind: "traveler", what: "Job traveler", pages: 2 },
|
||||
{ id: 1, at: "2026-10-17T09:00:00Z", by: null, kind: "csv", what: "Traceability CSV", pages: null },
|
||||
];
|
||||
mockFetch(base(makePacket(), { "/api/packets/1/prints": { body: { prints } } }));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "History" }));
|
||||
const sec = await screen.findByRole("region", { name: "Printed copies and exports" });
|
||||
const items = await within(sec).findAllByRole("listitem");
|
||||
expect(items.map((i) => i.textContent)).toEqual([expect.stringMatching(/ · Job traveler · 2 pages · Clara Clerk$/), expect.stringMatching(/ · Traceability CSV$/)]);
|
||||
});
|
||||
|
||||
it("says when nothing was printed", async () => {
|
||||
mockFetch(base(makePacket(), { "/api/packets/1/prints": { body: { prints: [] } } }));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "History" }));
|
||||
const sec = await screen.findByRole("region", { name: "Printed copies and exports" });
|
||||
expect(await within(sec).findByText("Nothing printed yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useId } from "react";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { openPrint, printRows } from "./closeUtil";
|
||||
|
||||
/** The print menu: one row per print kind. A row opens the stamped PDF in a new tab and closes the sheet. */
|
||||
export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onClose: () => void }) {
|
||||
const base = useId();
|
||||
const rows = printRows(p);
|
||||
return (
|
||||
<Sheet title="Print" sub={sub} onClose={onClose}>
|
||||
<div className="ck">
|
||||
{rows.map((r) => (
|
||||
<button
|
||||
key={r.kind}
|
||||
type="button"
|
||||
className="ck-item prow"
|
||||
aria-label={r.label}
|
||||
aria-describedby={`${base}-${r.kind}`}
|
||||
onClick={() => {
|
||||
openPrint(p.id, r.kind);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="t">
|
||||
<b>{r.label}</b>
|
||||
<span className="small muted" id={`${base}-${r.kind}`}>
|
||||
{r.desc}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded.
|
||||
</div>
|
||||
<div className="sheet-acts start">
|
||||
<button className="btn ghost" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { voidText } from "../lib/voidStamp";
|
||||
import { weldId, weldSummary } from "../lib/weldDefaults";
|
||||
import type { Write, WriteResult } from "../warehouse/PullTable";
|
||||
import { MapViewer } from "./MapViewer";
|
||||
import { openPrint } from "./closeUtil";
|
||||
import { WeldMark, type MarkMove } from "./marks";
|
||||
import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil";
|
||||
import { useSerial } from "./useSerial";
|
||||
@@ -167,6 +168,11 @@ export function WeldMapTab({
|
||||
{p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded
|
||||
</span>
|
||||
) : null}
|
||||
{!p.removed ? (
|
||||
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "map-w", { sheet: page.n })}>
|
||||
Print this sheet
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dv">
|
||||
<MapViewer
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PacketRead, PacketSeal, SealView, VcloseCheck } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch, type Route } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
|
||||
const CLARA = { id: "11111111-1111-1111-1111-111111111111", display_name: "Clara Clerk" };
|
||||
const ANN = { id: "22222222-2222-2222-2222-222222222222", display_name: "Ann Admin" };
|
||||
|
||||
function check(over: Partial<VcloseCheck> = {}): VcloseCheck {
|
||||
return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over };
|
||||
}
|
||||
|
||||
function seal(over: Partial<PacketSeal> = {}): PacketSeal {
|
||||
return {
|
||||
id: 4,
|
||||
sealed_at: "2026-10-17T10:00:00Z",
|
||||
sealed_by: CLARA,
|
||||
key_id: "k1",
|
||||
signed: true,
|
||||
broken_at: null,
|
||||
broken_by: null,
|
||||
broken_reason: null,
|
||||
open_wos: null,
|
||||
databook: { asset_id: "a-db", url: "/_blob/a-db", download_url: "/_blob/a-db?download=1", pages: 14, size: 90000, file_name: "L999-LotL999001_CI-DWG-1_Manufacturing_Data_Book.pdf" },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Complete, routed to the data book, not confirmed closed in VISUAL. */
|
||||
function toClose(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return makePacket({
|
||||
status: "complete",
|
||||
routed_to: "databook",
|
||||
holder: "Clerical",
|
||||
source_kind: "visual",
|
||||
can: ["set_vclosed"],
|
||||
blocked: { file: ["Close the job in VISUAL first."] },
|
||||
vclose_check: check(),
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete and confirmed closed: ready for the data book. */
|
||||
function toFile(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over });
|
||||
}
|
||||
|
||||
function filed(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return toFile({ status: "filed", version: 9, databook_at: "2026-10-17T10:00:00Z", seal: seal(), seals: 1, can: [], blocked: {}, ...over });
|
||||
}
|
||||
|
||||
function base(packet: PacketRead, extra: Record<string, Route> = {}, roles = ["clerical"]) {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe({ roles }) },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function sealView(valid: boolean | null, s: PacketSeal = seal()): SealView {
|
||||
return { seal: s, valid, seals: [s] };
|
||||
}
|
||||
|
||||
async function sign(secret = "pw-1") {
|
||||
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
const panel = () => screen.findByRole("region", { name: "Close and file" });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Close and file: ready to close", () => {
|
||||
it("shows the state, the exports, and confirms the close", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||||
const file = within(box).getByRole("button", { name: "File in the data book" });
|
||||
expect(file).toBeDisabled();
|
||||
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
||||
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/pdf/databook", "_blank", "noopener");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/api/packets/1/pdf/databook?download=1");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("download");
|
||||
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toHaveAttribute("href", "/api/packets/1/export/traceability.csv");
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Print…" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Print" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Job closed in VISUAL" }));
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the feed saw the job closed", check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }), /^VISUAL shows the job closed since .+\.$/],
|
||||
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
|
||||
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
|
||||
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
|
||||
])("the VISUAL line when %s", async (_name, c, text) => {
|
||||
mockFetch(base(toClose({ vclose_check: c })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(within(box).getByTestId("visual-line")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("a hand packet has no VISUAL line", async () => {
|
||||
mockFetch(base(toClose({ source_kind: "hand", vclose_check: null })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||||
expect(within(box).queryByTestId("visual-line")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown before complete or to a role with nothing to do", async () => {
|
||||
mockFetch(base(toClose({ can: [], blocked: {} }), {}, ["warehouse"]));
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("tab", { name: /^Materials/ });
|
||||
expect(screen.queryByRole("region", { name: "Close and file" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close and file: ready for the data book", () => {
|
||||
it("files with the signature", async () => {
|
||||
const fetch = mockFetch(
|
||||
base(toFile(), {
|
||||
"POST /api/packets/1/transitions": (req) =>
|
||||
(req.body as { sign?: unknown }).sign
|
||||
? { body: filed() }
|
||||
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } },
|
||||
"/api/packets/1/seal": { body: sealView(true) },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Ready for the data book.");
|
||||
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
||||
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns in a callout when VISUAL still shows work orders open", async () => {
|
||||
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
const line = within(box).getByTestId("visual-line");
|
||||
expect(line).toHaveClass("callout", "warn");
|
||||
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
||||
});
|
||||
|
||||
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
|
||||
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
|
||||
const fetch = mockFetch(
|
||||
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
|
||||
"POST /api/packets/1/transitions": (req) => {
|
||||
const b = req.body as { sign?: unknown; confirm_open_wos?: boolean };
|
||||
if (!b.confirm_open_wos) return { status: 409, body: { code: "open_work_orders", detail: msg, work_orders: ["L999001/001"] } };
|
||||
if (!b.sign) return { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } };
|
||||
return { body: filed({ seal: seal({ open_wos: ["L999001/001"] }) }) };
|
||||
},
|
||||
"/api/packets/1/seal": { body: sealView(true, seal({ open_wos: ["L999001/001"] })) },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||||
expect(within(sheet).getByText(msg)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "File anyway" }));
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true, sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed with work orders open in VISUAL: L999001/001.");
|
||||
});
|
||||
|
||||
it("Cancel on the confirm sends nothing more", async () => {
|
||||
const fetch = mockFetch(
|
||||
base(toFile(), {
|
||||
"POST /api/packets/1/transitions": { status: 409, body: { code: "open_work_orders", detail: "VISUAL still shows 1 work order open.", work_orders: ["L999001/001"] } },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "File with open work orders?" })).not.toBeInTheDocument());
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(1);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close and file: filed", () => {
|
||||
it("shows the seal intact and opens the filed data book", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(true) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent(/Filed in the data book .* by Clara Clerk\. The data book has 14 pages\./);
|
||||
expect(await within(box).findByText("Seal intact (key k1)")).toHaveClass("pill", "st-ok");
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||||
expect(open).toHaveBeenCalledWith("/_blob/a-db", "_blank", "noopener");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/_blob/a-db?download=1");
|
||||
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toBeInTheDocument();
|
||||
expect(within(box).getByRole("button", { name: "Print…" })).toBeInTheDocument();
|
||||
expect(within(box).queryByRole("button", { name: "Reopen (IT)" })).not.toBeInTheDocument();
|
||||
expect(within(box).queryByRole("button", { name: "File in the data book" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when the seal does not match", async () => {
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(false) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
const state = await within(box).findByTestId("seal-state");
|
||||
expect(state).toHaveClass("callout", "warn");
|
||||
expect(state).toHaveTextContent("Seal does not match the record.");
|
||||
});
|
||||
|
||||
it("says when the seal cannot be checked", async () => {
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(null) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(await within(box).findByTestId("seal-state")).toHaveTextContent("Seal unknown (key not available)");
|
||||
});
|
||||
|
||||
it("IT reopens with the reason and the signature; the panel keeps the broken seal line", async () => {
|
||||
const reopened = toFile({
|
||||
version: 10,
|
||||
can: ["admin_reopen"],
|
||||
seal: seal({ broken_at: "2026-10-17T11:00:00Z", broken_by: ANN, broken_reason: "wrong MTR filed" }),
|
||||
seals: 1,
|
||||
});
|
||||
const fetch = mockFetch(
|
||||
base(filed({ can: ["admin_reopen"] }), { "/api/packets/1/seal": { body: sealView(true) }, "POST /api/packets/1/reopen": { body: reopened } }, ["admin"]),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Reopen (IT)" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Reopen a filed packet" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong MTR filed" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Reopen" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Reopen a filed packet")).toBeInTheDocument();
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByTestId("reopened")).toHaveTextContent(/^Reopened by Ann Admin .+: wrong MTR filed\. The earlier seal is kept as broken\.$/));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/reopen")).toEqual([{ reason: "wrong MTR filed", sign: { factor: "password", secret: "pw-1" } }]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import QRCode from "qrcode";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, QcHistoryRow, WorkOrder } from "../api/types";
|
||||
import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, PrintRow, QcHistoryRow, WorkOrder } from "../api/types";
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
@@ -11,6 +11,7 @@ import { useLoad } from "../lib/useLoad";
|
||||
import { MtrViewer } from "../warehouse/MtrViewer";
|
||||
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
|
||||
import { ShortSheet } from "../warehouse/ShortSheet";
|
||||
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
|
||||
|
||||
export type Mutate = (
|
||||
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
||||
@@ -209,6 +210,19 @@ export function MaterialsTab({
|
||||
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
|
||||
MTR
|
||||
</button>
|
||||
{!p.removed ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm ghost"
|
||||
title={`Print the MTR of item ${b.item}`}
|
||||
onClick={() => openPrint(p.id, "doc", { asset: b.mtr_asset_id ?? undefined })}
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
@@ -279,11 +293,28 @@ function Qr({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The work orders, and one QR code per work order key. */
|
||||
/** The work orders, the kiosk QR code of each (as the old ticketsTab), and the traveler print. */
|
||||
export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="callout flush">
|
||||
<div className="acts center">
|
||||
<span className="grow">
|
||||
Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.
|
||||
</span>
|
||||
{!p.removed ? (
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => openPrint(p.id, "traveler")}>
|
||||
Print traveler
|
||||
</button>
|
||||
<a className="btn ghost" href={packetsApi.pdfUrl(p.id, "traveler")} target="_blank" rel="noopener">
|
||||
Traveler (PDF)
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -316,13 +347,21 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||
</div>
|
||||
<div className="qrs">
|
||||
{p.work_orders.map((w) => {
|
||||
const k = woKey(w);
|
||||
const code = kioskCode(w);
|
||||
return (
|
||||
<div key={w.id ?? k} className="qr">
|
||||
<Qr text={k} />
|
||||
<div key={w.id ?? woKey(w)} className="qr">
|
||||
<Qr text={code} />
|
||||
<div>
|
||||
<div className="wo">{w.wo_base}</div>
|
||||
<div className="mono small">{k}</div>
|
||||
<div className="small">
|
||||
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
|
||||
</div>
|
||||
<div className="small">
|
||||
Target <b className="mono">{show(w.target_hrs)} h</b> · Qty <b className="mono">{show(w.qty)}</b>
|
||||
</div>
|
||||
<div className="mono small" data-testid="kiosk-code">
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -420,7 +459,21 @@ function AddDocSheet({ p, sub, write, onDone, onCancel }: { p: PacketRead; sub:
|
||||
);
|
||||
}
|
||||
|
||||
function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean; canRemove: boolean; busy: boolean; onOut: () => void }) {
|
||||
function DocRow({
|
||||
d,
|
||||
old,
|
||||
canRemove,
|
||||
busy,
|
||||
onOut,
|
||||
onPrint,
|
||||
}: {
|
||||
d: PacketDoc;
|
||||
old: boolean;
|
||||
canRemove: boolean;
|
||||
busy: boolean;
|
||||
onOut: () => void;
|
||||
onPrint?: (() => void) | undefined;
|
||||
}) {
|
||||
const label = d.kind_label || docKindLabel(d.kind);
|
||||
const meta = [d.file_name || null, `added by ${d.added_by?.display_name ?? "-"} ${fmtTime(d.added_at)}`, d.note || null].filter((x): x is string => !!x);
|
||||
return (
|
||||
@@ -440,6 +493,11 @@ function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean
|
||||
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
|
||||
View
|
||||
</button>
|
||||
{!old && onPrint ? (
|
||||
<button className="btn sm" type="button" onClick={onPrint}>
|
||||
Print
|
||||
</button>
|
||||
) : null}
|
||||
{!old && canRemove ? (
|
||||
<button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}>
|
||||
Take out of data book
|
||||
@@ -511,7 +569,17 @@ export function DocumentsTab({
|
||||
</div>
|
||||
<div className="qcdocs">
|
||||
{live.length ? (
|
||||
live.map((d) => <DocRow key={d.id} d={d} old={false} canRemove={canRemove} busy={busy} onOut={() => void takeOut(d)} />)
|
||||
live.map((d) => (
|
||||
<DocRow
|
||||
key={d.id}
|
||||
d={d}
|
||||
old={false}
|
||||
canRemove={canRemove}
|
||||
busy={busy}
|
||||
onOut={() => void takeOut(d)}
|
||||
onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div>
|
||||
)}
|
||||
@@ -551,6 +619,11 @@ export function DocumentsTab({
|
||||
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
|
||||
View
|
||||
</button>
|
||||
{!p.removed ? (
|
||||
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "doc", { asset: d.asset_id ?? assetOfUrl(d.url) })}>
|
||||
Print
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -582,10 +655,41 @@ export function HistoryTab({ p }: { p: PacketRead }) {
|
||||
) : (
|
||||
<div className="empty slim">Nothing recorded yet.</div>
|
||||
)}
|
||||
<PrintsList p={p} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /prints: the printed copies and exports of this packet, newest first. */
|
||||
function PrintsList({ p }: { p: PacketRead }) {
|
||||
const [load] = useLoad((s) => packetsApi.prints(p.id, s), `${p.id}:${p.version}`);
|
||||
return (
|
||||
<section className="sec" aria-label="Printed copies and exports">
|
||||
<div className="sec-h">
|
||||
<h2>Printed copies and exports</h2>
|
||||
{load.state === "ok" ? <span className="ct">{load.data.length}</span> : null}
|
||||
</div>
|
||||
{load.state === "loading" ? (
|
||||
<div className="empty slim">Loading</div>
|
||||
) : load.state === "error" ? (
|
||||
<div className="callout warn">{load.message}</div>
|
||||
) : load.data.length ? (
|
||||
<ul className="prints">
|
||||
{load.data.map((r) => (
|
||||
<PrintLine key={r.id} r={r} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="empty slim">Nothing printed yet.</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PrintLine({ r }: { r: PrintRow }) {
|
||||
return <li className="small">{printLineText(r)}</li>;
|
||||
}
|
||||
|
||||
/** One row per closed Q.C. inspection, oldest first. */
|
||||
function QcInspections({ rows }: { rows: QcHistoryRow[] }) {
|
||||
if (!rows.length) return null;
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
enterNcr,
|
||||
getItp,
|
||||
getUnitFolder,
|
||||
itpPdfUrl,
|
||||
itpPrintUrl,
|
||||
patchBlock,
|
||||
patchItp,
|
||||
patchLine,
|
||||
@@ -445,6 +447,15 @@ export function ItpPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<button className="btn sm" type="button" onClick={() => window.open(itpPdfUrl(id), "_blank", "noopener")}>
|
||||
ITP (PDF)
|
||||
</button>
|
||||
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
|
||||
Download
|
||||
</a>
|
||||
<button className="btn sm" type="button" onClick={() => window.open(itpPrintUrl(id), "_blank", "noopener")}>
|
||||
Print
|
||||
</button>
|
||||
{r.can.sign_off ? (
|
||||
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
|
||||
Sign off ITP
|
||||
@@ -461,7 +472,16 @@ export function ItpPage() {
|
||||
{complete && r.signoff ? (
|
||||
<div className="callout mb10" role="note">
|
||||
Signed off {fmtQcDate(r.signoff.at)} by {r.signoff.by?.display_name ?? ""}. The ITP is locked; a signed PDF copy{" "}
|
||||
{r.signoff.asset_id ? "is filed in this unit's documents." : "will be filed when the ITP PDF is built."}
|
||||
{r.signoff.asset_id ? (
|
||||
<>
|
||||
is filed in this unit's documents.{" "}
|
||||
<a className="lnk" href={`/_blob/${encodeURIComponent(r.signoff.asset_id)}`} target="_blank" rel="noopener">
|
||||
Signed PDF
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
"will be filed when the ITP PDF is built."
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Itp } from "../../api/qc";
|
||||
import { DEPARTMENTS, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { makeItp, makeUnitFolder, QUINN } from "./fixtures";
|
||||
|
||||
function base(itp: Itp) {
|
||||
return {
|
||||
"/api/auth/me": { body: QUINN },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/units/12/itp": { body: itp },
|
||||
"/api/qc/folders/units/12": { body: makeUnitFolder() },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ItpPage PDF and print", () => {
|
||||
it("opens the ITP PDF, offers the download, and opens the printed copy", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(makeItp()));
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "ITP (PDF)" }));
|
||||
expect(open).toHaveBeenLastCalledWith("/api/itps/5/pdf", "_blank", "noopener");
|
||||
const dl = screen.getByRole("link", { name: "Download" });
|
||||
expect(dl).toHaveAttribute("href", "/api/itps/5/pdf?download=1");
|
||||
expect(dl).toHaveAttribute("download");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
||||
expect(open).toHaveBeenLastCalledWith("/api/itps/5/print", "_blank", "noopener");
|
||||
expect(screen.queryByRole("link", { name: "Signed PDF" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links the signed PDF on a signed-off ITP", async () => {
|
||||
mockFetch(
|
||||
base(
|
||||
makeItp({
|
||||
status: "complete",
|
||||
can: { edit: false, sign_off: false, reopen: false },
|
||||
signoff: { at: "2026-10-17T12:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, asset_id: "a-signed" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderApp("/qc/units/12/itp");
|
||||
const link = await screen.findByRole("link", { name: "Signed PDF" });
|
||||
expect(link).toHaveAttribute("href", "/_blob/a-signed");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByRole("note")).toHaveTextContent(/a signed PDF copy is filed in this unit's documents\. Signed PDF$/);
|
||||
});
|
||||
});
|
||||
@@ -633,3 +633,9 @@ a.itpcard { flex-direction: row; text-align: left; }
|
||||
.itpck input { width: 20px; height: 20px; }
|
||||
.itplog { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; color: var(--body); }
|
||||
.itpmsg { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; max-width: calc(100% - 32px); box-shadow: 0 6px 20px rgba(0, 0, 0, .4); }
|
||||
|
||||
/* Phase 7: a print menu row is a whole-width button in the .ck list. */
|
||||
.ck-item.prow { width: 100%; text-align: left; font: inherit; color: inherit; }
|
||||
.ck-item.prow .t .small { display: block; margin-top: 2px; font-weight: 400; }
|
||||
.prints { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.callout .acts .grow { min-width: 240px; }
|
||||
|
||||
Reference in New Issue
Block a user