Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+16 -3
View File
@@ -16,6 +16,7 @@ import type {
PacketHeader,
PacketListQuery,
PacketRead,
QcFields,
ReceiptBody,
ShortBody,
StageFields,
@@ -43,10 +44,12 @@ export const packetsApi = {
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
/** Phase 6: kind from the closed set; the title defaults to the kind label on the server. */
addDoc: (id: number, body: { asset_id: string; kind: string; title?: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/docs`, body),
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
/** Takes a document out of the data book. The row stays, with the reason. */
removeDoc: (id: number, docId: number, body: { reason: string } & Guarded) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, body),
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
@@ -114,6 +117,16 @@ export const packetsApi = {
clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/stage/clock`, { op, ...g }),
coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/verify`, body),
/* Phase 6: the Q.C. inspection. Every answer is the packet. */
/** One check of the open inspection. */
qcCheck: (id: number, key: string, done: boolean, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/checks/${encodeURIComponent(key)}`, { done, ...g }),
/** The visual exam of one weld. result "" clears it. */
qcWeld: (id: number, no: number, body: { result?: "accept" | "reject" | ""; note?: string }, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/welds/${no}`, { ...body, ...g }),
/** The scalar inspection fields with a field-level base: 409 field_conflict when someone changed one first. */
qcPatch: (id: number, body: FieldCas<QcFields>) => api.patch<PacketRead>(`${base(id)}/qc`, body),
};
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
+436
View File
@@ -0,0 +1,436 @@
// The Q.C. home, the Q.C. folders and the digital ITP (phase 6 contract, api/qc.py and api/itp.py).
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
import { api, request } from "./client";
import { qs } from "./query";
import type { Guarded, PacketCard, ProjectStatus, SignBody } from "./types";
/** A person on a record. */
export interface QcWho {
id: string;
display_name: string;
emp_no?: string | null;
}
/* ---------- Q.C. home ---------- */
export interface QcCardInfo {
location: string | null;
sent_at: string | null;
inspector: string | null;
result: string | null;
}
/** A board card: the list card plus the inspection facts and the open ITP hold count. */
export interface QcCard extends PacketCard {
qc?: QcCardInfo | null;
itp_hold_count?: number | null;
}
export interface QcKpis {
waiting: number;
accepted_7d: number;
rejected_7d: number;
}
export const QC_COLUMNS = ["ready", "accepted", "rejected"] as const;
export type QcColumn = (typeof QC_COLUMNS)[number];
/** One project on the folder grid. */
export interface QcFolderCard {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
units: number;
docs: number;
units_without_itp: number;
}
export interface QcHome {
kpis: QcKpis;
columns: Record<QcColumn, QcCard[]>;
folders: QcFolderCard[];
}
export interface QcHomeQuery {
project_id?: number;
unit_id?: number;
}
/* ---------- Q.C. folders ---------- */
/** A Q.C. folder document. Nothing is deleted; a new revision supersedes the old row. */
export interface QcDoc {
id: number;
project_id: number | null;
unit_id: number | null;
type: string;
title: string | null;
rev: string | null;
note: string | null;
asset_id: string;
url: string;
file_name: string | null;
size: number | null;
auto: boolean;
added_at: string;
added_by: QcWho | null;
superseded_by: number | null;
superseded_at: string | null;
superseded_by_user: QcWho | null;
}
/** The unit's ITP in short. */
export interface ItpSummary {
id: number;
no: string;
status: ItpStatus;
done: number;
total: number;
holds: number;
}
export interface QcFolderProject {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
}
export interface QcFolderUnitRow {
id: number;
number: string;
lot: string;
name: string | null;
status: ProjectStatus;
docs: number;
itp: ItpSummary | null;
has_itp_doc: boolean;
}
export interface QcProjectFolder {
project: QcFolderProject;
docs: QcDoc[];
superseded: QcDoc[];
units: QcFolderUnitRow[];
}
export interface QcUnitFolder {
unit: { id: number; number: string; lot: string; name: string | null; status: ProjectStatus; po: string | null };
project: QcFolderProject;
docs: QcDoc[];
inherited: QcDoc[];
superseded: QcDoc[];
itp: ItpSummary | null;
no_itp: boolean;
}
export type QcDocBody = ({ project_id: number; unit_id?: never } | { unit_id: number; project_id?: never }) & {
type: string;
title?: string;
rev?: string;
note?: string;
asset_id: string;
replaces?: number;
} & Guarded;
/* ---------- Digital ITP ---------- */
export type ItpStatus = "open" | "complete";
export type ItpPageCode = "WP" | "C" | "FA";
export interface ItpItem {
n: string;
t: string;
code: string;
cust: string;
crit: string;
}
export interface ItpSection {
n: string;
t: string;
page: ItpPageCode | string;
sig?: boolean;
final?: boolean;
items: ItpItem[];
}
export interface ItpTemplate {
ver: string;
legend: string;
sections: ItpSection[];
}
export interface ItpReject {
at: string;
by: QcWho | null;
ncr: string | null;
note: string | null;
kind: "reject" | "ncr";
}
export interface ItpLine {
key: string;
result: "approve" | "reject" | "na" | null;
na: boolean;
by: QcWho | null;
initials: string | null;
at: string | null;
ncr: string | null;
/** Rejected, no NCR yet. */
ncr_missing: boolean;
note: string | null;
cust_code: string | null;
cust_by: string | null;
cust_at: string | null;
cust_rec: QcWho | null;
rejects: ItpReject[];
row_version: number;
}
export interface ItpBlock {
key: string;
sections: string[];
/** "Sections 1–3" or "Section 4". */
scope: string;
final: boolean;
ready: boolean;
all_na: boolean;
signed: boolean;
signed_at: string | null;
signed_by: QcWho | null;
qn: string | null;
remarks: string | null;
cust_name: string | null;
cust_at: string | null;
cust_by: QcWho | null;
row_version: number;
}
export interface ItpHoldRow {
n: string;
t: string;
page: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
export interface ItpLogRow {
id: number;
at: string;
by: QcWho | null;
by_name: string | null;
action: string;
note: string | null;
signed: boolean;
}
export interface ItpHeader {
client: string;
pname: string;
pno: string;
po: string;
unit: string;
}
export interface Itp {
id: number;
unit_id: number;
no: string;
hdr: ItpHeader;
status: ItpStatus;
version: number;
template: ItpTemplate;
pages: Array<{ code: string; name: string }>;
lines: Record<string, ItpLine>;
blocks: ItpBlock[];
progress: { done: number; total: number; holds: number };
holds: ItpHoldRow[];
signoff: { at: string; by: QcWho | null; asset_id: string | null } | null;
reopen: { at: string; by: QcWho | null; reason: string } | null;
created_at: string;
created_by: QcWho | null;
can: { edit: boolean; sign_off: boolean; reopen: boolean };
log: ItpLogRow[];
}
/** GET /api/units/{id}/itp when the unit has none. */
export interface NoItp {
itp: null;
unit: { id: number; number: string };
}
export type HeaderField = "no" | keyof ItpHeader;
export interface ItpPatch {
set: Partial<Record<HeaderField, string>>;
base: Partial<Record<HeaderField, string>>;
}
export interface LinePatch {
na?: boolean;
cust_code?: string;
cust_by?: string;
cust_at?: string | null;
note?: string;
}
export interface BlockPatch {
qn?: string;
remarks?: string;
cust_name?: string;
cust_at?: string;
cust_clear?: true;
}
/* ---------- normalising ---------- */
function list<T>(raw: unknown): T[] {
return Array.isArray(raw) ? (raw as T[]) : [];
}
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
return {
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
columns,
folders: list<QcFolderCard>(raw?.folders),
};
}
function normaliseProjectFolder(raw: QcProjectFolder): QcProjectFolder {
return { ...raw, docs: list(raw.docs), superseded: list(raw.superseded), units: list(raw.units) };
}
function normaliseUnitFolder(raw: QcUnitFolder): QcUnitFolder {
return { ...raw, docs: list(raw.docs), inherited: list(raw.inherited), superseded: list(raw.superseded), itp: raw.itp ?? null };
}
/** Every list present, so the screen never trips on a missing field. */
export function normaliseItp(raw: Itp): Itp {
const lines: Record<string, ItpLine> = {};
for (const [k, l] of Object.entries(raw.lines ?? {})) lines[k] = { ...l, rejects: list(l.rejects) };
return {
...raw,
hdr: { client: "", pname: "", pno: "", po: "", unit: "", ...((raw.hdr ?? {}) as Partial<ItpHeader>) },
template: { ...raw.template, sections: list(raw.template?.sections) },
pages: list(raw.pages),
lines,
blocks: list<ItpBlock>(raw.blocks).map((b) => ({ ...b, sections: list(b.sections) })),
progress: { done: 0, total: 0, holds: 0, ...((raw.progress ?? {}) as Partial<Itp["progress"]>) },
holds: list(raw.holds),
log: list(raw.log),
can: { edit: false, sign_off: false, reopen: false, ...((raw.can ?? {}) as Partial<Itp["can"]>) },
};
}
function isItp(raw: Itp | NoItp | null): raw is Itp {
return !!raw && "id" in raw && typeof (raw as Itp).id === "number";
}
/* ---------- calls ---------- */
const enc = encodeURIComponent;
export async function getQcHome(q: QcHomeQuery = {}, signal?: AbortSignal): Promise<QcHome> {
return normaliseHome(await api.get<Partial<QcHome> | null>(`/api/qc/home${qs({ ...q })}`, signal));
}
export async function getFolders(show: "active" | "all" = "active", signal?: AbortSignal): Promise<QcFolderCard[]> {
const raw = await api.get<unknown>(`/api/qc/folders${qs({ show })}`, signal);
if (Array.isArray(raw)) return raw as QcFolderCard[];
return list<QcFolderCard>((raw as { folders?: unknown } | null)?.folders);
}
export async function getProjectFolder(id: number, signal?: AbortSignal): Promise<QcProjectFolder> {
return normaliseProjectFolder(await api.get<QcProjectFolder>(`/api/qc/folders/projects/${id}`, signal));
}
export async function getUnitFolder(id: number, signal?: AbortSignal): Promise<QcUnitFolder> {
return normaliseUnitFolder(await api.get<QcUnitFolder>(`/api/qc/folders/units/${id}`, signal));
}
/** Answers the folder read of the scope the document went into. */
export async function addQcDoc(body: QcDocBody): Promise<QcProjectFolder | QcUnitFolder> {
const raw = await api.post<QcProjectFolder | QcUnitFolder>("/api/qc/docs", body);
return "unit" in raw ? normaliseUnitFolder(raw) : normaliseProjectFolder(raw);
}
/** The unit's ITP, or null when it has none yet. */
export async function getItp(unitId: number, signal?: AbortSignal): Promise<Itp | null> {
const raw = await api.get<Itp | NoItp | null>(`/api/units/${unitId}/itp`, signal);
return isItp(raw) ? normaliseItp(raw) : null;
}
const itp = async (p: Promise<Itp>) => normaliseItp(await p);
export const startItp = (unitId: number, g: Guarded = {}) => itp(api.post<Itp>(`/api/units/${unitId}/itp`, { ...g }));
export const patchItp = (id: number, body: ItpPatch, g: Guarded = {}) => itp(api.patch<Itp>(`/api/itps/${id}`, { ...body, ...g }));
/** Approve (initial) a line. `rowVersion` makes it a compare-and-set. */
export const signLine = (id: number, key: string, rowVersion?: number, g: Guarded = {}) =>
itp(
request<Itp>(`/api/itps/${id}/lines/${enc(key)}/sign`, {
method: "POST",
body: { ...g },
...(rowVersion !== undefined ? { headers: { "If-Match": String(rowVersion) } } : {}),
}),
);
export const unsignLine = (id: number, key: string, g: Guarded = {}) => itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/unsign`, { ...g }));
export const patchLine = (id: number, key: string, body: LinePatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/lines/${enc(key)}`, { ...body, ...g }));
export const sectionNa = (id: number, n: string, na: boolean, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/sections/${enc(n)}/na`, { na, ...g }));
export const rejectLine = (id: number, key: string, body: { ncr?: string; note?: string }, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/reject`, { ...body, ...g }));
export const enterNcr = (id: number, key: string, ncr: string, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/ncr`, { ncr, ...g }));
export const patchBlock = (id: number, key: string, body: BlockPatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/blocks/${enc(key)}`, { ...body, ...g }));
export const signBlock = (id: number, key: string, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { ...g, ...(sign ? { sign } : {}) }));
export const unsignBlock = (id: number, key: string, g: Guarded = {}) =>
itp(request<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { method: "DELETE", body: { ...g } }));
export const signOff = (id: number, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/signoff`, { ...g, ...(sign ? { sign } : {}) }));
export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body));
export const qcApi = {
getQcHome,
getFolders,
getProjectFolder,
getUnitFolder,
addQcDoc,
getItp,
startItp,
patchItp,
signLine,
unsignLine,
patchLine,
sectionNa,
rejectLine,
enterNcr,
patchBlock,
signBlock,
unsignBlock,
signOff,
reopenItp,
};
+144 -3
View File
@@ -293,6 +293,137 @@ export interface PacketDoc {
added_at: string | null;
added_by: WhoRef | null;
url: string;
/** Phase 6: the kind's label from the closed set. */
kind_label?: string;
note?: string | null;
file_name?: string | null;
size?: number | null;
/** Phase 6: a document taken out of the data book. The row stays as a record. */
removed?: { at: string; by: WhoRef | null; reason: string | null } | null;
}
/* ---------- Phase 6: Q.C. on the packet ---------- */
/** One Q.C. check of the open inspection. */
export interface QcCheckEntry {
done: boolean;
at?: string | null;
by?: string | null;
by_name?: string | null;
stamp?: string | null;
}
/** The visual exam of one weld, keyed by the weld number as text. An empty result means cleared. */
export interface QcWeldExam {
result?: "accept" | "reject" | "" | null;
note?: string | null;
at?: string | null;
by?: string | null;
by_name?: string | null;
}
/** The open Q.C. inspection (`qc` on the packet read). */
export interface QcInspection {
id: number;
sent_at: string | null;
sent_by?: WhoRef | null;
book: boolean | null;
spool: boolean | null;
location: string | null;
sent_note: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
inspector: string | null;
stamp: string | null;
notes: string | null;
result: "accepted" | "rejected" | null;
result_at: string | null;
result_note: string | null;
result_by?: WhoRef | null;
row_version: number;
checks: Record<string, QcCheckEntry>;
weld_exam: Record<string, QcWeldExam>;
ncr?: string | null;
/** The packet's WPS matches /NACE/i. */
nace_label?: boolean;
/** What Q.C. still has to record before an accept (the server's qc_missing). */
missing?: string[];
}
/** One closed inspection (`qc_history[]` and `qc_last`). */
export interface QcHistoryRow {
id: number;
at: string | null;
result: "accepted" | "rejected" | string | null;
inspector: string | null;
note: string | null;
dept_code: string | null;
ncr?: string | null;
rejected_welds?: string[];
by?: WhoRef | null;
location?: string | null;
sent_at?: string | null;
}
/** One Q.C. check item with the old qcTab hint. */
export interface QcItem {
group: string;
key: string;
text: string;
hint?: string | null;
}
/** A current Q.C. folder document of the packet's unit or project. */
export interface QcFolderDoc {
id: number;
type: string;
title: string | null;
rev: string | null;
url: string;
scope: "unit" | "project";
added_at: string | null;
}
/** The unit's ITP summary. */
export interface ItpSummary {
id: number;
no: string | null;
status: "open" | "complete" | string;
done: number;
total: number;
holds: number;
}
/** One open hold, witness or rejected point on the packet's ITP pages. */
export interface ItpHoldItem {
n: string;
t: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
/** The open ITP hold points for the packet's department (a warning only). */
export interface ItpHold {
no: string | null;
count: number;
rejected: boolean;
/** The department's page codes, or null for every page. */
pages: string[] | null;
items: ItpHoldItem[];
}
/** PATCH /api/packets/{id}/qc: the scalar inspection fields. */
export interface QcFields {
inspector: string | null;
stamp: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
notes: string | null;
}
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
@@ -343,8 +474,8 @@ export interface PacketRead {
stage: Stage | null;
stages: Stage[];
wh: WhInfo | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
qc: QcInspection | null;
qc_history: QcHistoryRow[];
change_orders: ChangeOrder[];
open_cos: number[];
open_shorts: number;
@@ -354,7 +485,7 @@ export interface PacketRead {
blocked: Record<string, string[]>;
holder: string;
checklist_items: Array<{ group: string; key: string; text: string }>;
qc_items: unknown[];
qc_items: QcItem[];
flag: string | null;
/** Phase 5: the database now(), to tick the clock from. */
server_now?: string | null;
@@ -364,6 +495,14 @@ export interface PacketRead {
labor?: Labor | null;
/** Phase 5: the borrowed heat map of a sibling packet, read-only. */
heat_src?: HeatSrc | null;
/** Phase 6: the newest closed inspection, or null. */
qc_last?: QcHistoryRow | null;
/** Phase 6: the current Q.C. folder documents of the unit and the project. */
qc_folder_docs?: QcFolderDoc[];
/** Phase 6: the unit's ITP summary. */
itp?: ItpSummary | null;
/** Phase 6: open ITP hold points for this packet's department. */
itp_hold?: ItpHold | null;
}
/* ---------- Phase 5: the shop on the packet ---------- */
@@ -585,6 +724,8 @@ export interface TransitionBody {
spool?: boolean;
location?: string;
for_sup_id?: string;
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
ncr?: string;
sign?: SignBody;
it_reason?: string;
}