- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
792 lines
29 KiB
TypeScript
792 lines
29 KiB
TypeScript
import QRCode from "qrcode";
|
||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||
import { assetsApi } from "../api/assets";
|
||
import { packetsApi } from "../api/packets";
|
||
import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, PrintRow, QcHistoryRow, WorkOrder } from "../api/types";
|
||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||
import { Sheet } from "../components/Sheet";
|
||
import { errorText, fmtTime } from "../lib/format";
|
||
import { DOC_KINDS, docKindLabel, ncrUrl, noteWithoutNcr } from "../lib/qc";
|
||
import { useLoad } from "../lib/useLoad";
|
||
import { MtrViewer } from "../warehouse/MtrViewer";
|
||
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
|
||
import { ShortSheet } from "../warehouse/ShortSheet";
|
||
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
|
||
import { ShortReceipts } from "./ShortReceipts";
|
||
import { lineReceipts } from "./shopUtil";
|
||
|
||
export type Mutate = (
|
||
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
||
opts?: { sign?: string | undefined; ok?: string | undefined },
|
||
) => Promise<boolean>;
|
||
|
||
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
|
||
|
||
/* ---------- Materials ---------- */
|
||
|
||
interface LineDraft {
|
||
qty: string;
|
||
part_id: string;
|
||
description: string;
|
||
material: string;
|
||
length: string;
|
||
dwg_ref: string;
|
||
}
|
||
|
||
const toDraft = (b: BomLine): LineDraft => ({
|
||
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
|
||
part_id: b.part_id ?? "",
|
||
description: b.description ?? "",
|
||
material: b.material ?? "",
|
||
length: b.length ?? "",
|
||
dwg_ref: b.dwg_ref ?? "",
|
||
});
|
||
|
||
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
||
|
||
/**
|
||
* The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table
|
||
* while the packet is with it. Anyone who may receive a short gets Receive on the line.
|
||
*/
|
||
export function MaterialsTab({
|
||
p,
|
||
mutate,
|
||
busy,
|
||
write,
|
||
reload,
|
||
}: {
|
||
p: PacketRead;
|
||
mutate: Mutate;
|
||
busy: boolean;
|
||
write?: Write | undefined;
|
||
reload?: (() => Promise<void>) | undefined;
|
||
}) {
|
||
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
|
||
const [lines, setLines] = useState<LineDraft[] | null>(null);
|
||
const [receiveFor, setReceiveFor] = useState<number | null>(null);
|
||
const [viewFor, setViewFor] = useState<number | null>(null);
|
||
const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom");
|
||
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||
|
||
const save = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
if (!lines) return;
|
||
const bom: BomLine[] = lines
|
||
.filter((l) => l.part_id.trim() || l.description.trim())
|
||
.map((l, i) => ({
|
||
item: i + 1,
|
||
qty: l.qty.trim() === "" ? null : Number(l.qty),
|
||
part_id: l.part_id.trim() || null,
|
||
description: l.description.trim() || null,
|
||
material: l.material.trim() || null,
|
||
length: l.length.trim() || null,
|
||
dwg_ref: l.dwg_ref.trim() || null,
|
||
}));
|
||
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
|
||
if (ok) setLines(null);
|
||
};
|
||
|
||
if (lines) {
|
||
return (
|
||
<form className="stack" onSubmit={save} noValidate>
|
||
<div className="tw">
|
||
<table className="edit">
|
||
<thead>
|
||
<tr>
|
||
<th>Item</th>
|
||
<th className="num">Qty</th>
|
||
<th>Part ID</th>
|
||
<th>Description</th>
|
||
<th>Material</th>
|
||
<th>Length</th>
|
||
<th>Dwg ref</th>
|
||
<th />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{lines.map((l, i) => (
|
||
<tr key={i}>
|
||
<td className="mono">{i + 1}</td>
|
||
<td>
|
||
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
|
||
</td>
|
||
<td>
|
||
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
|
||
Remove
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="sheet-acts start">
|
||
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
|
||
Add line
|
||
</button>
|
||
<span className="grow" />
|
||
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
|
||
Cancel
|
||
</button>
|
||
<button className="btn pri" type="submit" disabled={busy}>
|
||
Save BOM
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) {
|
||
return <PullTable p={p} write={write} reload={reload} busy={busy} />;
|
||
}
|
||
|
||
const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor);
|
||
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
|
||
const receive = async (item: number, body: Parameters<typeof packetsApi.receiveShort>[2]): Promise<string | null> => {
|
||
if (!write) return "Receiving is not available here.";
|
||
const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g));
|
||
if (r.ok || r.err === undefined) return null;
|
||
return errorText(r.err);
|
||
};
|
||
|
||
return (
|
||
<div className="stack">
|
||
{editable ? (
|
||
<div className="sec-h flush">
|
||
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
|
||
<span className="grow" />
|
||
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
|
||
Edit lines
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{(p.shorts_unapproved ?? 0) > 0 ? (
|
||
<div className="small due soon" data-testid="shorts-unapproved">
|
||
{p.shorts_unapproved === 1 ? "1 short is" : `${p.shorts_unapproved} shorts are`} still to receive or approve. The department supervisor approves every
|
||
received short before the packet can be complete.
|
||
</div>
|
||
) : null}
|
||
{p.bom.length ? (
|
||
<div className="tw">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Item</th>
|
||
<th className="num">Qty</th>
|
||
<th>Part ID</th>
|
||
<th>Description</th>
|
||
<th>Material</th>
|
||
<th>Length</th>
|
||
<th>Heat</th>
|
||
<th>Pulled</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{p.bom.map((b) => (
|
||
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
|
||
<td>
|
||
<span className="balloon">{b.item}</span>
|
||
</td>
|
||
<td className="num">{show(b.qty)}</td>
|
||
<td className="mono small">{show(b.part_id)}</td>
|
||
<td>
|
||
{show(b.description)}
|
||
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
|
||
</td>
|
||
<td className="small">{show(b.material)}</td>
|
||
<td className="small">{show(b.length)}</td>
|
||
<td className="mono small">
|
||
{show(b.heat)}
|
||
{b.mtr_asset_id ? (
|
||
<>
|
||
{" "}
|
||
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
|
||
MTR
|
||
</button>
|
||
{!p.removed ? (
|
||
<>
|
||
{" "}
|
||
<button
|
||
type="button"
|
||
className="btn sm ghost"
|
||
title={`Print the MTR of item ${b.item}`}
|
||
onClick={() => openPrint(p.id, "doc", { asset: b.mtr_asset_id ?? undefined })}
|
||
>
|
||
Print
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</td>
|
||
<td>
|
||
{b.short ? (
|
||
<div className="stack tight">
|
||
<ShortChip s={b.short} />
|
||
{canReceive && write ? (
|
||
<button className="btn sm pri" type="button" disabled={busy} onClick={() => setReceiveFor(b.item)}>
|
||
Receive
|
||
</button>
|
||
) : null}
|
||
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
|
||
</div>
|
||
) : lineReceipts(p, b).length ? (
|
||
<div className="stack tight">
|
||
<span className="pill st-ok">Short received</span>
|
||
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
|
||
</div>
|
||
) : b.na ? (
|
||
<span className="pill st-filed">N/A</span>
|
||
) : b.pulled ? (
|
||
<span className="pill st-ok">Pulled</span>
|
||
) : (
|
||
<span className="muted small">Not yet</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="empty">No BOM lines on this packet.</div>
|
||
)}
|
||
{receiving ? (
|
||
<ShortSheet mode="receive" line={receiving} onClose={() => setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} />
|
||
) : null}
|
||
{viewed?.mtr_asset_id ? (
|
||
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- Work orders ---------- */
|
||
|
||
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
|
||
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
|
||
}
|
||
|
||
function Qr({ text }: { text: string }) {
|
||
const ref = useRef<HTMLCanvasElement>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
useEffect(() => {
|
||
const c = ref.current;
|
||
if (!c) return;
|
||
let ok = true;
|
||
try {
|
||
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
|
||
if (ok) setFailed(true);
|
||
});
|
||
} catch {
|
||
setFailed(true);
|
||
}
|
||
return () => {
|
||
ok = false;
|
||
};
|
||
}, [text]);
|
||
return (
|
||
<div className="code">
|
||
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The work orders, the kiosk QR code of each (as the old ticketsTab), and the traveler print. */
|
||
export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
|
||
return (
|
||
<div className="stack">
|
||
<div className="callout flush">
|
||
<div className="acts center">
|
||
<span className="grow">
|
||
Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.
|
||
</span>
|
||
{!p.removed ? (
|
||
<>
|
||
<button className="btn" type="button" onClick={() => openPrint(p.id, "traveler")}>
|
||
Print traveler
|
||
</button>
|
||
<a className="btn ghost" href={packetsApi.pdfUrl(p.id, "traveler")} target="_blank" rel="noopener">
|
||
Traveler (PDF)
|
||
</a>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="tw">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>WO</th>
|
||
<th>Lot</th>
|
||
<th>Split</th>
|
||
<th>Sub</th>
|
||
<th>Seq</th>
|
||
<th>Operation</th>
|
||
<th className="num">Qty</th>
|
||
<th className="num">Target hrs</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{p.work_orders.map((w) => (
|
||
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
|
||
<td className="mono">{w.wo_base}</td>
|
||
<td className="mono">{w.lot}</td>
|
||
<td className="mono">{w.split}</td>
|
||
<td className="mono">{w.sub}</td>
|
||
<td className="mono">{show(w.seq)}</td>
|
||
<td>{show(w.op)}</td>
|
||
<td className="num">{show(w.qty)}</td>
|
||
<td className="num">{show(w.target_hrs)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="qrs">
|
||
{p.work_orders.map((w) => {
|
||
const code = kioskCode(w);
|
||
return (
|
||
<div key={w.id ?? woKey(w)} className="qr">
|
||
<Qr text={code} />
|
||
<div className="txt">
|
||
<div className="wo">{w.wo_base}</div>
|
||
<div className="small">
|
||
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
|
||
</div>
|
||
<div className="small">
|
||
Target <b className="mono">{show(w.target_hrs)} h</b> · Qty <b className="mono">{show(w.qty)}</b>
|
||
</div>
|
||
<div className="mono small" data-testid="kiosk-code">
|
||
{code}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- Documents ---------- */
|
||
|
||
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
|
||
|
||
const openBlob = (url: string) => {
|
||
window.open(url, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
/** The add sheet: type, title, note and the file. Uploads, then adds; errors stay in the sheet. */
|
||
function AddDocSheet({ p, sub, write, onDone, onCancel }: { p: PacketRead; sub: string; write: Write; onDone: () => void; onCancel: () => void }) {
|
||
const [kind, setKind] = useState(DOC_KINDS[0]?.[0] ?? "other");
|
||
const [title, setTitle] = useState("");
|
||
const [note, setNote] = useState("");
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [working, setWorking] = useState(false);
|
||
|
||
const add = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
if (!file) {
|
||
setError("Choose the file to add.");
|
||
return;
|
||
}
|
||
setWorking(true);
|
||
try {
|
||
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
|
||
const t = title.trim();
|
||
const n = note.trim();
|
||
const r = await write((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, ...(t ? { title: t } : {}), ...(n ? { note: n } : {}), ...g }));
|
||
if (r.ok) onDone();
|
||
else if (r.err !== undefined) setError(errorText(r.err));
|
||
} catch (err) {
|
||
setError(errorText(err));
|
||
} finally {
|
||
setWorking(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Sheet title="Add a document" sub={sub} onClose={onCancel}>
|
||
<form className="stack" onSubmit={add} noValidate>
|
||
<label className="f">
|
||
Type
|
||
<select aria-label="Type" className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||
{DOC_KINDS.map(([v, l]) => (
|
||
<option key={v} value={v}>
|
||
{l}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="f">
|
||
Title
|
||
<input aria-label="Title" className="in" value={title} maxLength={140} placeholder="e.g. RT report, welds W1-W6" onChange={(e) => setTitle(e.target.value)} />
|
||
</label>
|
||
<label className="f">
|
||
Note (optional)
|
||
<input aria-label="Note (optional)" className="in" value={note} maxLength={300} placeholder="Report no., inspector, result" onChange={(e) => setNote(e.target.value)} />
|
||
</label>
|
||
<label className="f">
|
||
File (PDF, PNG or JPG, up to 25 MB)
|
||
<input
|
||
aria-label="File (PDF, PNG or JPG, up to 25 MB)"
|
||
className="in"
|
||
type="file"
|
||
accept="application/pdf,image/png,image/jpeg"
|
||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||
/>
|
||
</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" disabled={working}>
|
||
{working ? "Uploading" : "Add to packet"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function DocRow({
|
||
d,
|
||
old,
|
||
canRemove,
|
||
busy,
|
||
onOut,
|
||
onPrint,
|
||
}: {
|
||
d: PacketDoc;
|
||
old: boolean;
|
||
canRemove: boolean;
|
||
busy: boolean;
|
||
onOut: () => void;
|
||
onPrint?: (() => void) | undefined;
|
||
}) {
|
||
const label = d.kind_label || docKindLabel(d.kind);
|
||
const meta = [d.file_name || null, `added by ${d.added_by?.display_name ?? "-"} ${fmtTime(d.added_at)}`, d.note || null].filter((x): x is string => !!x);
|
||
return (
|
||
<div className={`qcdoc${old ? " old" : ""}`}>
|
||
<div className="m">
|
||
<div>
|
||
<span className="pill st-ready">{label}</span> <b>{d.title || label}</b>
|
||
</div>
|
||
<div className="small muted">{meta.join(" · ")}</div>
|
||
{old && d.removed ? (
|
||
<div className="small due soon">
|
||
Taken out of the data book by {d.removed.by?.display_name ?? "-"} {fmtTime(d.removed.at)}: {d.removed.reason ?? ""}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="a">
|
||
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
|
||
View
|
||
</button>
|
||
{!old && onPrint ? (
|
||
<button className="btn sm" type="button" onClick={onPrint}>
|
||
Print
|
||
</button>
|
||
) : null}
|
||
{!old && canRemove ? (
|
||
<button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}>
|
||
Take out of data book
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The documents for the data book (the old pdocsTab): reports Q.C. and Clerical add. Nothing is deleted; a take-out
|
||
* keeps the row with the reason. Below them the unit's and the project's Q.C. folder documents, read-only.
|
||
*/
|
||
export function DocumentsTab({
|
||
p,
|
||
mutate,
|
||
busy,
|
||
write,
|
||
askReason,
|
||
sub,
|
||
}: {
|
||
p: PacketRead;
|
||
mutate: Mutate;
|
||
busy: boolean;
|
||
write: Write;
|
||
askReason: AskReason;
|
||
sub: string;
|
||
}) {
|
||
const canAdd = p.can.includes("doc_add");
|
||
const canRemove = p.can.includes("doc_remove");
|
||
const [adding, setAdding] = useState(false);
|
||
const [showOld, setShowOld] = useState(false);
|
||
const live = p.docs.filter((d) => !d.removed);
|
||
const gone = p.docs.filter((d) => !!d.removed);
|
||
const folder = p.qc_folder_docs ?? [];
|
||
|
||
const takeOut = async (d: PacketDoc) => {
|
||
const reason = await askReason({
|
||
title: "Take out of the data book",
|
||
sub: d.title || docKindLabel(d.kind),
|
||
note: "The file and this record are kept and shown under taken out. Say why.",
|
||
placeholder: "e.g. wrong packet, superseded by a new report",
|
||
min: 3,
|
||
max: 200,
|
||
danger: true,
|
||
submitLabel: "Take out",
|
||
});
|
||
if (reason === null) return;
|
||
await mutate((g) => packetsApi.removeDoc(p.id, d.id, { reason, ...g }), { ok: "Taken out of the data book." });
|
||
};
|
||
|
||
return (
|
||
<div className="stack">
|
||
<section className="sec" aria-label="Documents for the data book">
|
||
<div className="sec-h">
|
||
<h2>Documents for the data book</h2>
|
||
<span className="ct">{live.length} on this packet</span>
|
||
<span className="grow" />
|
||
{canAdd ? (
|
||
<button className="btn pri sm" type="button" disabled={busy} onClick={() => setAdding(true)}>
|
||
<span aria-hidden="true">+ </span>Add document
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="small muted mb8">
|
||
NDE, hydrotest, dimensional, PMI and coating reports, certificates, customer inspection reports and NCRs. They go into the data book export with the unit's
|
||
ITP and Q.C. folder documents. Drawings are never added here. They only come from M-Files.
|
||
</div>
|
||
<div className="qcdocs">
|
||
{live.length ? (
|
||
live.map((d) => (
|
||
<DocRow
|
||
key={d.id}
|
||
d={d}
|
||
old={false}
|
||
canRemove={canRemove}
|
||
busy={busy}
|
||
onOut={() => void takeOut(d)}
|
||
onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })}
|
||
/>
|
||
))
|
||
) : (
|
||
<div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div>
|
||
)}
|
||
</div>
|
||
{gone.length ? (
|
||
<div className="stack tight mt8">
|
||
<div>
|
||
<button className="lnk small" type="button" aria-expanded={showOld} onClick={() => setShowOld((v) => !v)}>
|
||
{showOld ? "Hide" : "Show"} {gone.length} taken out
|
||
</button>
|
||
</div>
|
||
{showOld ? (
|
||
<div className="qcdocs">
|
||
{gone.map((d) => (
|
||
<DocRow key={d.id} d={d} old canRemove={false} busy={busy} onOut={() => undefined} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="sec" aria-label="Also in the data book from the Q.C. folder">
|
||
<div className="sec-h">
|
||
<h2>Also in the data book from the Q.C. folder</h2>
|
||
<span className="ct">{p.unit.number}</span>
|
||
</div>
|
||
<div className="qcdocs">
|
||
{folder.length ? (
|
||
folder.map((d) => (
|
||
<div key={d.id} className="qcdoc">
|
||
<div className="m">
|
||
<span className="pill st-ready">{d.type}</span> <b>{d.title || d.type}</b>
|
||
{d.rev ? ` Rev ${d.rev}` : ""} <span className="small muted">{d.scope === "project" ? "from the project" : "this unit"}</span>
|
||
</div>
|
||
<div className="a">
|
||
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
|
||
View
|
||
</button>
|
||
{!p.removed ? (
|
||
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "doc", { asset: d.asset_id ?? assetOfUrl(d.url) })}>
|
||
Print
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="empty slim">No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{adding ? <AddDocSheet p={p} sub={sub} write={write} onDone={() => setAdding(false)} onCancel={() => setAdding(false)} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------- History ---------- */
|
||
|
||
/** GET /log, newest first. IT edits and signatures are marked. The closed Q.C. inspections sit above the log. */
|
||
export function HistoryTab({ p }: { p: PacketRead }) {
|
||
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
|
||
return (
|
||
<div className="stack">
|
||
<QcInspections rows={p.qc_history} />
|
||
{load.state === "loading" ? (
|
||
<div className="empty slim">Loading</div>
|
||
) : load.state === "error" ? (
|
||
<div className="callout warn">{load.message}</div>
|
||
) : load.data.length ? (
|
||
<LogTable rows={load.data} />
|
||
) : (
|
||
<div className="empty slim">Nothing recorded yet.</div>
|
||
)}
|
||
<PrintsList p={p} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** GET /prints: the printed copies and exports of this packet, newest first. */
|
||
function PrintsList({ p }: { p: PacketRead }) {
|
||
const [load] = useLoad((s) => packetsApi.prints(p.id, s), `${p.id}:${p.version}`);
|
||
return (
|
||
<section className="sec" aria-label="Printed copies and exports">
|
||
<div className="sec-h">
|
||
<h2>Printed copies and exports</h2>
|
||
{load.state === "ok" ? <span className="ct">{load.data.length}</span> : null}
|
||
</div>
|
||
{load.state === "loading" ? (
|
||
<div className="empty slim">Loading</div>
|
||
) : load.state === "error" ? (
|
||
<div className="callout warn">{load.message}</div>
|
||
) : load.data.length ? (
|
||
<ul className="prints">
|
||
{load.data.map((r) => (
|
||
<PrintLine key={r.id} r={r} />
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div className="empty slim">Nothing printed yet.</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PrintLine({ r }: { r: PrintRow }) {
|
||
return <li className="small">{printLineText(r)}</li>;
|
||
}
|
||
|
||
/** One row per closed Q.C. inspection, oldest first. */
|
||
function QcInspections({ rows }: { rows: QcHistoryRow[] }) {
|
||
if (!rows.length) return null;
|
||
return (
|
||
<section className="sec" aria-label="Q.C. inspections">
|
||
<div className="sec-h">
|
||
<h2>Q.C. inspections</h2>
|
||
<span className="ct">{rows.length}</span>
|
||
</div>
|
||
<div className="qcdocs">
|
||
{rows.map((h) => {
|
||
const ok = h.result === "accepted";
|
||
const who = h.inspector || h.by?.display_name || "";
|
||
const note = noteWithoutNcr(h.note, h.ncr);
|
||
const welds = h.rejected_welds ?? [];
|
||
const tail = [welds.length ? `Rejected welds: ${welds.join(", ")}` : null, h.location ? `Spool at ${h.location}` : null].filter((x): x is string => !!x);
|
||
return (
|
||
<div key={h.id} className="qcdoc qchist">
|
||
<div className="m">
|
||
<div>
|
||
<span className={`pill ${ok ? "st-in_progress" : "st-review"}`}>{String(h.result ?? "").toUpperCase()}</span>{" "}
|
||
<span className="small mono">
|
||
{h.sent_at ? `${fmtTime(h.sent_at)} → ` : ""}
|
||
{fmtTime(h.at)}
|
||
</span>
|
||
{who ? <b> {who}</b> : null}
|
||
{h.dept_code ? <span className="small muted"> · {h.dept_code}</span> : null}
|
||
</div>
|
||
{note ? <div className="small">{note}</div> : null}
|
||
{h.ncr || tail.length ? (
|
||
<div className="small muted">
|
||
{h.ncr ? (
|
||
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
|
||
NCR {h.ncr}
|
||
</a>
|
||
) : null}
|
||
{h.ncr && tail.length ? " · " : ""}
|
||
{tail.join(" · ")}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function LogTable({ rows }: { rows: LogRow[] }) {
|
||
return (
|
||
<div className="tw">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>When</th>
|
||
<th>Who</th>
|
||
<th>What</th>
|
||
<th>Note</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id}>
|
||
<td className="small mono nowrap">{fmtTime(r.at)}</td>
|
||
<td className="small">
|
||
{r.by?.display_name ?? "System"}
|
||
{r.role ? <div className="sub">{r.role}</div> : null}
|
||
</td>
|
||
<td>
|
||
{r.action}
|
||
{r.signed ? <span className="pill st-ok">Signed</span> : null}
|
||
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
|
||
</td>
|
||
<td className="small">
|
||
{r.note ?? ""}
|
||
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|