776 lines
28 KiB
TypeScript
776 lines
28 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
|
import { Link, useParams } from "react-router";
|
|||
|
|
import { ApiError } from "../../api/client";
|
|||
|
|
import {
|
|||
|
|
enterNcr,
|
|||
|
|
getItp,
|
|||
|
|
getUnitFolder,
|
|||
|
|
patchBlock,
|
|||
|
|
patchItp,
|
|||
|
|
patchLine,
|
|||
|
|
rejectLine,
|
|||
|
|
reopenItp,
|
|||
|
|
sectionNa,
|
|||
|
|
signBlock,
|
|||
|
|
signLine,
|
|||
|
|
signOff,
|
|||
|
|
unsignBlock,
|
|||
|
|
unsignLine,
|
|||
|
|
type HeaderField,
|
|||
|
|
type Itp,
|
|||
|
|
type ItpBlock,
|
|||
|
|
type ItpItem,
|
|||
|
|
type ItpLine,
|
|||
|
|
type LinePatch,
|
|||
|
|
type QcWho,
|
|||
|
|
} from "../../api/qc";
|
|||
|
|
import type { Guarded } from "../../api/types";
|
|||
|
|
import { useAuth } from "../../auth/useAuth";
|
|||
|
|
import { Sheet } from "../../components/Sheet";
|
|||
|
|
import { useGuard, type RunOptions } from "../../components/useGuard";
|
|||
|
|
import { useChanged } from "../../lib/changes";
|
|||
|
|
import { errorText, fmtTime } from "../../lib/format";
|
|||
|
|
import { useLoad } from "../../lib/useLoad";
|
|||
|
|
import { fmtQcDate } from "./folders";
|
|||
|
|
import {
|
|||
|
|
blockScope,
|
|||
|
|
initials,
|
|||
|
|
isApproved,
|
|||
|
|
isNa,
|
|||
|
|
isOpenHold,
|
|||
|
|
isRejected,
|
|||
|
|
ITP_CUST_CODES,
|
|||
|
|
ITP_PAGE_ORDER,
|
|||
|
|
ITP_PAGES,
|
|||
|
|
lastReject,
|
|||
|
|
lineOf,
|
|||
|
|
progressText,
|
|||
|
|
sectionsOf,
|
|||
|
|
todayIso,
|
|||
|
|
} from "./itpView";
|
|||
|
|
|
|||
|
|
/** What a write came to. A 409 reloads the ITP and shows the server's text. */
|
|||
|
|
type Outcome = "ok" | "cancel" | "conflict" | { error: string };
|
|||
|
|
|
|||
|
|
type LineSheetState = { kind: "line" | "reject" | "ncr"; key: string } | null;
|
|||
|
|
|
|||
|
|
/** An input saved on blur when its value changed. */
|
|||
|
|
function BlurInput({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
disabled,
|
|||
|
|
maxLength,
|
|||
|
|
required = false,
|
|||
|
|
onSave,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: string;
|
|||
|
|
disabled: boolean;
|
|||
|
|
maxLength: number;
|
|||
|
|
/** An empty value goes back to the saved one. */
|
|||
|
|
required?: boolean;
|
|||
|
|
onSave: (next: string, prev: string) => Promise<boolean>;
|
|||
|
|
}) {
|
|||
|
|
const [v, setV] = useState(value);
|
|||
|
|
const focused = useRef(false);
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!focused.current) setV(value);
|
|||
|
|
}, [value]);
|
|||
|
|
const blur = async () => {
|
|||
|
|
focused.current = false;
|
|||
|
|
const next = v.trim();
|
|||
|
|
if (next === value || (required && !next)) {
|
|||
|
|
setV(value);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const ok = await onSave(next, value);
|
|||
|
|
if (!ok) setV(value);
|
|||
|
|
};
|
|||
|
|
return (
|
|||
|
|
<label className="f">
|
|||
|
|
{label}
|
|||
|
|
<input
|
|||
|
|
className="in"
|
|||
|
|
aria-label={label}
|
|||
|
|
value={v}
|
|||
|
|
disabled={disabled}
|
|||
|
|
maxLength={maxLength}
|
|||
|
|
onFocus={() => {
|
|||
|
|
focused.current = true;
|
|||
|
|
}}
|
|||
|
|
onChange={(e) => setV(e.target.value)}
|
|||
|
|
onBlur={() => void blur()}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The line details sheet (the old openItpLine): N/A, the customer code and witness, the note. */
|
|||
|
|
function LineSheet({ item, line, onSave, onCancel }: { item: ItpItem; line: ItpLine; onSave: (p: LinePatch) => Promise<Outcome>; onCancel: () => void }) {
|
|||
|
|
const approved = isApproved(line);
|
|||
|
|
const rej = isRejected(line);
|
|||
|
|
const [na, setNa] = useState(isNa(line));
|
|||
|
|
const [cc, setCc] = useState(line.cust_code ?? "");
|
|||
|
|
const [cby, setCby] = useState(line.cust_by ?? "");
|
|||
|
|
const [cat, setCat] = useState(line.cust_at ? line.cust_at.slice(0, 10) : todayIso());
|
|||
|
|
const [note, setNote] = useState(line.note ?? "");
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
const [err, setErr] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const submit = async (e: FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const p: LinePatch = {};
|
|||
|
|
if (!approved && !rej && na !== isNa(line)) p.na = na;
|
|||
|
|
if (cc !== (line.cust_code ?? "")) p.cust_code = cc;
|
|||
|
|
const by = cby.trim();
|
|||
|
|
if (by !== (line.cust_by ?? "")) p.cust_by = by;
|
|||
|
|
if (by && (p.cust_by !== undefined || cat !== (line.cust_at ?? "").slice(0, 10))) p.cust_at = cat || null;
|
|||
|
|
const n = note.trim();
|
|||
|
|
if (n !== (line.note ?? "")) p.note = n;
|
|||
|
|
if (!Object.keys(p).length) {
|
|||
|
|
onCancel();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setBusy(true);
|
|||
|
|
setErr(null);
|
|||
|
|
const o = await onSave(p);
|
|||
|
|
if (typeof o === "object") {
|
|||
|
|
setErr(o.error);
|
|||
|
|
setBusy(false);
|
|||
|
|
} else if (o === "cancel") setBusy(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Sheet title="ITP line" sub={`${item.n} ${item.t}`} onClose={onCancel} wide>
|
|||
|
|
<div className="small muted">{item.crit}</div>
|
|||
|
|
<div className="small">
|
|||
|
|
PESCO code <b>{item.code || "-"}</b> · customer code on the template <b>{item.cust || "-"}</b>
|
|||
|
|
</div>
|
|||
|
|
<form className="stack" onSubmit={submit} noValidate>
|
|||
|
|
<label className="itpck">
|
|||
|
|
<input type="checkbox" checked={na} disabled={approved || rej} onChange={(e) => setNa(e.target.checked)} />
|
|||
|
|
Not applicable to this unit{approved ? " (undo the approval first)" : rej ? " (line is rejected)" : ""}
|
|||
|
|
</label>
|
|||
|
|
<div className="frow">
|
|||
|
|
<label className="f">
|
|||
|
|
Customer code
|
|||
|
|
<select className="in" aria-label="Customer code" value={cc} onChange={(e) => setCc(e.target.value)}>
|
|||
|
|
<option value="">as template ({item.cust || "-"})</option>
|
|||
|
|
{ITP_CUST_CODES.map((c) => (
|
|||
|
|
<option key={c} value={c}>
|
|||
|
|
{c}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
<label className="f">
|
|||
|
|
Customer witness / reviewer
|
|||
|
|
<input
|
|||
|
|
className="in"
|
|||
|
|
aria-label="Customer witness / reviewer"
|
|||
|
|
maxLength={80}
|
|||
|
|
placeholder="Name (leave blank if not witnessed)"
|
|||
|
|
value={cby}
|
|||
|
|
onChange={(e) => setCby(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<label className="f">
|
|||
|
|
Date
|
|||
|
|
<input className="in" aria-label="Date" type="date" value={cat} onChange={(e) => setCat(e.target.value)} />
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
<label className="f">
|
|||
|
|
Note
|
|||
|
|
<textarea className="in" aria-label="Note" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
|||
|
|
</label>
|
|||
|
|
{err ? (
|
|||
|
|
<div className="callout warn flush" role="alert">
|
|||
|
|
{err}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
<div className="sheet-acts">
|
|||
|
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
<button className="btn pri" type="submit" disabled={busy}>
|
|||
|
|
Save
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</Sheet>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The reject sheet, or the NCR sheet for a reject without its number (the old openItpReject). */
|
|||
|
|
function RejectSheet({
|
|||
|
|
item,
|
|||
|
|
line,
|
|||
|
|
ncrOnly,
|
|||
|
|
onSave,
|
|||
|
|
onCancel,
|
|||
|
|
}: {
|
|||
|
|
item: ItpItem;
|
|||
|
|
line: ItpLine;
|
|||
|
|
ncrOnly: boolean;
|
|||
|
|
onSave: (ncr: string, note: string) => Promise<Outcome>;
|
|||
|
|
onCancel: () => void;
|
|||
|
|
}) {
|
|||
|
|
const [ncr, setNcr] = useState("");
|
|||
|
|
const [note, setNote] = useState("");
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
const [err, setErr] = useState<string | null>(null);
|
|||
|
|
const lr = lastReject(line);
|
|||
|
|
|
|||
|
|
const submit = async (e: FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const n = ncr.trim();
|
|||
|
|
if (ncrOnly && !n) {
|
|||
|
|
setErr("Enter the NCR #.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setBusy(true);
|
|||
|
|
setErr(null);
|
|||
|
|
const o = await onSave(n, note.trim());
|
|||
|
|
if (typeof o === "object") {
|
|||
|
|
setErr(o.error);
|
|||
|
|
setBusy(false);
|
|||
|
|
} else if (o === "cancel") setBusy(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Sheet title={ncrOnly ? `NCR # for ${item.n} ${item.t}` : "Reject check point"} sub={ncrOnly ? undefined : `${item.n} ${item.t}`} onClose={onCancel}>
|
|||
|
|
{ncrOnly && lr ? (
|
|||
|
|
<div className="small muted">
|
|||
|
|
Rejected {fmtQcDate(lr.at)}
|
|||
|
|
{lr.by ? ` by ${lr.by.display_name}` : ""}
|
|||
|
|
{lr.note ? `: ${lr.note}` : ""}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="small muted">{item.crit}</div>
|
|||
|
|
)}
|
|||
|
|
<form className="stack" onSubmit={submit} noValidate>
|
|||
|
|
<label className="f">
|
|||
|
|
NCR #
|
|||
|
|
<input
|
|||
|
|
className="in"
|
|||
|
|
aria-label="NCR #"
|
|||
|
|
maxLength={40}
|
|||
|
|
autoComplete="off"
|
|||
|
|
placeholder={ncrOnly ? "NCR number" : "NCR number (can be added later)"}
|
|||
|
|
value={ncr}
|
|||
|
|
onChange={(e) => setNcr(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
{ncrOnly ? null : (
|
|||
|
|
<label className="f">
|
|||
|
|
Reason / what failed
|
|||
|
|
<textarea className="in" aria-label="Reason / what failed" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
|||
|
|
</label>
|
|||
|
|
)}
|
|||
|
|
<div className="small muted">The line can't be approved until its NCR # is entered.</div>
|
|||
|
|
{err ? (
|
|||
|
|
<div className="callout warn flush" role="alert">
|
|||
|
|
{err}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
<div className="sheet-acts">
|
|||
|
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
<button className={ncrOnly ? "btn pri" : "btn rejb"} type="submit" disabled={busy}>
|
|||
|
|
{ncrOnly ? "Save NCR #" : "Reject"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</Sheet>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The customer complete inputs on a block. */
|
|||
|
|
function CustRecord({ onRecord }: { onRecord: (name: string, date: string) => Promise<boolean> }) {
|
|||
|
|
const [name, setName] = useState("");
|
|||
|
|
const [date, setDate] = useState(todayIso());
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
const record = async () => {
|
|||
|
|
setBusy(true);
|
|||
|
|
const ok = await onRecord(name.trim(), date);
|
|||
|
|
setBusy(false);
|
|||
|
|
if (ok) setName("");
|
|||
|
|
};
|
|||
|
|
return (
|
|||
|
|
<span className="custrec">
|
|||
|
|
<input className="in" aria-label="Customer rep name" placeholder="Customer rep name" maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
|
|||
|
|
<input className="in" aria-label="Customer complete date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
|||
|
|
<button type="button" className="btn sm" disabled={busy || !name.trim()} onClick={() => void record()}>
|
|||
|
|
Record
|
|||
|
|
</button>
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** /qc/units/:unitId/itp (the old itpView). */
|
|||
|
|
export function ItpPage() {
|
|||
|
|
const { unitId } = useParams();
|
|||
|
|
const uid = Number(unitId);
|
|||
|
|
const { me } = useAuth();
|
|||
|
|
const roles = me?.roles ?? [];
|
|||
|
|
const isAdmin = roles.includes("admin");
|
|||
|
|
const guard = useGuard();
|
|||
|
|
const [load, reload] = useLoad((s) => getItp(uid, s), String(uid));
|
|||
|
|
const [folderLoad] = useLoad((s) => getUnitFolder(uid, s), String(uid));
|
|||
|
|
const [fresh, setFresh] = useState<Itp | null>(null);
|
|||
|
|
const [sheet, setSheet] = useState<LineSheetState>(null);
|
|||
|
|
const [showLog, setShowLog] = useState(false);
|
|||
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|||
|
|
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!msg) return;
|
|||
|
|
const t = window.setTimeout(() => setMsg(null), 8000);
|
|||
|
|
return () => window.clearTimeout(t);
|
|||
|
|
}, [msg]);
|
|||
|
|
|
|||
|
|
const loaded = load.state === "ok" ? load.data : null;
|
|||
|
|
// A write's answer is the newest copy until a reload brings one as new or newer.
|
|||
|
|
const r = fresh && fresh.unit_id === uid && (!loaded || fresh.version > loaded.version) ? fresh : loaded;
|
|||
|
|
|
|||
|
|
const folder = folderLoad.state === "ok" ? folderLoad.data : null;
|
|||
|
|
const projectId = folder?.project.id;
|
|||
|
|
const projectNo = folder?.project.number ?? r?.hdr.pno ?? "";
|
|||
|
|
const unitNo = folder?.unit.number ?? r?.hdr.unit ?? "";
|
|||
|
|
|
|||
|
|
const exec = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<Outcome> => {
|
|||
|
|
setMsg(null);
|
|||
|
|
try {
|
|||
|
|
const out = await guard.run(fn, opts);
|
|||
|
|
if (out === undefined) return "cancel";
|
|||
|
|
setFresh(out);
|
|||
|
|
return "ok";
|
|||
|
|
} catch (err) {
|
|||
|
|
if (err instanceof ApiError && err.status === 409) {
|
|||
|
|
reload();
|
|||
|
|
setSheet(null);
|
|||
|
|
setMsg(errorText(err));
|
|||
|
|
return "conflict";
|
|||
|
|
}
|
|||
|
|
return { error: errorText(err) };
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
const write = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<boolean> => {
|
|||
|
|
const o = await exec(fn, opts);
|
|||
|
|
if (typeof o === "object") setMsg(o.error);
|
|||
|
|
return o === "ok";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const back = (
|
|||
|
|
<Link className="back" to={`/qc/units/${uid}`}>
|
|||
|
|
← Back
|
|||
|
|
</Link>
|
|||
|
|
);
|
|||
|
|
const crumbs = (
|
|||
|
|
<nav className="crumbs" aria-label="Where you are">
|
|||
|
|
<Link className="back" to="/qc">
|
|||
|
|
All project folders
|
|||
|
|
</Link>
|
|||
|
|
<span>›</span>
|
|||
|
|
{projectId ? (
|
|||
|
|
<Link className="back" to={`/qc/projects/${projectId}`}>
|
|||
|
|
{projectNo}
|
|||
|
|
</Link>
|
|||
|
|
) : (
|
|||
|
|
<span>{projectNo}</span>
|
|||
|
|
)}
|
|||
|
|
<span>›</span>
|
|||
|
|
<Link className="back" to={`/qc/units/${uid}`}>
|
|||
|
|
{unitNo || "Unit"}
|
|||
|
|
</Link>
|
|||
|
|
<span>›</span>
|
|||
|
|
<b>ITP</b>
|
|||
|
|
</nav>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (!r) {
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
{back}
|
|||
|
|
{crumbs}
|
|||
|
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
|||
|
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
|||
|
|
{load.state === "ok" ? (
|
|||
|
|
<div className="itpcard">
|
|||
|
|
<div className="grow">
|
|||
|
|
<b>No digital ITP yet.</b>
|
|||
|
|
<div className="small muted">Start it from the unit's Q.C. folder.</div>
|
|||
|
|
</div>
|
|||
|
|
<Link className="btn" to={`/qc/units/${uid}`}>
|
|||
|
|
Open the unit folder
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const id = r.id;
|
|||
|
|
const complete = r.status === "complete";
|
|||
|
|
const ed = r.can.edit && !complete;
|
|||
|
|
const mayUndo = (who: QcWho | null) => ed && (isAdmin || (!!who && who.id === me?.user.id));
|
|||
|
|
const sections = r.template.sections;
|
|||
|
|
const pages = r.pages.length
|
|||
|
|
? r.pages
|
|||
|
|
: ITP_PAGE_ORDER.filter((c) => sectionsOf(sections, c).length).map((c) => ({ code: c, name: ITP_PAGES[c] ?? c }));
|
|||
|
|
const blockAt = new Map<string, ItpBlock>(r.blocks.map((b) => [b.key, b]));
|
|||
|
|
const hdr = r.hdr;
|
|||
|
|
|
|||
|
|
const saveHdr = (field: HeaderField) => (next: string, prev: string) => write((g) => patchItp(id, { set: { [field]: next }, base: { [field]: prev } }, g));
|
|||
|
|
|
|||
|
|
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
|
|||
|
|
|
|||
|
|
const reopen = async () => {
|
|||
|
|
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
|
|||
|
|
if (reason === null) return;
|
|||
|
|
await write((g) => reopenItp(id, { reason, ...(g.it_reason ? { it_reason: g.it_reason } : {}) }));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
{back}
|
|||
|
|
{crumbs}
|
|||
|
|
<div className="dhead">
|
|||
|
|
<div className="grow0" style={{ minWidth: 0 }}>
|
|||
|
|
<div className="eyebrow">Q.C. · Inspection and Test Plan · {complete ? "Signed off" : "Open"}</div>
|
|||
|
|
<h1>{r.no}</h1>
|
|||
|
|
<div className="small muted">
|
|||
|
|
{progressText({ ...r.progress, status: r.status })} · template {r.template.ver}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="pacts">
|
|||
|
|
{r.can.sign_off ? (
|
|||
|
|
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
|
|||
|
|
Sign off ITP
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
{r.can.reopen ? (
|
|||
|
|
<button className="btn ghost" type="button" onClick={() => void reopen()}>
|
|||
|
|
Reopen
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{complete && r.signoff ? (
|
|||
|
|
<div className="callout mb10" role="note">
|
|||
|
|
Signed off {fmtQcDate(r.signoff.at)} by {r.signoff.by?.display_name ?? ""}. The ITP is locked; a signed PDF copy{" "}
|
|||
|
|
{r.signoff.asset_id ? "is filed in this unit's documents." : "will be filed when the ITP PDF is built."}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<section className="sec" aria-label="ITP header">
|
|||
|
|
<div className="itphdr">
|
|||
|
|
<BlurInput label="Inspection plan no." value={r.no} disabled={!ed} maxLength={120} required onSave={saveHdr("no")} />
|
|||
|
|
<BlurInput label="Client" value={hdr.client} disabled={!ed} maxLength={120} onSave={saveHdr("client")} />
|
|||
|
|
<BlurInput label="Project name" value={hdr.pname} disabled={!ed} maxLength={120} onSave={saveHdr("pname")} />
|
|||
|
|
<BlurInput label="Project no." value={hdr.pno} disabled={!ed} maxLength={120} onSave={saveHdr("pno")} />
|
|||
|
|
<BlurInput label="Purchase order" value={hdr.po} disabled={!ed} maxLength={120} onSave={saveHdr("po")} />
|
|||
|
|
<BlurInput label="Unit no." value={hdr.unit} disabled={!ed} maxLength={120} onSave={saveHdr("unit")} />
|
|||
|
|
</div>
|
|||
|
|
<div className="small muted mt8">
|
|||
|
|
<b>Codes:</b> {r.template.legend}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
{pages.map((pg, pi) => {
|
|||
|
|
const secs = sectionsOf(sections, pg.code);
|
|||
|
|
if (!secs.length) return null;
|
|||
|
|
return (
|
|||
|
|
<div key={pg.code} className="itppagewrap">
|
|||
|
|
<div className="itppage">
|
|||
|
|
<span>
|
|||
|
|
{pi + 1} of {pages.length} · {pg.code}
|
|||
|
|
</span>{" "}
|
|||
|
|
{pg.name}
|
|||
|
|
</div>
|
|||
|
|
{secs.map((s) => {
|
|||
|
|
const lines = s.items.map((i) => lineOf(r, i.n));
|
|||
|
|
const anyApproved = lines.some(isApproved);
|
|||
|
|
const allNa = lines.length > 0 && lines.every(isNa);
|
|||
|
|
const b = blockAt.get(s.n);
|
|||
|
|
const hid = `itpsec-${s.n}`;
|
|||
|
|
return (
|
|||
|
|
<div key={s.n}>
|
|||
|
|
<section className="sec itpsec" aria-labelledby={hid}>
|
|||
|
|
<div className="sec-h">
|
|||
|
|
<h2 id={hid}>
|
|||
|
|
{s.n}. {s.t}
|
|||
|
|
</h2>
|
|||
|
|
<span className="grow" />
|
|||
|
|
{ed && !anyApproved ? (
|
|||
|
|
<button type="button" className="btn sm ghost" onClick={() => void write((g) => sectionNa(id, s.n, !allNa, g))}>
|
|||
|
|
{allNa ? "Section applies" : "Section N/A"}
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
<div className="itprows">
|
|||
|
|
<div className="itprow hd" aria-hidden="true">
|
|||
|
|
<span>No.</span>
|
|||
|
|
<span>Inspection item · acceptance</span>
|
|||
|
|
<span>PESCO</span>
|
|||
|
|
<span>Q.C. approval</span>
|
|||
|
|
<span>Customer</span>
|
|||
|
|
<span />
|
|||
|
|
</div>
|
|||
|
|
{s.items.map((i) => {
|
|||
|
|
const l = lineOf(r, i.n);
|
|||
|
|
const na = isNa(l);
|
|||
|
|
const approved = isApproved(l);
|
|||
|
|
const rej = isRejected(l);
|
|||
|
|
const lr = lastReject(l);
|
|||
|
|
const ncr = l.ncr ?? lr?.ncr ?? null;
|
|||
|
|
const rejTxt =
|
|||
|
|
lr || rej ? (
|
|||
|
|
<span className="rejtag">
|
|||
|
|
{rej ? "Rejected" : "Was rejected"} {fmtQcDate(lr?.at)} ·{" "}
|
|||
|
|
{ncr ? (
|
|||
|
|
<>
|
|||
|
|
NCR <b className="mono">{ncr}</b>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<b>NCR # needed</b>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
) : null;
|
|||
|
|
const cls = ["itprow", na ? "na" : "", isOpenHold(i, l) ? "hold" : "", rej ? "rej" : ""].filter(Boolean).join(" ");
|
|||
|
|
let qc;
|
|||
|
|
if (na) qc = <span className="muted">N/A</span>;
|
|||
|
|
else if (approved)
|
|||
|
|
qc = (
|
|||
|
|
<>
|
|||
|
|
<span className="ini" title={l.by?.display_name ?? undefined}>
|
|||
|
|
{l.initials || initials(l.by?.display_name)}
|
|||
|
|
</span>
|
|||
|
|
<span className="small muted">Approved {fmtQcDate(l.at)}</span>
|
|||
|
|
{mayUndo(l.by) ? (
|
|||
|
|
<button type="button" className="lnk small" onClick={() => void write((g) => unsignLine(id, i.n, g))}>
|
|||
|
|
undo
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
{rejTxt}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
else if (ed)
|
|||
|
|
qc = (
|
|||
|
|
<>
|
|||
|
|
{rejTxt}
|
|||
|
|
<span className="apr">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn sm pri"
|
|||
|
|
disabled={l.ncr_missing}
|
|||
|
|
title={l.ncr_missing ? "Enter the NCR # first" : undefined}
|
|||
|
|
onClick={() => void write((g) => signLine(id, i.n, r.lines[i.n]?.row_version, g))}
|
|||
|
|
>
|
|||
|
|
Approve
|
|||
|
|
</button>
|
|||
|
|
{rej ? null : (
|
|||
|
|
<button type="button" className="btn sm rejb" onClick={() => setSheet({ kind: "reject", key: i.n })}>
|
|||
|
|
Reject
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{l.ncr_missing ? (
|
|||
|
|
<button type="button" className="btn sm" onClick={() => setSheet({ kind: "ncr", key: i.n })}>
|
|||
|
|
Enter NCR #
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
</span>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
else qc = rejTxt ?? <span className="muted small">open</span>;
|
|||
|
|
return (
|
|||
|
|
<div key={i.n} className={cls} role="group" aria-label={`Line ${i.n}`}>
|
|||
|
|
<span className="n mono">{i.n}</span>
|
|||
|
|
<div className="t">
|
|||
|
|
<b>{i.t}</b>
|
|||
|
|
<div className="small muted">{i.crit}</div>
|
|||
|
|
{l.note ? <div className="small">Note: {l.note}</div> : null}
|
|||
|
|
</div>
|
|||
|
|
<span className="cd" title="PESCO inspection code">
|
|||
|
|
{i.code || "-"}
|
|||
|
|
</span>
|
|||
|
|
<div className="qc">{qc}</div>
|
|||
|
|
<div className="cu" title="Customer code">
|
|||
|
|
<span className="cd">{l.cust_code || i.cust || "-"}</span>
|
|||
|
|
{l.cust_by ? (
|
|||
|
|
<span className="small muted">
|
|||
|
|
{l.cust_by} · {fmtQcDate(l.cust_at)}
|
|||
|
|
</span>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
{ed ? (
|
|||
|
|
<button type="button" className="btn sm ghost mb" aria-label={`Line ${i.n} details`} onClick={() => setSheet({ kind: "line", key: i.n })}>
|
|||
|
|
⋯
|
|||
|
|
</button>
|
|||
|
|
) : (
|
|||
|
|
<span />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
{b ? <BlockView itpId={id} b={b} ed={ed} mayUndo={mayUndo} write={write} /> : null}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
|
|||
|
|
<section className="sec" aria-labelledby="itplog-h">
|
|||
|
|
<div className="sec-h">
|
|||
|
|
<h2 id="itplog-h">ITP log</h2>
|
|||
|
|
<span className="ct">
|
|||
|
|
{r.log.length} entr{r.log.length === 1 ? "y" : "ies"}
|
|||
|
|
</span>
|
|||
|
|
<span className="grow" />
|
|||
|
|
<button type="button" className="btn sm ghost" aria-expanded={showLog} onClick={() => setShowLog((v) => !v)}>
|
|||
|
|
{showLog ? "Hide log" : "Show log"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{showLog ? (
|
|||
|
|
r.log.length ? (
|
|||
|
|
<ol className="itplog">
|
|||
|
|
{r.log.map((e) => (
|
|||
|
|
<li key={e.id} className="small">
|
|||
|
|
{[fmtTime(e.at), e.by_name || e.by?.display_name || "", e.action, e.note ?? ""].filter(Boolean).join(" · ")}
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ol>
|
|||
|
|
) : (
|
|||
|
|
<div className="empty slim">Nothing logged yet.</div>
|
|||
|
|
)
|
|||
|
|
) : null}
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
{sheet && sheetItem && sheet.kind === "line" ? (
|
|||
|
|
<LineSheet
|
|||
|
|
item={sheetItem}
|
|||
|
|
line={lineOf(r, sheetItem.n)}
|
|||
|
|
onCancel={() => setSheet(null)}
|
|||
|
|
onSave={async (p) => {
|
|||
|
|
const o = await exec((g) => patchLine(id, sheetItem.n, p, g));
|
|||
|
|
if (o === "ok") setSheet(null);
|
|||
|
|
return o;
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
{sheet && sheetItem && sheet.kind !== "line" ? (
|
|||
|
|
<RejectSheet
|
|||
|
|
item={sheetItem}
|
|||
|
|
line={lineOf(r, sheetItem.n)}
|
|||
|
|
ncrOnly={sheet.kind === "ncr"}
|
|||
|
|
onCancel={() => setSheet(null)}
|
|||
|
|
onSave={async (ncr, note) => {
|
|||
|
|
const o = await exec((g) =>
|
|||
|
|
sheet.kind === "ncr"
|
|||
|
|
? enterNcr(id, sheetItem.n, ncr, g)
|
|||
|
|
: rejectLine(id, sheetItem.n, { ...(ncr ? { ncr } : {}), ...(note ? { note } : {}) }, g),
|
|||
|
|
);
|
|||
|
|
if (o === "ok") setSheet(null);
|
|||
|
|
return o;
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
{guard.sheet}
|
|||
|
|
{msg ? (
|
|||
|
|
<div className="callout warn itpmsg" role="alert">
|
|||
|
|
{msg}{" "}
|
|||
|
|
<button type="button" className="lnk small" onClick={() => setMsg(null)}>
|
|||
|
|
Dismiss
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The signature block after a signing section (the old itpBlockHtml). */
|
|||
|
|
function BlockView({
|
|||
|
|
itpId,
|
|||
|
|
b,
|
|||
|
|
ed,
|
|||
|
|
mayUndo,
|
|||
|
|
write,
|
|||
|
|
}: {
|
|||
|
|
itpId: number;
|
|||
|
|
b: ItpBlock;
|
|||
|
|
ed: boolean;
|
|||
|
|
mayUndo: (who: QcWho | null) => boolean;
|
|||
|
|
write: (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions) => Promise<boolean>;
|
|||
|
|
}) {
|
|||
|
|
const scope = b.scope || blockScope(b.sections);
|
|||
|
|
return (
|
|||
|
|
<div className="itpblock" role="group" aria-label={`ITP block ${b.key}`}>
|
|||
|
|
<div className="frow">
|
|||
|
|
<BlurInput label="QN #" value={b.qn ?? ""} disabled={!ed} maxLength={40} onSave={(v) => write((g) => patchBlock(itpId, b.key, { qn: v }, g))} />
|
|||
|
|
<BlurInput label="Remarks / notes" value={b.remarks ?? ""} disabled={!ed} maxLength={400} onSave={(v) => write((g) => patchBlock(itpId, b.key, { remarks: v }, g))} />
|
|||
|
|
</div>
|
|||
|
|
<div className="sigs">
|
|||
|
|
<div>
|
|||
|
|
<div className="eyebrow">PESCO Q.C. signature · {scope}</div>
|
|||
|
|
{b.signed ? (
|
|||
|
|
<>
|
|||
|
|
<b>{b.signed_by?.display_name ?? ""}</b> {b.signed_by?.emp_no ? <span className="mono small">{b.signed_by.emp_no}</span> : null}{" "}
|
|||
|
|
<span className="small muted">{fmtQcDate(b.signed_at)} · signed</span>
|
|||
|
|
{mayUndo(b.signed_by) ? (
|
|||
|
|
<>
|
|||
|
|
{" "}
|
|||
|
|
<button type="button" className="lnk small" onClick={() => void write((g) => unsignBlock(itpId, b.key, g))}>
|
|||
|
|
undo
|
|||
|
|
</button>
|
|||
|
|
</>
|
|||
|
|
) : null}
|
|||
|
|
</>
|
|||
|
|
) : ed ? (
|
|||
|
|
<>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className={b.ready ? "btn sm pri" : "btn sm"}
|
|||
|
|
disabled={!b.ready}
|
|||
|
|
onClick={() => void write((g) => signBlock(itpId, b.key, g.sign, g), { sign: "Sign an ITP block", sub: scope })}
|
|||
|
|
>
|
|||
|
|
{b.all_na ? "Sign (all N/A)" : "Sign section"}
|
|||
|
|
</button>
|
|||
|
|
{b.ready ? null : <span className="small muted"> every line must be initialed or N/A first</span>}
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<span className="muted small">not signed</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div className="eyebrow">{b.final ? "COP complete" : "Customer complete"}</div>
|
|||
|
|
{b.cust_name ? (
|
|||
|
|
<>
|
|||
|
|
<b>{b.cust_name}</b>{" "}
|
|||
|
|
<span className="small muted">
|
|||
|
|
{fmtQcDate(b.cust_at)} · recorded by {b.cust_by?.display_name ?? ""}
|
|||
|
|
</span>
|
|||
|
|
{ed ? (
|
|||
|
|
<>
|
|||
|
|
{" "}
|
|||
|
|
<button type="button" className="lnk small" onClick={() => void write((g) => patchBlock(itpId, b.key, { cust_clear: true }, g))}>
|
|||
|
|
clear
|
|||
|
|
</button>
|
|||
|
|
</>
|
|||
|
|
) : null}
|
|||
|
|
</>
|
|||
|
|
) : ed ? (
|
|||
|
|
<CustRecord onRecord={(name, date) => write((g) => patchBlock(itpId, b.key, { cust_name: name, ...(date ? { cust_at: date } : {}) }, g))} />
|
|||
|
|
) : (
|
|||
|
|
<span className="muted small">-</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|