Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+335
View File
@@ -0,0 +1,335 @@
import { useState } from "react";
import { packetsApi } from "../api/packets";
import type { ChangeOrder, PacketRead } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { useAuth } from "../auth/useAuth";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { fmtTime } from "../lib/format";
import { canVerifyDept } from "./shopUtil";
import { ReviewPanel } from "./visual";
import type { Mutate } from "./tabs";
export const SIGN_CO_VERIFY = "Verify a change order for the department";
const isCl = (roles: readonly string[]) =>
roles.some(
(r) => r === "clerical" || r === "clerical_supervisor" || r === "admin",
);
const coLabel = (co: ChangeOrder) => co.label ?? `CO-${co.co_no}`;
const pendingOf = (co: ChangeOrder) =>
co.pending_depts ?? co.required.filter((d) => !co.verified[d]);
function coPill(co: ChangeOrder) {
if (co.review === "rejected")
return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending")
return <span className="pill st-review">Waiting on Clerical</span>;
const open = pendingOf(co);
if (open.length)
return (
<span className="pill st-review">Open - {open.length} to verify</span>
);
return <span className="pill st-in_progress">Verified</span>;
}
/** The supervisor's verify box for one department: the confirmation, a note, Verify and the signature. */
function VerifyBox({
p,
co,
dept,
deptName,
mutate,
busy,
}: {
p: PacketRead;
co: ChangeOrder;
dept: string;
deptName: string;
mutate: Mutate;
busy: boolean;
}) {
const [ok, setOk] = useState(false);
const [note, setNote] = useState("");
const label = coLabel(co);
const verify = () =>
mutate(
(g) =>
packetsApi.coVerify(p.id, co.co_no, {
dept,
...(note.trim() ? { note: note.trim() } : {}),
...g,
}),
{
sign: SIGN_CO_VERIFY,
ok: `${label} verified for ${deptName}.`,
},
);
return (
<div
className="panel stack verifybox"
role="group"
aria-label={`Verify ${label} for ${deptName}`}
>
<h3>
Verify {label} for {deptName}
</h3>
<div className="small">
Review the new sheet and the changes with your department. Sign-offs on
this packet stay on hold until every department verifies.
</div>
<label className="ckl">
<input
type="checkbox"
checked={ok}
onChange={(e) => setOk(e.target.checked)}
/>
I reviewed this change with my department
</label>
<label className="f">
Note (optional)
<input
aria-label="Note (optional)"
className="in"
value={note}
maxLength={500}
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="acts">
<button
className="btn pri"
type="button"
disabled={!ok || busy}
onClick={() => void verify()}
>
Verify
</button>
{!ok ? (
<span className="small muted">
Tick the box to confirm you reviewed the change.
</span>
) : null}
</div>
</div>
);
}
/** The Changes tab: every change order, newest first. Clerical reviews the feed ones; supervisors verify here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: (opts: ReasonOptions) => Promise<string | null>;
}) {
const { departments } = useAuth();
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length)
return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
const deptName = (code: string) => {
if (code === "WH") return "Warehouse";
if (code === "QC") return "Q.C.";
const d = departments.find((x) => x.code === code);
return d ? `${d.code} ${d.name}` : code;
};
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
const label = coLabel(co);
const pending = pendingOf(co);
const mine =
co.review === "approved"
? pending.filter((d) => canVerifyDept(roles, d))
: [];
return (
<section
key={co.id}
className="sec"
aria-label={`Change order ${label}`}
>
<div className="sec-h flush">
<h2>
{label}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed
? co.origin === "drawing"
? "from M-Files"
: "from VISUAL"
: `issued by ${co.issued_by?.display_name ?? "-"}`}{" "}
{fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel
p={p}
co={co}
sub={sub}
mutate={mutate}
busy={busy}
askReason={askReason}
canReview={canReview}
/>
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep
working to the current drawing. If Clerical approves it, the
new drawing and changes come onto this packet and you will be
asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name}{" "}
{fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The
shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}:{" "}
{co.rejected_reason ??
"VISUAL went back to what the packet has"}
. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name}{" "}
{fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`${label} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span>{" "}
{it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? (
<span className="muted">
{" "}
· page {it.old_page}
</span>
) : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">
{it.added ? "ADDED" : "NEW"}
</span>{" "}
{it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? (
<span className="muted"> · page {it.new_page}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`${label} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td>{deptName(d)}</td>
<td className="small">
{ver ? (
<>
✓ {ver.by?.display_name ?? "-"}{" "}
{fmtTime(ver.at)}
{ver.signed ? (
<span className="muted"> · signed</span>
) : null}
</>
) : (
<span className="due soon">
Pending{mine.includes(d) ? " · you" : ""}
</span>
)}
</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && !co.required.length ? (
<div className="small muted">
No department needs to verify this change.
</div>
) : null}
{mine.map((d) => (
<VerifyBox
key={d}
p={p}
co={co}
dept={d}
deptName={deptName(d)}
mutate={mutate}
busy={busy}
/>
))}
</section>
);
})}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import type { Write } from "../warehouse/PullTable";
import { WeldRecord } from "./WeldRecord";
import type { Mutate } from "./tabs";
/**
* The stage checklist, grouped as the department's template, with the weld record beside it. Each tick is one PUT
* for its key; ticks of different keys never clash.
*/
export function ChecklistTab({
p,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const editable = !!p.stage && p.can.includes("edit_checklist");
const [pending, setPending] = useState<Record<string, boolean>>({});
const state = p.stage?.checklist ?? {};
const groups: Array<{ group: string; items: PacketRead["checklist_items"] }> = [];
for (const it of p.checklist_items) {
const g = groups.find((x) => x.group === it.group);
if (g) g.items.push(it);
else groups.push({ group: it.group, items: [it] });
}
const tick = async (key: string, done: boolean) => {
setPending((o) => ({ ...o, [key]: done }));
const r = await write((g) => packetsApi.setChecklist(p.id, key, done, g));
setPending((o) => {
const n = { ...o };
delete n[key];
return n;
});
if (!r.ok && r.err !== undefined) {
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
onError(errorText(r.err));
}
};
const weld = p.dept.weld !== false;
return (
<div className="cklay">
<div className="ckmain">
{groups.length ? null : <div className="empty">No checklist for this department.</div>}
{groups.map((g) => (
<section className="sec" key={g.group} aria-label={g.group}>
<div className="sec-h">
<h2>{g.group}</h2>
</div>
<div className="ck">
{g.items.map((i) => {
const c = state[i.key];
const done = pending[i.key] ?? !!c?.done;
const by = c?.done ? [c.stamp || c.by_name, fmtTime(c.at)].filter(Boolean).join(" · ") : "";
return (
<label key={i.key} className={`ck-item${done ? " done" : ""}`} aria-disabled={!editable}>
<input
type="checkbox"
checked={done}
disabled={!editable || pending[i.key] !== undefined}
onChange={(e) => void tick(i.key, e.target.checked)}
/>
<span className="t">{i.text}</span>
<span className="by">{by}</span>
</label>
);
})}
</div>
</section>
))}
{!editable && groups.length ? <div className="small muted">Checklist is completed at the stall.</div> : null}
</div>
{weld ? (
<div className="ckside">
<WeldRecord p={p} mutate={mutate} />
</div>
) : null}
</div>
);
}
+515
View File
@@ -0,0 +1,515 @@
import {
useRef,
useState,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Link } from "react-router";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { HeatTag, PacketPage, PacketRead } from "../api/types";
import { Sheet } from "../components/Sheet";
import { onPage } from "../lib/geometry";
import {
clampCorner,
heatRows,
NO_HEAT,
stepSize,
type HeatRow,
} from "../lib/heatTable";
import { errorText } from "../lib/format";
import { voidText } from "../lib/voidStamp";
import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer";
import {
HeatTableInner,
HeatTableOnDrawing,
HeatTagMark,
type MarkMove,
} from "./marks";
import { landingPage, pageLabel, viewPages } from "./shopUtil";
import { useSerial } from "./useSerial";
import type { ViewerApi } from "./viewerContext";
const sheetName = (pg: PacketPage | undefined, n: number) =>
pg?.dwg || `sheet ${n}`;
/** The heat table panel's grab: drag the table from here onto the drawing. */
function HeatGrab({
rows,
apiRef,
onDrop,
onMiss,
}: {
rows: readonly HeatRow[];
apiRef: MutableRefObject<ViewerApi | null>;
onDrop: (x: number, y: number) => void;
onMiss: () => void;
}) {
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
const down = (e: ReactPointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
const id = e.pointerId;
let started = false;
const mv = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 8) return;
started = true;
setGhost({ x: ev.clientX, y: ev.clientY });
};
const up = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
setGhost(null);
if (!started || ev.type !== "pointerup") return;
const f = apiRef.current?.toFrac(ev.clientX, ev.clientY);
if (f && f.inside && onPage(f)) {
const c = clampCorner(f.fx, f.fy);
onDrop(c.x, c.y);
} else onMiss();
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
return (
<>
<div
className="htprev htgrab"
data-testid="heat-table-grab"
title="Drag onto the drawing"
onPointerDown={down}
>
<div className="small muted mb6">Drag me onto the drawing</div>
<div className="htbl static">
<HeatTableInner rows={rows} />
</div>
</div>
{ghost ? (
<div
className="htbl htghost"
style={{ left: ghost.x, top: ghost.y }}
aria-hidden="true"
>
<HeatTableInner rows={rows} />
</div>
) : null}
</>
);
}
function TagSheet({
p,
tag,
sub,
onSave,
onRemove,
onClose,
}: {
p: PacketRead;
tag: HeatTag;
sub: string;
onSave: (item: number) => void;
onRemove: () => void;
onClose: () => void;
}) {
const [item, setItem] = useState(tag.item);
return (
<Sheet title={`Heat tag H${tag.tag_no}`} sub={sub} onClose={onClose}>
<label className="f">
Points to BOM item
<select
aria-label="Points to BOM item"
className="in"
value={item}
onChange={(e) => setItem(Number(e.target.value))}
>
{p.bom.map((b) => (
<option key={b.item} value={b.item}>
{b.item} - {b.description ?? ""} ·{" "}
{b.heat || (b.na ? "N/A" : "no heat")}
</option>
))}
</select>
</label>
<div className="sheet-acts start">
<button
className="btn pri"
type="button"
onClick={() => (item === tag.item ? onClose() : onSave(item))}
>
Save
</button>
<button className="btn ghost" type="button" onClick={onClose}>
Cancel
</button>
<span className="grow" />
<button className="btn ghost pinktxt" type="button" onClick={onRemove}>
Remove tag
</button>
</div>
</Sheet>
);
}
/**
* The heat map: the heat table placed on each sheet (always the live BOM heats), and the older amber heat bubbles
* where a sheet has no table. A packet with no heat map and no raw material of its own shows its sibling's map,
* read-only.
*/
export function HeatMapTab({
p,
write,
reload,
sub,
onError,
}: {
p: PacketRead;
write: Write;
reload: () => Promise<void>;
sub: string;
onError: (text: string) => void;
}) {
const own = p.heat_table.length > 0 || p.heat_tags.length > 0;
const src = !own && p.heat_src ? p.heat_src : null;
const allPages = src ? src.pages : p.pages;
const pages = viewPages(allPages);
const [picked, setPicked] = useState<{
n: number;
onPurpose: boolean;
} | null>(null);
const page = landingPage(
pages,
picked?.n ?? null,
picked?.onPurpose ?? false,
);
const [tagOpen, setTagOpen] = useState<number | null>(null);
const apiRef = useRef<ViewerApi | null>(null);
const serial = useSerial(p, write);
if (!page) {
return <div className="empty">No drawing pages in this packet.</div>;
}
const bom = src ? src.bom : p.bom;
const rows = heatRows(bom);
const missing = rows.filter((r) => !r.heat).length;
const tables = src ? src.heat_table : p.heat_table;
const tags = src ? src.heat_tags : p.heat_tags;
const placement = tables.find((t) => t.page === page.n) ?? null;
const legacyHere = tags.filter((t) => t.page === page.n);
const shownTags = placement ? [] : legacyHere;
const isVoid = !!page.void;
const editTable = !src && !isVoid && p.can.includes("edit_heat_table");
const editTags = !src && !isVoid && p.can.includes("edit_heats");
const vText = isVoid ? voidText(page, allPages) : "";
const heatOf = (item: number) => {
const b = bom.find((x) => x.item === item);
return b ? b.heat || (b.na ? "N/A" : "") : "";
};
const name = sheetName(page, page.n);
const tag =
tagOpen === null
? null
: (p.heat_tags.find((t) => t.tag_no === tagOpen) ?? null);
const done = async (r: WriteResult): Promise<boolean> => {
if (r.ok) return true;
if (r.err !== undefined) {
if (
r.err instanceof ApiError &&
(r.err.status === 409 || r.err.status === 412)
)
await reload();
onError(errorText(r.err));
}
return false;
};
const cur = (c: PacketRead) =>
c.heat_table.find((t) => t.page === page.n) ?? null;
const put = (x: number, y: number, s?: number) =>
serial((c, g) =>
packetsApi.putHeatTable(
c.id,
page.n,
{ x, y, s: s ?? cur(c)?.s ?? 1 },
g,
),
).then(done);
const resize = (dir: number) =>
serial((c, g) => {
const t = cur(c);
if (!t) return Promise.resolve(c);
const s = stepSize(t.s, dir);
return s === t.s
? Promise.resolve(c)
: packetsApi.putHeatTable(c.id, page.n, { x: t.x, y: t.y, s }, g);
}).then(done);
const removeTable = () =>
serial((c, g) => packetsApi.removeHeatTable(c.id, page.n, g)).then(done);
const tagRv = (c: PacketRead, t: HeatTag) =>
c.heat_tags.find((x) => x.tag_no === t.tag_no)?.row_version ??
t.row_version;
const moveTag = (t: HeatTag, m: MarkMove) =>
serial((c, g) =>
packetsApi.patchHeatTag(
c.id,
t.tag_no,
tagRv(c, t),
m.bubble
? { bfx: m.bfx, bfy: m.bfy }
: { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
g,
),
).then(done);
const tagCount = (item: number) => tags.filter((t) => t.item === item).length;
return (
<div className="stack">
{src ? (
<div className="callout flush" role="note">
Heat map from{" "}
<Link to={`/packets/${src.packet_id}`}>
{src.op ?? "another packet"}
</Link>{" "}
on this unit (same drawing). Record heats there.
</div>
) : null}
{isVoid ? (
<div className="callout warn flush">
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record
with its heat marks.{" "}
{page.void?.new_n ? (
<button
className="btn sm"
type="button"
onClick={() =>
setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })
}
>
Go to the current sheet
</button>
) : null}
</div>
) : null}
<div className="pagesel">
{pages.length > 1 ? (
<div className="chips" role="group" aria-label="Sheets">
{pages.map((x) => (
<button
key={x.n}
type="button"
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
aria-pressed={x.n === page.n}
onClick={() => setPicked({ n: x.n, onPurpose: true })}
>
{pageLabel(x)}
</button>
))}
</div>
) : (
<span className="mono small muted">{pageLabel(page)}</span>
)}
{!editTable ? (
<span className="small muted">
{placement
? "Heat table on this sheet"
: "No heat table on this sheet"}
</span>
) : null}
</div>
<div className="dv">
<MapViewer
page={page}
voidLabel={vText}
apiRef={apiRef}
label="Heat map"
fsBar={<span className="fst">{pageLabel(page)}</span>}
>
{shownTags.map((t) => (
<HeatTagMark
key={t.tag_no}
tag={t}
heat={heatOf(t.item)}
editable={editTags}
onOpen={(x) => {
if (editTags) setTagOpen(x.tag_no);
}}
onMove={moveTag}
/>
))}
{placement ? (
<HeatTableOnDrawing
placement={placement}
rows={rows}
editable={editTable}
onMove={(x, y) => put(x, y)}
/>
) : null}
</MapViewer>
<div className="side">
<div className="panel" role="group" aria-label="Heat table panel">
<h3>Heat table</h3>
<div className="small mb10">
{editTable
? placement
? "Drag the table on the drawing to move it. It always shows the heat numbers from the Materials tab."
: "Drag the table onto the drawing, or tap Place on this sheet. Heat numbers fill in from the Materials tab."
: placement
? "The heat table is on this sheet."
: "No heat table on this sheet yet."}
</div>
{placement?.carried_from ? (
<div className="small amber mb10">
Copied from the VOID copy. Check its position on the new
revision.
</div>
) : null}
{editTable ? (
<HeatGrab
rows={rows}
apiRef={apiRef}
onDrop={(x, y) => void put(x, y)}
onMiss={() => onError("Drop the table on the drawing.")}
/>
) : (
<div className="htprev">
<div className="htbl static">
<HeatTableInner rows={rows} />
</div>
</div>
)}
{missing ? (
<div className="small pinktxt mt8">
{missing} item{missing > 1 ? "s" : ""} with no heat number yet.
</div>
) : null}
{editTable ? (
<div className="acts mt10">
{placement ? (
<>
<button
className="btn sm"
type="button"
aria-label="Smaller table"
title="Smaller table (A-)"
onClick={() => void resize(-1)}
>
A−
</button>
<button
className="btn sm"
type="button"
aria-label="Larger table"
title="Larger table (A+)"
onClick={() => void resize(1)}
>
A+
</button>
<button
className="btn sm ghost"
type="button"
onClick={() => void removeTable()}
>
Remove from this sheet
</button>
</>
) : (
<button
className="btn sm pri"
type="button"
onClick={() => void put(0.02, 0.02, 1)}
>
Place on this sheet
</button>
)}
</div>
) : null}
{legacyHere.length ? (
<div className="small muted mt8">
{legacyHere.length} older heat bubble
{legacyHere.length > 1 ? "s are" : " is"} on this sheet
{placement ? " (hidden now that the table is placed)" : ""}.
</div>
) : null}
{placement ? (
<div className="small muted mt8">
Size {placement.s.toFixed(2)} · {name}
</div>
) : null}
</div>
<div className="panel">
<h3>Items and heat numbers</h3>
<div className="hlist" aria-label="Items and heat numbers">
{bom.length ? (
bom.map((b) => {
const line = p.bom.find((x) => x.item === b.item);
const n = tagCount(b.item);
return (
<div className="row" key={b.item}>
<span className="balloon">{b.item}</span>
<span className="d">
{line?.description ?? ""}
<div className="small muted mono">
{line?.material ?? ""}
{n ? ` · ${n} tag${n > 1 ? "s" : ""} on drawing` : ""}
</div>
</span>
<span className={b.heat ? "heat" : "heat none"}>
{b.heat || (b.na ? "N/A" : NO_HEAT)}
</span>
</div>
);
})
) : (
<div className="small muted">No BOM lines.</div>
)}
</div>
</div>
</div>
</div>
{tag ? (
<TagSheet
p={p}
tag={tag}
sub={sub}
onClose={() => setTagOpen(null)}
onSave={(item) =>
void serial((c, g) =>
packetsApi.patchHeatTag(
c.id,
tag.tag_no,
tagRv(c, tag),
{ item },
g,
),
)
.then(done)
.then((ok) => {
if (ok) setTagOpen(null);
})
}
onRemove={() =>
void serial((c, g) =>
packetsApi.removeHeatTag(c.id, tag.tag_no, tagRv(c, tag), g),
)
.then(done)
.then((ok) => {
if (ok) setTagOpen(null);
})
}
/>
) : null}
</div>
);
}
+476
View File
@@ -0,0 +1,476 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import type { PacketPage } from "../api/types";
import {
fitView,
onPage,
toFraction,
zoomAt,
type Frac,
type View,
} from "../lib/geometry";
import { openPdf } from "../lib/pdf";
import { stampVoid } from "../lib/voidStamp";
import {
DRAG_PX,
TAP_PX,
ViewerCtx as Ctx,
renderScale,
type DragHandlers,
type ViewerApi,
type ViewerInfo,
} from "./viewerContext";
interface MapViewerProps {
page: PacketPage;
/** The text under VOID on a voided page. */
voidLabel?: string | undefined;
/** marking: crosshair with a pink outline; placing: crosshair with an amber outline. */
mode?: "" | "marking" | "placing";
/** A tap on the drawing (pointer moved no more than 6 px). */
onTap?: ((fx: number, fy: number) => void) | undefined;
/** The overlay: marks, leader lines, the heat table. Positioned in percent of the page. */
children?: ReactNode;
/** Shown at the top left in full screen, for the marking controls. */
fsBar?: ReactNode;
/** Filled with the viewer's api, for drags that start outside it (the heat table panel). */
apiRef?: MutableRefObject<ViewerApi | null>;
label?: string;
}
const ICON_FS = (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
);
const ICON_EXIT = (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
</svg>
);
/**
* One drawing page in a pan and zoom viewer. pdf.js renders the page once to a canvas; panning and zooming are a CSS
* transform on the stage. Mouse, pen and touch pan on every drag; two fingers pinch; the wheel zooms at the pointer.
* Ported from the old wireViewer with its fixes: the text selection is cleared, the browser's own drag is blocked,
* stale pointers are cleared, and moves and ups are followed on the window so a drag that leaves the drawing ends.
*/
export function MapViewer({
page,
voidLabel,
mode = "",
onTap,
children,
fsBar,
apiRef,
label,
}: MapViewerProps) {
const viewerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const view = useRef<View>({ x: 0, y: 0, z: 1, w: 0, h: 0 });
const fitZ = useRef(1);
const atFit = useRef(true);
const [size, setSize] = useState({ w: 0, h: 0 });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(true);
const [fs, setFs] = useState(false);
const tapRef = useRef(onTap);
const dragEnd = useRef(0);
useEffect(() => {
tapRef.current = onTap;
});
const apply = useCallback(() => {
const st = stageRef.current;
if (!st) return;
const v = view.current;
st.style.transform = `translate(${v.x}px, ${v.y}px) scale(${v.z})`;
st.style.setProperty("--inv", String(1 / v.z));
}, []);
const fit = useCallback(() => {
const el = viewerRef.current;
const v = view.current;
if (!el || !v.w || !v.h) return;
const f = fitView(el.clientWidth || v.w, el.clientHeight || v.h, v.w, v.h);
fitZ.current = f.z;
view.current = f;
atFit.current = true;
apply();
}, [apply]);
const zoom = useCallback(
(f: number, cx?: number, cy?: number) => {
const el = viewerRef.current;
if (!el || !view.current.w) return;
const x = cx ?? el.clientWidth / 2;
const y = cy ?? el.clientHeight / 2;
view.current = zoomAt(view.current, f, x, y, fitZ.current * 0.6);
atFit.current = false;
apply();
},
[apply],
);
// Render the page once per page.
const url = page.url;
const pn = page.pn || 1;
const voided = !!page.void;
useEffect(() => {
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, doc.numPages))))
.then(async (pg) => {
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const vp = pg.getViewport({
scale: renderScale(pg.getViewport({ scale: 1 }).width),
});
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
task = pg.render({ canvas, viewport: vp });
await task.promise;
if (cancelled) return;
if (voided) stampVoid(canvas, voidLabel ?? "VOID");
view.current = { ...view.current, w: canvas.width, h: canvas.height };
setSize({ w: canvas.width, h: canvas.height });
setBusy(false);
fit();
})
.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, voided, voidLabel, fit]);
// Refit when the frame changes size and the user has not zoomed.
useEffect(() => {
const el = viewerRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
if (atFit.current) fit();
});
ro.observe(el);
return () => ro.disconnect();
}, [fit]);
// Full screen: a body class; Esc leaves it.
useEffect(() => {
if (!fs) return;
document.body.classList.add("fs-view");
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".scrim")) setFs(false);
};
document.addEventListener("keydown", onKey);
const t = window.setTimeout(fit, 0);
return () => {
document.body.classList.remove("fs-view");
document.removeEventListener("keydown", onKey);
window.clearTimeout(t);
window.setTimeout(fit, 0);
};
}, [fs, fit]);
const toFrac = useCallback((clientX: number, clientY: number) => {
const el = viewerRef.current;
if (!el) return { fx: NaN, fy: NaN, inside: false };
const r = el.getBoundingClientRect();
const f = toFraction(clientX, clientY, r, view.current);
return {
...f,
inside:
clientX >= r.left &&
clientX <= r.right &&
clientY >= r.top &&
clientY <= r.bottom,
};
}, []);
// Pan, pinch and tap. Pointers are tracked on the window.
useEffect(() => {
const el = viewerRef.current;
if (!el) return;
const pts = new Map<number, { x: number; y: number }>();
let moved = false;
let start: { x: number; y: number; vx: number; vy: number } | null = null;
let pinch: { d: number; z: number } | null = null;
const down = (e: PointerEvent) => {
const t = e.target as Element | null;
if (t?.closest("[data-mark], .vtools, .fsbar")) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
try {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
} catch {
/* nothing selected */
}
if (e.isPrimary) pts.clear();
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 1) {
moved = false;
start = {
x: e.clientX,
y: e.clientY,
vx: view.current.x,
vy: view.current.y,
};
pinch = null;
el.classList.add("panning");
}
if (pts.size === 2) {
const [a, b] = [...pts.values()];
if (a && b)
pinch = {
d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
z: view.current.z,
};
moved = true;
}
};
const move = (e: PointerEvent) => {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size >= 2 && pinch) {
const [a, b] = [...pts.values()];
if (!a || !b) return;
const r = el.getBoundingClientRect();
const d = Math.hypot(a.x - b.x, a.y - b.y);
zoom(
(pinch.z * d) / pinch.d / view.current.z,
(a.x + b.x) / 2 - r.left,
(a.y + b.y) / 2 - r.top,
);
return;
}
if (!start) return;
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > TAP_PX)
moved = true;
if (moved && pts.size === 1) {
view.current = {
...view.current,
x: start.vx + (e.clientX - start.x),
y: start.vy + (e.clientY - start.y),
};
atFit.current = false;
apply();
}
};
const end = (e: PointerEvent) => {
if (!pts.has(e.pointerId)) return;
pts.delete(e.pointerId);
if (pts.size === 1) {
// One finger lifted from a pinch: keep panning with the other.
const [a] = [...pts.values()];
if (a)
start = { x: a.x, y: a.y, vx: view.current.x, vy: view.current.y };
pinch = null;
return;
}
if (pts.size === 0) {
el.classList.remove("panning");
if (!moved && view.current.w && e.type === "pointerup") {
const f = toFrac(e.clientX, e.clientY);
if (onPage(f)) tapRef.current?.(f.fx, f.fy);
}
start = null;
pinch = null;
}
};
const wheel = (e: WheelEvent) => {
e.preventDefault();
const r = el.getBoundingClientRect();
zoom(e.deltaY < 0 ? 1.15 : 0.87, e.clientX - r.left, e.clientY - r.top);
};
const block = (e: Event) => e.preventDefault();
el.addEventListener("pointerdown", down);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", end);
el.addEventListener("wheel", wheel, { passive: false });
el.addEventListener("dragstart", block);
el.addEventListener("selectstart", block);
return () => {
el.removeEventListener("pointerdown", down);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
el.removeEventListener("wheel", wheel);
el.removeEventListener("dragstart", block);
el.removeEventListener("selectstart", block);
};
}, [apply, zoom, toFrac]);
const startDrag = useCallback(
(e: ReactPointerEvent, h: DragHandlers) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
const id = e.pointerId;
let moved = false;
let last: Frac | null = null;
const mv = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_PX)
return;
moved = true;
const f = toFrac(ev.clientX, ev.clientY);
last = { fx: f.fx, fy: f.fy };
h.onMove(last);
};
const up = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
if (moved) dragEnd.current = Date.now();
h.onEnd(moved && ev.type === "pointerup" ? last : null);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
},
[toFrac],
);
const justDragged = useCallback(() => Date.now() - dragEnd.current < 300, []);
const api = useMemo<ViewerApi>(
() => ({ toFrac, startDrag, justDragged }),
[toFrac, startDrag, justDragged],
);
useEffect(() => {
if (!apiRef) return;
apiRef.current = api;
return () => {
apiRef.current = null;
};
}, [api, apiRef]);
const info = useMemo<ViewerInfo>(
() => ({
...api,
w: size.w,
h: size.h,
aspect: size.h ? size.w / size.h : 1.5,
}),
[api, size],
);
return (
<div
ref={viewerRef}
className={`viewer${mode ? ` ${mode}` : ""}`}
data-testid="map-viewer"
data-ready={busy || error ? "0" : "1"}
aria-label={label ?? `Drawing page ${page.n}`}
role="group"
>
<div
ref={stageRef}
className="stage"
style={
size.w
? ({
width: size.w,
height: size.h,
["--sw" as string]: `${size.w}px`,
} as CSSProperties)
: undefined
}
>
<canvas
ref={canvasRef}
className="stage-canvas"
role="img"
aria-label={`Drawing page ${page.n}${voided ? ", VOID" : ""}`}
/>
{size.w ? <Ctx.Provider value={info}>{children}</Ctx.Provider> : null}
</div>
{busy && !error ? (
<div className="vload small">Drawing the page</div>
) : null}
{error ? (
<div className="vload">
<div className="callout warn flush">{error}</div>
</div>
) : null}
<div className="vtools">
<button type="button" aria-label="Zoom in" onClick={() => zoom(1.25)}>
+
</button>
<button type="button" aria-label="Zoom out" onClick={() => zoom(0.8)}>
−
</button>
<button
type="button"
aria-label="Fit drawing"
className="fit"
onClick={fit}
>
FIT
</button>
<button
type="button"
aria-label={fs ? "Exit full screen" : "Full screen"}
title={fs ? "Exit full screen (Esc)" : "Full screen"}
onClick={() => setFs((v) => !v)}
>
{fs ? ICON_EXIT : ICON_FS}
</button>
</div>
{fsBar ? <div className="fsbar">{fsBar}</div> : null}
</div>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ describe("PacketPage", () => {
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step.");
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
});
+59 -11
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { assetsApi } from "../api/assets";
import { ApiError } from "../api/client";
import { ApiError, errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
import { homePath } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { usePacketPresence } from "../auth/useEvents";
import { useGuard } from "../components/useGuard";
import { useChanged } from "../lib/changes";
import { errorText } from "../lib/format";
@@ -14,12 +15,23 @@ import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual";
import { VisualBox } from "./visual";
import { ChangesTab } from "./ChangesTab";
import { ChecklistTab } from "./ChecklistTab";
import { HeatMapTab } from "./HeatMapTab";
import { ShopBar } from "./ShopActions";
import { WeldMapTab } from "./WeldMapTab";
import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history";
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v);
}
const TOAST_MS = 5000;
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand";
@@ -119,9 +131,13 @@ export function PacketPage() {
const [loadError, setLoadError] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab | null>(null);
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 [editing, setEditing] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const others = usePacketPresence(Number.isFinite(id) ? id : null);
const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -140,14 +156,20 @@ export function PacketPage() {
useEffect(() => {
setPacket(null);
setTab(null);
setTab(isTab(wantTab) ? wantTab : null);
void reload();
}, [reload]);
}, [reload, wantTab]);
useEffect(() => {
versionRef.current = packet?.version ?? null;
}, [packet]);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), TOAST_MS);
return () => window.clearTimeout(t);
}, [toast]);
// Live refresh: another screen changed this packet.
useChanged(() => void reload(), {
delayMs: 500,
@@ -195,7 +217,9 @@ export function PacketPage() {
} catch (err) {
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
setMsg({ ok: false, text: errorText(err) });
// A step someone else already took: the page shows their state and says so once, as a toast.
if (errorCode(err) === "stale_step") setToast(errorText(err));
else setMsg({ ok: false, text: errorText(err) });
return false;
} finally {
setBusy(false);
@@ -293,7 +317,7 @@ export function PacketPage() {
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p);
const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials");
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]);
const viewOnly =
@@ -322,14 +346,20 @@ export function PacketPage() {
const hasDraftControls =
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
const cos = (p.change_orders ?? []).length;
const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length;
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "heat", label: "Heat map" },
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length },
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
{ id: "changes", label: "Changes", count: (p.change_orders ?? []).length },
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" },
];
const onTabError = (text: string) => setMsg({ ok: false, text });
return (
<>
@@ -446,6 +476,12 @@ export function PacketPage() {
</div>
</div>
) : null}
{others.length ? (
<div className="presence small" data-testid="presence" role="status">
<span className="pdot" aria-hidden="true" />
Also here: {others.map((u) => u.display_name).join(", ")}
</div>
) : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
@@ -454,7 +490,7 @@ export function PacketPage() {
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
<div className="callout warn" role="status">
<b>Change order {review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
<b>Change order CO-{review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
{review.hold ? <b> Shop packet on hold.</b> : null}{" "}
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
Review change
@@ -615,6 +651,8 @@ export function PacketPage() {
</section>
) : null}
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
<button
@@ -648,7 +686,12 @@ export function PacketPage() {
<DrawingViewer pages={drawingPages} />
</div>
) : null}
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
{shownTab === "checklist" ? <ChecklistTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
@@ -679,6 +722,11 @@ export function PacketPage() {
) : null}
{wh.sheet}
{guardSheet}
{toast ? (
<div className="toast" role="status" data-testid="toast">
{toast}
</div>
) : null}
</>
);
}
+742
View File
@@ -0,0 +1,742 @@
import { useState, type FormEvent, type ReactNode } from "react";
import type { PacketRead } from "../api/types";
import { packetsApi } from "../api/packets";
import { useAuth } from "../auth/useAuth";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { fmtClock, isRunning, liveElapsed, useTicker } from "../lib/clock";
import { targetHours } from "../lib/packets";
import { useShopDepartments } from "../lib/useDepartments";
import {
makeStep,
shopLevel,
stallMissing,
usePeople,
type ShopTab,
type StepFn,
} from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_SUBMIT = "Submit work to the supervisor";
export const SIGN_SEND_TO_QC = "Send to Q.C.";
export const SIGN_ROUTE = "Route to another department";
/** The server's precondition texts the stall shows as links instead. */
const STALL_PRECONDITIONS = new Set([
"Finish the checklist first.",
"Record at least one weld first.",
"Enter the filler metal first.",
]);
function stationsOf(
depts: ReturnType<typeof useAuth>["departments"],
code: string,
): Array<{ id: number; name: string }> {
const d = depts.find((x) => x.code === code);
const out: Array<{ id: number; name: string }> = [];
for (const s of d?.stations ?? []) {
if (typeof s === "object" && s.id !== undefined && s.active !== false)
out.push({ id: s.id, name: s.name });
}
return out;
}
/** The clock line: HH:MM:SS, running or paused, the target, and the VISUAL ticket hours beside it. */
export function ClockLine({ p }: { p: PacketRead }) {
const clock = p.stage?.clock ?? null;
const running = isRunning(clock);
const key = `${p.id}:${p.version}:${clock?.elapsed_ms ?? 0}:${clock?.running_since ?? ""}`;
const [fetched, setFetched] = useState(() => ({ key, at: Date.now() }));
if (fetched.key !== key) setFetched({ key, at: Date.now() });
const now = useTicker(running);
const ms = liveElapsed(clock, fetched.key === key ? fetched.at : now, now);
const target = p.target_hrs ?? targetHours(p);
const state = running
? "running"
: p.status === "in_progress"
? "paused"
: "";
return (
<div className="clockrow">
<div className="clock" data-testid="clock" aria-live="off">
<span
className={`cdot${running ? " run" : ""}`}
title={running ? "Running" : "Stopped"}
aria-hidden="true"
/>
{fmtClock(ms)}
<small>
{state ? ` ${state}` : ""} · target {Number(target || 0).toFixed(1)} h
</small>
</div>
{p.labor ? (
<div
className="labor small"
data-testid="labor"
title={
p.labor.observed_at
? `From VISUAL, read ${p.labor.observed_at}`
: "From VISUAL"
}
>
VISUAL tickets <b>{Number(p.labor.hours || 0).toFixed(1)} h</b>
</div>
) : null}
</div>
);
}
/** The supervisor's assign sheet: station, welder (with search) and stamp. */
export function AssignSheet({
p,
sub,
onCancel,
onSend,
}: {
p: PacketRead;
sub: string;
onCancel: () => void;
onSend: (stationId: number, welderId: string, stamp: string) => void;
}) {
const { departments } = useAuth();
const stations = stationsOf(departments, p.dept.code);
const { people, loaded, error: peopleError } = usePeople(p.dept.code);
const [station, setStation] = useState<number | null>(
p.station?.id ?? stations[0]?.id ?? null,
);
const [q, setQ] = useState("");
const [welder, setWelder] = useState<string>(p.welder?.id ?? "");
const [stamp, setStamp] = useState<string>(p.welder?.stamp ?? "");
const [error, setError] = useState<string | null>(null);
const st = stations.find((s) => s.id === station) ?? stations[0];
const allowed = people.filter(
(u) =>
u.level !== "worker" ||
!u.station_limits.length ||
(st ? u.station_limits.includes(st.id) : true),
);
const needle = q.trim().toLowerCase();
const shown = allowed.filter(
(u) =>
!needle ||
u.display_name.toLowerCase().includes(needle) ||
(u.stamp ?? "").toLowerCase().includes(needle) ||
u.id === welder,
);
const pick = (id: string) => {
setWelder(id);
const u = people.find((x) => x.id === id);
setStamp(u?.stamp ?? "");
setError(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (!st) {
setError("This department has no stations. Ask an admin to add one.");
return;
}
if (!welder || !allowed.some((u) => u.id === welder)) {
setError("Pick a welder in this department.");
return;
}
onSend(st.id, welder, stamp.trim());
};
return (
<Sheet title="Send to station" sub={sub} onClose={onCancel}>
<div className="small">
The packet appears on that station's screen right away.
</div>
<form className="stack" onSubmit={submit} noValidate aria-label="Assign">
<label className="f">
Station
<select
aria-label="Station"
className="in"
value={st?.id ?? ""}
onChange={(e) => setStation(Number(e.target.value))}
>
{stations.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="f">
Find a welder
<input
aria-label="Find a welder"
className="in"
type="search"
value={q}
placeholder="Name or stamp"
onChange={(e) => setQ(e.target.value)}
/>
</label>
<label className="f">
Welder
<select
aria-label="Welder"
className="in"
value={welder}
onChange={(e) => pick(e.target.value)}
>
<option value="">{loaded ? "Choose" : "Loading"}</option>
{shown.map((u) => (
<option key={u.id} value={u.id}>
{u.display_name}
{u.stamp ? ` [${u.stamp}]` : ""}
</option>
))}
</select>
</label>
{peopleError ? (
<div className="small pinktxt">{peopleError}</div>
) : null}
{loaded && !allowed.length ? (
<div className="small muted">
No one in this department may work at this station.
</div>
) : null}
<label className="f">
Stamp
<input
aria-label="Stamp"
className="in w110"
value={stamp}
maxLength={20}
placeholder="ID"
onChange={(e) => setStamp(e.target.value)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit">
Send to station
</button>
</div>
</form>
</Sheet>
);
}
/** One step button: hidden when the user can neither do it nor is blocked from it. */
function Step({
p,
action,
busy,
onClick,
className = "btn",
disabled = false,
why,
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
disabled?: boolean;
why?: ReactNode;
children: ReactNode;
}) {
const can = p.can.includes(action);
const blocked = p.blocked[action];
if (!can && !blocked) return null;
const reason = blocked?.join(" ") ?? "";
return (
<div className="act">
<button
className={className}
type="button"
disabled={!can || busy || disabled}
title={!can ? reason : undefined}
onClick={onClick}
>
{children}
</button>
{why ??
(!can && reason ? <div className="why small">{reason}</div> : null)}
</div>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/** The supervisor's review: send to Q.C., return to the station, or skip Q.C. and route on. */
function ReviewBox({
p,
busy,
step,
askReason,
sub,
}: {
p: PacketRead;
busy: boolean;
step: StepFn;
askReason: AskReason;
sub: string;
}) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState(
String((p.qc as { location?: string } | null)?.location ?? ""),
);
const [note, setNote] = useState("");
const [ret, setRet] = useState("");
const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route)
? route
: (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected";
const ready = book && spool && !!loc.trim();
const returnIt = async () => {
let reason = ret.trim();
if (reason.length < 8) {
const r = await askReason({
title: "Return to station",
sub,
label: "Return note",
note: "The station sees this note. Say what needs fixing.",
min: 8,
submitLabel: "Return to station",
});
if (r === null) return;
reason = r;
}
if (
await step(
"return_to_station",
{ reason },
{ ok: "Returned to the station." },
)
)
setRet("");
};
return (
<section className="panel actbox" aria-label="Supervisor review">
<div>
{rejected ? (
<>
<b>Rejected by Q.C.</b> Return it to the station for repair, or fix
it and send it back to Q.C.
</>
) : (
<>
<b>Submitted by {p.station?.name ?? "the station"}.</b> Check the
weld map, checklist and heats. When the data book package and the
spool are ready, send it to Q.C.
</>
)}
</div>
{has(p, "send_to_qc") ? (
<div className="revrow">
<label className="ckl">
<input
type="checkbox"
checked={book}
onChange={(e) => setBook(e.target.checked)}
/>
Data book package ready
</label>
<label className="ckl">
<input
type="checkbox"
checked={spool}
onChange={(e) => setSpool(e.target.checked)}
/>
Spool ready for inspection
</label>
<label className="f">
Spool location
<input
aria-label="Spool location"
className="in"
value={loc}
maxLength={80}
placeholder="e.g. Bay 2, rack B"
onChange={(e) => setLoc(e.target.value)}
/>
</label>
<label className="f grow2">
Note to Q.C.
<input
aria-label="Note to Q.C."
className="in"
value={note}
maxLength={500}
placeholder="Optional"
onChange={(e) => setNote(e.target.value)}
/>
</label>
<Step
p={p}
action="send_to_qc"
busy={busy}
className="btn pri"
disabled={!ready}
onClick={() =>
void step(
"send_to_qc",
{
book: true,
spool: true,
location: loc.trim(),
...(note.trim() ? { note: note.trim() } : {}),
},
{ sign: SIGN_SEND_TO_QC, ok: "Sent to Q.C." },
)
}
>
Send to Q.C.
</Step>
</div>
) : null}
{has(p, "return_to_station") ? (
<div className="revrow sep">
<label className="f grow2">
Return note
<input
aria-label="Return note"
className="in"
value={ret}
maxLength={500}
placeholder="What needs fixing"
onChange={(e) => setRet(e.target.value)}
/>
</label>
<Step
p={p}
action="return_to_station"
busy={busy}
className="btn pink"
onClick={() => void returnIt()}
>
Return to station
</Step>
</div>
) : null}
{has(p, "route_from_review") ? (
<div className="revrow sep">
<label className="f grow2">
Skip Q.C. - send to department
<select
aria-label="Skip Q.C. - send to department"
className="in"
value={routeTo}
onChange={(e) => setRoute(e.target.value)}
>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<Step
p={p}
action="route_from_review"
busy={busy}
disabled={!routeTo}
onClick={() =>
void step(
"route_from_review",
{ dept: routeTo },
{ sign: SIGN_ROUTE, ok: "Sent to the department." },
)
}
>
Send to department
</Step>
</div>
) : null}
</section>
);
}
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
/**
* The shop action bars on the packet page, per status and what `can` allows: assign, pull back, the review, and
* the stall's start, pause, resume and submit. The server decides; this only shapes the buttons.
*/
export function ShopBar({
p,
busy,
mutate,
askReason,
sub,
goTab,
}: {
p: PacketRead;
busy: boolean;
mutate: Mutate;
askReason: AskReason;
sub: string;
goTab: (t: ShopTab) => void;
}) {
const { me } = useAuth();
const roles = me?.roles ?? [];
const [assign, setAssign] = useState(false);
const step = makeStep(p, mutate);
const st = p.status;
const level = shopLevel(roles, p.dept.code);
const deptLabel = `${p.dept.code} ${p.dept.name}`;
const reasonStep = async (
action: string,
title: string,
note: string,
ok: string,
) => {
const reason = await askReason({
title,
sub,
note,
min: 8,
submitLabel: title,
});
if (reason === null) return;
await step(action, { reason }, { ok });
};
if (st === "ready") {
if (
!has(p, "assign") &&
!has(p, "send_back_to_warehouse") &&
!has(p, "return_to_clerical")
)
return null;
return (
<section className="panel actbox" aria-label="Assign">
<div>
{p.return_note ? (
<>
<b>Returned.</b> Assign it again when it is ready.
</>
) : (
<>
<b>Assign this packet</b> to a station and welder. It appears on
that station's screen right away.
</>
)}
</div>
<div className="acts">
<Step
p={p}
action="assign"
busy={busy}
className="btn pri"
onClick={() => setAssign(true)}
>
Send to station
</Step>
<Step
p={p}
action="send_back_to_warehouse"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"send_back_to_warehouse",
"Send back to Warehouse",
"The warehouse gets it back for the parts. Say what is missing.",
"Sent back to the warehouse.",
)
}
>
Send back to Warehouse
</Step>
<Step
p={p}
action="return_to_clerical"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"return_to_clerical",
"Send back to Clerical",
"The packet goes back to Clerical as a draft. Say what needs fixing.",
"Sent back to Clerical.",
)
}
>
Send back to Clerical
</Step>
</div>
{assign ? (
<AssignSheet
p={p}
sub={sub}
onCancel={() => setAssign(false)}
onSend={(station_id, welder_id, stamp) => {
setAssign(false);
void step(
"assign",
{ station_id, welder_id, ...(stamp ? { stamp } : {}) },
{ ok: "Sent to the station." },
);
}}
/>
) : null}
</section>
);
}
if (st === "assigned" || st === "in_progress") {
const sup = has(p, "unassign") || has(p, "pull_back");
const stall = has(p, "start") || has(p, "submit");
if (!sup && !stall) return null;
const running = isRunning(p.stage?.clock);
const miss = st === "in_progress" ? stallMissing(p) : [];
const other = (p.blocked.submit ?? []).filter(
(m) => !STALL_PRECONDITIONS.has(m),
);
return (
<section className="panel actbox" aria-label="Station">
<div>
{stall && st === "assigned" ? (
<>
<b>Ready to start.</b> Clock on to the work order in VISUAL (Work
orders tab) and start the timer.
</>
) : stall && st === "in_progress" ? (
miss.length ? (
<span data-testid="submit-missing">
<b>Before submitting:</b>{" "}
{miss.map((m, i) => (
<span key={m.t}>
{i ? " · " : ""}
<button
className="lnk"
type="button"
onClick={() => goTab(m.tab)}
>
{m.t}
</button>
</span>
))}
</span>
) : (
<>
<b>Everything is filled in.</b> Submit when the parts are done.
</>
)
) : (
<>
In <b>{p.station?.name ?? "a station"}</b> with{" "}
{p.welder?.display_name ?? "-"}. You can edit the heat map, weld
map, materials and checklist while it is out.
</>
)}
</div>
{other.length && stall && st === "in_progress" ? (
<div className="why small">{other.join(" ")}</div>
) : null}
<div className="acts center">
<ClockLine p={p} />
{stall && st === "assigned" ? (
<Step
p={p}
action="start"
busy={busy}
className="btn pri"
onClick={() => void step("start", {}, { ok: "Work started." })}
>
Start work
</Step>
) : null}
{stall && st === "in_progress" && p.can.includes("clock") ? (
<div className="act">
<button
className="btn"
type="button"
disabled={busy}
onClick={() =>
void mutate((g) =>
packetsApi.clock(p.id, running ? "pause" : "resume", g),
)
}
>
{running ? "Pause" : "Resume"}
</button>
</div>
) : null}
{stall && st === "in_progress" ? (
<Step
p={p}
action="submit"
busy={busy}
className="btn pri"
why={<span />}
onClick={() =>
void step(
"submit",
{},
{ sign: SIGN_SUBMIT, ok: "Submitted to the supervisor." },
)
}
>
Submit to supervisor
</Step>
) : null}
<Step
p={p}
action={st === "assigned" ? "unassign" : "pull_back"}
busy={busy}
onClick={() =>
void step(
st === "assigned" ? "unassign" : "pull_back",
{},
{ ok: "Pulled back to the queue." },
)
}
>
Pull back to queue
</Step>
</div>
</section>
);
}
if (st === "review") {
if (
has(p, "send_to_qc") ||
has(p, "return_to_station") ||
has(p, "route_from_review")
) {
return (
<ReviewBox
p={p}
busy={busy}
step={step}
askReason={askReason}
sub={sub}
/>
);
}
if (level && level !== "supervisor") {
return (
<div className="callout" role="note" data-testid="review-readonly">
<b>Submitted by {p.station?.name ?? "the station"}.</b> A supervisor
for {deptLabel} reviews it and sends it on.
</div>
);
}
}
return null;
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead, Weld, WeldBody } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { errorText } from "../lib/format";
import { voidText } from "../lib/voidStamp";
import { weldId, weldSummary } from "../lib/weldDefaults";
import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer";
import { WeldMark, type MarkMove } from "./marks";
import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil";
import { useSerial } from "./useSerial";
import { WeldRecord } from "./WeldRecord";
import { WeldSheet, type NewWeldAt } from "./WeldSheet";
import type { Mutate } from "./tabs";
const rv = (cur: PacketRead, no: number, fallback: number) => cur.welds.find((w) => w.weld_no === no)?.row_version ?? fallback;
function WeldRow({ w, onOpen }: { w: Weld; onOpen: (w: Weld) => void }) {
return (
<button type="button" className="row rowbtn" onClick={() => onOpen(w)} aria-label={`Open weld ${weldId(w)}`}>
<span className={`balloon pinkb${w.carried ? " carried" : ""}`}>{w.weld_no}</span>
<span className="d">
{weldSummary(w)}
<div className="small muted mono">
{w.wo_base ?? "-"} #{w.piece ?? 1} · {w.process ?? "-"} · {w.stamp ?? ""}
</div>
</span>
<span />
</button>
);
}
/**
* The weld map: the drawing with the welds of the open sheet as pink leader marks. "Mark a weld" turns on
* marking; a tap on the joint opens the weld sheet. Bubbles and arrows drag when the user may edit welds.
*/
export function WeldMapTab({
p,
write,
mutate,
reload,
askReason,
sub,
myId,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
askReason: (o: ReasonOptions) => Promise<string | null>;
sub: string;
myId: string | null;
onError: (text: string) => void;
}) {
const pages = viewPages(p.pages);
const [picked, setPicked] = useState<{ n: number; onPurpose: boolean } | null>(null);
const page = landingPage(pages, picked?.n ?? null, picked?.onPurpose ?? false);
const [marking, setMarking] = useState(false);
const [sheet, setSheet] = useState<{ weldNo: number | null; at: NewWeldAt | null } | null>(null);
const serial = useSerial(p, write);
const { people } = usePeople(p.dept.code);
const myStamp = people.find((x) => x.id === myId)?.stamp ?? null;
if (!page) return <div className="empty">No drawing pages in this packet.</div>;
const isVoid = !!page.void;
const canEdit = p.can.includes("edit_welds");
const editable = canEdit && !isVoid;
const onSheet = p.welds.filter((w) => w.page === page.n);
const current = onSheet.filter((w) => !w.carried);
const carried = onSheet.filter((w) => w.carried);
const vText = isVoid ? voidText(page, p.pages) : "";
const open = sheet ? (sheet.weldNo !== null ? (p.welds.find((w) => w.weld_no === sheet.weldNo) ?? null) : null) : null;
const done = async (r: WriteResult): Promise<boolean> => {
if (r.ok) return true;
if (r.err !== undefined) {
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
onError(errorText(r.err));
}
return false;
};
const add = async (body: WeldBody) => {
if (await done(await serial((cur, g) => packetsApi.addWeld(cur.id, body, g)))) {
setSheet(null);
setMarking(false);
}
};
const save = async (w: Weld, body: WeldBody) => {
if (await done(await serial((cur, g) => packetsApi.patchWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), body, g)))) setSheet(null);
};
const remove = async (w: Weld) => {
const reason = await askReason({
title: `Remove weld ${weldId(w)}`,
sub,
note: "The weld leaves the weld map. It stays in the record with your reason.",
min: 3,
danger: true,
submitLabel: "Remove weld",
});
if (reason === null) return;
if (await done(await serial((cur, g) => packetsApi.removeWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), { reason, ...g })))) setSheet(null);
};
const move = (w: Weld, m: MarkMove) =>
serial((cur, g) =>
packetsApi.patchWeld(
cur.id,
w.weld_no,
rv(cur, w.weld_no, w.row_version),
m.bubble ? { bfx: m.bfx, bfy: m.bfy } : { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
g,
),
).then(done);
const markBtn = editable ? (
<button
type="button"
className={marking ? "btn pink" : "btn pri"}
aria-pressed={marking}
onClick={() => setMarking((v) => !v)}
>
{marking ? "Cancel marking" : "Mark a weld"}
</button>
) : null;
return (
<div className="stack">
{isVoid ? (
<div className="callout warn flush">
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record with its weld marks.{" "}
{page.void?.new_n ? (
<button className="btn sm" type="button" onClick={() => setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })}>
Go to the current sheet
</button>
) : null}
</div>
) : null}
<div className="pagesel">
{pages.length > 1 ? (
<div className="chips" role="group" aria-label="Sheets">
{pages.map((x) => (
<button
key={x.n}
type="button"
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
aria-pressed={x.n === page.n}
onClick={() => setPicked({ n: x.n, onPurpose: true })}
>
{pageLabel(x)}
</button>
))}
</div>
) : (
<span className="mono small muted">{pageLabel(page)}</span>
)}
{markBtn}
{marking ? <span className="small">Tap the weld location on the drawing.</span> : null}
{!canEdit ? (
<span className="small muted">
{p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded
</span>
) : null}
</div>
<div className="dv">
<MapViewer
page={page}
voidLabel={vText}
mode={marking && editable ? "marking" : ""}
label="Weld map"
onTap={(fx, fy) => {
if (marking && editable) setSheet({ weldNo: null, at: { page: page.n, fx, fy } });
}}
fsBar={
<>
<span className="fst">{pageLabel(page)}</span>
{markBtn}
</>
}
>
{onSheet.map((w) => (
<WeldMark
key={w.weld_no}
weld={w}
editable={editable && !w.carried}
onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })}
onMove={move}
/>
))}
</MapViewer>
<div className="side">
<div className="panel">
<h3>
Welds on this sheet ({current.length})
{current.length !== p.welds.filter((w) => !w.carried).length ? <span className="small muted"> of {p.welds.filter((w) => !w.carried).length}</span> : null}
</h3>
{editable && current.length ? <div className="small muted mb8">Drag a bubble to move it off the linework. Drag the arrow to fix the weld location.</div> : null}
<div className="hlist">
{current.length ? (
current.map((w) => <WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />)
) : (
<div className="small muted">{editable ? "Tap Mark a weld, then tap the joint on the drawing." : "No welds recorded yet."}</div>
)}
</div>
</div>
{carried.length ? (
<div className="panel">
<h3>Carried from an earlier department</h3>
<div className="hlist">
{carried.map((w) => (
<WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />
))}
</div>
</div>
) : null}
<WeldRecord p={p} mutate={mutate} />
</div>
</div>
{sheet && (sheet.weldNo === null || open) ? (
<WeldSheet
key={sheet.weldNo ?? "new"}
p={p}
weld={open}
at={sheet.at}
editable={canEdit && !(open ? !!p.pages.find((x) => x.n === open.page)?.void : isVoid) && !open?.carried}
myStamp={myStamp}
sub={sub}
onSave={(body) => void (open ? save(open, body) : add(body))}
onRemove={() => {
if (open) void remove(open);
}}
onClose={() => setSheet(null)}
/>
) : null}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useRef, useState } from "react";
import { packetsApi } from "../api/packets";
import type { PacketRead, StageFields } from "../api/types";
import type { Mutate } from "./tabs";
const FIELDS: ReadonlyArray<{
k: keyof StageFields;
label: string;
ph: string;
max: number;
}> = [
{ k: "filler", label: "Filler metal", ph: "e.g. ER70S-6", max: 40 },
{ k: "filler_lot", label: "Filler lot / heat", ph: "Lot no.", max: 40 },
{ k: "preheat", label: "Preheat °F", ph: "e.g. 50 min", max: 40 },
];
/**
* The weld record of the open stage: filler, filler lot, preheat, notes. Each field saves on blur (or Enter) with
* a field-level base, so two people editing different fields never clash; the same field answers field_conflict.
*/
export function WeldRecord({ p, mutate }: { p: PacketRead; mutate: Mutate }) {
const st = p.stage;
const editable = !!st && p.can.includes("edit_stage_fields");
const [draft, setDraft] = useState<
Partial<Record<keyof StageFields, string>>
>({});
const server = (k: keyof StageFields) => (st ? (st[k] ?? "") : "");
const shown = (k: keyof StageFields) => draft[k] ?? server(k);
const saving = useRef(new Set<keyof StageFields>());
const save = async (k: keyof StageFields) => {
const now = draft[k];
if (now === undefined || saving.current.has(k)) return;
const was = st ? st[k] : null;
const value = now.trim() || null;
if (value === (was ?? null)) {
setDraft((d) => ({ ...d, [k]: undefined }));
return;
}
saving.current.add(k);
try {
await mutate((g) =>
packetsApi.patchStage(p.id, {
set: { [k]: value },
base: { [k]: was ?? null },
...g,
}),
);
} finally {
saving.current.delete(k);
}
// On a conflict the page reloads; the field then shows the value someone else saved.
setDraft((d) => ({ ...d, [k]: undefined }));
};
if (!st) {
return (
<div className="panel stack">
<h3>Weld record</h3>
<div className="small muted">
The weld record opens when the packet is at a station.
</div>
</div>
);
}
return (
<div className="panel stack" aria-label="Weld record" role="group">
<h3>Weld record</h3>
{FIELDS.map((f) =>
editable ? (
<label key={f.k} className="f">
{f.label}
<input
aria-label={f.label}
className="in"
value={shown(f.k)}
maxLength={f.max}
placeholder={f.ph}
onChange={(e) =>
setDraft((d) => ({ ...d, [f.k]: e.target.value }))
}
onBlur={() => void save(f.k)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void save(f.k);
}
}}
/>
</label>
) : (
<div key={f.k} className="small">
<span className="muted">{f.label}: </span>
<b className="near">{server(f.k) || "-"}</b>
</div>
),
)}
{editable ? (
<label className="f">
Notes
<textarea
aria-label="Notes"
className="in"
value={shown("notes")}
maxLength={2000}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
onBlur={() => void save("notes")}
/>
</label>
) : (
<div className="small">
<span className="muted">Notes: </span>
{server("notes") || "-"}
</div>
)}
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, type FormEvent } from "react";
import type { PacketRead, Weld, WeldBody } from "../api/types";
import { Sheet } from "../components/Sheet";
import { bpos } from "../lib/geometry";
import { fmtTime } from "../lib/format";
import {
DEFAULT_SIZE,
DEFAULT_TYPE,
PROCESSES,
WELD_TYPES,
lastProcess,
weldId,
woChoices,
} from "../lib/weldDefaults";
/** The new weld's spot, from the tap on the drawing. */
export interface NewWeldAt {
page: number;
fx: number;
fy: number;
}
interface Draft {
wo_base: string;
piece: number;
type: string;
size: string;
process: string;
stamp: string;
items: number[];
}
/**
* The weld sheet: work order, piece, type, size, process, stamp and the BOM items the weld joins (at least one).
* A new weld sends its point and the default label position; an existing one sends only what changed.
*/
export function WeldSheet({
p,
weld,
at,
editable,
myStamp,
sub,
onSave,
onRemove,
onClose,
}: {
p: PacketRead;
/** The weld to edit, or null for a new one. */
weld: Weld | null;
at: NewWeldAt | null;
editable: boolean;
/** The signed-in user's welder stamp, when known. */
myStamp: string | null;
sub: string;
onSave: (body: WeldBody) => void;
onRemove: () => void;
onClose: () => void;
}) {
const wos = woChoices(p);
const [d, setD] = useState<Draft>(() =>
weld
? {
wo_base: weld.wo_base ?? wos[0]?.base ?? "",
piece: weld.piece ?? 1,
type: weld.type ?? DEFAULT_TYPE,
size: weld.size ?? DEFAULT_SIZE,
process: weld.process ?? lastProcess(p),
stamp: weld.stamp ?? "",
items: [...weld.items],
}
: {
wo_base: wos[0]?.base ?? "",
piece: 1,
type: DEFAULT_TYPE,
size: DEFAULT_SIZE,
process: lastProcess(p),
stamp: myStamp || p.welder?.stamp || "",
items: [],
},
);
const [error, setError] = useState<string | null>(null);
const qty = wos.find((w) => w.base === d.wo_base)?.qty ?? 1;
const title = weld ? `Weld ${weldId(weld)}` : "New weld";
const dis = !editable;
const toggle = (item: number) =>
setD((o) => ({
...o,
items: o.items.includes(item)
? o.items.filter((x) => x !== item)
: [...o.items, item].sort((a, b) => a - b),
}));
const heats = d.items
.map((i) => {
const b = p.bom.find((x) => x.item === i);
return b ? `${i}: ${b.heat || (b.na ? "N/A" : "-")}` : "";
})
.filter(Boolean)
.join(" · ");
const submit = (e: FormEvent) => {
e.preventDefault();
if (!editable) return;
if (!d.items.length) {
setError("Pick at least one BOM item this weld joins.");
return;
}
const size = d.size.trim() || DEFAULT_SIZE;
const stamp = d.stamp.trim() || null;
if (!weld) {
if (!at) return;
const b = bpos({ fx: at.fx, fy: at.fy });
onSave({
page: at.page,
fx: at.fx,
fy: at.fy,
bfx: b.bfx,
bfy: b.bfy,
...(d.wo_base ? { wo_base: d.wo_base } : {}),
piece: d.piece,
items: d.items,
type: d.type,
size,
process: d.process,
...(stamp ? { stamp } : {}),
});
return;
}
const body: WeldBody = {};
if ((weld.wo_base ?? "") !== d.wo_base && d.wo_base)
body.wo_base = d.wo_base;
if ((weld.piece ?? 1) !== d.piece) body.piece = d.piece;
if ((weld.type ?? "") !== d.type) body.type = d.type;
if ((weld.size ?? "") !== size) body.size = size;
if ((weld.process ?? "") !== d.process) body.process = d.process;
if ((weld.stamp ?? null) !== stamp) body.stamp = stamp;
if ([...weld.items].sort((a, b) => a - b).join(",") !== d.items.join(","))
body.items = d.items;
if (!Object.keys(body).length) {
onClose();
return;
}
onSave(body);
};
return (
<Sheet
title={title}
sub={weld ? `${sub} · recorded ${fmtTime(weld.at)}` : sub}
onClose={onClose}
wide
>
{weld?.carried ? (
<div className="callout flush">
Logged in an earlier department. Shown for the record.
</div>
) : null}
<form className="stack" onSubmit={submit} noValidate aria-label={title}>
<div className="frow">
<label className="f">
Work order
<select
aria-label="Work order"
className="in"
value={d.wo_base}
disabled={dis}
onChange={(e) =>
setD((o) => ({ ...o, wo_base: e.target.value, piece: 1 }))
}
>
{wos.map((w) => (
<option key={w.base} value={w.base}>
{w.base}
</option>
))}
</select>
</label>
<label className="f">
Piece
<select
aria-label="Piece"
className="in"
value={d.piece}
disabled={dis}
onChange={(e) =>
setD((o) => ({ ...o, piece: Number(e.target.value) }))
}
>
{Array.from({ length: Math.max(qty, d.piece) }, (_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1} of {qty}
</option>
))}
</select>
</label>
<label className="f">
Type
<select
aria-label="Type"
className="in"
value={d.type}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, type: e.target.value }))}
>
{WELD_TYPES.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
<label className="f">
Size
<input
aria-label="Size"
className="in"
value={d.size}
maxLength={40}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, size: e.target.value }))}
/>
</label>
<label className="f">
Process
<select
aria-label="Process"
className="in"
value={d.process}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, process: e.target.value }))}
>
{PROCESSES.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
<label className="f">
Welder stamp
<input
aria-label="Welder stamp"
className="in"
value={d.stamp}
maxLength={40}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, stamp: e.target.value }))}
/>
</label>
</div>
<div role="group" aria-label="Mating BOM items">
<div className="flabel">Mating BOM items</div>
<div className="chips">
{p.bom.map((b) => (
<button
key={b.item}
type="button"
className="ichip"
aria-pressed={d.items.includes(b.item)}
aria-label={`Item ${b.item}`}
disabled={dis}
onClick={() => {
toggle(b.item);
setError(null);
}}
>
{b.item}
</button>
))}
</div>
<div className="small muted">
{d.items.length
? `Selected: item ${d.items.join(" + ")}${heats ? ` · heats ${heats}` : ""}`
: "Tap each BOM item number this weld joins."}
</div>
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
{editable ? (
<button className="btn pri" type="submit">
{weld ? "Save changes" : "Log weld"}
</button>
) : null}
<button className="btn ghost" type="button" onClick={onClose}>
{editable ? "Cancel" : "Close"}
</button>
{editable && weld ? (
<>
<span className="grow" />
<button
className="btn ghost pinktxt"
type="button"
onClick={onRemove}
>
Remove weld
</button>
</>
) : null}
</div>
</form>
</Sheet>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import type { HeatTablePlacement, HeatTag, Weld } from "../api/types";
import { arrowRotation, bpos, clamp01 } from "../lib/geometry";
import { clampCorner, heatGroups, NO_HEAT, type HeatRow } from "../lib/heatTable";
import { weldId } from "../lib/weldDefaults";
import { useViewer } from "./viewerContext";
/** A mark's position while it is dragged, before the server has it. */
interface Pos {
fx: number;
fy: number;
bfx: number;
bfy: number;
}
export interface MarkMove {
/** Only the bubble moved. */
bubble: boolean;
fx: number;
fy: number;
bfx: number;
bfy: number;
}
const pct = (v: number) => `${v * 100}%`;
/**
* One leader mark: the line, the arrowhead at the point and the bubble. Shared by welds (pink) and the older heat
* bubbles (amber). The bubble and the arrowhead drag when editable; a tap on the bubble opens it.
*/
function Leader({
kind,
at,
editable,
carried,
label,
bubble,
bubbleClass,
onOpen,
onMove,
}: {
kind: "w" | "h";
at: Pos;
editable: boolean;
carried: boolean;
label: string;
bubble: ReactNode;
bubbleClass: string;
onOpen: () => void;
onMove: (m: MarkMove) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${at.fx},${at.fy},${at.bfx},${at.bfy}`;
// The dragged spot shows until the server's answer moves the mark (a new key) or the write fails.
const [drag, setDrag] = useState<{ key: string; pos: Pos } | null>(null);
const pos = drag && drag.key === key ? drag.pos : at;
const rot = arrowRotation(pos.fx, pos.fy, pos.bfx, pos.bfy, v.aspect);
const begin = (e: ReactPointerEvent, part: "anchor" | "bubble") => {
if (!editable) return;
let last: Pos | null = null;
v.startDrag(e, {
onMove: (f) => {
const x = clamp01(f.fx);
const y = clamp01(f.fy);
last = part === "anchor" ? { ...at, fx: x, fy: y } : { ...at, bfx: x, bfy: y };
setDrag({ key, pos: last });
},
onEnd: (f) => {
const done = f ? last : null;
if (!done) {
setDrag(null);
return;
}
void onMove({ bubble: part === "bubble", ...done }).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<>
<svg className="lead" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<line className={`ld ${kind}${carried ? " carried" : ""}`} x1={pos.fx} y1={pos.fy} x2={pos.bfx} y2={pos.bfy} />
</svg>
<span
className={`arw ${kind}${editable ? " drag" : ""}`}
data-mark={editable ? "anchor" : undefined}
style={{ left: pct(pos.fx), top: pct(pos.fy), ["--rot" as string]: `${rot}deg` } as CSSProperties}
title={editable ? "Drag to move where the arrow points" : undefined}
aria-hidden="true"
onPointerDown={editable ? (e) => begin(e, "anchor") : undefined}
/>
<button
type="button"
className={`${bubbleClass}${editable ? " drag" : ""}${carried ? " carried" : ""}`}
data-mark="bubble"
style={{ left: pct(pos.bfx), top: pct(pos.bfy) }}
aria-label={label}
onPointerDown={editable ? (e) => begin(e, "bubble") : undefined}
onClick={() => {
if (!v.justDragged()) onOpen();
}}
>
{bubble}
</button>
</>
);
}
function posOf(m: { fx: number | null; fy: number | null; bfx?: number | null | undefined; bfy?: number | null | undefined }): Pos {
const b = bpos(m);
return { fx: m.fx ?? 0.5, fy: m.fy ?? 0.5, ...b };
}
export function WeldMark({
weld,
editable,
onOpen,
onMove,
}: {
weld: Weld;
editable: boolean;
onOpen: (w: Weld) => void;
onMove: (w: Weld, m: MarkMove) => Promise<boolean>;
}) {
const id = weldId(weld);
return (
<Leader
kind="w"
at={posOf(weld)}
editable={editable}
carried={!!weld.carried}
label={`Weld ${id}${weld.carried ? ", carried" : ""}`}
bubble={id}
bubbleClass="mk"
onOpen={() => onOpen(weld)}
onMove={(m) => onMove(weld, m)}
/>
);
}
export function HeatTagMark({
tag,
heat,
editable,
onOpen,
onMove,
}: {
tag: HeatTag;
heat: string;
editable: boolean;
onOpen: (t: HeatTag) => void;
onMove: (t: HeatTag, m: MarkMove) => Promise<boolean>;
}) {
return (
<Leader
kind="h"
at={posOf(tag)}
editable={editable}
carried={!!tag.carried}
label={`Heat tag H${tag.tag_no}, item ${tag.item}, ${heat || "no heat"}`}
bubble={
<>
<span className="hn">{tag.item}</span>
<span>{heat || "no heat"}</span>
</>
}
bubbleClass={heat ? "ht" : "ht none"}
onOpen={() => onOpen(tag)}
onMove={(m) => onMove(tag, m)}
/>
);
}
/** The table body: the caption and the groups side by side. */
export function HeatTableInner({ rows }: { rows: readonly HeatRow[] }) {
return (
<>
<div className="htcap">HEAT NUMBERS</div>
<div className="htcols">
{heatGroups(rows).map((g, i) => (
<table key={i}>
<thead>
<tr>
<th>ITEM</th>
<th>HEAT NO.</th>
</tr>
</thead>
<tbody>
{g.map((r) => (
<tr key={r.item}>
<td className="i">{r.item}</td>
<td className={`h${r.heat ? "" : " none"}${r.na ? " na" : ""}`}>{r.heat || NO_HEAT}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
</>
);
}
/** The heat table on the drawing. Its font follows the page width (--sw) and the size (--hs). Drags when editable. */
export function HeatTableOnDrawing({
placement,
rows,
editable,
onMove,
}: {
placement: HeatTablePlacement;
rows: readonly HeatRow[];
editable: boolean;
onMove: (x: number, y: number) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${placement.x},${placement.y}`;
const [drag, setDrag] = useState<{ key: string; x: number; y: number } | null>(null);
const at = drag && drag.key === key ? drag : placement;
const down = (e: ReactPointerEvent) => {
if (!editable) return;
const f0 = v.toFrac(e.clientX, e.clientY);
let last: { x: number; y: number } | null = null;
v.startDrag(e, {
onMove: (f) => {
last = clampCorner(placement.x + (f.fx - f0.fx), placement.y + (f.fy - f0.fy));
setDrag({ key, ...last });
},
onEnd: (f) => {
if (!f || !last) {
setDrag(null);
return;
}
void onMove(last.x, last.y).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<div
className={`htbl${editable ? " drag" : ""}${drag && drag.key === key ? " moving" : ""}`}
data-mark={editable ? "table" : undefined}
data-testid="heat-table"
role="group"
aria-label="Heat table on the drawing"
style={{ left: pct(at.x), top: pct(at.y), ["--hs" as string]: String(placement.s || 1) } as CSSProperties}
title={editable ? "Drag to move the heat table" : "Heat numbers from the Materials tab"}
onPointerDown={editable ? down : undefined}
>
<HeatTableInner rows={rows} />
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PacketRead, Stage } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
import { WeldSheet } from "./WeldSheet";
const DEPTS = DEPARTMENTS.map((d) => (d.code === "03" ? { ...d, weld: true, stations: [{ id: 1, name: "Stall 1" }, { id: 2, name: "Stall 2" }] } : d));
const CHECK = [
{ group: "Before welding", key: "dwg", text: "Drawing CI-DWG-1 Rev 0 matches the packet" },
{ group: "Before welding", key: "heat", text: "Heat numbers on material match the Materials tab" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
];
function stage(over: Partial<Stage> = {}): Stage {
return {
id: 5,
dept_code: "03",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
checklist: {},
filler: null,
filler_lot: null,
preheat: null,
notes: null,
clock: { elapsed_ms: 3_723_000, running: true, running_since: "2026-10-16T08:00:00Z", started_at: "2026-10-16T08:00:00Z" },
opened_at: "2026-10-16T07:00:00Z",
row_version: 1,
...over,
};
}
function shopPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "in_progress",
holder: "Stall 1",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
heat_map: true,
bom: [
{ item: 1, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H1", row_version: 1 },
{ item: 2, qty: 2, part_id: "P-2", description: "Flange", dwg_ref: null, length: null, material: "A105", heat: null, row_version: 1 },
],
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 3, target_hrs: 4.5, row_version: 1 }],
checklist_items: CHECK,
stage: stage(),
can: [],
blocked: {},
...over,
});
}
function base(packet: PacketRead, roles: string[], name = "Wes Super") {
return {
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor", "stall"], user: { id: name === "Andy Welder" ? "andy" : "wes", display_name: name } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
"/api/departments/03/people": { body: [{ id: "andy", display_name: "Andy Welder", stamp: "75", level: "worker", station_limits: [] }] },
};
}
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());
}
describe("Checklist tab", () => {
it("each tick PUTs its key and the answer replaces the packet", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const after = shopPacket({ version: 4, can: ["edit_checklist", "edit_stage_fields"], stage: stage({ checklist: { dwg: { done: true, at: "2026-10-16T09:00:00Z", stamp: "75" } } }) });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PUT /api/packets/1/stage/checklist/dwg": { body: after } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const box = screen.getByRole("checkbox", { name: /Drawing CI-DWG-1 Rev 0 matches the packet/ });
expect(box).toBeEnabled();
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: /Drawing CI-DWG-1/ })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/stage/checklist/dwg")).toEqual([{ done: true }]);
expect(screen.getByText(/^75 · /)).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Weld record" })).toBeInTheDocument();
});
it("is read-only without edit_checklist", async () => {
mockFetch(base(shopPacket({ can: [] }), ["shop:03:lead"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.getByText("Checklist is completed at the stall.")).toBeInTheDocument();
});
});
describe("Weld record", () => {
it("saves a field on blur with its base", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PATCH /api/packets/1/stage": { body: shopPacket({ version: 4, stage: stage({ filler: "ER70S-6" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const filler = screen.getByLabelText("Filler metal");
fireEvent.change(filler, { target: { value: "ER70S-6" } });
fireEvent.blur(filler);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/stage")).toEqual([{ set: { filler: "ER70S-6" }, base: { filler: null } }]));
});
});
describe("Weld sheet", () => {
it("needs at least one item, then sends the point and the default label position", () => {
const onSave = vi.fn();
render(
<WeldSheet
p={shopPacket()}
weld={null}
at={{ page: 1, fx: 0.5, fy: 0.5 }}
editable
myStamp="75"
sub="L999001 · WELD-PART"
onSave={onSave}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByLabelText("Size")).toHaveValue('5/16"');
expect(screen.getByLabelText("Type")).toHaveValue("Fillet");
expect(screen.getByLabelText("Process")).toHaveValue("GMAW");
expect(screen.getByLabelText("Welder stamp")).toHaveValue("75");
expect(screen.getByLabelText("Piece")).toHaveTextContent("1 of 3");
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(screen.getByRole("alert")).toHaveTextContent("Pick at least one BOM item this weld joins.");
expect(onSave).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Item 2" }));
fireEvent.click(screen.getByRole("button", { name: "Item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(onSave).toHaveBeenCalledTimes(1);
const body = onSave.mock.calls[0]?.[0] as Record<string, unknown>;
expect(body).toMatchObject({ page: 1, fx: 0.5, fy: 0.5, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75" });
expect(body.bfx).toBeCloseTo(0.55);
expect(body.bfy).toBeCloseTo(0.43);
});
it("is read-only without edit rights", () => {
render(
<WeldSheet
p={shopPacket()}
weld={{ weld_no: 4, page: 1, fx: 0.2, fy: 0.3, wo_base: "L999001", piece: 1, items: [1], type: "Groove", size: "1/4\"", process: "GTAW", stamp: "75", at: null, row_version: 1, welder: null }}
at={null}
editable={false}
myStamp={null}
sub=""
onSave={() => undefined}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByRole("heading", { name: "Weld W4" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.getByLabelText("Type")).toBeDisabled();
});
});
describe("Packet page action bars", () => {
it("sup.wes on a review packet: Send to Q.C. waits for both boxes and a location, then signs", async () => {
const p = shopPacket({ status: "review", holder: "Supervisor", stage: stage({ clock: { elapsed_ms: 1000, running: false, running_since: null, started_at: null } }), can: ["send_to_qc", "return_to_station", "route_from_review"] });
const fetch = mockFetch({
...base(p, ["shop:03:supervisor"]),
"POST /api/packets/1/transitions": { body: shopPacket({ status: "qc", version: 5, holder: "Q.C.", can: [], blocked: {} }) },
});
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Supervisor review" });
const send = within(box).getByRole("button", { name: "Send to Q.C." });
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Data book package ready"));
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Spool ready for inspection"));
expect(send).toBeDisabled();
fireEvent.change(within(box).getByLabelText("Spool location"), { target: { value: "Bay 2" } });
expect(send).toBeEnabled();
expect(within(box).getByRole("button", { name: "Return to station" })).toBeEnabled();
expect(within(box).getByRole("button", { name: "Send to department" })).toBeEnabled();
fireEvent.click(send);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Send to Q.C.")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "send_to_qc", expect: { status: "review", dept: "03" }, book: true, spool: true, location: "Bay 2", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Q.C. inspection"));
});
it("a lead sees the review read-only", async () => {
mockFetch(base(shopPacket({ status: "review", holder: "Supervisor", can: [] }), ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
expect(await screen.findByTestId("review-readonly")).toHaveTextContent("A supervisor for 03 Weld Parts reviews it and sends it on.");
expect(screen.queryByRole("button", { name: "Send to Q.C." })).not.toBeInTheDocument();
});
it("weld.andy in progress: the clock, Pause, and Submit with the missing steps as links", async () => {
const p = shopPacket({
can: ["clock", "edit_checklist", "edit_welds", "edit_stage_fields"],
blocked: { submit: ["Finish the checklist first.", "Record at least one weld first.", "Enter the filler metal first."] },
labor: { hours: 3.24, tickets: 2, per_wo: [], observed_at: null },
});
mockFetch(base(p, ["shop:03:worker"], "Andy Welder"));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByRole("button", { name: "Submit to supervisor" })).toBeDisabled();
expect(within(bar).getByTestId("submit-missing")).toHaveTextContent("Before submitting: 3 checklist items · no welds marked on the drawing · filler metal");
expect(within(bar).getByRole("button", { name: "Pause" })).toBeEnabled();
expect(within(bar).getByTestId("clock")).toHaveTextContent(/01:02:0\d running · target 4.5 h/);
expect(within(bar).getByTestId("labor")).toHaveTextContent("VISUAL tickets 3.2 h");
fireEvent.click(within(bar).getByRole("button", { name: "no welds marked on the drawing" }));
expect(screen.getByRole("tab", { name: /Weld map/ })).toHaveAttribute("aria-selected", "true");
});
it("Submit signs and sends the expect guard; a stale step reloads and toasts the server text", async () => {
let fresh = false;
const p = shopPacket({ can: ["clock", "submit"], stage: stage({ filler: "ER70S-6", checklist: { dwg: { done: true }, heat: { done: true }, vt: { done: true } } }), welds: [{ weld_no: 1, page: 1, fx: 0.4, fy: 0.4, wo_base: "L999001", piece: 1, items: [1], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null, dept_code: "03" }] });
const back = shopPacket({ status: "ready", version: 9, station: null, welder: null, stage: null, holder: "Supervisor", can: [], blocked: {} });
const fetch = mockFetch({
...base(p, ["shop:03:worker"], "Andy Welder"),
"/api/packets/1": () => ({ body: fresh ? back : p }),
"POST /api/packets/1/transitions": () => {
fresh = true;
return { status: 409, body: { detail: "Someone else already took this step.", code: "stale_step" } };
},
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByText(/Everything is filled in/)).toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Submit to supervisor" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "submit", expect: { status: "in_progress", dept: "03" }, sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
});
it("the assign sheet sends the station, the welder and the stamp", async () => {
const p = shopPacket({ status: "ready", station: null, welder: null, stage: null, holder: "Supervisor", can: ["assign", "send_back_to_warehouse"] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/transitions": { body: shopPacket({ status: "assigned", version: 5, can: [] }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to station" }));
const sheet = await screen.findByRole("dialog", { name: "Send to station" });
fireEvent.change(within(sheet).getByLabelText("Station"), { target: { value: "2" } });
await within(sheet).findByRole("option", { name: "Andy Welder [75]" });
fireEvent.change(within(sheet).getByLabelText("Find a welder"), { target: { value: "andy" } });
fireEvent.change(within(sheet).getByLabelText("Welder"), { target: { value: "andy" } });
expect(within(sheet).getByLabelText("Stamp")).toHaveValue("75");
fireEvent.click(within(sheet).getByRole("button", { name: "Send to station" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "assign", expect: { status: "ready", dept: "03" }, station_id: 2, welder_id: "andy", stamp: "75" },
]),
);
});
});
describe("Changes tab, verify", () => {
it("a supervisor of a pending department ticks, verifies and signs", async () => {
const co = {
id: 9,
co_no: 2,
label: "CO-2",
origin: "manual",
review: "approved",
minor: false,
hold: false,
ecn: null,
reason: "New rev",
issued_at: "2026-10-16T08:00:00Z",
issued_by: { id: "clara", display_name: "Clara Clerk" },
items: [],
required: ["03", "09"],
verified: { "09": { by: { id: "x", display_name: "Hal Hydro" }, at: "2026-10-16T09:00:00Z", note: null, signed: true } },
pending_depts: ["03"],
open: true,
} as const;
const p = shopPacket({ change_orders: [{ ...co, items: [], verified: { ...co.verified }, required: [...co.required], pending_depts: [...co.pending_depts] }] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/change-orders/2/verify": { body: shopPacket({ version: 6 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const box = await screen.findByRole("group", { name: "Verify CO-2 for 03 Weld Parts" });
const verify = within(box).getByRole("button", { name: "Verify" });
expect(verify).toBeDisabled();
fireEvent.click(within(box).getByLabelText("I reviewed this change with my department"));
fireEvent.change(within(box).getByLabelText("Note (optional)"), { target: { value: "Checked with the crew" } });
fireEvent.click(verify);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Verify a change order for the department")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/verify")).toEqual([
{ dept: "03", note: "Checked with the crew", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("a lead gets no verify box", async () => {
const p = shopPacket({
change_orders: [
{ id: 9, co_no: 2, origin: "manual", review: "approved", minor: false, hold: false, ecn: null, reason: null, issued_at: null, issued_by: null, items: [], required: ["03"], verified: {} },
],
});
mockFetch(base(p, ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
await screen.findByRole("region", { name: "Change order CO-2" });
expect(screen.queryByRole("button", { name: "Verify" })).not.toBeInTheDocument();
});
});
+104
View File
@@ -0,0 +1,104 @@
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
import { useEffect, useState } from "react";
import { packetsApi, peopleApi } from "../api/packets";
import type { DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, TransitionBody } from "../api/types";
import type { Mutate } from "./tabs";
import { parseRole } from "../auth/roles";
/** Live drawing pages first, then the VOID copies (the old viewPages). */
export function viewPages(pages: readonly PacketPage[]): PacketPage[] {
const dwg = pages.filter((x) => x.type === "drawing");
return [...dwg.filter((x) => !x.void), ...dwg.filter((x) => !!x.void)];
}
/**
* The page to show: the one picked when it exists, else the first live page. A VOID page that was not picked on
* purpose lands on the sheet that replaced it.
*/
export function landingPage(pages: readonly PacketPage[], picked: number | null, onPurpose: boolean): PacketPage | undefined {
let pg = pages.find((x) => x.n === picked) ?? pages[0];
for (let i = 0; i < 10 && pg?.void && !onPurpose; i++) {
const next = pages.find((x) => x.n === pg?.void?.new_n);
if (!next) break;
pg = next;
}
return pg;
}
/** "M00543 R2 · VOID" for the page buttons. */
export function pageLabel(pg: PacketPage): string {
return `${pg.dwg || `Page ${pg.n}`}${pg.rev ? ` R${pg.rev}` : ""}${pg.void ? " · VOID" : ""}`;
}
export type Level = "worker" | "lead" | "supervisor";
/** The highest shop level in a department; admin counts as supervisor. */
export function shopLevel(roles: readonly string[], dept: string): Level | null {
if (roles.includes("admin")) return "supervisor";
const order: Level[] = ["worker", "lead", "supervisor"];
let best = -1;
for (const r of roles) {
const p = parseRole(r);
if (p.shop && p.dept === dept) best = Math.max(best, order.indexOf(p.level));
}
return best < 0 ? null : (order[best] ?? null);
}
/** May this user verify a change order for the department? Mirrors the server's can_verify. */
export function canVerifyDept(roles: readonly string[], dept: string): boolean {
if (roles.includes("admin")) return true;
if (dept === "WH") return roles.includes("warehouse_supervisor");
if (dept === "QC") return roles.includes("qc_supervisor");
if (dept === "CL" || dept === "PM") return false;
return shopLevel(roles, dept) === "supervisor";
}
/** The active people of a shop department, for the welder picker and the weld sheet's stamp. */
export function usePeople(dept: string | null): { people: DeptPerson[]; loaded: boolean; error: string | null } {
const [state, setState] = useState<{ dept: string | null; people: DeptPerson[]; error: string | null } | null>(null);
useEffect(() => {
if (!dept) return;
const ctl = new AbortController();
peopleApi
.list(dept, ctl.signal)
.then((people) => {
if (!ctl.signal.aborted) setState({ dept, people, error: null });
})
.catch((err: unknown) => {
if (ctl.signal.aborted) return;
setState({ dept, people: [], error: err instanceof Error ? err.message : "The people list could not be read." });
});
return () => ctl.abort();
}, [dept]);
const mine = state && state.dept === dept ? state : null;
return { people: mine?.people ?? [], loaded: !!mine, error: mine?.error ?? null };
}
export type ShopTab = "checklist" | "welds";
/** The old missing(): what the stall still has to do before Submit, each with the tab that fixes it. */
export function stallMissing(p: PacketRead): Array<{ t: string; tab: ShopTab }> {
const out: Array<{ t: string; tab: ShopTab }> = [];
const done = p.stage?.checklist ?? {};
const open = p.checklist_items.filter((i) => !done[i.key]?.done).length;
if (open) out.push({ t: `${open} checklist item${open > 1 ? "s" : ""}`, tab: "checklist" });
if (p.dept.weld !== false) {
const here = p.welds.some((w) => !w.carried && (w.dept_code === undefined || w.dept_code === null || w.dept_code === p.dept.code));
if (!here) out.push({ t: "no welds marked on the drawing", tab: "welds" });
if (!p.stage?.filler) out.push({ t: "filler metal", tab: "checklist" });
}
return out;
}
export type StepFn = (
action: string,
extra?: Omit<TransitionBody, "action" | "expect">,
opts?: { sign?: string; ok?: string },
) => Promise<boolean>;
/** A transition with the expect guard, through the page's mutate. */
export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
const expect: { status: PacketStatus; dept: string } = { status: p.status, dept: p.dept.code };
return (action, extra = {}, opts = {}) =>
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
}
+40
View File
@@ -0,0 +1,40 @@
// Row writes on the packet page run one after another. Each takes the row version from the newest packet seen
// (the prop, or an earlier write's own answer before React re-rendered), and a row_conflict reads the packet
// again and retries once. The phase 4 pull table found these races at full speed.
import { useCallback, useRef } from "react";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { Guarded, PacketRead } from "../api/types";
import type { Write, WriteResult } from "../warehouse/PullTable";
export type RowCall = (cur: PacketRead, g: Guarded) => Promise<PacketRead>;
export function useSerial(p: PacketRead, write: Write): (call: RowCall) => Promise<WriteResult> {
const latest = useRef(p);
if (latest.current.id !== p.id || p.version >= latest.current.version) latest.current = p;
const chain = useRef<Promise<unknown>>(Promise.resolve());
const id = p.id;
return useCallback(
(call: RowCall) => {
const next = chain.current.then(async () => {
const send = () =>
write((g) =>
call(latest.current, g).then((r) => {
if (r && r.version >= latest.current.version) latest.current = r;
return r;
}),
);
let r = await send();
if (!r.ok && r.err !== undefined && errorCode(r.err) === "row_conflict") {
const fresh = await packetsApi.get(id);
if (fresh.version >= latest.current.version) latest.current = fresh;
r = await send();
}
return r;
});
chain.current = next.catch(() => undefined);
return next;
},
[write, id],
);
}
+46
View File
@@ -0,0 +1,46 @@
// The viewer context the drawing overlay reads: pointer to page fractions, mark drags, the page size.
import { createContext, useContext, type PointerEvent as ReactPointerEvent } from "react";
import type { Frac } from "../lib/geometry";
/** The bitmap: at most 2.2 times the PDF size and at most 4200 px wide. */
export function renderScale(pdfWidth: number): number {
return Math.min(2.2, 4200 / Math.max(1, pdfWidth));
}
/** A pointer that moved less than this is a tap. */
export const TAP_PX = 6;
/** A mark drag starts after this. */
export const DRAG_PX = 5;
export interface DragHandlers {
/** The pointer is at f (page fractions, not clamped). */
onMove: (f: Frac) => void;
/** The drag ended at f, or null when it never moved (a tap). */
onEnd: (f: Frac | null) => void;
}
export interface ViewerApi {
/** A client point as page fractions, and whether it lies inside the viewer frame. */
toFrac: (clientX: number, clientY: number) => Frac & { inside: boolean };
/** Starts a mark drag from a pointerdown. Moves and ends follow the window. */
startDrag: (e: ReactPointerEvent, h: DragHandlers) => void;
/** True for a short while after a drag ended, so the click that follows it is ignored. */
justDragged: () => boolean;
}
export interface ViewerInfo extends ViewerApi {
/** Page size in bitmap pixels; 0 until the page is drawn. */
w: number;
h: number;
aspect: number;
}
export const ViewerCtx = createContext<ViewerInfo | null>(null);
/** The viewer the overlay lives in. Marks use it to turn pointers into page fractions. */
export function useViewer(): ViewerInfo {
const v = useContext(ViewerCtx);
if (!v) throw new Error("useViewer must be used inside MapViewer");
return v;
}
+6 -6
View File
@@ -167,9 +167,9 @@ describe("Packet page, Changes tab", () => {
"POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) },
});
renderApp("/packets/1");
expect(await screen.findByText(/Change order 2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
expect(await screen.findByText(/Change order CO-2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Review change" }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
const sec = await screen.findByRole("region", { name: "Change order CO-2" });
expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument();
expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument();
expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2");
@@ -177,8 +177,8 @@ describe("Packet page, Changes tab", () => {
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByText("Change order 2 approved. The shop packet is updated.")).toBeInTheDocument();
expect(await screen.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument();
expect(await screen.findByText("Change order CO-2 approved. The shop packet is updated.")).toBeInTheDocument();
expect(await screen.findByRole("table", { name: "CO-2 verification" })).toBeInTheDocument();
});
it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => {
@@ -190,7 +190,7 @@ describe("Packet page, Changes tab", () => {
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
const sec = await screen.findByRole("region", { name: "Change order CO-2" });
expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled();
expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument();
@@ -200,7 +200,7 @@ describe("Packet page, Changes tab", () => {
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]);
fireEvent.click(within(sec).getByRole("button", { name: "Reject" }));
const ask = await screen.findByRole("dialog", { name: "Reject change order 2" });
const ask = await screen.findByRole("dialog", { name: "Reject change order CO-2" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Reject" }));
await sign();
+5 -153
View File
@@ -1,4 +1,4 @@
// The VISUAL box and the Changes tab on the packet page (phase 3).
// The VISUAL box and Clerical's change order review panel on the packet page (phase 3). The Changes tab is ChangesTab.tsx.
import { useState, type FormEvent } from "react";
import { assetsApi, blobUrl } from "../api/assets";
import { packetsApi } from "../api/packets";
@@ -6,7 +6,6 @@ import type { ChangeOrder, FoldedOp, HandUpload, PacketRead, WhoRef } from "../a
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { DRAWING_FLAGS } from "../lib/packets";
import type { Mutate } from "./tabs";
@@ -307,15 +306,7 @@ export function VisualBox({
/* ---------- Change orders ---------- */
function coPill(co: ChangeOrder) {
if (co.review === "rejected") return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending") return <span className="pill st-review">Waiting on Clerical</span>;
const open = co.required.filter((d) => !co.verified[d]);
if (open.length) return <span className="pill st-review">Open - {open.length} to verify</span>;
return <span className="pill st-in_progress">Verified</span>;
}
function ReviewPanel({
export function ReviewPanel({
p,
co,
sub,
@@ -339,11 +330,11 @@ function ReviewPanel({
const old = p.pages.find((x) => x.type === "drawing" && !x.void);
const approve = () =>
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order ${co.co_no} approved. The shop packet is updated.` });
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order CO-${co.co_no} approved. The shop packet is updated.` });
const reject = async () => {
const reason = await askReason({
title: `Reject change order ${co.co_no}`,
title: `Reject change order CO-${co.co_no}`,
sub,
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
min: 3,
@@ -352,7 +343,7 @@ function ReviewPanel({
submitLabel: "Reject",
});
if (reason === null) return;
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order ${co.co_no} rejected.` });
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order CO-${co.co_no} rejected.` });
};
const hold = () =>
@@ -421,142 +412,3 @@ function ReviewPanel({
</div>
);
}
/** The Changes tab: every change order, newest first. Clerical reviews the ones from VISUAL and M-Files here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
}) {
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length) return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
return (
<section key={co.id} className="sec" aria-label={`Change order ${co.co_no}`}>
<div className="sec-h flush">
<h2>
Change order {co.co_no}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed ? (co.origin === "drawing" ? "from M-Files" : "from VISUAL") : `issued by ${co.issued_by?.display_name ?? "-"}`} {fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel p={p} co={co} sub={sub} mutate={mutate} busy={busy} askReason={askReason} canReview={canReview} />
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep working to the current drawing. If Clerical approves it, the new drawing and changes come onto
this packet and you will be asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name} {fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}: {co.rejected_reason ?? "VISUAL went back to what the packet has"}. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name} {fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span> {it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? <span className="muted"> · page {it.old_page}</span> : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">{it.added ? "ADDED" : "NEW"}</span> {it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? <span className="muted"> · page {it.new_page}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td className="mono">{d}</td>
<td className="small">{ver ? `${ver.by?.display_name ?? "-"} ${fmtTime(ver.at)}` : <span className="due soon">Pending</span>}</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</section>
);
})}
</div>
);
}