- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
114 lines
5.0 KiB
TypeScript
114 lines
5.0 KiB
TypeScript
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
|
|
import { useEffect, useState } from "react";
|
|
import { packetsApi, peopleApi } from "../api/packets";
|
|
import type { BomLine, DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, ShortReceipt, TransitionBody } from "../api/types";
|
|
import type { Mutate } from "./tabs";
|
|
import { parseRole } from "../auth/roles";
|
|
|
|
/** Live drawing pages first, then the VOID copies (the old viewPages). */
|
|
export function viewPages(pages: readonly PacketPage[]): PacketPage[] {
|
|
const dwg = pages.filter((x) => x.type === "drawing");
|
|
return [...dwg.filter((x) => !x.void), ...dwg.filter((x) => !!x.void)];
|
|
}
|
|
|
|
/**
|
|
* The page to show: the one picked when it exists, else the first live page. A VOID page that was not picked on
|
|
* purpose lands on the sheet that replaced it.
|
|
*/
|
|
export function landingPage(pages: readonly PacketPage[], picked: number | null, onPurpose: boolean): PacketPage | undefined {
|
|
let pg = pages.find((x) => x.n === picked) ?? pages[0];
|
|
for (let i = 0; i < 10 && pg?.void && !onPurpose; i++) {
|
|
const next = pages.find((x) => x.n === pg?.void?.new_n);
|
|
if (!next) break;
|
|
pg = next;
|
|
}
|
|
return pg;
|
|
}
|
|
|
|
/** "M00543 R2 · VOID" for the page buttons. */
|
|
export function pageLabel(pg: PacketPage): string {
|
|
return `${pg.dwg || `Page ${pg.n}`}${pg.rev ? ` R${pg.rev}` : ""}${pg.void ? " · VOID" : ""}`;
|
|
}
|
|
|
|
export type Level = "worker" | "lead" | "supervisor";
|
|
|
|
/** The highest shop level in a department; admin counts as supervisor. */
|
|
export function shopLevel(roles: readonly string[], dept: string): Level | null {
|
|
if (roles.includes("admin")) return "supervisor";
|
|
const order: Level[] = ["worker", "lead", "supervisor"];
|
|
let best = -1;
|
|
for (const r of roles) {
|
|
const p = parseRole(r);
|
|
if (p.shop && p.dept === dept) best = Math.max(best, order.indexOf(p.level));
|
|
}
|
|
return best < 0 ? null : (order[best] ?? null);
|
|
}
|
|
|
|
/** May this user verify a change order for the department? Mirrors the server's can_verify. */
|
|
export function canVerifyDept(roles: readonly string[], dept: string): boolean {
|
|
if (roles.includes("admin")) return true;
|
|
if (dept === "WH") return roles.includes("warehouse_supervisor");
|
|
if (dept === "QC") return roles.includes("qc_supervisor");
|
|
if (dept === "CL" || dept === "PM") return false;
|
|
return shopLevel(roles, dept) === "supervisor";
|
|
}
|
|
|
|
/** The active people of a shop department, for the welder picker and the weld sheet's stamp. */
|
|
export function usePeople(dept: string | null): { people: DeptPerson[]; loaded: boolean; error: string | null } {
|
|
const [state, setState] = useState<{ dept: string | null; people: DeptPerson[]; error: string | null } | null>(null);
|
|
useEffect(() => {
|
|
if (!dept) return;
|
|
const ctl = new AbortController();
|
|
peopleApi
|
|
.list(dept, ctl.signal)
|
|
.then((people) => {
|
|
if (!ctl.signal.aborted) setState({ dept, people, error: null });
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (ctl.signal.aborted) return;
|
|
setState({ dept, people: [], error: err instanceof Error ? err.message : "The people list could not be read." });
|
|
});
|
|
return () => ctl.abort();
|
|
}, [dept]);
|
|
const mine = state && state.dept === dept ? state : null;
|
|
return { people: mine?.people ?? [], loaded: !!mine, error: mine?.error ?? null };
|
|
}
|
|
|
|
export type ShopTab = "checklist" | "welds";
|
|
|
|
/** The old missing(): what the stall still has to do before Submit, each with the tab that fixes it. */
|
|
export function stallMissing(p: PacketRead): Array<{ t: string; tab: ShopTab }> {
|
|
const out: Array<{ t: string; tab: ShopTab }> = [];
|
|
const done = p.stage?.checklist ?? {};
|
|
const open = p.checklist_items.filter((i) => !done[i.key]?.done).length;
|
|
if (open) out.push({ t: `${open} checklist item${open > 1 ? "s" : ""}`, tab: "checklist" });
|
|
if (p.dept.weld !== false) {
|
|
const here = p.welds.some((w) => !w.carried && (w.dept_code === undefined || w.dept_code === null || w.dept_code === p.dept.code));
|
|
if (!here) out.push({ t: "no welds marked on the drawing", tab: "welds" });
|
|
if (!p.stage?.filler) out.push({ t: "filler metal", tab: "checklist" });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export type StepFn = (
|
|
action: string,
|
|
extra?: Omit<TransitionBody, "action" | "expect">,
|
|
opts?: { sign?: string; ok?: string },
|
|
) => Promise<boolean>;
|
|
|
|
/** A transition with the expect guard, through the page's mutate. */
|
|
export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
|
|
const expect: { status: PacketStatus; dept: string } = { status: p.status, dept: p.dept.code };
|
|
return (action, extra = {}, opts = {}) =>
|
|
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
|
|
}
|
|
|
|
/**
|
|
* The receipts of a BOM line's shorts, open or closed: `shorts` on the read when the server sends it, else the
|
|
* open short on the line.
|
|
*/
|
|
export function lineReceipts(p: PacketRead, b: BomLine): ShortReceipt[] {
|
|
if (p.shorts) return p.shorts.filter((s) => s.item === b.item).flatMap((s) => s.receipts ?? []);
|
|
return b.short?.receipts ?? [];
|
|
}
|