Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
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:
@@ -10,6 +10,9 @@ import { SignInPage } from "./pages/SignInPage";
|
||||
import { AccountPage } from "./screens/account/AccountPage";
|
||||
import { ProjectPage } from "./screens/clerical/ProjectPage";
|
||||
import { UnitPage } from "./screens/clerical/UnitPage";
|
||||
import { ItpPage } from "./screens/qc/ItpPage";
|
||||
import { QcProjectFolder } from "./screens/qc/QcProjectFolder";
|
||||
import { QcUnitFolder } from "./screens/qc/QcUnitFolder";
|
||||
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
|
||||
|
||||
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
||||
@@ -39,6 +42,30 @@ export function AppRoutes() {
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qc/projects/:projectId"
|
||||
element={
|
||||
<RequireScreen screen="qc">
|
||||
<QcProjectFolder />
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qc/units/:unitId"
|
||||
element={
|
||||
<RequireScreen screen="qc">
|
||||
<QcUnitFolder />
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qc/units/:unitId/itp"
|
||||
element={
|
||||
<RequireScreen screen="qc">
|
||||
<ItpPage />
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route path="packets/:id" element={<PacketPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ItpHold } from "../api/types";
|
||||
import { makePacket } from "../test/packets";
|
||||
import { DOC_KINDS, docKindLabel, historyLine, holdItemsText, holdItemText, holdText, isNcr, ncrUrl, NDE_METHODS, NDE_RESULTS, normNcr, noteWithoutNcr, plural, rejectedWelds, showsQcRejected } from "./qc";
|
||||
|
||||
const hold = (over: Partial<ItpHold> = {}): ItpHold => ({
|
||||
no: "L073028-ITP",
|
||||
count: 2,
|
||||
rejected: false,
|
||||
pages: ["WP"],
|
||||
items: [
|
||||
{ n: "3.4", t: "Fit-up check", codes: ["W"], rejected: false, ncr: null },
|
||||
{ n: "4.2", t: "Prior to vessel closing", codes: ["H", "W"], rejected: false, ncr: null },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("lib/qc", () => {
|
||||
it("has the closed lists", () => {
|
||||
expect(NDE_METHODS).toEqual(["None required", "RT", "UT", "MT", "PT"]);
|
||||
expect(NDE_RESULTS).toEqual(["", "Acceptable", "Rejectable"]);
|
||||
expect(DOC_KINDS.map(([k]) => k)).toEqual(["nde", "hydro", "dimensional", "pmi", "coating", "coc", "customer", "ncr", "other"]);
|
||||
expect(docKindLabel("nde")).toBe("NDE report (RT / UT / MT / PT)");
|
||||
expect(docKindLabel("mystery")).toBe("mystery");
|
||||
});
|
||||
|
||||
it("checks and links NCR numbers", () => {
|
||||
expect(isNcr("NCR-2026-0021")).toBe(true);
|
||||
expect(isNcr(" ncr-2026-0021 ")).toBe(true);
|
||||
expect(isNcr("NCR-26-21")).toBe(false);
|
||||
expect(isNcr("")).toBe(false);
|
||||
expect(normNcr(" ncr-2026-0021 ")).toBe("NCR-2026-0021");
|
||||
expect(ncrUrl("NCR-2026-0021")).toBe("https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
|
||||
});
|
||||
|
||||
it("lists rejected welds by number with their notes", () => {
|
||||
expect(rejectedWelds({ "10": { result: "reject" }, "3": { result: "reject", note: " undercut " }, "2": { result: "accept" }, "1": { result: "" } })).toEqual([
|
||||
{ id: "W3", note: "undercut" },
|
||||
{ id: "W10", note: null },
|
||||
]);
|
||||
expect(rejectedWelds(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds the hold callout text with and without pages", () => {
|
||||
expect(holdText(hold())).toBe("ITP L073028-ITP: 2 open hold / witness points for this stage.");
|
||||
expect(holdText(hold({ pages: null }))).toBe("ITP L073028-ITP: 2 open hold / witness points.");
|
||||
expect(holdText(hold({ rejected: true }))).toBe("ITP L073028-ITP: 2 open hold / witness or rejected points for this stage.");
|
||||
expect(holdText(hold({ count: 1 }))).toBe("ITP L073028-ITP: 1 open hold / witness point for this stage.");
|
||||
});
|
||||
|
||||
it("names the points, the rejected ones with their NCR", () => {
|
||||
expect(holdItemText({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
|
||||
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: "NCR-2026-0021" })).toBe("1.3 Fit-up (rejected, NCR NCR-2026-0021)");
|
||||
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: null })).toBe("1.3 Fit-up (rejected, NCR pending)");
|
||||
expect(holdItemsText(hold())).toBe("3.4 Fit-up check (W) · 4.2 Prior to vessel closing (H, W)");
|
||||
const many = hold({ count: 6, items: Array.from({ length: 6 }, (_, i) => ({ n: `5.${i + 1}`, t: "Point", codes: ["W"], rejected: false, ncr: null })) });
|
||||
expect(holdItemsText(many)).toBe("5.1 Point (W) · 5.2 Point (W) · 5.3 Point (W) · 5.4 Point (W) · and 2 more");
|
||||
});
|
||||
|
||||
it("takes the NCR off the front of a reject note", () => {
|
||||
expect(noteWithoutNcr("NCR-2026-0021 · undercut at W1", "NCR-2026-0021")).toBe("undercut at W1");
|
||||
expect(noteWithoutNcr("NCR-2026-0021", "NCR-2026-0021")).toBe("");
|
||||
expect(noteWithoutNcr("undercut", null)).toBe("undercut");
|
||||
expect(plural(1, "weld")).toBe("1 weld");
|
||||
expect(plural(3, "weld")).toBe("3 welds");
|
||||
});
|
||||
|
||||
it("builds the earlier inspection line and knows when the rejected callout shows", () => {
|
||||
const h = { id: 1, at: null, result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] };
|
||||
expect(historyLine(h)).toBe("-: REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3");
|
||||
expect(historyLine({ ...h, result: "accepted", note: null, ncr: null, rejected_welds: [] })).toBe("-: ACCEPTED by Quinn QC");
|
||||
expect(showsQcRejected(makePacket({ status: "review", qc_last: h }))).toBe(true);
|
||||
expect(showsQcRejected(makePacket({ status: "qc", qc_last: h }))).toBe(false);
|
||||
expect(showsQcRejected(makePacket({ status: "assigned", qc_last: { ...h, result: "accepted" } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// Q.C. on the packet: the closed lists, NCR numbers and the texts the packet page builds. Pure functions.
|
||||
import type { ItpHold, ItpHoldItem, PacketRead, QcHistoryRow, QcWeldExam } from "../api/types";
|
||||
import { fmtTime } from "./format";
|
||||
|
||||
/** The NDE methods (the old NDE_METHODS). */
|
||||
export const NDE_METHODS = ["None required", "RT", "UT", "MT", "PT"] as const;
|
||||
export const NDE_NONE = "None required";
|
||||
|
||||
/** The NDE results. Empty means no result yet. */
|
||||
export const NDE_RESULTS = ["", "Acceptable", "Rejectable"] as const;
|
||||
|
||||
/** Packet document kinds and their labels (the old PDOC_TYPES), in the old order. */
|
||||
export const DOC_KINDS: ReadonlyArray<readonly [string, string]> = [
|
||||
["nde", "NDE report (RT / UT / MT / PT)"],
|
||||
["hydro", "Hydrotest report"],
|
||||
["dimensional", "Dimensional report"],
|
||||
["pmi", "PMI report"],
|
||||
["coating", "Coating / paint report"],
|
||||
["coc", "Certificate of conformance"],
|
||||
["customer", "Customer inspection report"],
|
||||
["ncr", "NCR"],
|
||||
["other", "Other"],
|
||||
];
|
||||
|
||||
/** The label of a document kind. An unknown kind shows as it is. */
|
||||
export function docKindLabel(kind: string): string {
|
||||
return DOC_KINDS.find(([k]) => k === kind)?.[1] ?? kind;
|
||||
}
|
||||
|
||||
/** The accept routes besides the shop departments. The en dash is the old text. */
|
||||
export const ROUTE_DATABOOK = "Clerical – Data Book";
|
||||
export const ROUTE_SHIPPING = "Shipping";
|
||||
|
||||
const RX_NCR = /^NCR-\d{4}-\d{4}$/;
|
||||
|
||||
/** "NCR-2026-0021" from " ncr-2026-0021 ". The server does the same. */
|
||||
export function normNcr(text: string): string {
|
||||
return text.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/** A well-formed NCR number, after trimming and upper-casing. */
|
||||
export function isNcr(text: string): boolean {
|
||||
return RX_NCR.test(normNcr(text));
|
||||
}
|
||||
|
||||
/** The NCR's page in pesco-ncr. */
|
||||
export function ncrUrl(ncr: string): string {
|
||||
return `https://ncr.pescoinc.biz/ncrs/${encodeURIComponent(normNcr(ncr))}`;
|
||||
}
|
||||
|
||||
/** "W3" from 3. */
|
||||
export function weldId(no: number | string): string {
|
||||
return `W${no}`;
|
||||
}
|
||||
|
||||
/** The rejected welds of a visual exam, by weld number: [{id: "W1", note: "undercut"}, ...]. */
|
||||
export function rejectedWelds(exam: Record<string, QcWeldExam> | null | undefined): Array<{ id: string; note: string | null }> {
|
||||
return Object.entries(exam ?? {})
|
||||
.filter(([, v]) => v?.result === "reject")
|
||||
.map(([k, v]) => ({ no: Number(k), id: weldId(k), note: v.note?.trim() || null }))
|
||||
.sort((a, b) => (Number.isFinite(a.no) && Number.isFinite(b.no) ? a.no - b.no : a.id.localeCompare(b.id)))
|
||||
.map(({ id, note }) => ({ id, note }));
|
||||
}
|
||||
|
||||
/** "2 welds", "1 weld". */
|
||||
export function plural(n: number, word: string): string {
|
||||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** The old itpHoldNote head: "ITP L073028-ITP: 2 open hold / witness points for this stage." */
|
||||
export function holdText(hold: ItpHold): string {
|
||||
const n = hold.count;
|
||||
return `ITP ${hold.no ?? ""}: ${n} open hold / witness ${hold.rejected ? "or rejected " : ""}point${n === 1 ? "" : "s"}${hold.pages ? " for this stage" : ""}.`;
|
||||
}
|
||||
|
||||
/** "4.2 Prior to vessel closing (H)" or "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
|
||||
export function holdItemText(i: ItpHoldItem): string {
|
||||
const why = i.rejected ? `rejected, NCR ${i.ncr || "pending"}` : i.codes.join(", ");
|
||||
return `${i.n} ${i.t} (${why})`;
|
||||
}
|
||||
|
||||
/** The first four points joined by " · ", then "· and N more". */
|
||||
export function holdItemsText(hold: ItpHold, show = 4): string {
|
||||
const head = hold.items.slice(0, show).map(holdItemText).join(" · ");
|
||||
const more = hold.items.length - show;
|
||||
return more > 0 ? `${head} · and ${more} more` : head;
|
||||
}
|
||||
|
||||
/** A reject note without the NCR number in front: the server stores "NCR-2026-0021 · reason". */
|
||||
export function noteWithoutNcr(note: string | null | undefined, ncr: string | null | undefined): string {
|
||||
const t = (note ?? "").trim();
|
||||
if (!ncr) return t;
|
||||
if (t === ncr) return "";
|
||||
const lead = `${ncr} · `;
|
||||
return t.startsWith(lead) ? t.slice(lead.length) : t;
|
||||
}
|
||||
|
||||
/** "<time>: REJECTED by Quinn QC: note · NCR-2026-0021 · welds W1, W3". */
|
||||
export function historyLine(h: QcHistoryRow): string {
|
||||
const who = h.inspector || h.by?.display_name || "";
|
||||
const parts = [noteWithoutNcr(h.note, h.ncr), h.ncr, h.rejected_welds?.length ? `welds ${h.rejected_welds.join(", ")}` : null].filter(
|
||||
(x): x is string => !!x,
|
||||
);
|
||||
return `${fmtTime(h.at)}: ${String(h.result ?? "").toUpperCase()}${who ? ` by ${who}` : ""}${parts.length ? `: ${parts.join(" · ")}` : ""}`;
|
||||
}
|
||||
|
||||
const REJECTED_SHOWN_ON = new Set(["review", "assigned", "in_progress"]);
|
||||
|
||||
/** True when the packet shows the "Q.C. rejected" callout: back in the shop after a reject. */
|
||||
export function showsQcRejected(p: PacketRead): boolean {
|
||||
return REJECTED_SHOWN_ON.has(p.status) && p.qc_last?.result === "rejected";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Link } from "react-router";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { holdItemsText, holdText } from "../lib/qc";
|
||||
|
||||
/**
|
||||
* The open ITP hold, witness or rejected points for this packet's department (the old itpHoldNote). A warning
|
||||
* only: nothing is blocked by it. Users with the Q.C. screen get a link to the ITP.
|
||||
*/
|
||||
export function HoldCallout({ p, qcScreen }: { p: PacketRead; qcScreen: boolean }) {
|
||||
const hold = p.itp_hold;
|
||||
if (!hold || p.status === "filed" || !hold.count) return null;
|
||||
const items = holdItemsText(hold);
|
||||
return (
|
||||
<div className="callout warn itphold" role="note">
|
||||
<b>{holdText(hold)}</b>
|
||||
{items ? ` ${items}.` : ""} Check with Q.C. before moving past these points.
|
||||
{qcScreen ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link className="lnk" to={`/qc/units/${p.unit.id}/itp`}>
|
||||
Open ITP
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,13 +20,18 @@ import { ChangesTab } from "./ChangesTab";
|
||||
import { ChecklistTab } from "./ChecklistTab";
|
||||
import { HeatMapTab } from "./HeatMapTab";
|
||||
import { ShopBar } from "./ShopActions";
|
||||
import { HoldCallout } from "./HoldCallout";
|
||||
import { QcBar } from "./QcActions";
|
||||
import { QcRejectedCallout } from "./QcRejectedCallout";
|
||||
import { showsQcRejected } from "../lib/qc";
|
||||
import { QcTab } from "./QcTab";
|
||||
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" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
|
||||
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
|
||||
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
|
||||
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
|
||||
function isTab(v: string | null): v is Tab {
|
||||
return v !== null && (TABS as readonly string[]).includes(v);
|
||||
}
|
||||
@@ -317,7 +322,9 @@ export function PacketPage() {
|
||||
|
||||
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||
const review = coToReview(p);
|
||||
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
|
||||
const hasQc = !!p.qc || p.qc_history.length > 0;
|
||||
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
|
||||
const shownTab: Tab = hidden ? "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 =
|
||||
@@ -354,9 +361,10 @@ export function PacketPage() {
|
||||
{ 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 },
|
||||
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
|
||||
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
|
||||
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
|
||||
{ id: "docs", label: "Documents", count: p.docs.length },
|
||||
{ id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
const onTabError = (text: string) => setMsg({ ok: false, text });
|
||||
@@ -463,7 +471,8 @@ export function PacketPage() {
|
||||
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
|
||||
</div>
|
||||
) : null}
|
||||
{p.return_note ? (
|
||||
<QcRejectedCallout p={p} />
|
||||
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
|
||||
<div className="callout warn">
|
||||
<b>Returned:</b> {p.return_note}
|
||||
</div>
|
||||
@@ -652,6 +661,8 @@ export function PacketPage() {
|
||||
) : null}
|
||||
|
||||
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
|
||||
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
|
||||
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Packet">
|
||||
{tabs.map((t) => (
|
||||
@@ -692,9 +703,10 @@ export function PacketPage() {
|
||||
{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 === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} 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}
|
||||
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
|
||||
{shownTab === "history" ? <HistoryTab p={p} /> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { PacketRead, RouteKind } from "../api/types";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { isNcr, normNcr, ROUTE_DATABOOK, ROUTE_SHIPPING } from "../lib/qc";
|
||||
import { useShopDepartments } from "../lib/useDepartments";
|
||||
import { makeStep } from "./shopUtil";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
/** Signature meanings, as the rules state them. */
|
||||
export const SIGN_QC_ACCEPT = "Q.C. accept";
|
||||
export const SIGN_QC_ACCEPT_ROUTE = "Q.C. accept and route";
|
||||
export const SIGN_QC_REJECT = "Q.C. reject";
|
||||
|
||||
/** The precondition message the bar shows as the list of links instead. */
|
||||
const MISSING_PREFIX = "Q.C. still needs";
|
||||
|
||||
const REJECT_MIN = 8;
|
||||
|
||||
/** The reject sheet: a reason, an NCR number, or both. */
|
||||
export function RejectSheet({
|
||||
sub,
|
||||
onCancel,
|
||||
onReject,
|
||||
}: {
|
||||
sub: string;
|
||||
onCancel: () => void;
|
||||
onReject: (body: { reason?: string; ncr?: string }) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [ncr, setNcr] = useState("");
|
||||
const r = reason.trim();
|
||||
const n = ncr.trim();
|
||||
const ncrOk = isNcr(n);
|
||||
const ok = r.length >= REJECT_MIN || ncrOk;
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ok) return;
|
||||
onReject({ ...(r ? { reason: r } : {}), ...(ncrOk ? { ncr: normNcr(n) } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title="Reject" sub={sub} onClose={onCancel}>
|
||||
<div className="small">Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.</div>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
Reason / what failed
|
||||
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
NCR number
|
||||
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
|
||||
</label>
|
||||
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pink" type="submit" disabled={!ok}>
|
||||
Reject to supervisor
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
|
||||
|
||||
/**
|
||||
* The Q.C. bar on a packet at Q.C.: what is still needed (links to the Q.C. tab), the route, Accept and Reject.
|
||||
* The server decides; `can` and `blocked` only shape the buttons. Accept and reject are signed.
|
||||
*/
|
||||
export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boolean; mutate: Mutate; sub: string; goQc: () => void }) {
|
||||
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
|
||||
const [route, setRoute] = useState<string>("databook");
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
if (p.status !== "qc") return null;
|
||||
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
|
||||
|
||||
const step = makeStep(p, mutate);
|
||||
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
|
||||
const action = toDept ? "qc_accept_and_route" : "qc_accept";
|
||||
const canAccept = p.can.includes(action);
|
||||
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
|
||||
const reason = blocked.join(" ");
|
||||
const missing = p.qc?.missing ?? [];
|
||||
const listed = blocked.some((m) => m.startsWith(MISSING_PREFIX)) && missing.length > 0;
|
||||
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
|
||||
|
||||
const accept = () => {
|
||||
if (toDept) void step("qc_accept_and_route", { dept: toDept }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
|
||||
else void step("qc_accept", { routed_to: route as RouteKind }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel actbox qcbar" aria-label="Q.C. inspection">
|
||||
<div>
|
||||
{listed ? (
|
||||
<span data-testid="qc-missing">
|
||||
<b>Still needed:</b>{" "}
|
||||
{missing.map((m, i) => (
|
||||
<span key={m}>
|
||||
{i ? " · " : ""}
|
||||
<button className="lnk" type="button" onClick={goQc}>
|
||||
{m}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : canAccept ? (
|
||||
<>
|
||||
<b>Everything is recorded.</b> Pick where it goes and accept, or reject it back to the supervisor.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Inspect the spool and the data book package.</b> Record the checks, the visual exam and NDE on the Q.C. tab.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
|
||||
<div className="acts center">
|
||||
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
|
||||
<>
|
||||
<label className="f qcroute">
|
||||
Route
|
||||
<select aria-label="Route" className="in" value={route} onChange={(e) => setRoute(e.target.value)}>
|
||||
<option value="databook">{ROUTE_DATABOOK}</option>
|
||||
{depts.map((d) => (
|
||||
<option key={d.code} value={`DEPT:${d.code}`}>
|
||||
{d.code} {d.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="shipping">{ROUTE_SHIPPING}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="act">
|
||||
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{has(p, "qc_reject") ? (
|
||||
<div className="act">
|
||||
<button
|
||||
className="btn pink"
|
||||
type="button"
|
||||
disabled={!p.can.includes("qc_reject") || busy}
|
||||
title={!p.can.includes("qc_reject") ? (p.blocked.qc_reject ?? []).join(" ") : undefined}
|
||||
onClick={() => setRejecting(true)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{rejecting ? (
|
||||
<RejectSheet
|
||||
sub={sub}
|
||||
onCancel={() => setRejecting(false)}
|
||||
onReject={(body) => {
|
||||
setRejecting(false);
|
||||
void step("qc_reject", body, { sign: SIGN_QC_REJECT, ok: "Q.C. rejected. It is back with the supervisor." });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { fmtTime } from "../lib/format";
|
||||
import { ncrUrl, noteWithoutNcr, showsQcRejected } from "../lib/qc";
|
||||
|
||||
/** "Q.C. rejected <time> by Quinn QC: <note>", the NCR link and the rejected welds. */
|
||||
export function QcRejectedCallout({ p }: { p: PacketRead }) {
|
||||
const h = p.qc_last;
|
||||
if (!h || !showsQcRejected(p)) return null;
|
||||
const who = h.inspector || h.by?.display_name || "";
|
||||
const note = noteWithoutNcr(h.note, h.ncr);
|
||||
const welds = h.rejected_welds ?? [];
|
||||
return (
|
||||
<div className="callout warn qcrej" role="note">
|
||||
<b>
|
||||
Q.C. rejected {fmtTime(h.at)}
|
||||
{who ? ` by ${who}` : ""}:
|
||||
</b>
|
||||
{note ? ` ${note}` : ""}
|
||||
{h.ncr ? (
|
||||
<>
|
||||
{" "}
|
||||
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
|
||||
NCR {h.ncr}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
{welds.length ? <div className="small mt4">Rejected welds: {welds.join(", ")}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead, QcFields, QcHistoryRow, QcInspection, QcItem, Weld } from "../api/types";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import { historyLine, NDE_METHODS, NDE_NONE, NDE_RESULTS, plural } from "../lib/qc";
|
||||
import type { Write } from "../warehouse/PullTable";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
type TextField = Exclude<keyof QcFields, "nde_method" | "nde_result">;
|
||||
|
||||
/** The earlier inspections, newest last as the old build listed them. */
|
||||
function Earlier({ rows }: { rows: QcHistoryRow[] }) {
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section className="sec" aria-label="Earlier inspections">
|
||||
<div className="sec-h">
|
||||
<h2>Earlier inspections</h2>
|
||||
</div>
|
||||
<div className="stack tight">
|
||||
{rows.map((h) => (
|
||||
<div key={h.id} className="small">
|
||||
{historyLine(h)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function groupsOf(items: readonly QcItem[]): Array<{ group: string; items: QcItem[] }> {
|
||||
const out: Array<{ group: string; items: QcItem[] }> = [];
|
||||
for (const it of items) {
|
||||
const g = out.find((x) => x.group === it.group);
|
||||
if (g) g.items.push(it);
|
||||
else out.push({ group: it.group, items: [it] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function weldDetail(w: Weld): string {
|
||||
const parts = [
|
||||
w.items.length ? `items ${w.items.join(" + ")}` : null,
|
||||
w.wo_base ? `${w.wo_base}${w.piece ? ` #${w.piece}` : ""}` : null,
|
||||
w.process,
|
||||
w.stamp,
|
||||
].filter((x): x is string => !!x);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The Q.C. inspection (the old qcTab): the supervisor's send-off, the inspector, the ten checks, the visual exam
|
||||
* per weld, NDE and hardness, notes, and the earlier inspections. Editable only with `edit_qc`. Each check and
|
||||
* each weld is its own PUT; the other fields save on blur with a field-level base.
|
||||
*/
|
||||
export function QcTab({
|
||||
p,
|
||||
write,
|
||||
mutate,
|
||||
reload,
|
||||
onError,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
write: Write;
|
||||
mutate: Mutate;
|
||||
reload: () => Promise<void>;
|
||||
onError: (text: string) => void;
|
||||
}) {
|
||||
const q = p.qc;
|
||||
if (!q) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="empty slim">No open Q.C. inspection.</div>
|
||||
<Earlier rows={p.qc_history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Inspection key={q.id} p={p} q={q} write={write} mutate={mutate} reload={reload} onError={onError} />;
|
||||
}
|
||||
|
||||
function Inspection({
|
||||
p,
|
||||
q,
|
||||
write,
|
||||
mutate,
|
||||
reload,
|
||||
onError,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
q: QcInspection;
|
||||
write: Write;
|
||||
mutate: Mutate;
|
||||
reload: () => Promise<void>;
|
||||
onError: (text: string) => void;
|
||||
}) {
|
||||
const ed = p.can.includes("edit_qc");
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
const [vtPending, setVtPending] = useState<Record<string, boolean>>({});
|
||||
const [draft, setDraft] = useState<Partial<Record<TextField, string>>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const saving = useRef(new Set<string>());
|
||||
|
||||
const fail = async (err: unknown) => {
|
||||
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
|
||||
onError(errorText(err));
|
||||
};
|
||||
|
||||
const tick = async (key: string, done: boolean) => {
|
||||
setPending((o) => ({ ...o, [key]: done }));
|
||||
const r = await write((g) => packetsApi.qcCheck(p.id, key, done, g));
|
||||
setPending((o) => {
|
||||
const n = { ...o };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
if (!r.ok && r.err !== undefined) await fail(r.err);
|
||||
};
|
||||
|
||||
const exam = async (no: number, body: { result?: "accept" | "reject" | ""; note?: string }) => {
|
||||
const k = String(no);
|
||||
setVtPending((o) => ({ ...o, [k]: true }));
|
||||
const r = await write((g) => packetsApi.qcWeld(p.id, no, body, g));
|
||||
setVtPending((o) => {
|
||||
const n = { ...o };
|
||||
delete n[k];
|
||||
return n;
|
||||
});
|
||||
if (!r.ok && r.err !== undefined) await fail(r.err);
|
||||
return r.ok;
|
||||
};
|
||||
|
||||
const saveNote = async (no: number) => {
|
||||
const k = String(no);
|
||||
const now = notes[k];
|
||||
if (now === undefined) return;
|
||||
const was = q.weld_exam[k]?.note ?? "";
|
||||
if (now.trim() !== was.trim()) await exam(no, { note: now.trim() });
|
||||
setNotes((o) => {
|
||||
const n = { ...o };
|
||||
delete n[k];
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const server = (k: keyof QcFields) => q[k] ?? "";
|
||||
const shown = (k: TextField) => draft[k] ?? server(k);
|
||||
|
||||
const setField = async (k: keyof QcFields, value: string) => {
|
||||
if (saving.current.has(k)) return;
|
||||
const was = q[k] ?? null;
|
||||
const now = value.trim() || null;
|
||||
if (now === was) return;
|
||||
saving.current.add(k);
|
||||
try {
|
||||
await mutate((g) => packetsApi.qcPatch(p.id, { set: { [k]: now }, base: { [k]: was }, ...g }));
|
||||
} finally {
|
||||
saving.current.delete(k);
|
||||
}
|
||||
};
|
||||
|
||||
const saveText = async (k: TextField) => {
|
||||
const now = draft[k];
|
||||
if (now === undefined) return;
|
||||
await setField(k, now);
|
||||
// On a conflict the page reloads; the field then shows the value someone else saved.
|
||||
setDraft((d) => ({ ...d, [k]: undefined }));
|
||||
};
|
||||
|
||||
const textInput = (k: TextField, label: string, ph: string, max: number, cls = "in") => (
|
||||
<label className="f" key={k}>
|
||||
{label}
|
||||
<input
|
||||
aria-label={label}
|
||||
className={cls}
|
||||
value={shown(k)}
|
||||
maxLength={max}
|
||||
placeholder={ph}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
|
||||
onBlur={() => void saveText(k)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void saveText(k);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
const groups = groupsOf(p.qc_items);
|
||||
const welds = p.welds;
|
||||
const method = q.nde_method || NDE_NONE;
|
||||
const hardLabel = q.nace_label ? "Hardness (NACE)" : "Hardness";
|
||||
|
||||
return (
|
||||
<div className="stack qctab">
|
||||
<div className="callout">
|
||||
<b>Sent {fmtTime(q.sent_at)}</b>: data book package {q.book ? "ready" : "not confirmed"}, spool {q.spool ? "ready" : "not confirmed"}. Spool location:{" "}
|
||||
<b>{q.location || "-"}</b>
|
||||
{q.sent_note ? <div className="small mt4">Note: {q.sent_note}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="frow qcinsp">
|
||||
{ed ? (
|
||||
<>
|
||||
{textInput("inspector", "Inspector", "Name", 80)}
|
||||
{textInput("stamp", "Stamp / ID", "ID", 20)}
|
||||
</>
|
||||
) : (
|
||||
<div className="small">
|
||||
<span className="muted">Inspector: </span>
|
||||
<b className="near">
|
||||
{q.inspector || "-"}
|
||||
{q.stamp ? ` [${q.stamp}]` : ""}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
</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 = q.checks[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={!ed}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={i.text}
|
||||
checked={done}
|
||||
disabled={!ed || pending[i.key] !== undefined}
|
||||
onChange={(e) => void tick(i.key, e.target.checked)}
|
||||
/>
|
||||
<span className="t">
|
||||
{i.text}
|
||||
{i.hint ? <span className="hint small muted mono">{i.hint}</span> : null}
|
||||
</span>
|
||||
<span className="by">{by}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="sec" aria-label="Visual weld inspection">
|
||||
<div className="sec-h">
|
||||
<h2>Visual weld inspection</h2>
|
||||
<span className="ct">{plural(welds.length, "weld")} · open the Weld map tab to find them</span>
|
||||
</div>
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Weld</th>
|
||||
<th>Detail</th>
|
||||
<th>Result</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{welds.length ? (
|
||||
welds.map((w) => {
|
||||
const k = String(w.weld_no);
|
||||
const v = q.weld_exam[k] ?? {};
|
||||
const res = v.result || "";
|
||||
const busy = !!vtPending[k];
|
||||
return (
|
||||
<tr key={k} aria-label={`Weld W${w.weld_no}`}>
|
||||
<td>
|
||||
<span className="balloon pinkb">{w.weld_no}</span>
|
||||
</td>
|
||||
<td>
|
||||
{[w.type, w.size].filter(Boolean).join(" ")}
|
||||
<div className="sub">{weldDetail(w)}</div>
|
||||
</td>
|
||||
<td>
|
||||
{ed ? (
|
||||
<div className="vtbs">
|
||||
<button
|
||||
type="button"
|
||||
className="vtb acc"
|
||||
aria-pressed={res === "accept"}
|
||||
disabled={busy}
|
||||
onClick={() => void exam(w.weld_no, { result: res === "accept" ? "" : "accept" })}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vtb rej"
|
||||
aria-pressed={res === "reject"}
|
||||
disabled={busy}
|
||||
onClick={() => void exam(w.weld_no, { result: res === "reject" ? "" : "reject" })}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
) : res ? (
|
||||
<span className={`pill ${res === "accept" ? "st-in_progress" : "st-review"}`}>{res}</span>
|
||||
) : (
|
||||
<span className="muted small">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{ed ? (
|
||||
<input
|
||||
className="in"
|
||||
aria-label={`W${w.weld_no} note`}
|
||||
value={notes[k] ?? v.note ?? ""}
|
||||
maxLength={300}
|
||||
placeholder={res === "reject" ? "Defect (undercut, porosity…)" : "Note"}
|
||||
onChange={(e) => setNotes((o) => ({ ...o, [k]: e.target.value }))}
|
||||
onBlur={() => void saveNote(w.weld_no)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void saveNote(w.weld_no);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="small">{v.note ?? ""}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted">
|
||||
No welds on the weld map.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sec" aria-label="NDE & testing">
|
||||
<div className="sec-h">
|
||||
<h2>NDE & testing</h2>
|
||||
</div>
|
||||
{ed ? (
|
||||
<>
|
||||
<div className="frow qcnde">
|
||||
<label className="f">
|
||||
NDE method
|
||||
<select aria-label="NDE method" className="in" value={method} onChange={(e) => void setField("nde_method", e.target.value)}>
|
||||
{NDE_METHODS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
NDE result
|
||||
<select aria-label="NDE result" className="in" value={q.nde_result ?? ""} onChange={(e) => void setField("nde_result", e.target.value)}>
|
||||
{NDE_RESULTS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "-"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{textInput("nde_report", "Report no.", "Optional", 60)}
|
||||
{textInput("hardness", hardLabel, "e.g. 198 HB", 40)}
|
||||
</div>
|
||||
<label className="f qcnotes">
|
||||
Inspection notes
|
||||
<textarea
|
||||
aria-label="Inspection notes"
|
||||
className="in"
|
||||
value={shown("notes")}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
|
||||
onBlur={() => void saveText("notes")}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<div className="stack tight">
|
||||
<div className="small">
|
||||
<span className="muted">NDE: </span>
|
||||
{[method, q.nde_result, q.nde_report].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<div className="small">
|
||||
<span className="muted">{hardLabel}: </span>
|
||||
{q.hardness || "-"}
|
||||
</div>
|
||||
{q.notes ? <div className="small">{q.notes}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Earlier rows={p.qc_history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -291,16 +291,15 @@ function ReviewBox({
|
||||
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 ?? ""),
|
||||
);
|
||||
// After a Q.C. reject the inspection is closed into qc_last; the open inspection (qc) is null.
|
||||
const [loc, setLoc] = useState(String(p.qc?.location ?? p.qc_last?.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 rejected = p.qc_last?.result === "rejected";
|
||||
const ready = book && spool && !!loc.trim();
|
||||
|
||||
const returnIt = async () => {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PacketRead, QcInspection } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
|
||||
const DEPTS = [...DEPARTMENTS, { code: "09", slug: "Hydro", name: "Hydro", kind: "shop", stations: [] }];
|
||||
|
||||
const ITEMS = [
|
||||
{ group: "Data book package", key: "db_heats", text: "Heat number recorded for every BOM item", hint: "1/2 recorded" },
|
||||
{ group: "Physical spool", key: "ph_dim", text: "Dimensions verified against CI-DWG-1 Rev 0", hint: null },
|
||||
];
|
||||
|
||||
function inspection(over: Partial<QcInspection> = {}): QcInspection {
|
||||
return {
|
||||
id: 9,
|
||||
sent_at: "2026-10-17T08:00:00Z",
|
||||
book: true,
|
||||
spool: true,
|
||||
location: "Bay 2",
|
||||
sent_note: "Rush",
|
||||
nde_method: "None required",
|
||||
nde_result: null,
|
||||
nde_report: null,
|
||||
hardness: null,
|
||||
inspector: null,
|
||||
stamp: null,
|
||||
notes: null,
|
||||
result: null,
|
||||
result_at: null,
|
||||
result_note: null,
|
||||
row_version: 1,
|
||||
checks: {},
|
||||
weld_exam: {},
|
||||
ncr: null,
|
||||
nace_label: false,
|
||||
missing: ["2 inspection items", "1 weld without a visual result", "inspector name"],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function qcPacket(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return makePacket({
|
||||
status: "qc",
|
||||
holder: "Q.C.",
|
||||
qc: inspection(),
|
||||
qc_items: ITEMS,
|
||||
welds: [
|
||||
{ weld_no: 1, page: 1, fx: 0.1, fy: 0.1, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null },
|
||||
],
|
||||
can: ["edit_qc", "qc_reject", "doc_add", "doc_remove"],
|
||||
blocked: { qc_accept: ["Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name."], qc_accept_and_route: ["Q.C. still needs: 2 inspection items."] },
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function base(packet: PacketRead, roles = ["qc"], screens = ["qc"]) {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe({ roles, screens, user: { id: "quinn", display_name: "Quinn QC" } }) },
|
||||
"/api/departments": { body: DEPTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
};
|
||||
}
|
||||
|
||||
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("Q.C. tab", () => {
|
||||
it("each tick PUTs its key; the weld toggles PUT the result", async () => {
|
||||
const p = qcPacket();
|
||||
const after = qcPacket({ version: 4, qc: inspection({ checks: { db_heats: { done: true, at: "2026-10-17T09:00:00Z", stamp: "Q1" } } }) });
|
||||
const fetch = mockFetch({
|
||||
...base(p),
|
||||
"PUT /api/packets/1/qc/checks/db_heats": { body: after },
|
||||
"PUT /api/packets/1/qc/welds/1": { body: qcPacket({ version: 5, qc: inspection({ weld_exam: { "1": { result: "accept" } } }) }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
|
||||
expect(screen.getByText(/data book package ready, spool ready/)).toHaveTextContent("Spool location: Bay 2");
|
||||
expect(screen.getByText("1/2 recorded")).toBeInTheDocument();
|
||||
const box = screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" });
|
||||
fireEvent.click(box);
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" })).toBeChecked());
|
||||
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/checks/db_heats")).toEqual([{ done: true }]);
|
||||
expect(screen.getByText(/^Q1 · /)).toBeInTheDocument();
|
||||
|
||||
const row = screen.getByRole("row", { name: "Weld W1" });
|
||||
expect(within(row).getByText("items 1 + 2 · L999001 #1 · GMAW · 75")).toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Accept" }));
|
||||
await waitFor(() => expect(within(screen.getByRole("row", { name: "Weld W1" })).getByRole("button", { name: "Accept" })).toHaveAttribute("aria-pressed", "true"));
|
||||
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/welds/1")).toEqual([{ result: "accept" }]);
|
||||
expect(screen.getByText("1 weld · open the Weld map tab to find them")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the inspector on blur with its base", async () => {
|
||||
const fetch = mockFetch({ ...base(qcPacket()), "PATCH /api/packets/1/qc": { body: qcPacket({ version: 4, qc: inspection({ inspector: "Quinn QC" }) }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
|
||||
const f = screen.getByLabelText("Inspector");
|
||||
fireEvent.change(f, { target: { value: "Quinn QC" } });
|
||||
fireEvent.blur(f);
|
||||
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/qc")).toEqual([{ set: { inspector: "Quinn QC" }, base: { inspector: null } }]));
|
||||
});
|
||||
|
||||
it("is read-only without edit_qc and lists earlier inspections", async () => {
|
||||
const p = qcPacket({
|
||||
can: [],
|
||||
blocked: {},
|
||||
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] }],
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
|
||||
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Inspector")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown without an inspection or history", async () => {
|
||||
mockFetch(base(makePacket()));
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("tab", { name: /^Materials/ });
|
||||
expect(screen.queryByRole("tab", { name: /^Q\.C\./ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Q.C. bar", () => {
|
||||
it("lists what is still needed and keeps Accept disabled", async () => {
|
||||
mockFetch(base(qcPacket()));
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||
expect(within(bar).getByTestId("qc-missing")).toHaveTextContent("Still needed: 2 inspection items · 1 weld without a visual result · inspector name");
|
||||
const accept = within(bar).getByRole("button", { name: "Accept" });
|
||||
expect(accept).toBeDisabled();
|
||||
expect(accept).toHaveAttribute("title", "Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name.");
|
||||
const route = within(bar).getByLabelText("Route");
|
||||
expect(within(route).getAllByRole("option").map((o) => o.textContent)).toEqual(["Clerical – Data Book", "09 Hydro", "14 Electrical", "Shipping"]);
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "inspector name" }));
|
||||
expect(await screen.findByLabelText("Inspector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts to the data book after the signature", async () => {
|
||||
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["edit_qc", "qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
|
||||
const fetch = mockFetch({
|
||||
...base(p),
|
||||
"POST /api/packets/1/transitions": { body: makePacket({ status: "complete", version: 6, routed_to: "databook", can: [], blocked: {}, holder: "Clerical" }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||
expect(within(bar).queryByTestId("qc-missing")).not.toBeInTheDocument();
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Routed"));
|
||||
});
|
||||
|
||||
it("accepts and routes to a department", async () => {
|
||||
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
|
||||
const fetch = mockFetch({ ...base(p), "POST /api/packets/1/transitions": { body: makePacket({ status: "ready", version: 6, can: [], blocked: {} }) } });
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||
fireEvent.change(within(bar).getByLabelText("Route"), { target: { value: "DEPT:09" } });
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept and route")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("the reject sheet enables on an NCR alone and sends it upper-cased", async () => {
|
||||
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Reject" });
|
||||
expect(within(sheet).getByText("Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.")).toBeInTheDocument();
|
||||
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
|
||||
expect(submit).toBeDisabled();
|
||||
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "short" } });
|
||||
expect(submit).toBeDisabled();
|
||||
const ncr = within(sheet).getByLabelText("NCR number");
|
||||
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
|
||||
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "" } });
|
||||
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
|
||||
expect(submit).toBeEnabled();
|
||||
fireEvent.click(submit);
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callouts", () => {
|
||||
const hold = {
|
||||
no: "L999001-ITP",
|
||||
count: 5,
|
||||
rejected: true,
|
||||
pages: ["WP"] as string[] | null,
|
||||
items: [
|
||||
{ n: "3.4", t: "Fit-up", codes: ["W"], rejected: false, ncr: null },
|
||||
{ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null },
|
||||
{ n: "4.4", t: "Hydro", codes: ["H"], rejected: true, ncr: "NCR-2026-0021" },
|
||||
{ n: "4.5", t: "Final", codes: ["H"], rejected: true, ncr: null },
|
||||
{ n: "5.1", t: "Paint", codes: ["W"], rejected: false, ncr: null },
|
||||
],
|
||||
};
|
||||
|
||||
it("the hold callout names the points and links Q.C. users to the ITP", async () => {
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: hold })));
|
||||
renderApp("/packets/1");
|
||||
const note = await screen.findByText(/^ITP L999001-ITP:/);
|
||||
expect(note).toHaveTextContent("ITP L999001-ITP: 5 open hold / witness or rejected points for this stage.");
|
||||
const box = note.closest(".itphold");
|
||||
expect(box).toHaveTextContent(
|
||||
"3.4 Fit-up (W) · 4.2 Prior to vessel closing (H) · 4.4 Hydro (rejected, NCR NCR-2026-0021) · 4.5 Final (rejected, NCR pending) · and 1 more. Check with Q.C. before moving past these points.",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Open ITP" })).toHaveAttribute("href", "/qc/units/11/itp");
|
||||
});
|
||||
|
||||
it("the hold callout without pages says nothing about the stage, and no link without the qc screen", async () => {
|
||||
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: { ...hold, pages: null, rejected: false, count: 1, items: hold.items.slice(0, 1) } }), ["shop:03:supervisor"], ["supervisor"]));
|
||||
renderApp("/packets/1");
|
||||
expect(await screen.findByText(/^ITP L999001-ITP:/)).toHaveTextContent(/^ITP L999001-ITP: 1 open hold \/ witness point\.$/);
|
||||
expect(screen.queryByRole("link", { name: "Open ITP" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the Q.C. rejected callout shows the note, the NCR link and the welds", async () => {
|
||||
const p = makePacket({
|
||||
status: "review",
|
||||
can: [],
|
||||
blocked: {},
|
||||
return_note: "NCR-2026-0021 · undercut at W1",
|
||||
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] }],
|
||||
qc_last: { id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] },
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
|
||||
renderApp("/packets/1");
|
||||
const note = await screen.findByText(/^Q\.C\. rejected/);
|
||||
const box = note.closest(".callout") as HTMLElement;
|
||||
expect(box).toHaveTextContent(/by Quinn QC: undercut at W1 NCR NCR-2026-0021Rejected welds: W1$/);
|
||||
const link = within(box).getByRole("link", { name: "NCR NCR-2026-0021" });
|
||||
expect(link).toHaveAttribute("href", "https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(screen.queryByText(/^Returned:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Documents tab", () => {
|
||||
const docs = [
|
||||
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report (RT / UT / MT / PT)", title: "RT report W1-W4", note: "RPT-2210, accept", file_name: "rt.pdf", size: 900, added_at: "2026-10-17T08:00:00Z", added_by: { id: "quinn", display_name: "Quinn QC" }, url: "/_blob/a4", removed: null },
|
||||
{ id: 5, asset_id: "a5", kind: "other", kind_label: "Other", title: "Old note", file_name: "o.pdf", added_at: "2026-10-16T08:00:00Z", added_by: null, url: "/_blob/a5", removed: { at: "2026-10-16T09:00:00Z", by: { id: "quinn", display_name: "Quinn QC" }, reason: "wrong packet" } },
|
||||
];
|
||||
|
||||
it("takes a document out with the reason, and lists the taken-out rows", async () => {
|
||||
const p = qcPacket({ docs, qc_folder_docs: [{ id: 1, type: "WPS / PQR", title: "WPS-101", rev: "2", url: "/_blob/w1", scope: "project", added_at: null }] });
|
||||
const fetch = mockFetch({ ...base(p), "DELETE /api/packets/1/docs/4": { body: qcPacket({ version: 4 }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
||||
expect(screen.getByRole("tab", { name: /^Documents/ })).toHaveTextContent("Documents1");
|
||||
const sec = screen.getByRole("region", { name: "Documents for the data book" });
|
||||
expect(within(sec).getByText("1 on this packet")).toBeInTheDocument();
|
||||
expect(within(sec).getByText(/rt\.pdf · added by Quinn QC .* · RPT-2210, accept/)).toBeInTheDocument();
|
||||
expect(within(sec).getByRole("button", { name: "Add document" })).toBeInTheDocument();
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Show 1 taken out" }));
|
||||
expect(within(sec).getByText(/Taken out of the data book by Quinn QC .*: wrong packet/)).toBeInTheDocument();
|
||||
const folder = screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" });
|
||||
expect(within(folder).getByText("from the project")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Take out of data book" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Take out of the data book" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong packet" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Take out" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/packets/1/docs/4")).toEqual([{ reason: "wrong packet" }]));
|
||||
});
|
||||
|
||||
it("adds a document: uploads, then posts the kind, title and note", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(qcPacket()),
|
||||
"POST /api/assets": { status: 201, body: { id: "a9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "rt.pdf", url: "/_blob/a9" } },
|
||||
"POST /api/packets/1/docs": { body: qcPacket({ version: 4, docs }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add document" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Add a document" });
|
||||
expect(within(within(sheet).getByLabelText("Type")).getAllByRole("option")).toHaveLength(9);
|
||||
fireEvent.change(within(sheet).getByLabelText("Title"), { target: { value: "RT report W1-W4" } });
|
||||
fireEvent.change(within(sheet).getByLabelText("Note (optional)"), { target: { value: "RPT-2210, accept" } });
|
||||
const file = new File(["%PDF-1.4\n"], "rt.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(within(sheet).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Add to packet" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add a document" })).not.toBeInTheDocument());
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/docs")).toEqual([{ asset_id: "a9", kind: "nde", title: "RT report W1-W4", note: "RPT-2210, accept" }]);
|
||||
});
|
||||
|
||||
it("shows no add button without doc_add and an empty folder text", async () => {
|
||||
mockFetch(base(qcPacket({ can: [], blocked: {} }), ["warehouse"], ["warehouse"]));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
||||
expect(screen.queryByRole("button", { name: "Add document" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+273
-123
@@ -2,8 +2,11 @@ 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, PacketRead, WorkOrder } from "../api/types";
|
||||
import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, 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";
|
||||
@@ -331,161 +334,308 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
const DOC_KINDS: ReadonlyArray<[string, string]> = [
|
||||
["mtr", "Material test report"],
|
||||
["nde", "NDE report"],
|
||||
["procedure", "Procedure (WPS / PQR)"],
|
||||
["photo", "Photo"],
|
||||
["other", "Other"],
|
||||
];
|
||||
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
|
||||
|
||||
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
|
||||
const openBlob = (url: string) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
|
||||
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
||||
const canAdd = p.can.includes("doc_add");
|
||||
const canRemove = p.can.includes("doc_remove");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [kind, setKind] = useState("mtr");
|
||||
/** 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 [uploading, setUploading] = useState(false);
|
||||
const [confirm, setConfirm] = useState<number | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const add = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!file) {
|
||||
setError("Choose a file.");
|
||||
setError("Choose the file to add.");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setWorking(true);
|
||||
try {
|
||||
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
|
||||
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
|
||||
if (ok) {
|
||||
setTitle("");
|
||||
setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
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 {
|
||||
setUploading(false);
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{p.docs.length ? (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Kind</th>
|
||||
<th>Added</th>
|
||||
<th>By</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.docs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
|
||||
{d.title || "Untitled"}
|
||||
</a>
|
||||
</td>
|
||||
<td className="small">{kindLabel(d.kind)}</td>
|
||||
<td className="small mono">{fmtTime(d.added_at)}</td>
|
||||
<td className="small">{d.added_by?.display_name ?? "-"}</td>
|
||||
<td className="nowrap">
|
||||
{canRemove ? (
|
||||
confirm === d.id ? (
|
||||
<>
|
||||
<button
|
||||
className="btn sm rejb"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
|
||||
>
|
||||
Confirm remove
|
||||
</button>{" "}
|
||||
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
|
||||
Remove
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
) : (
|
||||
<div className="empty slim">No documents on this packet.</div>
|
||||
)}
|
||||
{canAdd ? (
|
||||
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
|
||||
<h3 id="docadd-h">Add a document</h3>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
File (PDF, PNG or JPEG)
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="in"
|
||||
type="file"
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Kind
|
||||
<select 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 className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean; canRemove: boolean; busy: boolean; onOut: () => void }) {
|
||||
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>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<button className="btn pri" type="submit" disabled={busy || uploading}>
|
||||
{uploading ? "Uploading" : "Add document"}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="a">
|
||||
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
|
||||
View
|
||||
</button>
|
||||
{!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)} />)
|
||||
) : (
|
||||
<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>
|
||||
</form>
|
||||
) : null}
|
||||
) : 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>
|
||||
</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. */
|
||||
/** 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}`);
|
||||
if (load.state === "loading") return <div className="empty slim">Loading</div>;
|
||||
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
|
||||
const rows = load.data;
|
||||
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { ClericalHome } from "./clerical/ClericalHome";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
import { QcHome } from "./qc/QcHome";
|
||||
import { StallHome } from "./stall/StallHome";
|
||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||
import { WarehouseHome } from "./warehouse/WarehouseHome";
|
||||
@@ -29,5 +30,6 @@ export function ScreenRoute() {
|
||||
if (screen === "warehouse") return <WarehouseHome />;
|
||||
if (screen === "supervisor") return <SupervisorHome />;
|
||||
if (screen === "stall") return <StallHome />;
|
||||
if (screen === "qc") return <QcHome />;
|
||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Itp } from "../../api/qc";
|
||||
import { DEPARTMENTS, renderApp } from "../../test/auth";
|
||||
import { callsTo, mockFetch, type Route } from "../../test/fetchMock";
|
||||
import { makeItp, makeUnitFolder, QUINN } from "./fixtures";
|
||||
|
||||
function base(itp: Itp = makeItp(), extra: Record<string, Route> = {}) {
|
||||
return {
|
||||
"/api/auth/me": { body: QUINN },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/units/12/itp": { body: itp },
|
||||
"/api/qc/folders/units/12": { body: makeUnitFolder() },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const approved = (itp: Itp, key: string): Itp => ({
|
||||
...itp,
|
||||
version: itp.version + 1,
|
||||
lines: { ...itp.lines, [key]: { ...itp.lines[key]!, result: "approve", by: { id: "u-quinn", display_name: "Quinn QC" }, initials: "QQ", at: "2026-10-17T10:00:00Z", row_version: 2 } },
|
||||
});
|
||||
|
||||
describe("ItpPage", () => {
|
||||
it("renders the head, the header fields, the pages, the sections and the rows", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc/units/12/itp");
|
||||
expect(await screen.findByRole("heading", { name: "L999002-ITP" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Q.C. · Inspection and Test Plan · Open")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 of 5 lines signed or N/A · 1 open hold / witness point · template ITP_Generation_Revised_2")).toBeInTheDocument();
|
||||
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
|
||||
expect(await within(crumbs).findByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
|
||||
expect(within(crumbs).getByRole("link", { name: "L999002" })).toHaveAttribute("href", "/qc/units/12");
|
||||
|
||||
expect(screen.getByLabelText("Inspection plan no.")).toHaveValue("L999002-ITP");
|
||||
expect(screen.getByLabelText("Client")).toHaveValue("Test Co");
|
||||
expect(screen.getByLabelText("Unit no.")).toHaveValue("L999002");
|
||||
expect(screen.getByText("1 of 3 · WP")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 of 3 · FA")).toBeInTheDocument();
|
||||
|
||||
const sec1 = screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" });
|
||||
expect(within(sec1).getAllByRole("group")).toHaveLength(2);
|
||||
const l12 = within(sec1).getByRole("group", { name: "Line 1.2" });
|
||||
expect(l12).toHaveClass("itprow", "hold");
|
||||
expect(within(l12).getByRole("button", { name: "Approve" })).toBeEnabled();
|
||||
expect(within(l12).getByRole("button", { name: "Reject" })).toBeInTheDocument();
|
||||
expect(within(sec1).getByRole("button", { name: "Section N/A" })).toBeInTheDocument();
|
||||
|
||||
const l31 = screen.getByRole("group", { name: "Line 3.1" });
|
||||
expect(l31).toHaveClass("na");
|
||||
expect(l31).toHaveTextContent("N/A");
|
||||
expect(within(screen.getByRole("region", { name: "3. CODE" })).getByRole("button", { name: "Section applies" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Sign off ITP" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves a line with a POST sign and the row version, and shows the initials", async () => {
|
||||
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/lines/1.1/sign": { body: approved(makeItp(), "1.1") } }));
|
||||
renderApp("/qc/units/12/itp");
|
||||
const row = await screen.findByRole("group", { name: "Line 1.1" });
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Approve" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1));
|
||||
const call = fetch.mock.calls.find(([u]) => String(u) === "/api/itps/5/lines/1.1/sign");
|
||||
expect((call?.[1]?.headers as Record<string, string>)["If-Match"]).toBe("1");
|
||||
const after = await screen.findByRole("group", { name: "Line 1.1" });
|
||||
expect(await within(after).findByText("QQ")).toHaveClass("ini");
|
||||
expect(within(after).getByText(/^Approved /)).toBeInTheDocument();
|
||||
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
|
||||
expect(within(after).queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(within(screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" })).queryByRole("button", { name: "Section N/A" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a section N/A", async () => {
|
||||
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/sections/4/na": { body: makeItp({ version: 4 }) } }));
|
||||
renderApp("/qc/units/12/itp");
|
||||
const sec = await screen.findByRole("region", { name: "4. FINAL" });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Section N/A" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/sections/4/na")).toEqual([{ na: true }]));
|
||||
});
|
||||
|
||||
it("disables the block button until ready, and signs a ready block with the signature sheet", async () => {
|
||||
const signed = makeItp({ version: 4 });
|
||||
signed.blocks = signed.blocks.map((b) => (b.key === "3" ? { ...b, signed: true, signed_at: "2026-10-17T10:00:00Z", signed_by: { id: "u-quinn", display_name: "Quinn QC" } } : b));
|
||||
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/blocks/3/sign": { body: signed } }));
|
||||
renderApp("/qc/units/12/itp");
|
||||
const b2 = await screen.findByRole("group", { name: "ITP block 2" });
|
||||
expect(b2).toHaveTextContent("PESCO Q.C. signature · Sections 1–2");
|
||||
expect(within(b2).getByRole("button", { name: "Sign section" })).toBeDisabled();
|
||||
expect(b2).toHaveTextContent("every line must be initialed or N/A first");
|
||||
expect(b2).toHaveTextContent("Customer complete");
|
||||
expect(screen.getByRole("group", { name: "ITP block 4" })).toHaveTextContent("COP complete");
|
||||
|
||||
const b3 = screen.getByRole("group", { name: "ITP block 3" });
|
||||
fireEvent.click(within(b3).getByRole("button", { name: "Sign (all N/A)" }));
|
||||
const dlg = await screen.findByRole("dialog", { name: "Sign" });
|
||||
expect(dlg).toHaveTextContent("Sign an ITP block");
|
||||
fireEvent.change(within(dlg).getByLabelText("Password"), { target: { value: "secret-1" } });
|
||||
fireEvent.click(within(dlg).getByRole("button", { name: "Sign" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")).toHaveLength(1));
|
||||
expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")[0]).toEqual({ sign: { factor: "password", secret: "secret-1" } });
|
||||
const after = await screen.findByRole("group", { name: "ITP block 3" });
|
||||
await waitFor(() => expect(after).toHaveTextContent("Quinn QC"));
|
||||
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables Sign off ITP when the server says so and signs off with the meaning", async () => {
|
||||
mockFetch(base(makeItp({ can: { edit: true, sign_off: true, reopen: false } })));
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Sign off ITP" }));
|
||||
const dlg = await screen.findByRole("dialog", { name: "Sign" });
|
||||
expect(dlg).toHaveTextContent("Sign off the ITP");
|
||||
});
|
||||
|
||||
it("rejects a line, keeps Approve locked until the NCR, and saves the NCR", async () => {
|
||||
const rejected = makeItp({ version: 4 });
|
||||
rejected.lines["1.1"] = {
|
||||
...rejected.lines["1.1"]!,
|
||||
result: "reject",
|
||||
ncr_missing: true,
|
||||
rejects: [{ at: "2026-10-17T10:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, ncr: null, note: "undercut", kind: "reject" }],
|
||||
};
|
||||
const fetch = mockFetch(
|
||||
base(makeItp(), {
|
||||
"POST /api/itps/5/lines/1.1/reject": { body: rejected },
|
||||
"POST /api/itps/5/lines/1.1/ncr": { status: 422, body: { detail: "Enter an NCR number like NCR-2026-0021.", code: "invalid_ncr" } },
|
||||
}),
|
||||
);
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Reject" }));
|
||||
const dlg = screen.getByRole("dialog", { name: "Reject check point" });
|
||||
expect(within(dlg).getByLabelText("NCR #")).toHaveAttribute("placeholder", "NCR number (can be added later)");
|
||||
expect(dlg).toHaveTextContent("The line can't be approved until its NCR # is entered.");
|
||||
fireEvent.change(within(dlg).getByLabelText("Reason / what failed"), { target: { value: "undercut" } });
|
||||
fireEvent.click(within(dlg).getByRole("button", { name: "Reject" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/reject")).toEqual([{ note: "undercut" }]));
|
||||
|
||||
const row = await screen.findByRole("group", { name: "Line 1.1" });
|
||||
await waitFor(() => expect(row).toHaveClass("rej"));
|
||||
expect(row).toHaveTextContent(/Rejected .* · NCR # needed/);
|
||||
const approve = within(row).getByRole("button", { name: "Approve" });
|
||||
expect(approve).toBeDisabled();
|
||||
expect(approve).toHaveAttribute("title", "Enter the NCR # first");
|
||||
expect(within(row).queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Enter NCR #" }));
|
||||
const ncr = screen.getByRole("dialog", { name: /^NCR # for 1\.1/ });
|
||||
fireEvent.change(within(ncr).getByLabelText("NCR #"), { target: { value: "bad" } });
|
||||
fireEvent.click(within(ncr).getByRole("button", { name: "Save NCR #" }));
|
||||
expect(await within(ncr).findByRole("alert")).toHaveTextContent("Enter an NCR number like NCR-2026-0021.");
|
||||
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/ncr")).toEqual([{ ncr: "bad" }]);
|
||||
});
|
||||
|
||||
it("saves the line details", async () => {
|
||||
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5/lines/1.2": { body: makeItp({ version: 4 }) } }));
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Line 1.2 details" }));
|
||||
const dlg = screen.getByRole("dialog", { name: "ITP line" });
|
||||
expect(within(dlg).getByLabelText("Customer code")).toHaveDisplayValue("as template (W)");
|
||||
expect(within(dlg).getByLabelText(/^Not applicable to this unit/)).not.toBeChecked();
|
||||
fireEvent.change(within(dlg).getByLabelText("Customer witness / reviewer"), { target: { value: "Rick Inspector" } });
|
||||
fireEvent.change(within(dlg).getByLabelText("Date"), { target: { value: "2026-10-17" } });
|
||||
fireEvent.change(within(dlg).getByLabelText("Note"), { target: { value: "witnessed" } });
|
||||
fireEvent.click(within(dlg).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(callsTo(fetch, "PATCH", "/api/itps/5/lines/1.2")).toEqual([{ cust_by: "Rick Inspector", cust_at: "2026-10-17", note: "witnessed" }]),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "ITP line" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("saves a header field on blur with set and base", async () => {
|
||||
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5": { body: makeItp({ version: 4, hdr: { ...makeItp().hdr, client: "ACME" } }) } }));
|
||||
renderApp("/qc/units/12/itp");
|
||||
const client = await screen.findByLabelText("Client");
|
||||
fireEvent.focus(client);
|
||||
fireEvent.change(client, { target: { value: "ACME" } });
|
||||
fireEvent.blur(client);
|
||||
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/itps/5")).toEqual([{ set: { client: "ACME" }, base: { client: "Test Co" } }]));
|
||||
});
|
||||
|
||||
it("reloads on a 409 and shows the server's text", async () => {
|
||||
let gets = 0;
|
||||
const fetch = mockFetch(
|
||||
base(makeItp(), {
|
||||
"GET /api/units/12/itp": () => {
|
||||
gets += 1;
|
||||
return { body: gets === 1 ? makeItp() : approved(makeItp(), "1.1") };
|
||||
},
|
||||
"POST /api/itps/5/lines/1.1/sign": { status: 409, body: { detail: "This line is already initialed.", code: "already_signed" } },
|
||||
}),
|
||||
);
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Approve" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("This line is already initialed.");
|
||||
await waitFor(() => expect(gets).toBe(2));
|
||||
expect(await within(screen.getByRole("group", { name: "Line 1.1" })).findByText("QQ")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is read-only on a signed-off ITP and shows the callout", async () => {
|
||||
mockFetch(
|
||||
base(
|
||||
makeItp({
|
||||
status: "complete",
|
||||
can: { edit: false, sign_off: false, reopen: true },
|
||||
signoff: { at: "2026-10-17T12:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, asset_id: null },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderApp("/qc/units/12/itp");
|
||||
expect(await screen.findByText("Q.C. · Inspection and Test Plan · Signed off")).toBeInTheDocument();
|
||||
expect(screen.getByRole("note")).toHaveTextContent(
|
||||
/^Signed off .* by Quinn QC\. The ITP is locked; a signed PDF copy will be filed when the ITP PDF is built\.$/,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /details$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client")).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reopen" }));
|
||||
expect(screen.getByRole("dialog", { name: "Reopen ITP" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the log behind the toggle", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc/units/12/itp");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show log" }));
|
||||
expect(screen.getByText(/· Quinn QC · ITP started · L999002-ITP · template ITP_Generation_Revised_2$/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,775 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { assetsApi } from "../../api/assets";
|
||||
import { addQcDoc, type QcDoc, type QcProjectFolder, type QcUnitFolder } from "../../api/qc";
|
||||
import { Sheet } from "../../components/Sheet";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { QC_TYPES } from "./folders";
|
||||
|
||||
const MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface QcDocTarget {
|
||||
scope: "unit" | "project";
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
|
||||
interface QcDocSheetProps {
|
||||
target: QcDocTarget;
|
||||
/** The document a new revision replaces. */
|
||||
replaces?: QcDoc | null;
|
||||
onDone: (folder: QcProjectFolder | QcUnitFolder, text: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Add a Q.C. folder document, or file a new revision of one (the old openQcDocSheet). */
|
||||
export function QcDocSheet({ target, replaces = null, onDone, onCancel }: QcDocSheetProps) {
|
||||
const guard = useGuard();
|
||||
const r = replaces;
|
||||
const [type, setType] = useState<string>(r?.type ?? "ITP");
|
||||
const [title, setTitle] = useState(r?.title ?? "");
|
||||
const [rev, setRev] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
if (!file) {
|
||||
setMsg("Choose the file to add.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
setMsg("The file is over 25 MB.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const up = await assetsApi.upload(file, { purpose: "document" });
|
||||
const scope = target.scope === "unit" ? { unit_id: target.id } : { project_id: target.id };
|
||||
const body = {
|
||||
...scope,
|
||||
type,
|
||||
asset_id: up.id,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(rev.trim() ? { rev: rev.trim() } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...(r ? { replaces: r.id } : {}),
|
||||
};
|
||||
const folder = await guard.run((g) => addQcDoc({ ...body, ...g }), { sub: `${target.scope} ${target.number}` });
|
||||
if (folder === undefined) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
onDone(folder, r ? "New revision filed." : `Added to ${target.number}.`);
|
||||
} catch (err) {
|
||||
setBusy(false);
|
||||
setMsg(errorText(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet title={r ? "New revision" : "Add document"} sub={`${target.scope} ${target.number}`} onClose={onCancel} wide>
|
||||
{r ? (
|
||||
<div className="small muted">
|
||||
Replaces <b>{r.title || r.file_name || "Document"}</b>
|
||||
{r.rev ? ` Rev ${r.rev}` : ""}. The old one stays on file under Superseded.
|
||||
</div>
|
||||
) : null}
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Type
|
||||
<select className="in" aria-label="Type" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{QC_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Title
|
||||
<input className="in" aria-label="Title" maxLength={140} placeholder="e.g. Inspection & Test Plan" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Rev
|
||||
<input className="in" aria-label="Rev" maxLength={12} placeholder={r?.rev ? `was ${r.rev}` : "0"} value={rev} onChange={(e) => setRev(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="f">
|
||||
File (PDF, PNG or JPG, up to 25 MB)
|
||||
<input className="in" type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Note
|
||||
<textarea className="in" aria-label="Note" rows={2} maxLength={600} placeholder="Optional" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
{msg ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{msg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
{busy ? "Uploading" : r ? "File new revision" : "Add to folder"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
{guard.sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||
import { makeDoc, makeProjectFolder, makeUnitFolder, QUINN } from "./fixtures";
|
||||
|
||||
function base(me = QUINN) {
|
||||
return {
|
||||
"/api/auth/me": { body: me },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/qc/folders/projects/7": { body: makeProjectFolder() },
|
||||
"/api/qc/folders/units/12": { body: makeUnitFolder() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("QcProjectFolder", () => {
|
||||
it("shows the head, the documents, the unit tiles and the superseded rows", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc/projects/7");
|
||||
expect(await screen.findByRole("heading", { name: "L999 — CI project" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Q.C. folder · Project · Open")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Co · PO 5541")).toBeInTheDocument();
|
||||
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
|
||||
expect(within(crumbs).getByRole("link", { name: "All project folders" })).toHaveAttribute("href", "/qc");
|
||||
|
||||
const docs = screen.getByRole("list", { name: "Project documents" });
|
||||
const row = within(docs).getByRole("listitem");
|
||||
expect(row).toHaveTextContent("WPS / PQR");
|
||||
expect(row).toHaveTextContent("WPS-101 GTAW/SMAW");
|
||||
expect(row).toHaveTextContent("Rev 2");
|
||||
expect(row).toHaveTextContent(/wps\.pdf · added .* by Quinn QC/);
|
||||
expect(within(row).getByRole("button", { name: "View" })).toBeInTheDocument();
|
||||
expect(within(row).getByRole("button", { name: "New revision" })).toBeInTheDocument();
|
||||
|
||||
const units = screen.getByRole("region", { name: "Unit folders" });
|
||||
expect(within(units).getByRole("link", { name: /L999001/ })).toHaveTextContent("ITP 12/68");
|
||||
expect(within(units).getByText("ITP 12/68")).toHaveClass("due", "soon");
|
||||
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveTextContent("no ITP");
|
||||
expect(within(units).getByRole("link", { name: /L999003/ })).toHaveTextContent("ITP ✓");
|
||||
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveAttribute("href", "/qc/units/12");
|
||||
|
||||
const old = screen.getByRole("region", { name: "Superseded" });
|
||||
expect(within(old).queryByText("WPS-101 old")).not.toBeInTheDocument();
|
||||
fireEvent.click(within(old).getByRole("button", { name: "Show" }));
|
||||
expect(within(old).getByText("WPS-101 old")).toBeInTheDocument();
|
||||
expect(within(old).getByRole("listitem")).toHaveTextContent(/superseded .* by Quinn QC/);
|
||||
expect(within(old).getByRole("button", { name: "Hide" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("files a new revision: upload with purpose document, then the post with replaces", async () => {
|
||||
const after = makeProjectFolder({ docs: [makeDoc({ id: 2, rev: "3" })] });
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"POST /api/assets": { body: { id: "a2", mime: "application/pdf", size: 10, sha256: "x", pages: 1, original_name: "wps3.pdf", url: "/_blob/a2" } },
|
||||
"POST /api/qc/docs": { body: after },
|
||||
});
|
||||
renderApp("/qc/projects/7");
|
||||
const row = within(await screen.findByRole("list", { name: "Project documents" })).getByRole("listitem");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "New revision" }));
|
||||
const dlg = screen.getByRole("dialog", { name: "New revision" });
|
||||
expect(dlg).toHaveTextContent("project L999");
|
||||
expect(dlg).toHaveTextContent("Replaces WPS-101 GTAW/SMAW Rev 2. The old one stays on file under Superseded.");
|
||||
expect(within(dlg).getByLabelText("Type")).toHaveValue("WPS / PQR");
|
||||
expect(within(dlg).getByLabelText("Rev")).toHaveAttribute("placeholder", "was 2");
|
||||
fireEvent.change(within(dlg).getByLabelText("Rev"), { target: { value: "3" } });
|
||||
const file = new File(["%PDF-1.4"], "wps3.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
|
||||
fireEvent.click(within(dlg).getByRole("button", { name: "File new revision" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/qc/docs")).toHaveLength(1));
|
||||
const upload = fetch.mock.calls.find(([u, init]) => String(u) === "/api/assets" && init?.method === "POST");
|
||||
expect((upload?.[1]?.body as FormData).get("purpose")).toBe("document");
|
||||
expect(callsTo(fetch, "POST", "/api/qc/docs")[0]).toEqual({
|
||||
project_id: 7,
|
||||
type: "WPS / PQR",
|
||||
asset_id: "a2",
|
||||
title: "WPS-101 GTAW/SMAW",
|
||||
rev: "3",
|
||||
replaces: 1,
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "New revision" })).not.toBeInTheDocument());
|
||||
expect(screen.getByText("New revision filed.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rev 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's message for a wrong file type", async () => {
|
||||
mockFetch({
|
||||
...base(),
|
||||
"POST /api/assets": { body: { id: "a3", mime: "text/plain", size: 10, sha256: "x", pages: null, original_name: "a.txt", url: "/_blob/a3" } },
|
||||
"POST /api/qc/docs": { status: 422, body: { detail: "Documents must be a PDF, PNG or JPEG file.", code: "invalid_asset" } },
|
||||
});
|
||||
renderApp("/qc/projects/7");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "+ Add document" }));
|
||||
const dlg = screen.getByRole("dialog", { name: "Add document" });
|
||||
expect(within(dlg).getByLabelText("Type")).toHaveValue("ITP");
|
||||
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [new File(["x"], "a.txt", { type: "text/plain" })] } });
|
||||
fireEvent.click(within(dlg).getByRole("button", { name: "Add to folder" }));
|
||||
expect(await within(dlg).findByRole("alert")).toHaveTextContent("Documents must be a PDF, PNG or JPEG file.");
|
||||
});
|
||||
|
||||
it("hides Add document from a user who cannot add", async () => {
|
||||
mockFetch(base(makeMe({ roles: ["pm"], screens: ["qc"] })));
|
||||
renderApp("/qc/projects/7");
|
||||
await screen.findByRole("heading", { name: "L999 — CI project" });
|
||||
expect(screen.queryByRole("button", { name: "+ Add document" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QcUnitFolder", () => {
|
||||
it("shows the no ITP warning, the start card and the project documents", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc/units/12");
|
||||
expect(await screen.findByRole("heading", { name: "L999002" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Q.C. folder · Unit · Open")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unit of L999 · CI project")).toBeInTheDocument();
|
||||
expect(screen.getByText("No ITP on file for this unit yet.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No digital ITP yet.")).toBeInTheDocument();
|
||||
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
|
||||
expect(within(crumbs).getByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
|
||||
const inh = screen.getByRole("list", { name: "From project L999" });
|
||||
expect(within(inh).getByRole("listitem")).toHaveTextContent("WPS-101 GTAW/SMAW");
|
||||
expect(within(inh).queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No documents yet. Use "Add document" to file the ITP or other Q.C. paperwork for this unit.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts the ITP and opens it", async () => {
|
||||
const fetch = mockFetch({ ...base(), "POST /api/units/12/itp": { body: { id: 5 } }, "/api/units/12/itp": { body: { itp: null, unit: { id: 12, number: "L999002" } } } });
|
||||
renderApp("/qc/units/12");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Start ITP from template" }));
|
||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/units/12/itp")).toHaveLength(1));
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/units/12/itp")).toHaveLength(1));
|
||||
expect(await screen.findByText("ITP")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the open ITP card with its progress", async () => {
|
||||
mockFetch({
|
||||
...base(),
|
||||
"/api/qc/folders/units/12": { body: makeUnitFolder({ no_itp: false, itp: { id: 5, no: "L999002-ITP", status: "open", done: 12, total: 68, holds: 2 } }) },
|
||||
});
|
||||
renderApp("/qc/units/12");
|
||||
const card = await screen.findByRole("link", { name: /Open ITP/ });
|
||||
expect(card).toHaveAttribute("href", "/qc/units/12/itp");
|
||||
expect(card).toHaveTextContent("L999002-ITP");
|
||||
expect(card).toHaveTextContent("Open");
|
||||
expect(card).toHaveTextContent("12 of 68 lines signed or N/A · 2 open hold / witness points");
|
||||
expect(screen.queryByText("No ITP on file for this unit yet.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { makeHome, QUINN } from "./fixtures";
|
||||
|
||||
function base(home = makeHome(), me = QUINN) {
|
||||
return {
|
||||
"/api/auth/me": { body: me },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/qc/home": { body: home },
|
||||
"/api/projects": { body: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("QcHome", () => {
|
||||
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc");
|
||||
expect(await screen.findByRole("heading", { name: "Inspect the spool and the data book" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Q.C. · Inspection")).toBeInTheDocument();
|
||||
await screen.findByRole("heading", { name: /^Ready for inspection/ });
|
||||
|
||||
expect(screen.getByLabelText("Waiting for inspection: 1")).toHaveClass("hot");
|
||||
expect(screen.getByLabelText("Accepted last 7 days: 2")).not.toHaveClass("hot");
|
||||
expect(screen.getByLabelText("Rejected last 7 days: 0")).toHaveClass("zero");
|
||||
|
||||
expect(screen.getByRole("heading", { name: /^Accepted/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /^Rejected — back with supervisor/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("No open rejections.")).toBeInTheDocument();
|
||||
|
||||
const waiting = screen.getByRole("link", { name: /^L999001 · WELD-PART/ });
|
||||
expect(waiting).toHaveAttribute("href", "/packets/1");
|
||||
expect(within(waiting).getByText("Spool at Bay 2")).toBeInTheDocument();
|
||||
expect(within(waiting).getByText("ITP: 2 open hold / witness points")).toBeInTheDocument();
|
||||
|
||||
const folders = screen.getByRole("region", { name: "Project folders" });
|
||||
const card = within(folders).getByRole("link", { name: /L999/ });
|
||||
expect(card).toHaveAttribute("href", "/qc/projects/7");
|
||||
expect(card).toHaveClass("pcard");
|
||||
expect(card).toHaveTextContent("2 units · 3 documents");
|
||||
expect(card).toHaveTextContent("1 unit without an ITP");
|
||||
expect(card).toHaveTextContent("PO 5541");
|
||||
});
|
||||
|
||||
it("filters the boards on the client", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/qc");
|
||||
await screen.findByRole("link", { name: /^L999002/ });
|
||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||
expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing here matches the filters.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all folders on All, and the empty texts", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(makeHome({ folders: [], columns: { ready: [], accepted: [], rejected: [] } })),
|
||||
"/api/qc/folders": { body: [] },
|
||||
});
|
||||
renderApp("/qc");
|
||||
expect(await screen.findByText("Supervisors send packets here when the data book package and the spool are ready.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing accepted yet.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No projects yet. Folders appear here as soon as Clerical sets up a project and its units.")).toBeInTheDocument();
|
||||
const chips = screen.getByRole("group", { name: "Show folders" });
|
||||
expect(within(chips).getByRole("button", { name: "Active" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/qc/folders?show=all")).toBe(true));
|
||||
expect(within(chips).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("sends a user without the screen to their own screen", async () => {
|
||||
mockFetch(base(makeHome(), makeMe({ roles: ["warehouse"], screens: ["warehouse"] })));
|
||||
renderApp("/qc/projects/7");
|
||||
await waitFor(() => expect(screen.queryByText("Q.C. folder · Project · Open")).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
|
||||
import type { ColumnDef } from "../../components/Board";
|
||||
import { Card } from "../../components/Card";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProjectPill } from "../clerical/common";
|
||||
import { matchCard, plural } from "./folders";
|
||||
|
||||
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
|
||||
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
|
||||
{ key: "accepted", title: "Accepted", empty: "Nothing accepted yet." },
|
||||
{ key: "rejected", title: "Rejected — back with supervisor", empty: "No open rejections." },
|
||||
];
|
||||
|
||||
type FolderShow = "active" | "all";
|
||||
|
||||
function FolderCard({ f }: { f: QcFolderCard }) {
|
||||
return (
|
||||
<Link className="card pcard" to={`/qc/projects/${f.id}`}>
|
||||
<div className="r1">
|
||||
<span className="job">{f.number}</span>
|
||||
<ProjectPill status={f.status} />
|
||||
</div>
|
||||
<div className="tt">{f.name || "-"}</div>
|
||||
<div className="small muted">
|
||||
{f.customer ?? ""}
|
||||
{f.po ? (
|
||||
<>
|
||||
{f.customer ? " · " : ""}PO <span className="mono">{f.po}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{plural(f.units, "unit")} · {plural(f.docs, "document")}
|
||||
</div>
|
||||
{f.units_without_itp ? <div className="small due soon">{plural(f.units_without_itp, "unit")} without an ITP</div> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** The project folders: Active from the home answer, All on its own call. */
|
||||
function FoldersSection({ active }: { active: QcFolderCard[] }) {
|
||||
const id = useId();
|
||||
const [show, setShow] = useState<FolderShow>("active");
|
||||
const [allLoad, reloadAll] = useLoad((s) => (show === "all" ? getFolders("all", s) : Promise.resolve(null)), show);
|
||||
useChanged(() => {
|
||||
if (show === "all") reloadAll();
|
||||
});
|
||||
const list = show === "all" ? (allLoad.state === "ok" ? (allLoad.data ?? []) : null) : active;
|
||||
return (
|
||||
<section className="sec qcfolders" aria-labelledby={id}>
|
||||
<div className="sec-h">
|
||||
<h2 id={id}>Project folders</h2>
|
||||
<span className="ct">Q.C. documents for each project and unit: ITP, WPS/PQR, procedures</span>
|
||||
<span className="grow" />
|
||||
<div className="chips" role="group" aria-label="Show folders">
|
||||
{(
|
||||
[
|
||||
["active", "Active"],
|
||||
["all", "All"],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{show === "all" && allLoad.state === "error" ? <div className="callout warn">{allLoad.message}</div> : null}
|
||||
{list === null ? (
|
||||
allLoad.state === "loading" ? <div className="empty slim">Loading</div> : null
|
||||
) : list.length ? (
|
||||
<div className="projgrid">
|
||||
{list.map((f) => (
|
||||
<FolderCard key={f.id} f={f} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty">No projects yet. Folders appear here as soon as Clerical sets up a project and its units.</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function cardExtra(col: QcColumn, c: QcCard) {
|
||||
const loc = col === "ready" ? c.qc?.location : null;
|
||||
const holds = c.itp_hold_count ?? 0;
|
||||
if (!loc && !holds) return undefined;
|
||||
return (
|
||||
<>
|
||||
{loc ? <div className="small near">Spool at {loc}</div> : null}
|
||||
{holds ? <div className="small due soon">ITP: {holds} open hold / witness point{holds === 1 ? "" : "s"}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
|
||||
export function QcHome() {
|
||||
const [q, setQ] = useState("");
|
||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||
const projectId = pick.project?.id;
|
||||
const unitId = pick.unit?.id;
|
||||
const [load, reload] = useLoad(
|
||||
(s) => getQcHome(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}, s),
|
||||
`${projectId ?? ""}|${unitId ?? ""}`,
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const data = load.state === "ok" ? load.data : null;
|
||||
const k = data?.kpis;
|
||||
|
||||
const columns = COLUMNS.map((c) => {
|
||||
const all = data?.columns[c.key] ?? [];
|
||||
const shown = all.filter((x) => matchCard(x, q));
|
||||
return { ...c, cards: shown, empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty };
|
||||
});
|
||||
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="phead">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="eyebrow">Q.C. · Inspection</div>
|
||||
<h1>Inspect the spool and the data book</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true },
|
||||
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
|
||||
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
|
||||
{data ? (
|
||||
<div className="board">
|
||||
{columns.map((c) => (
|
||||
<QcColumnView key={c.key} title={c.title} col={c.key} cards={c.cards} empty={c.empty} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data ? <FoldersSection active={data.folders} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared Column, with the Q.C. lines under each card. */
|
||||
function QcColumnView({ title, col, cards, empty }: { title: string; col: QcColumn; cards: QcCard[]; empty: ColumnDef["empty"] }) {
|
||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
return (
|
||||
<section className="col" aria-labelledby={id}>
|
||||
<h3 className="col-h" id={id}>
|
||||
{title}
|
||||
<span className="c">{cards.length}</span>
|
||||
</h3>
|
||||
{cards.length ? cards.map((p) => <Card key={p.id} p={p} extra={cardExtra(col, p)} />) : <div className="empty">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { getProjectFolder, type QcDoc, type QcProjectFolder as Folder, type QcUnitFolder } from "../../api/qc";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProjectPill } from "../clerical/common";
|
||||
import { canQcDocs, itpTile, plural, useToast } from "./folders";
|
||||
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
|
||||
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
|
||||
|
||||
/** /qc/projects/:projectId (the old qcFolderView for a project). */
|
||||
export function QcProjectFolder() {
|
||||
const { projectId } = useParams();
|
||||
const id = Number(projectId);
|
||||
const { me } = useAuth();
|
||||
const roles = me?.roles ?? [];
|
||||
const [load, reload] = useLoad((s) => getProjectFolder(id, s), String(id));
|
||||
const [data, setData] = useState<Folder | null>(null);
|
||||
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
|
||||
const [toast, setToast] = useToast();
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
useEffect(() => {
|
||||
if (load.state === "ok") setData(load.data);
|
||||
}, [load]);
|
||||
|
||||
const back = (
|
||||
<Link className="back" to="/qc">
|
||||
← Back
|
||||
</Link>
|
||||
);
|
||||
if (!data) {
|
||||
if (load.state === "error")
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="callout warn">{load.message}</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="empty slim">Loading</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const p = data.project;
|
||||
const canAdd = canQcDocs(roles);
|
||||
const projectHasItp = data.docs.some((d) => d.type === "ITP");
|
||||
const target: QcDocTarget = { scope: "project", id: p.id, number: p.number };
|
||||
const units = [...data.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true }));
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<nav className="crumbs" aria-label="Where you are">
|
||||
<Link className="back" to="/qc">
|
||||
All project folders
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<b>{p.number}</b>
|
||||
</nav>
|
||||
<div className="dhead">
|
||||
<div className="grow0" style={{ minWidth: 0 }}>
|
||||
<div className="eyebrow">Q.C. folder · Project · {PROJECT_STATUS_LABEL[p.status] ?? p.status}</div>
|
||||
<h1>
|
||||
{p.number}
|
||||
{p.name ? ` — ${p.name}` : ""}
|
||||
</h1>
|
||||
<div className="small muted">{[p.customer, p.po ? `PO ${p.po}` : ""].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
{canAdd ? (
|
||||
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
|
||||
+ Add document
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
|
||||
<FolderSection title="Project documents" ct="cover every unit of this project">
|
||||
{data.docs.length ? (
|
||||
<DocList docs={data.docs} label="Project documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
|
||||
) : (
|
||||
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this project.' : ""}</div>
|
||||
)}
|
||||
</FolderSection>
|
||||
|
||||
<FolderSection title="Unit folders" ct={String(units.length)}>
|
||||
{units.length ? (
|
||||
<div className="qcunits">
|
||||
{units.map((u) => {
|
||||
const tile = itpTile(u, projectHasItp);
|
||||
return (
|
||||
<Link key={u.id} className="card qcunit" to={`/qc/units/${u.id}`}>
|
||||
<div className="r1">
|
||||
<span className="job">{u.number}</span>
|
||||
<ProjectPill status={u.status} />
|
||||
</div>
|
||||
<div className="qm">
|
||||
<span>{plural(u.docs, "doc")}</span>
|
||||
{tile ? <span className={tile.cls || undefined}>{tile.text}</span> : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty">No units yet. They appear here when Clerical adds them.</div>
|
||||
)}
|
||||
</FolderSection>
|
||||
|
||||
<SupersededSection docs={data.superseded} />
|
||||
|
||||
{sheet ? (
|
||||
<QcDocSheet
|
||||
target={target}
|
||||
replaces={sheet.replaces}
|
||||
onCancel={() => setSheet(null)}
|
||||
onDone={(folder: Folder | QcUnitFolder, text) => {
|
||||
setSheet(null);
|
||||
if ("units" in folder) setData(folder);
|
||||
else reload();
|
||||
setToast(text);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Toast text={toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { getUnitFolder, startItp, type ItpSummary, type QcDoc, type QcProjectFolder, type QcUnitFolder as Folder } from "../../api/qc";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { canItp, canQcDocs, useToast } from "./folders";
|
||||
import { progressText } from "./itpView";
|
||||
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
|
||||
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
|
||||
|
||||
/** The old itpFolderCard: start the ITP, or open it with its progress. */
|
||||
function ItpCard({ unitId, itp, canStart, onStart, busy }: { unitId: number; itp: ItpSummary | null; canStart: boolean; onStart: () => void; busy: boolean }) {
|
||||
if (!itp)
|
||||
return (
|
||||
<div className="itpcard">
|
||||
<div className="grow">
|
||||
<b>No digital ITP yet.</b>
|
||||
<div className="small muted">
|
||||
Start it from the Q.C. template. The header fills in from this project and unit. Mark lines N/A that don't apply to this unit.
|
||||
</div>
|
||||
</div>
|
||||
{canStart ? (
|
||||
<button className="btn pri" type="button" disabled={busy} onClick={onStart}>
|
||||
Start ITP from template
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const pct = Math.round((itp.done / Math.max(1, itp.total)) * 100);
|
||||
const done = itp.status === "complete";
|
||||
return (
|
||||
<Link className="itpcard card" to={`/qc/units/${unitId}/itp`}>
|
||||
<div className="grow">
|
||||
<div className="r1">
|
||||
<span className="job">{itp.no}</span>
|
||||
{done ? <span className="pill st-in_progress">Signed off</span> : <span className="pill st-review">Open</span>}
|
||||
</div>
|
||||
<div className="bar" role="progressbar" aria-label="ITP progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className={`small ${!done && itp.holds ? "due soon" : "muted"}`}>{progressText(itp)}</div>
|
||||
</div>
|
||||
<span className="btn sm">Open ITP</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** /qc/units/:unitId: the unit's Q.C. folder, its ITP and the project's documents. */
|
||||
export function QcUnitFolder() {
|
||||
const { unitId } = useParams();
|
||||
const id = Number(unitId);
|
||||
const { me } = useAuth();
|
||||
const roles = me?.roles ?? [];
|
||||
const navigate = useNavigate();
|
||||
const guard = useGuard();
|
||||
const [load, reload] = useLoad((s) => getUnitFolder(id, s), String(id));
|
||||
const [data, setData] = useState<Folder | null>(null);
|
||||
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useToast();
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
useEffect(() => {
|
||||
if (load.state === "ok") setData(load.data);
|
||||
}, [load]);
|
||||
|
||||
const back = (
|
||||
<Link className="back" to={data ? `/qc/projects/${data.project.id}` : "/qc"}>
|
||||
← Back
|
||||
</Link>
|
||||
);
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : <div className="empty slim">Loading</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const u = data.unit;
|
||||
const t = data.project;
|
||||
const canAdd = canQcDocs(roles);
|
||||
const target: QcDocTarget = { scope: "unit", id: u.id, number: u.number };
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await guard.run((g) => startItp(u.id, g), { sub: u.number });
|
||||
if (r) navigate(`/qc/units/${u.id}/itp`);
|
||||
else setBusy(false);
|
||||
} catch (err) {
|
||||
setBusy(false);
|
||||
setError(errorText(err));
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<nav className="crumbs" aria-label="Where you are">
|
||||
<Link className="back" to="/qc">
|
||||
All project folders
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link className="back" to={`/qc/projects/${t.id}`}>
|
||||
{t.number}
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<b>{u.number}</b>
|
||||
</nav>
|
||||
<div className="dhead">
|
||||
<div className="grow0" style={{ minWidth: 0 }}>
|
||||
<div className="eyebrow">Q.C. folder · Unit · {PROJECT_STATUS_LABEL[u.status] ?? u.status}</div>
|
||||
<h1>{u.number}</h1>
|
||||
<div className="small muted">
|
||||
Unit of {t.number}
|
||||
{t.name ? ` · ${t.name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
{canAdd ? (
|
||||
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
|
||||
+ Add document
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{data.no_itp ? <div className="callout warn mb10">No ITP on file for this unit yet.</div> : null}
|
||||
{error ? (
|
||||
<div className="callout warn" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FolderSection title="Inspection and Test Plan">
|
||||
<ItpCard unitId={u.id} itp={data.itp} canStart={canItp(roles)} onStart={() => void start()} busy={busy} />
|
||||
</FolderSection>
|
||||
|
||||
<FolderSection title="Unit documents" ct="for this unit only">
|
||||
{data.docs.length ? (
|
||||
<DocList docs={data.docs} label="Unit documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
|
||||
) : (
|
||||
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this unit.' : ""}</div>
|
||||
)}
|
||||
</FolderSection>
|
||||
|
||||
<FolderSection title={`From project ${t.number}`} ct="apply to every unit">
|
||||
{data.inherited.length ? <DocList docs={data.inherited} label={`From project ${t.number}`} /> : <div className="empty">No project-wide documents.</div>}
|
||||
</FolderSection>
|
||||
|
||||
<SupersededSection docs={data.superseded} />
|
||||
|
||||
{sheet ? (
|
||||
<QcDocSheet
|
||||
target={target}
|
||||
replaces={sheet.replaces}
|
||||
onCancel={() => setSheet(null)}
|
||||
onDone={(folder: Folder | QcProjectFolder, text) => {
|
||||
setSheet(null);
|
||||
if ("unit" in folder) setData(folder);
|
||||
else reload();
|
||||
setToast(text);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{guard.sheet}
|
||||
<Toast text={toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Synthetic Q.C. answers for the screen tests. No real job data.
|
||||
import type { Itp, ItpBlock, ItpLine, QcDoc, QcHome, QcProjectFolder, QcUnitFolder } from "../../api/qc";
|
||||
import { makeMe } from "../../test/auth";
|
||||
import { makeCard } from "../../test/packets";
|
||||
import { emptyLine } from "./itpView";
|
||||
|
||||
export const QUINN = makeMe({
|
||||
roles: ["qc"],
|
||||
screens: ["qc"],
|
||||
user: { id: "u-quinn", sam_account: "qc.quinn", display_name: "Quinn QC", emp_no: "05001" },
|
||||
});
|
||||
|
||||
export function makeHome(over: Partial<QcHome> = {}): QcHome {
|
||||
return {
|
||||
kpis: { waiting: 1, accepted_7d: 2, rejected_7d: 0 },
|
||||
columns: {
|
||||
ready: [
|
||||
{
|
||||
...makeCard({ id: 1, status: "qc", unit: { id: 11, number: "L999001" } }),
|
||||
qc: { location: "Bay 2", sent_at: "2026-10-17T08:00:00Z", inspector: null, result: null },
|
||||
itp_hold_count: 2,
|
||||
},
|
||||
],
|
||||
accepted: [{ ...makeCard({ id: 2, status: "complete", routed_to: "databook", unit: { id: 12, number: "L999002" }, title: "Elbow" }) }],
|
||||
rejected: [],
|
||||
},
|
||||
folders: [
|
||||
{ id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open", units: 2, docs: 3, units_without_itp: 1 },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDoc(over: Partial<QcDoc> = {}): QcDoc {
|
||||
return {
|
||||
id: 1,
|
||||
project_id: 7,
|
||||
unit_id: null,
|
||||
type: "WPS / PQR",
|
||||
title: "WPS-101 GTAW/SMAW",
|
||||
rev: "2",
|
||||
note: null,
|
||||
asset_id: "a1",
|
||||
url: "/_blob/a1",
|
||||
file_name: "wps.pdf",
|
||||
size: 1000,
|
||||
auto: false,
|
||||
added_at: "2026-10-17T08:00:00Z",
|
||||
added_by: { id: "u-quinn", display_name: "Quinn QC" },
|
||||
superseded_by: null,
|
||||
superseded_at: null,
|
||||
superseded_by_user: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT = { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open" as const };
|
||||
|
||||
export function makeProjectFolder(over: Partial<QcProjectFolder> = {}): QcProjectFolder {
|
||||
return {
|
||||
project: PROJECT,
|
||||
docs: [makeDoc()],
|
||||
superseded: [makeDoc({ id: 9, title: "WPS-101 old", rev: "1", superseded_by: 1, superseded_at: "2026-10-17T09:00:00Z", superseded_by_user: { id: "u-quinn", display_name: "Quinn QC" } })],
|
||||
units: [
|
||||
{ id: 11, number: "L999001", lot: "001", name: null, status: "open", docs: 1, itp: { id: 5, no: "L999001-ITP", status: "open", done: 12, total: 68, holds: 2 }, has_itp_doc: false },
|
||||
{ id: 12, number: "L999002", lot: "002", name: null, status: "open", docs: 0, itp: null, has_itp_doc: false },
|
||||
{ id: 13, number: "L999003", lot: "003", name: null, status: "open", docs: 0, itp: { id: 6, no: "L999003-ITP", status: "complete", done: 68, total: 68, holds: 0 }, has_itp_doc: true },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeUnitFolder(over: Partial<QcUnitFolder> = {}): QcUnitFolder {
|
||||
return {
|
||||
unit: { id: 12, number: "L999002", lot: "002", name: null, status: "open", po: null },
|
||||
project: PROJECT,
|
||||
docs: [],
|
||||
inherited: [makeDoc()],
|
||||
superseded: [],
|
||||
itp: null,
|
||||
no_itp: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function line(key: string, over: Partial<ItpLine> = {}): ItpLine {
|
||||
return { ...emptyLine(key), row_version: 1, ...over };
|
||||
}
|
||||
|
||||
function block(key: string, sections: string[], over: Partial<ItpBlock> = {}): ItpBlock {
|
||||
return {
|
||||
key,
|
||||
sections,
|
||||
scope: sections.length > 1 ? `Sections ${sections[0]}–${sections[sections.length - 1]}` : `Section ${key}`,
|
||||
final: false,
|
||||
ready: false,
|
||||
all_na: false,
|
||||
signed: false,
|
||||
signed_at: null,
|
||||
signed_by: null,
|
||||
qn: null,
|
||||
remarks: null,
|
||||
cust_name: null,
|
||||
cust_at: null,
|
||||
cust_by: null,
|
||||
row_version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Four sections on three pages; blocks 2 (sections 1 to 2), 3 and 4 (final). */
|
||||
export function makeItp(over: Partial<Itp> = {}): Itp {
|
||||
return {
|
||||
id: 5,
|
||||
unit_id: 12,
|
||||
no: "L999002-ITP",
|
||||
hdr: { client: "Test Co", pname: "CI project", pno: "L999", po: "5541", unit: "L999002" },
|
||||
status: "open",
|
||||
version: 3,
|
||||
template: {
|
||||
ver: "ITP_Generation_Revised_2",
|
||||
legend: "O = Observe, R = Review, H = Hold",
|
||||
sections: [
|
||||
{
|
||||
n: "1",
|
||||
t: "GENERAL FABRICATION and DATA",
|
||||
page: "WP",
|
||||
items: [
|
||||
{ n: "1.1", t: "Material review", code: "R", cust: "R", crit: "MTRs match" },
|
||||
{ n: "1.2", t: "Fit-up", code: "H", cust: "W", crit: "Per drawing" },
|
||||
],
|
||||
},
|
||||
{ n: "2", t: "WELDING", page: "WP", sig: true, items: [{ n: "2.1", t: "Visual weld exam", code: "I", cust: "R", crit: "No defects" }] },
|
||||
{ n: "3", t: "CODE", page: "C", sig: true, items: [{ n: "3.1", t: "Hydrotest", code: "W", cust: "W", crit: "Hold 30 min" }] },
|
||||
{ n: "4", t: "FINAL", page: "FA", sig: true, final: true, items: [{ n: "4.1", t: "Final review", code: "R", cust: "R", crit: "All done" }] },
|
||||
],
|
||||
},
|
||||
pages: [
|
||||
{ code: "WP", name: "Weld Parts" },
|
||||
{ code: "C", name: "Code" },
|
||||
{ code: "FA", name: "Final Assembly" },
|
||||
],
|
||||
lines: {
|
||||
"1.1": line("1.1"),
|
||||
"1.2": line("1.2"),
|
||||
"2.1": line("2.1"),
|
||||
"3.1": line("3.1", { na: true, result: "na" }),
|
||||
"4.1": line("4.1"),
|
||||
},
|
||||
blocks: [block("2", ["1", "2"]), block("3", ["3"], { ready: true, all_na: true }), block("4", ["4"], { final: true })],
|
||||
progress: { done: 1, total: 5, holds: 1 },
|
||||
holds: [{ n: "1.2", t: "Fit-up", page: "WP", codes: ["H", "W"], rejected: false, ncr: null }],
|
||||
signoff: null,
|
||||
reopen: null,
|
||||
created_at: "2026-10-17T08:00:00Z",
|
||||
created_by: { id: "u-quinn", display_name: "Quinn QC" },
|
||||
can: { edit: true, sign_off: false, reopen: false },
|
||||
log: [{ id: 1, at: "2026-10-17T08:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, by_name: "Quinn QC", action: "ITP started", note: "L999002-ITP · template ITP_Generation_Revised_2", signed: false }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Q.C. folder helpers: the document types, who may add, dates, the card search, the unit tile text and the toast.
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ItpSummary, QcCard, QcDoc, QcFolderUnitRow } from "../../api/qc";
|
||||
import { jobName } from "../../lib/packets";
|
||||
|
||||
/** The closed set of Q.C. folder document types (the old QC_TYPES). */
|
||||
export const QC_TYPES = [
|
||||
"ITP",
|
||||
"WPS / PQR",
|
||||
"NDE procedure",
|
||||
"Hydrotest procedure",
|
||||
"PMI / material",
|
||||
"Calibration certificate",
|
||||
"Customer spec",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
/** Q.C. and Clerical add folder documents (QC_DOC_ADD, CL_OR_QC). Admin can, with an IT reason. */
|
||||
export function canQcDocs(roles: readonly string[]): boolean {
|
||||
return roles.some((r) => ["qc", "qc_supervisor", "clerical", "clerical_supervisor", "admin"].includes(r));
|
||||
}
|
||||
|
||||
/** Q.C. starts and works the ITP. Admin can, with an IT reason. */
|
||||
export function canItp(roles: readonly string[]): boolean {
|
||||
return roles.some((r) => r === "qc" || r === "qc_supervisor" || r === "admin");
|
||||
}
|
||||
|
||||
/** "Oct 17, 2026" (the old fmtQcDate), or empty. */
|
||||
export function fmtQcDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T12:00:00` : iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
/** "WPS-101.pdf · added Oct 17, 2026 by Quinn QC", plus the superseded part on an old row. */
|
||||
export function docMeta(d: QcDoc): string {
|
||||
const parts = [d.file_name ?? "", `added ${fmtQcDate(d.added_at)}${d.added_by ? ` by ${d.added_by.display_name}` : ""}`];
|
||||
if (d.superseded_by !== null && d.superseded_by !== undefined) {
|
||||
parts.push(`superseded ${fmtQcDate(d.superseded_at)}${d.superseded_by_user ? ` by ${d.superseded_by_user.display_name}` : ""}`);
|
||||
}
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The unit tile's ITP text and colour: "ITP ✓", "ITP 12/68" (amber with holds), "no ITP" (amber), or null. */
|
||||
export function itpTile(u: Pick<QcFolderUnitRow, "itp" | "has_itp_doc" | "status">, projectHasItpDoc = false): { text: string; cls: string } | null {
|
||||
const r: ItpSummary | null = u.itp;
|
||||
if (r) {
|
||||
if (r.status === "complete") return { text: "ITP ✓", cls: "ok" };
|
||||
return { text: `ITP ${r.done}/${r.total}`, cls: r.holds ? "due soon" : "" };
|
||||
}
|
||||
if (u.has_itp_doc || projectHasItpDoc) return { text: "ITP ✓", cls: "ok" };
|
||||
if (u.status !== "closed") return { text: "no ITP", cls: "due soon" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "1 unit", "2 units". */
|
||||
export function plural(n: number, word: string): string {
|
||||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Whether a board card matches the find box: unit, op, title, drawing, customer, project or location. */
|
||||
export function matchCard(c: QcCard, q: string): boolean {
|
||||
const t = q.trim().toLowerCase();
|
||||
if (!t) return true;
|
||||
const hay = [jobName(c), c.title, c.drawing, c.customer, c.project?.number, c.project?.name, c.qc?.location, c.qc?.inspector]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return t.split(/\s+/).every((w) => hay.includes(w));
|
||||
}
|
||||
|
||||
/** A toast text that clears itself after a moment. */
|
||||
export function useToast(): [string | null, (t: string | null) => void] {
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = window.setTimeout(() => setToast(null), 2600);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toast]);
|
||||
return [toast, setToast];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { itpTile } from "./folders";
|
||||
import { blockScope, emptyLine, holdLabel, initials, isDone, isHoldCode, isOpenHold, progressText } from "./itpView";
|
||||
|
||||
describe("itpView helpers", () => {
|
||||
it("initials: first letters, upper case, at most three", () => {
|
||||
expect(initials("Quinn QC")).toBe("QQ");
|
||||
expect(initials("mary ann de la cruz")).toBe("MAD");
|
||||
expect(initials("")).toBe("");
|
||||
expect(initials(null)).toBe("");
|
||||
});
|
||||
|
||||
it("blockScope", () => {
|
||||
expect(blockScope(["1", "2", "3"])).toBe("Sections 1–3");
|
||||
expect(blockScope(["4"])).toBe("Section 4");
|
||||
});
|
||||
|
||||
it("isHoldCode: H or W alone or after a slash", () => {
|
||||
expect(isHoldCode("H")).toBe(true);
|
||||
expect(isHoldCode("W")).toBe(true);
|
||||
expect(isHoldCode("R/W")).toBe(true);
|
||||
expect(isHoldCode("R/AU")).toBe(false);
|
||||
expect(isHoldCode("NA")).toBe(false);
|
||||
expect(isHoldCode("")).toBe(false);
|
||||
});
|
||||
|
||||
it("holdLabel", () => {
|
||||
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
|
||||
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H", "R"], rejected: true, ncr: "NCR-2026-0021" })).toBe(
|
||||
"4.2 Prior to vessel closing (rejected, NCR NCR-2026-0021)",
|
||||
);
|
||||
expect(holdLabel({ n: "4.2", t: "X", codes: [], rejected: true, ncr: null })).toBe("4.2 X (rejected, NCR pending)");
|
||||
});
|
||||
|
||||
it("progressText", () => {
|
||||
expect(progressText({ done: 12, total: 68, holds: 2, status: "open" })).toBe("12 of 68 lines signed or N/A · 2 open hold / witness points");
|
||||
expect(progressText({ done: 12, total: 68, holds: 1, status: "open" })).toBe("12 of 68 lines signed or N/A · 1 open hold / witness point");
|
||||
expect(progressText({ done: 68, total: 68, holds: 0, status: "open" })).toBe("68 of 68 lines signed or N/A");
|
||||
expect(progressText({ done: 60, total: 68, holds: 3, status: "complete" })).toBe("60 of 68 lines signed or N/A");
|
||||
});
|
||||
|
||||
it("done and open holds", () => {
|
||||
const item = { n: "1.2", t: "Fit-up", code: "R", cust: "W", crit: "" };
|
||||
const l = emptyLine("1.2");
|
||||
expect(isDone(l)).toBe(false);
|
||||
expect(isOpenHold(item, l)).toBe(true);
|
||||
expect(isOpenHold(item, { ...l, cust_code: "R" })).toBe(false);
|
||||
expect(isOpenHold(item, { ...l, result: "approve" })).toBe(false);
|
||||
expect(isDone({ ...l, na: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("itpTile", () => {
|
||||
const itp = { id: 1, no: "X-ITP", status: "open" as const, done: 12, total: 68, holds: 2 };
|
||||
expect(itpTile({ itp, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "due soon" });
|
||||
expect(itpTile({ itp: { ...itp, holds: 0 }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "" });
|
||||
expect(itpTile({ itp: { ...itp, status: "complete" }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
|
||||
expect(itpTile({ itp: null, has_itp_doc: true, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
|
||||
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" }, true)).toEqual({ text: "ITP ✓", cls: "ok" });
|
||||
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" })).toEqual({ text: "no ITP", cls: "due soon" });
|
||||
expect(itpTile({ itp: null, has_itp_doc: false, status: "closed" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Pure helpers for the ITP screen. The server decides every rule; these only shape the page.
|
||||
import type { Itp, ItpHoldRow, ItpItem, ItpLine, ItpSection, ItpSummary } from "../../api/qc";
|
||||
|
||||
/** Page names and order (the old ITP_PAGES and ITP_PAGE_ORDER). */
|
||||
export const ITP_PAGES: Record<string, string> = { WP: "Weld Parts", C: "Code", FA: "Final Assembly" };
|
||||
export const ITP_PAGE_ORDER = ["WP", "C", "FA"] as const;
|
||||
|
||||
/** Customer codes (the old ITP_CUST_CODES). */
|
||||
export const ITP_CUST_CODES = ["R", "R/AU", "AU", "W", "H", "NA"] as const;
|
||||
|
||||
/** A hold or witness code: H or W, alone or after a slash. */
|
||||
export function isHoldCode(code: string | null | undefined): boolean {
|
||||
return /(^|\/)(H|W)$/.test(String(code ?? ""));
|
||||
}
|
||||
|
||||
/** The first letter of each word of the name, upper case, at most three (the old initials()). */
|
||||
export function initials(name: string | null | undefined): string {
|
||||
return String(name ?? "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => (w[0] ?? "").toUpperCase())
|
||||
.join("")
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
/** "Sections 1–3" or "Section 4". */
|
||||
export function blockScope(sections: readonly string[]): string {
|
||||
if (sections.length > 1) return `Sections ${sections[0] ?? ""}–${sections[sections.length - 1] ?? ""}`;
|
||||
return `Section ${sections[0] ?? ""}`;
|
||||
}
|
||||
|
||||
/** "4.2 Prior to vessel closing (H)", "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
|
||||
export function holdLabel(h: Pick<ItpHoldRow, "n" | "t" | "codes" | "rejected" | "ncr">): string {
|
||||
const why = h.rejected ? `rejected, NCR ${h.ncr || "pending"}` : h.codes.filter(isHoldCode).join(", ");
|
||||
return `${h.n} ${h.t} (${why})`;
|
||||
}
|
||||
|
||||
/** "12 of 68 lines signed or N/A · 2 open hold / witness points". Holds only while the ITP is open. */
|
||||
export function progressText(p: Pick<ItpSummary, "done" | "total" | "holds" | "status">): string {
|
||||
const base = `${p.done} of ${p.total} lines signed or N/A`;
|
||||
if (p.status === "complete" || !p.holds) return base;
|
||||
return `${base} · ${p.holds} open hold / witness point${p.holds === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** An empty line, for keys the server has no row for yet. */
|
||||
export function emptyLine(key: string): ItpLine {
|
||||
return {
|
||||
key,
|
||||
result: null,
|
||||
na: false,
|
||||
by: null,
|
||||
initials: null,
|
||||
at: null,
|
||||
ncr: null,
|
||||
ncr_missing: false,
|
||||
note: null,
|
||||
cust_code: null,
|
||||
cust_by: null,
|
||||
cust_at: null,
|
||||
cust_rec: null,
|
||||
rejects: [],
|
||||
row_version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function lineOf(r: Pick<Itp, "lines">, key: string): ItpLine {
|
||||
return r.lines[key] ?? emptyLine(key);
|
||||
}
|
||||
|
||||
export function isNa(l: ItpLine): boolean {
|
||||
return l.na || l.result === "na";
|
||||
}
|
||||
|
||||
export function isApproved(l: ItpLine): boolean {
|
||||
return l.result === "approve";
|
||||
}
|
||||
|
||||
/** Done: N/A or approved. */
|
||||
export function isDone(l: ItpLine): boolean {
|
||||
return isNa(l) || isApproved(l);
|
||||
}
|
||||
|
||||
/** An open reject: rejected and not approved since. */
|
||||
export function isRejected(l: ItpLine): boolean {
|
||||
return l.result === "reject";
|
||||
}
|
||||
|
||||
/** A hold point not yet done: H or W on the PESCO code or the customer code (the line's own, else the template's). */
|
||||
export function isOpenHold(item: ItpItem, l: ItpLine): boolean {
|
||||
return (isHoldCode(item.code) || isHoldCode(l.cust_code || item.cust)) && !isDone(l);
|
||||
}
|
||||
|
||||
/** The newest reject entry (kind reject), if any. */
|
||||
export function lastReject(l: ItpLine) {
|
||||
const rs = l.rejects.filter((x) => x.kind !== "ncr");
|
||||
return rs[rs.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** The sections of a page, in template order. */
|
||||
export function sectionsOf(sections: readonly ItpSection[], page: string): ItpSection[] {
|
||||
return sections.filter((s) => s.page === page);
|
||||
}
|
||||
|
||||
/** Today as a date input value: "2026-10-17". */
|
||||
export function todayIso(now: Date = new Date()): string {
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${m}-${d}`;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import type { QcDoc } from "../../api/qc";
|
||||
import { docMeta } from "./folders";
|
||||
|
||||
/** The folder documents as .qcdoc rows: type tag, title, rev, file and who, note, View and New revision. */
|
||||
export function DocList({ docs, label, old = false, onRevise }: { docs: QcDoc[]; label: string; old?: boolean; onRevise?: ((d: QcDoc) => void) | undefined }) {
|
||||
return (
|
||||
<ul className="qcdocs" aria-label={label}>
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className={old ? "qcdoc old" : "qcdoc"}>
|
||||
<span className="tag">{d.type || "Other"}</span>
|
||||
<div className="m">
|
||||
<b>{d.title || d.file_name || "Document"}</b>
|
||||
{d.rev ? <span className="muted small"> Rev {d.rev}</span> : null}
|
||||
<div className="small muted">{docMeta(d)}</div>
|
||||
{d.note ? <div className="small">{d.note}</div> : null}
|
||||
</div>
|
||||
<div className="a">
|
||||
<button type="button" className="btn sm" onClick={() => window.open(d.url, "_blank", "noopener")}>
|
||||
View
|
||||
</button>
|
||||
{onRevise ? (
|
||||
<button type="button" className="btn sm ghost" onClick={() => onRevise(d)}>
|
||||
New revision
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** A section with an h2, the count or note beside it, and its body. */
|
||||
export function FolderSection({ title, ct, extra, children }: { title: string; ct?: string; extra?: ReactNode; children: ReactNode }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<section className="sec" aria-labelledby={id}>
|
||||
<div className="sec-h">
|
||||
<h2 id={id}>{title}</h2>
|
||||
{ct ? <span className="ct">{ct}</span> : null}
|
||||
{extra ? (
|
||||
<>
|
||||
<span className="grow" />
|
||||
{extra}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Superseded section: count, Show / Hide, the old rows. */
|
||||
export function SupersededSection({ docs }: { docs: QcDoc[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!docs.length) return null;
|
||||
return (
|
||||
<FolderSection
|
||||
title="Superseded"
|
||||
ct={String(docs.length)}
|
||||
extra={
|
||||
<button type="button" className="btn sm ghost" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "Hide" : "Show"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open ? <DocList docs={docs} label="Superseded documents" old /> : null}
|
||||
</FolderSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toast({ text }: { text: string | null }) {
|
||||
return text ? (
|
||||
<div className="toast" role="status">
|
||||
{text}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -559,3 +559,77 @@ body.fs-view .toast { z-index: 99; }
|
||||
.mb10 { margin-bottom: 10px; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
.mt10 { margin-top: 10px; }
|
||||
|
||||
/* Phase 6: packet page. From .vtb, .qcdoc, .qcdocs, .itphold and the qcTab layout of the old build. */
|
||||
.vtbs { display: flex; gap: 6px; }
|
||||
.vtb { border: 2px solid var(--line2); background: var(--panel); color: var(--body); border-radius: 8px; padding: 8px 14px; font-weight: 700; cursor: pointer; min-height: 42px; }
|
||||
.vtb:disabled { opacity: .6; cursor: default; }
|
||||
.vtb.acc[aria-pressed="true"] { background: var(--green); border-color: var(--green); color: var(--on-green); }
|
||||
.vtb.rej[aria-pressed="true"] { background: var(--pink); border-color: var(--pink); color: var(--on-pink); }
|
||||
.qctab .ck-item .t .hint { display: block; margin-top: 2px; font-weight: 400; }
|
||||
.qcinsp { max-width: 720px; }
|
||||
.qcnde { max-width: 900px; }
|
||||
.qcnotes { margin-top: 12px; max-width: 900px; }
|
||||
.qcroute { flex: 0 1 260px; }
|
||||
.qcbar .acts { align-items: flex-end; }
|
||||
.qcrej a.lnk, .itphold a.lnk { font-weight: 600; }
|
||||
.itphold b { color: var(--amber); }
|
||||
.qcdocs { display: grid; gap: 8px; }
|
||||
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
||||
.qcdoc.old { opacity: .7; }
|
||||
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
|
||||
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.qchist .pill { margin-right: 4px; }
|
||||
input.in.w220 { width: 220px; max-width: 100%; }
|
||||
.mt4 { margin-top: 4px; }
|
||||
|
||||
/* Phase 6: Q.C. screens. Ported from the old .qcunits, .qcunit, .qcdocs, .qcdoc, .itpcard, .itphdr, .itppage,
|
||||
.itpsec, .itprows, .itprow, .itpblock, .sigs, .ini, .rejtag, .custrec blocks, plus a few new helpers. */
|
||||
.qcfolders { margin-top: 22px; }
|
||||
.qcunits { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||
.qcunit { gap: 8px; padding: 10px 12px; align-items: stretch; text-align: left; }
|
||||
.qcunit .job { font-size: 17px; }
|
||||
.qcunit .qm { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); }
|
||||
.qcunit .qm .ok, .qcdoc .ok { color: var(--green); }
|
||||
.qcdocs { display: grid; gap: 8px; list-style: none; margin: 0; padding: 0; }
|
||||
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
||||
.qcdoc.old { opacity: .7; }
|
||||
.qcdoc .tag { font-size: 12px; font-weight: 700; padding: 3px 8px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); white-space: nowrap; }
|
||||
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
|
||||
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.itpcard { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
||||
a.itpcard { flex-direction: row; text-align: left; }
|
||||
.itpcard .grow { flex: 1 1 260px; min-width: 0; }
|
||||
.itpcard .bar { background: var(--panel2); margin: 8px 0 6px; }
|
||||
.itphdr { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; }
|
||||
.itppage { margin: 26px 0 4px; font-family: var(--f-disp); font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--cyan); }
|
||||
.itppage span { color: var(--muted); margin-right: 6px; }
|
||||
.itpsec .sec-h h2 { font-size: 17px; }
|
||||
.itprows { display: grid; gap: 6px; }
|
||||
.itprow { display: grid; grid-template-columns: 42px minmax(0, 1fr) 44px 210px 130px 44px; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
||||
.itprow.hold { border-left: 3px solid var(--amber); }
|
||||
.itprow.rej { border-left: 3px solid var(--red); }
|
||||
.itprow .rejtag { font-size: 12px; color: var(--red); flex-basis: 100%; }
|
||||
.itprow .apr { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.itprow.na { opacity: .55; }
|
||||
.itprow.na .t b { text-decoration: line-through; }
|
||||
.itprow .cd { display: inline-block; min-width: 34px; text-align: center; font-weight: 700; font-size: 12px; padding: 3px 6px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); }
|
||||
.itprow .qc, .itprow .cu { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
|
||||
.itprow.hd { background: none; border: 0; padding: 0 12px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||
.itprow .ini { font-family: var(--f-disp); font-weight: 700; font-size: 16px; color: var(--green); }
|
||||
.itpblock { margin: 8px 0 18px; padding: 12px 14px; border: 1px dashed var(--line2); border-radius: 10px; }
|
||||
.itpblock .sigs { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 8px; }
|
||||
.itpblock .custrec { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.itpblock .custrec .in { max-width: 200px; }
|
||||
@media (max-width: 760px) {
|
||||
.itprow.hd { display: none; }
|
||||
.itprow { grid-template-columns: 38px minmax(0, 1fr) 40px; }
|
||||
.itprow .qc { grid-column: 2; grid-row: 2; }
|
||||
.itprow .cu { grid-column: 2 / 4; grid-row: 3; }
|
||||
.itprow .mb { grid-column: 3; grid-row: 2; }
|
||||
}
|
||||
/* New: the line sheet's N/A checkbox row, the ITP log list and the write message pinned to the bottom. */
|
||||
.itpck { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--near); }
|
||||
.itpck input { width: 20px; height: 20px; }
|
||||
.itplog { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; color: var(--body); }
|
||||
.itpmsg { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; max-width: calc(100% - 32px); box-shadow: 0 6px 20px rgba(0, 0, 0, .4); }
|
||||
|
||||
Reference in New Issue
Block a user