Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -56,7 +56,7 @@ describe("sign-in", () => {
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Supervisor" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Dispatch, review, route" })).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]);
|
||||
expect(screen.getByText("Clara Clerk")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument();
|
||||
@@ -247,8 +247,7 @@ describe("role switcher", () => {
|
||||
expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
||||
expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument();
|
||||
expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true");
|
||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"));
|
||||
});
|
||||
|
||||
it("is hidden for a user with one screen", async () => {
|
||||
|
||||
@@ -5,8 +5,11 @@ import type {
|
||||
AttachPagesBody,
|
||||
BomLine,
|
||||
BomPatch,
|
||||
DeptPerson,
|
||||
FieldCas,
|
||||
Guarded,
|
||||
HeatTableBody,
|
||||
HeatTagBody,
|
||||
LogRow,
|
||||
PacketCard,
|
||||
PacketCreate,
|
||||
@@ -15,7 +18,9 @@ import type {
|
||||
PacketRead,
|
||||
ReceiptBody,
|
||||
ShortBody,
|
||||
StageFields,
|
||||
TransitionBody,
|
||||
WeldBody,
|
||||
WhStageBody,
|
||||
WorkOrder,
|
||||
} from "./types";
|
||||
@@ -86,4 +91,33 @@ export const packetsApi = {
|
||||
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
|
||||
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
|
||||
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
|
||||
|
||||
/* Phase 5: the shop. Every answer is the packet. */
|
||||
addWeld: (id: number, body: WeldBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/welds`, { ...body, ...g }),
|
||||
/** If-Match is the weld's row_version. */
|
||||
patchWeld: (id: number, no: number, rowVersion: number, body: WeldBody, g: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/welds/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
|
||||
removeWeld: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/welds/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }),
|
||||
addHeatTag: (id: number, body: HeatTagBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/heat-tags`, { ...body, ...g }),
|
||||
patchHeatTag: (id: number, no: number, rowVersion: number, body: HeatTagBody, g: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/heat-tags/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
|
||||
removeHeatTag: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/heat-tags/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }),
|
||||
putHeatTable: (id: number, page: number, body: HeatTableBody, g: Guarded = {}) =>
|
||||
api.put<PacketRead>(`${base(id)}/heat-table/${page}`, { ...body, ...g }),
|
||||
removeHeatTable: (id: number, page: number, g: Guarded = {}) => api.delWithBody<PacketRead>(`${base(id)}/heat-table/${page}`, g),
|
||||
/** The weld record fields with a field-level base: 409 field_conflict when someone changed one first. */
|
||||
patchStage: (id: number, body: FieldCas<StageFields>) => api.patch<PacketRead>(`${base(id)}/stage`, body),
|
||||
setChecklist: (id: number, key: string, done: boolean, g: Guarded = {}) =>
|
||||
api.put<PacketRead>(`${base(id)}/stage/checklist/${encodeURIComponent(key)}`, { done, ...g }),
|
||||
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),
|
||||
};
|
||||
|
||||
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
|
||||
export const peopleApi = {
|
||||
list: async (dept: string, signal?: AbortSignal): Promise<DeptPerson[]> =>
|
||||
asList<DeptPerson>(await api.get<unknown>(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// The shop board, the stall and the department people (phase 5 contract, api/shop.py and api/departments.py).
|
||||
// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts.
|
||||
import { api } from "./client";
|
||||
import { asList, qs } from "./query";
|
||||
import type { PacketCard } from "./types";
|
||||
|
||||
export interface ShopClock {
|
||||
/** Live on the server at the time of the answer: stored plus the running time. */
|
||||
elapsed_ms: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ShopCo {
|
||||
label: string;
|
||||
/** "pending" while Clerical reviews a change from VISUAL, "approved" once it applies. */
|
||||
review: string;
|
||||
}
|
||||
|
||||
/** A card on the shop board: the phase 4 card plus the station, welder, clock and open change orders. */
|
||||
export interface ShopCard extends Omit<PacketCard, "open_cos"> {
|
||||
welder_stamp?: string | null;
|
||||
clock?: ShopClock | null;
|
||||
target_hrs?: number | null;
|
||||
open_cos: ShopCo[];
|
||||
return_note?: string | null;
|
||||
}
|
||||
|
||||
export interface ShopStation {
|
||||
id: number;
|
||||
name: string;
|
||||
packets: ShopCard[];
|
||||
}
|
||||
|
||||
export type BoardLevel = "lead" | "supervisor" | "admin";
|
||||
|
||||
export interface ShopDept {
|
||||
code: string;
|
||||
name: string;
|
||||
weld: boolean;
|
||||
level: BoardLevel;
|
||||
stations: ShopStation[];
|
||||
}
|
||||
|
||||
export interface ShopKpis {
|
||||
ready: number;
|
||||
at_stations: number;
|
||||
review: number;
|
||||
elapsed_ms: number;
|
||||
target_hrs: number;
|
||||
}
|
||||
|
||||
/** One open change order on a packet in scope, or one waiting on a department of mine. */
|
||||
export interface ShopCoRow {
|
||||
packet_id: number;
|
||||
unit_number: string;
|
||||
op: string | null;
|
||||
title: string | null;
|
||||
dept_code: string;
|
||||
co_no: number;
|
||||
label: string;
|
||||
review: string;
|
||||
hold: boolean;
|
||||
pending_depts: string[];
|
||||
/** I can verify one of the pending departments. */
|
||||
mine: boolean;
|
||||
reason: string | null;
|
||||
ecn: string | null;
|
||||
issued_at: string | null;
|
||||
}
|
||||
|
||||
export const SHOP_COLUMNS = ["ready", "review", "qc", "warehouse", "complete"] as const;
|
||||
export type ShopColumn = (typeof SHOP_COLUMNS)[number];
|
||||
|
||||
export interface ShopBoard {
|
||||
depts: ShopDept[];
|
||||
kpis: ShopKpis;
|
||||
cos: ShopCoRow[];
|
||||
columns: Record<ShopColumn, ShopCard[]>;
|
||||
}
|
||||
|
||||
export interface ShopBoardQuery {
|
||||
/** A department code, or "all". */
|
||||
dept?: string;
|
||||
project_id?: number;
|
||||
unit_id?: number;
|
||||
}
|
||||
|
||||
export interface StationOption {
|
||||
dept_code: string;
|
||||
dept_name: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface StallAnswer {
|
||||
station: { id: number; name: string; dept_code: string; dept_name: string };
|
||||
mine: ShopCard[];
|
||||
submitted: ShopCard[];
|
||||
}
|
||||
|
||||
export interface DeptPerson {
|
||||
id: string;
|
||||
display_name: string;
|
||||
stamp: string | null;
|
||||
level: "worker" | "lead" | "supervisor";
|
||||
station_limits: number[];
|
||||
}
|
||||
|
||||
/** A card with every list present, so the screens never trip on a missing field. */
|
||||
export function normaliseCard(raw: ShopCard): ShopCard {
|
||||
return { ...raw, open_cos: Array.isArray(raw.open_cos) ? raw.open_cos : [] };
|
||||
}
|
||||
|
||||
function cards(raw: unknown): ShopCard[] {
|
||||
return Array.isArray(raw) ? (raw as ShopCard[]).map(normaliseCard) : [];
|
||||
}
|
||||
|
||||
/** Every department, column and number present, even when the server leaves one out. */
|
||||
export function normaliseBoard(raw: Partial<ShopBoard> | null): ShopBoard {
|
||||
const cols = (raw?.columns ?? {}) as Partial<Record<ShopColumn, unknown>>;
|
||||
const columns = Object.fromEntries(SHOP_COLUMNS.map((c) => [c, cards(cols[c])])) as ShopBoard["columns"];
|
||||
const depts = (Array.isArray(raw?.depts) ? raw.depts : []).map((d) => ({
|
||||
...d,
|
||||
stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })),
|
||||
}));
|
||||
return {
|
||||
depts,
|
||||
kpis: { ready: 0, at_stations: 0, review: 0, elapsed_ms: 0, target_hrs: 0, ...(raw?.kpis ?? {}) },
|
||||
cos: Array.isArray(raw?.cos) ? raw.cos.map((c) => ({ ...c, pending_depts: Array.isArray(c.pending_depts) ? c.pending_depts : [] })) : [],
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBoard(q: ShopBoardQuery = {}, signal?: AbortSignal): Promise<ShopBoard> {
|
||||
return normaliseBoard(await api.get<Partial<ShopBoard> | null>(`/api/shop/board${qs({ ...q })}`, signal));
|
||||
}
|
||||
|
||||
export async function getStations(signal?: AbortSignal): Promise<StationOption[]> {
|
||||
return asList<StationOption>(await api.get<unknown>("/api/shop/stations", signal), "stations");
|
||||
}
|
||||
|
||||
export async function getStall(stationId: number, signal?: AbortSignal): Promise<StallAnswer> {
|
||||
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId })}`, signal);
|
||||
return {
|
||||
station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" },
|
||||
mine: cards(raw?.mine),
|
||||
submitted: cards(raw?.submitted),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPeople(dept: string, signal?: AbortSignal): Promise<DeptPerson[]> {
|
||||
return asList<DeptPerson>(await api.get<unknown>(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people");
|
||||
}
|
||||
|
||||
export const shopApi = { getBoard, getStations, getStall, getPeople };
|
||||
+195
-7
@@ -227,10 +227,20 @@ export interface PacketPage {
|
||||
pn: number;
|
||||
dwg: string | null;
|
||||
rev: string | null;
|
||||
void: { co_id: number; at: string; new_n: number | null } | null;
|
||||
void: PageVoid | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** A voided drawing page. Phase 5 adds the CO number, who voided it and the page that replaced it. */
|
||||
export interface PageVoid {
|
||||
co_id: number;
|
||||
at: string;
|
||||
new_n: number | null;
|
||||
co_no?: number | null;
|
||||
by?: WhoRef | null;
|
||||
new?: { n: number; dwg: string | null; rev: string | null } | null;
|
||||
}
|
||||
|
||||
export interface BomShort {
|
||||
id: number;
|
||||
qty: number | string;
|
||||
@@ -327,11 +337,11 @@ export interface PacketRead {
|
||||
work_orders: WorkOrder[];
|
||||
pages: PacketPage[];
|
||||
bom: BomLine[];
|
||||
welds: unknown[];
|
||||
heat_tags: unknown[];
|
||||
heat_table: unknown[];
|
||||
stage: Record<string, unknown> | null;
|
||||
stages: unknown[];
|
||||
welds: Weld[];
|
||||
heat_tags: HeatTag[];
|
||||
heat_table: HeatTablePlacement[];
|
||||
stage: Stage | null;
|
||||
stages: Stage[];
|
||||
wh: WhInfo | null;
|
||||
qc: Record<string, unknown> | null;
|
||||
qc_history: unknown[];
|
||||
@@ -346,6 +356,178 @@ export interface PacketRead {
|
||||
checklist_items: Array<{ group: string; key: string; text: string }>;
|
||||
qc_items: unknown[];
|
||||
flag: string | null;
|
||||
/** Phase 5: the database now(), to tick the clock from. */
|
||||
server_now?: string | null;
|
||||
/** Phase 5: the sum of the work orders' target hours. */
|
||||
target_hrs?: number | null;
|
||||
/** Phase 5: VISUAL labor tickets on the packet's work orders. Null for a hand packet or no match. */
|
||||
labor?: Labor | null;
|
||||
/** Phase 5: the borrowed heat map of a sibling packet, read-only. */
|
||||
heat_src?: HeatSrc | null;
|
||||
}
|
||||
|
||||
/* ---------- Phase 5: the shop on the packet ---------- */
|
||||
|
||||
export interface Weld {
|
||||
weld_no: number;
|
||||
page: number | null;
|
||||
/** Where the arrow points, as a fraction of the page. */
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
/** The bubble. Null means the default position (geometry.bpos). */
|
||||
bfx?: number | null;
|
||||
bfy?: number | null;
|
||||
wo_base: string | null;
|
||||
piece: number | null;
|
||||
items: number[];
|
||||
type: string | null;
|
||||
size: string | null;
|
||||
process: string | null;
|
||||
stamp: string | null;
|
||||
at: string | null;
|
||||
row_version: number;
|
||||
welder: WhoRef | null;
|
||||
by?: WhoRef | null;
|
||||
dept_code?: string | null;
|
||||
stage_id?: number | null;
|
||||
/** Logged in an earlier department's stage. */
|
||||
carried?: boolean;
|
||||
}
|
||||
|
||||
export interface HeatTag {
|
||||
tag_no: number;
|
||||
page: number | null;
|
||||
item: number;
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
bfx?: number | null;
|
||||
bfy?: number | null;
|
||||
row_version: number;
|
||||
carried?: boolean;
|
||||
carried_from?: number | null;
|
||||
}
|
||||
|
||||
/** One heat table on a drawing page. x, y is the top-left corner as a fraction; s the size factor. */
|
||||
export interface HeatTablePlacement {
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
s: number;
|
||||
carried_from?: number | null;
|
||||
by?: WhoRef | null;
|
||||
at?: string | null;
|
||||
}
|
||||
|
||||
export interface StageClock {
|
||||
/** Live on the server at read time: stored plus the running time. */
|
||||
elapsed_ms: number;
|
||||
stored_ms?: number;
|
||||
running?: boolean;
|
||||
running_since: string | null;
|
||||
started_at: string | null;
|
||||
}
|
||||
|
||||
export interface ChecklistEntry {
|
||||
done: boolean;
|
||||
at?: string | null;
|
||||
by?: string | null;
|
||||
by_name?: string | null;
|
||||
stamp?: string | null;
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
id: number;
|
||||
dept_code: string;
|
||||
station: StationRef | null;
|
||||
welder: WelderRef | null;
|
||||
checklist: Record<string, ChecklistEntry>;
|
||||
filler: string | null;
|
||||
filler_lot: string | null;
|
||||
preheat: string | null;
|
||||
notes: string | null;
|
||||
clock: StageClock;
|
||||
opened_at: string | null;
|
||||
closed_at?: string | null;
|
||||
row_version: number;
|
||||
}
|
||||
|
||||
/** The weld record fields on the open stage. */
|
||||
export interface StageFields {
|
||||
filler: string | null;
|
||||
filler_lot: string | null;
|
||||
preheat: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface LaborWo {
|
||||
wo_base: string;
|
||||
lot: string;
|
||||
split: string;
|
||||
sub: string;
|
||||
seq: number | null;
|
||||
hours: number;
|
||||
tickets: number;
|
||||
last_ticket: string | null;
|
||||
}
|
||||
|
||||
export interface Labor {
|
||||
hours: number;
|
||||
tickets: number;
|
||||
per_wo: LaborWo[];
|
||||
observed_at: string | null;
|
||||
}
|
||||
|
||||
export interface HeatSrc {
|
||||
packet_id: number;
|
||||
op: string | null;
|
||||
status: PacketStatus;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
pages: PacketPage[];
|
||||
heat_table: HeatTablePlacement[];
|
||||
heat_tags: HeatTag[];
|
||||
bom: Array<{ item: number; heat: string | null; na: boolean }>;
|
||||
}
|
||||
|
||||
/** A weld write. Positions are fractions of the page. */
|
||||
export interface WeldBody {
|
||||
page?: number | null;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
bfx?: number | null;
|
||||
bfy?: number | null;
|
||||
wo_base?: string | null;
|
||||
piece?: number | null;
|
||||
items?: number[];
|
||||
type?: string | null;
|
||||
size?: string | null;
|
||||
process?: string | null;
|
||||
stamp?: string | null;
|
||||
}
|
||||
|
||||
export interface HeatTagBody {
|
||||
page?: number | null;
|
||||
item?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
bfx?: number | null;
|
||||
bfy?: number | null;
|
||||
}
|
||||
|
||||
export interface HeatTableBody {
|
||||
x: number;
|
||||
y: number;
|
||||
s: number;
|
||||
carried_from?: number | null;
|
||||
}
|
||||
|
||||
/** One person on GET /api/departments/{code}/people. */
|
||||
export interface DeptPerson {
|
||||
id: string;
|
||||
display_name: string;
|
||||
stamp: string | null;
|
||||
level: "worker" | "lead" | "supervisor";
|
||||
station_limits: number[];
|
||||
}
|
||||
|
||||
/** One row of GET /api/packets/{id}/log, newest first. */
|
||||
@@ -541,7 +723,13 @@ export interface ChangeOrder {
|
||||
rejected_reason?: string | null;
|
||||
items: ChangeOrderItem[];
|
||||
required: string[];
|
||||
verified: Record<string, { by: WhoRef | null; at: string; note: string | null }>;
|
||||
verified: Record<string, { by: WhoRef | null; at: string; note: string | null; signed?: boolean }>;
|
||||
/** Phase 5: "CO-<co_no>". */
|
||||
label?: string;
|
||||
/** Phase 5: required minus verified. */
|
||||
pending_depts?: string[];
|
||||
/** Phase 5: approved with pending departments, or pending with hold. */
|
||||
open?: boolean;
|
||||
vis?: CoVis | null;
|
||||
waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null;
|
||||
staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||
import { DEPARTMENTS } from "../test/auth";
|
||||
import type { Department } from "./types";
|
||||
import { firstScreen, homePath, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles";
|
||||
import { firstScreen, homePath, landingScreen, LAST_SCREEN_KEY, levelIn, myShopDepts, rememberScreen, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles";
|
||||
|
||||
const depts = DEPARTMENTS as Department[];
|
||||
|
||||
@@ -36,4 +37,43 @@ describe("roles", () => {
|
||||
expect(roleChipClass("admin")).toBe("rchip admin");
|
||||
expect(roleChipClass("clerical")).toBe("rchip");
|
||||
});
|
||||
|
||||
describe("shop departments", () => {
|
||||
it("levelIn gives the highest level in a department, admin is supervisor everywhere", () => {
|
||||
expect(levelIn(["shop:03:worker"], "03")).toBe("worker");
|
||||
expect(levelIn(["shop:03:worker", "shop:03:lead"], "03")).toBe("lead");
|
||||
expect(levelIn(["clerical", "shop:06:supervisor"], "06")).toBe("supervisor");
|
||||
expect(levelIn(["shop:06:supervisor"], "03")).toBeNull();
|
||||
expect(levelIn(["clerical"], "03")).toBeNull();
|
||||
expect(levelIn(["admin"], "09")).toBe("supervisor");
|
||||
});
|
||||
|
||||
it("myShopDepts lists lead and supervisor departments for the Supervisor screen, every one for the Stall", () => {
|
||||
const roles = ["clerical", "shop:09:worker", "shop:06:supervisor", "shop:03:lead"];
|
||||
expect(myShopDepts(roles, "supervisor")).toEqual(["03", "06"]);
|
||||
expect(myShopDepts(roles, "stall")).toEqual(["03", "06", "09"]);
|
||||
expect(myShopDepts(["shop:03:worker"], "supervisor")).toEqual([]);
|
||||
expect(myShopDepts(["shop:03:worker", "shop:03:lead"], "stall")).toEqual(["03"]);
|
||||
expect(myShopDepts(["admin"], "supervisor", ["14", "03"])).toEqual(["03", "14"]);
|
||||
expect(myShopDepts(["warehouse"], "stall", ["03"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("landing screen", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
it("lands on the last screen picked while it is still allowed", () => {
|
||||
expect(landingScreen(["stall", "supervisor"], null)).toBe("supervisor");
|
||||
expect(landingScreen(["stall", "supervisor"], "stall")).toBe("stall");
|
||||
expect(landingScreen(["clerical"], "stall")).toBe("clerical");
|
||||
rememberScreen("stall");
|
||||
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
|
||||
expect(homePath(["supervisor", "stall"])).toBe("/stall");
|
||||
expect(homePath(["clerical", "qc"])).toBe("/clerical");
|
||||
window.localStorage.setItem(LAST_SCREEN_KEY, "nonsense");
|
||||
expect(homePath(["supervisor", "stall"])).toBe("/supervisor");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,17 +79,76 @@ export function orderScreens(list: readonly string[]): Screen[] {
|
||||
return SCREENS.filter((s) => list.includes(s));
|
||||
}
|
||||
|
||||
/** The screen a user lands on: the first non-admin screen, else admin, else null. */
|
||||
/** The first non-admin screen, else admin, else null. */
|
||||
export function firstScreen(list: readonly string[]): Screen | null {
|
||||
const ordered = orderScreens(list);
|
||||
return ordered.find((s) => s !== "admin") ?? ordered[0] ?? null;
|
||||
}
|
||||
|
||||
/** localStorage key for the last screen picked in the role switcher (the old build's ps.role). */
|
||||
export const LAST_SCREEN_KEY = "ps.role";
|
||||
|
||||
/** The last screen picked, or null. Storage can be blocked; that reads as nothing remembered. */
|
||||
export function lastScreen(): Screen | null {
|
||||
try {
|
||||
const v = window.localStorage.getItem(LAST_SCREEN_KEY) ?? undefined;
|
||||
return isScreen(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberScreen(s: Screen): void {
|
||||
try {
|
||||
window.localStorage.setItem(LAST_SCREEN_KEY, s);
|
||||
} catch {
|
||||
// Nothing to do: the next sign-in lands on the first screen.
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen a user lands on: the last one picked while it is still allowed, else the first non-admin screen. */
|
||||
export function landingScreen(list: readonly string[], last: Screen | null = lastScreen()): Screen | null {
|
||||
const ordered = orderScreens(list);
|
||||
if (last && ordered.includes(last)) return last;
|
||||
return firstScreen(ordered);
|
||||
}
|
||||
|
||||
export function homePath(list: readonly string[]): string {
|
||||
const s = firstScreen(list);
|
||||
const s = landingScreen(list);
|
||||
return s ? `/${s}` : "/account";
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's level in one shop department (the old levelIn): the highest shop role there, or null.
|
||||
* Admin counts as supervisor everywhere.
|
||||
*/
|
||||
export function levelIn(roles: readonly string[], dept: string): ShopLevel | null {
|
||||
if (roles.includes("admin")) return "supervisor";
|
||||
let best = -1;
|
||||
for (const r of roles) {
|
||||
const p = parseRole(r);
|
||||
if (p.shop && p.dept === dept) best = Math.max(best, SHOP_LEVELS.indexOf(p.level));
|
||||
}
|
||||
return best < 0 ? null : (SHOP_LEVELS[best] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shop departments a user works in for a screen (the old myDepts), in code order.
|
||||
* "supervisor": where the user is lead or supervisor. "stall": every shop department at any level.
|
||||
* Admin gets every shop department in `allShop`.
|
||||
*/
|
||||
export function myShopDepts(roles: readonly string[], screen: "supervisor" | "stall", allShop: readonly string[] = []): string[] {
|
||||
const out = new Set<string>();
|
||||
if (roles.includes("admin")) allShop.forEach((d) => out.add(d));
|
||||
for (const r of roles) {
|
||||
const p = parseRole(r);
|
||||
if (!p.shop) continue;
|
||||
if (screen === "supervisor" && p.level === "worker") continue;
|
||||
out.add(p.dept);
|
||||
}
|
||||
return [...out].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
const OFFICE_LABELS: Record<string, string> = {
|
||||
admin: "Admin",
|
||||
clerical: "Clerical",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eventsUrl, othersOn } from "./useEvents";
|
||||
|
||||
describe("packet presence", () => {
|
||||
it("opens the stream with the packet in view", () => {
|
||||
expect(eventsUrl(null)).toBe("/api/events");
|
||||
expect(eventsUrl(42)).toBe("/api/events?packet_id=42");
|
||||
});
|
||||
|
||||
it("lists the other people on the packet once each, sorted, without me", () => {
|
||||
const users = [
|
||||
{ user_id: "w", display_name: "Wes Super", screen: "supervisor", packet_id: 7 },
|
||||
{ user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: "7" },
|
||||
{ user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: 7 },
|
||||
{ user_id: "me", display_name: "Me", screen: "stall", packet_id: 7 },
|
||||
{ user_id: "q", display_name: "Quinn QC", screen: "qc", packet_id: 8 },
|
||||
{ user_id: "c", display_name: "Clara Clerk", screen: "clerical", packet_id: null },
|
||||
];
|
||||
expect(othersOn(users, 7, "me").map((u) => u.display_name)).toEqual(["Andy Welder", "Wes Super"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { AuthCtx } from "./context";
|
||||
import type { PresenceUser } from "./types";
|
||||
|
||||
interface EventHandlers {
|
||||
@@ -8,6 +9,11 @@ interface EventHandlers {
|
||||
onChanged?: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
/** Phase 5: presence carries the packet each user has open. */
|
||||
export interface PacketPresenceUser extends PresenceUser {
|
||||
packet_id?: number | string | null;
|
||||
}
|
||||
|
||||
function parse(data: unknown): unknown {
|
||||
if (typeof data !== "string") return null;
|
||||
try {
|
||||
@@ -17,13 +23,41 @@ function parse(data: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The packet this browser is looking at. The packet page sets it (usePacketPresence) and the one EventSource
|
||||
* reopens with ?packet_id= so the server's presence knows it. A module store keeps AuthProvider unchanged.
|
||||
*/
|
||||
let focusPacket: number | null = null;
|
||||
const focusListeners = new Set<() => void>();
|
||||
|
||||
export function setPresencePacket(id: number | null): void {
|
||||
if (focusPacket === id) return;
|
||||
focusPacket = id;
|
||||
for (const fn of focusListeners) fn();
|
||||
}
|
||||
|
||||
function subscribeFocus(fn: () => void): () => void {
|
||||
focusListeners.add(fn);
|
||||
return () => {
|
||||
focusListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
const getFocus = () => focusPacket;
|
||||
|
||||
/** The stream URL for the packet in view. */
|
||||
export function eventsUrl(packetId: number | null): string {
|
||||
return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens /api/events with EventSource while `sessionId` is set.
|
||||
* A `session` event with this session id and action "revoked" calls onRevoked.
|
||||
* `presence` is kept in state for later phases.
|
||||
* `presence` is kept in state. The stream reopens when the packet in view changes.
|
||||
*/
|
||||
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
|
||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
||||
const ref = useRef(handlers);
|
||||
useEffect(() => {
|
||||
ref.current = handlers;
|
||||
@@ -31,7 +65,7 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof EventSource === "undefined") return;
|
||||
const es = new EventSource("/api/events", { withCredentials: true });
|
||||
const es = new EventSource(eventsUrl(packetId), { withCredentials: true });
|
||||
|
||||
es.addEventListener("session", (ev) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
@@ -59,7 +93,36 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
||||
es.close();
|
||||
setPresence([]);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, packetId]);
|
||||
|
||||
return presence;
|
||||
}
|
||||
|
||||
/** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */
|
||||
export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] {
|
||||
const seen = new Set<string>();
|
||||
const out: PacketPresenceUser[] = [];
|
||||
for (const u of users) {
|
||||
if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue;
|
||||
if (myId && u.user_id === myId) continue;
|
||||
if (seen.has(u.user_id)) continue;
|
||||
seen.add(u.user_id);
|
||||
out.push(u);
|
||||
}
|
||||
return out.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks `packetId` as the packet in view while mounted, and returns the other people looking at it.
|
||||
* Works under AuthProvider; outside it (a bare test render) it returns an empty list.
|
||||
*/
|
||||
export function usePacketPresence(packetId: number | null): PacketPresenceUser[] {
|
||||
const ctx = useContext(AuthCtx);
|
||||
useEffect(() => {
|
||||
if (!packetId) return;
|
||||
setPresencePacket(packetId);
|
||||
return () => setPresencePacket(null);
|
||||
}, [packetId]);
|
||||
if (!ctx || !packetId) return [];
|
||||
return othersOn(ctx.presence as PacketPresenceUser[], packetId, ctx.me?.user.id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export interface CoBannerItem {
|
||||
key: string;
|
||||
/** "CO-2". */
|
||||
label: string;
|
||||
/** "L662001 · WELD-PART". */
|
||||
job: string;
|
||||
/** The small line under it: when, for which departments, why. */
|
||||
sub?: string;
|
||||
/** Where the button goes: the packet's Change orders tab. */
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The change order headline (the old coBannerHtml). It has no close button: it stays until the
|
||||
* department verifies, and the next board answer drops the row.
|
||||
*/
|
||||
export function CoBanner({ items, it = false }: { items: readonly CoBannerItem[]; it?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="cobanner" role="alert" aria-label="Change orders to verify">
|
||||
<div className="cob-h">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3 2 21h20L12 3z" />
|
||||
<path d="M12 10v5M12 18h.01" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="cob-t">{it ? "Change order: verification pending (IT view)" : "Change order: your verification is required"}</div>
|
||||
<div className="small">
|
||||
A document in {items.length === 1 ? "this packet" : "these packets"} was replaced. Review the new revision against work already done, then
|
||||
verify. This stays here until you do.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{items.map((x) => (
|
||||
<div key={x.key} className="cob-row">
|
||||
<div className="cob-txt">
|
||||
<div>
|
||||
Change order <b>{x.label}</b> on <b>{x.job}</b> needs your verification
|
||||
</div>
|
||||
{x.sub ? <div className="small">{x.sub}</div> : null}
|
||||
</div>
|
||||
<button className="btn pri" type="button" aria-label={`Review & verify ${x.label} on ${x.job}`} onClick={() => navigate(x.href)}>
|
||||
Review & verify
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LAST_SCREEN_KEY } from "../auth/roles";
|
||||
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { mockFetch } from "../test/fetchMock";
|
||||
|
||||
const LOU = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor", "stall"], user: { sam_account: "lead.lou", display_name: "Lou Lead" } });
|
||||
|
||||
function routes() {
|
||||
return {
|
||||
"/api/auth/me": { body: LOU },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/shop/board": { body: {} },
|
||||
"/api/shop/stations": { body: [] },
|
||||
"/api/projects": { body: [] },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("RoleSwitcher", () => {
|
||||
it("lands on the first screen, and remembers the screen picked", async () => {
|
||||
mockFetch(routes());
|
||||
renderApp("/");
|
||||
const nav = await screen.findByRole("navigation", { name: "Screens" });
|
||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
|
||||
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
||||
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
|
||||
expect(await screen.findByText("You aren't assigned to a station. Ask an admin to add you to a department.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lands on the remembered screen when it is still allowed", async () => {
|
||||
window.localStorage.setItem(LAST_SCREEN_KEY, "stall");
|
||||
mockFetch(routes());
|
||||
renderApp("/");
|
||||
const nav = await screen.findByRole("navigation", { name: "Screens" });
|
||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"));
|
||||
});
|
||||
|
||||
it("ignores a remembered screen the user no longer has", async () => {
|
||||
window.localStorage.setItem(LAST_SCREEN_KEY, "warehouse");
|
||||
mockFetch(routes());
|
||||
renderApp("/");
|
||||
const nav = await screen.findByRole("navigation", { name: "Screens" });
|
||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { SCREEN_LABELS, type Screen } from "../auth/roles";
|
||||
import { rememberScreen, SCREEN_LABELS, type Screen } from "../auth/roles";
|
||||
|
||||
interface RoleSwitcherProps {
|
||||
screens: readonly Screen[];
|
||||
active: Screen | null;
|
||||
}
|
||||
|
||||
/** One button per screen the user has. Drawn only when there is more than one. */
|
||||
/** One button per screen the user has. Drawn only when there is more than one. The pick is remembered for the next landing. */
|
||||
export function RoleSwitcher({ screens, active }: RoleSwitcherProps) {
|
||||
const navigate = useNavigate();
|
||||
if (screens.length < 2) return null;
|
||||
return (
|
||||
<nav className="roles" aria-label="Screens">
|
||||
{screens.map((s) => (
|
||||
<button key={s} type="button" aria-pressed={active === s} onClick={() => navigate(`/${s}`)}>
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
aria-pressed={active === s}
|
||||
onClick={() => {
|
||||
rememberScreen(s);
|
||||
navigate(`/${s}`);
|
||||
}}
|
||||
>
|
||||
{SCREEN_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fmtClock, fmtHours, liveElapsed } from "./clock";
|
||||
|
||||
describe("clock", () => {
|
||||
it("fmtClock pads and counts hours", () => {
|
||||
expect(fmtClock(0)).toBe("00:00:00");
|
||||
expect(fmtClock(61_000)).toBe("00:01:01");
|
||||
expect(fmtClock(5_400_999)).toBe("01:30:00");
|
||||
expect(fmtClock(-5)).toBe("00:00:00");
|
||||
expect(fmtClock(360_000_000)).toBe("100:00:00");
|
||||
});
|
||||
|
||||
it("fmtHours has one decimal", () => {
|
||||
expect(fmtHours(5_400_000)).toBe("1.5 h");
|
||||
expect(fmtHours(0)).toBe("0.0 h");
|
||||
});
|
||||
|
||||
it("liveElapsed adds the time since the fetch only while running", () => {
|
||||
const fetched = 1_000_000;
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: false, running_since: null }, fetched, fetched + 5000)).toBe(60_000);
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: true, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 5000)).toBe(65_000);
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 2000)).toBe(62_000);
|
||||
// A client clock behind the fetch time never takes time away.
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: true }, fetched, fetched - 9000)).toBe(60_000);
|
||||
expect(liveElapsed(null, fetched)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// The station clock on screen. The server is the clock of record: every stored value comes from the read model.
|
||||
// The client only adds the time since the fetch while the clock runs, for the ticking display.
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ClockLike {
|
||||
elapsed_ms: number;
|
||||
running?: boolean | undefined;
|
||||
running_since?: string | null | undefined;
|
||||
}
|
||||
|
||||
/** HH:MM:SS. Hours keep counting past 99. */
|
||||
export function fmtClock(ms: number): string {
|
||||
const t = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Hours with one decimal: "1.5 h". */
|
||||
export function fmtHours(ms: number): string {
|
||||
return `${((Number(ms) || 0) / 3_600_000).toFixed(1)} h`;
|
||||
}
|
||||
|
||||
export function isRunning(c: ClockLike | null | undefined): boolean {
|
||||
if (!c) return false;
|
||||
return c.running ?? !!c.running_since;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elapsed time to show now. `fetchedAt` is the client time when the packet arrived; the server's elapsed_ms
|
||||
* was live at that moment. While running, the time since the fetch is added. Never below the server's value.
|
||||
*/
|
||||
export function liveElapsed(c: ClockLike | null | undefined, fetchedAt: number, now: number = Date.now()): number {
|
||||
if (!c) return 0;
|
||||
const base = Math.max(0, Number(c.elapsed_ms) || 0);
|
||||
if (!isRunning(c)) return base;
|
||||
return base + Math.max(0, now - fetchedAt);
|
||||
}
|
||||
|
||||
/** Re-renders every second while `running`. Returns the current time. */
|
||||
export function useTicker(running: boolean, everyMs = 1000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setNow(Date.now());
|
||||
const t = window.setInterval(() => setNow(Date.now()), everyMs);
|
||||
return () => window.clearInterval(t);
|
||||
}, [running, everyMs]);
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrowRotation, bpos, clamp01, fitView, markRadius, toFraction, toPixels, zoomAt, type View } from "./geometry";
|
||||
|
||||
describe("geometry", () => {
|
||||
it("clamp01 keeps a margin", () => {
|
||||
expect(clamp01(-1)).toBe(0.01);
|
||||
expect(clamp01(2)).toBe(0.99);
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("bpos defaults right and up, left near the right edge, down near the top", () => {
|
||||
const mid = bpos({ fx: 0.5, fy: 0.5 });
|
||||
expect(mid.bfx).toBeCloseTo(0.55);
|
||||
expect(mid.bfy).toBeCloseTo(0.43);
|
||||
const right = bpos({ fx: 0.9, fy: 0.5 });
|
||||
expect(right.bfx).toBeCloseTo(0.85);
|
||||
const top = bpos({ fx: 0.5, fy: 0.05 });
|
||||
expect(top.bfy).toBeCloseTo(0.12);
|
||||
const corner = bpos({ fx: 0.99, fy: 0.0 });
|
||||
expect(corner.bfx).toBeCloseTo(0.94);
|
||||
expect(corner.bfy).toBeCloseTo(0.07);
|
||||
expect(bpos({ fx: 0.995, fy: 0.5 }).bfx).toBeLessThan(0.99);
|
||||
});
|
||||
|
||||
it("bpos keeps a stored label", () => {
|
||||
expect(bpos({ fx: 0.5, fy: 0.5, bfx: 0.2, bfy: 0.3 })).toEqual({ bfx: 0.2, bfy: 0.3 });
|
||||
});
|
||||
|
||||
it("fractions to pixels and back on a few transforms", () => {
|
||||
const frame = { left: 100, top: 50 };
|
||||
const views: View[] = [
|
||||
{ x: 0, y: 0, z: 1, w: 1000, h: 700 },
|
||||
{ x: 40, y: -20, z: 0.5, w: 4200, h: 2970 },
|
||||
{ x: -300, y: 120, z: 2.75, w: 2000, h: 1400 },
|
||||
];
|
||||
for (const v of views) {
|
||||
for (const [fx, fy] of [
|
||||
[0, 0],
|
||||
[0.25, 0.75],
|
||||
[1, 1],
|
||||
] as const) {
|
||||
const { px, py } = toPixels(fx, fy, v);
|
||||
const back = toFraction(px + frame.left, py + frame.top, frame, v);
|
||||
expect(back.fx).toBeCloseTo(fx, 9);
|
||||
expect(back.fy).toBeCloseTo(fy, 9);
|
||||
}
|
||||
}
|
||||
expect(toPixels(0.5, 0.5, views[0]!)).toEqual({ px: 500, py: 350 });
|
||||
});
|
||||
|
||||
it("arrowRotation points from the bubble to the anchor", () => {
|
||||
expect(arrowRotation(0.5, 0.5, 0.4, 0.5, 1.5)).toBeCloseTo(0);
|
||||
expect(arrowRotation(0.4, 0.5, 0.5, 0.5, 1.5)).toBeCloseTo(180);
|
||||
expect(arrowRotation(0.5, 0.6, 0.5, 0.5, 1.5)).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it("markRadius has a floor", () => {
|
||||
expect(markRadius(1000)).toBe(18);
|
||||
expect(markRadius(4200)).toBe(28);
|
||||
});
|
||||
|
||||
it("fitView centres and zoomAt keeps the point under the cursor", () => {
|
||||
const v = fitView(800, 600, 1600, 800);
|
||||
expect(v.z).toBe(0.5);
|
||||
expect(v.y).toBe(100);
|
||||
const z = zoomAt(v, 2, 400, 300, 0.1);
|
||||
const before = toFraction(400, 300, { left: 0, top: 0 }, v);
|
||||
const after = toFraction(400, 300, { left: 0, top: 0 }, z);
|
||||
expect(after.fx).toBeCloseTo(before.fx);
|
||||
expect(after.fy).toBeCloseTo(before.fy);
|
||||
expect(zoomAt(v, 100, 0, 0, 0.1).z).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Mark positions on a drawing page. Every position is a fraction of the page (0..1), so a mark stays on its
|
||||
// joint whatever the zoom or the bitmap size. A port of the old build's clamp01, bpos, arrowRot and toNorm.
|
||||
|
||||
/** The viewer transform: the stage is translated by (x, y) and scaled by z. w and h are the page size at z = 1. */
|
||||
export interface View {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A point on the page, as fractions. */
|
||||
export interface Frac {
|
||||
fx: number;
|
||||
fy: number;
|
||||
}
|
||||
|
||||
/** Marks keep a small margin from the page edge. */
|
||||
export function clamp01(v: number): number {
|
||||
return Math.max(0.01, Math.min(0.99, v));
|
||||
}
|
||||
|
||||
/** Plain 0..1 clamp, for drops and table corners. */
|
||||
export function clamp(v: number, lo = 0, hi = 1): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
export interface Placed {
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
bfx?: number | null | undefined;
|
||||
bfy?: number | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the label (bubble) sits. A stored bfx, bfy wins. Otherwise the old default: a little right of the arrow
|
||||
* point (left near the right edge) and a little above it (below near the top edge).
|
||||
*/
|
||||
export function bpos(m: Placed): { bfx: number; bfy: number } {
|
||||
const fx = m.fx ?? 0.5;
|
||||
const fy = m.fy ?? 0.5;
|
||||
return {
|
||||
bfx: m.bfx ?? clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)),
|
||||
bfy: m.bfy ?? clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A client point to page fractions. `frame` is the viewer's bounding box. Not clamped. */
|
||||
export function toFraction(clientX: number, clientY: number, frame: { left: number; top: number }, view: View): Frac {
|
||||
if (!view.w || !view.h || !view.z) return { fx: NaN, fy: NaN };
|
||||
return {
|
||||
fx: (clientX - frame.left - view.x) / view.z / view.w,
|
||||
fy: (clientY - frame.top - view.y) / view.z / view.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** Page fractions to pixels inside the viewer frame. The inverse of toFraction. */
|
||||
export function toPixels(fx: number, fy: number, view: View): { px: number; py: number } {
|
||||
return { px: view.x + fx * view.w * view.z, py: view.y + fy * view.h * view.z };
|
||||
}
|
||||
|
||||
/** True when the fraction lies on the page. */
|
||||
export function onPage(f: Frac): boolean {
|
||||
return f.fx >= 0 && f.fx <= 1 && f.fy >= 0 && f.fy <= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrowhead's rotation in degrees: it points from the bubble (bx, by) to the anchor (ax, ay).
|
||||
* The fractions are scaled by the page aspect (width / height) so the angle is right on a non-square page.
|
||||
*/
|
||||
export function arrowRotation(ax: number, ay: number, bx: number, by: number, aspect: number): number {
|
||||
return (Math.atan2(ay - by, (ax - bx) * (aspect || 1.5)) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/** The weld circle radius in page pixels for the printed raster. */
|
||||
export function markRadius(width: number): number {
|
||||
return Math.max(18, width / 150);
|
||||
}
|
||||
|
||||
/** The fit transform: the whole page in the frame, centred. */
|
||||
export function fitView(frameW: number, frameH: number, w: number, h: number): View {
|
||||
const z = Math.min(frameW / w, frameH / h) || 1;
|
||||
return { x: (frameW - w * z) / 2, y: (frameH - h * z) / 2, z, w, h };
|
||||
}
|
||||
|
||||
/** Zoom by factor f around the frame point (cx, cy), clamped between minZ and maxZ. */
|
||||
export function zoomAt(view: View, f: number, cx: number, cy: number, minZ: number, maxZ = 6): View {
|
||||
const nz = Math.max(minZ, Math.min(maxZ, view.z * f));
|
||||
const k = nz / view.z;
|
||||
return { ...view, x: cx - (cx - view.x) * k, y: cy - (cy - view.y) * k, z: nz };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampCorner, fontPx, heatGroups, heatRows, stepSize } from "./heatTable";
|
||||
|
||||
const rows = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||
|
||||
describe("heatTable", () => {
|
||||
it("heatRows shows N/A and leaves a missing heat empty", () => {
|
||||
expect(heatRows([{ item: 1, heat: "H123" }, { item: 2, na: true }, { item: 3 }])).toEqual([
|
||||
{ item: "1", heat: "H123", na: false },
|
||||
{ item: "2", heat: "N/A", na: true },
|
||||
{ item: "3", heat: "", na: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups at most 12 rows, evenly", () => {
|
||||
expect(heatGroups(rows(12)).map((g) => g.length)).toEqual([12]);
|
||||
expect(heatGroups(rows(13)).map((g) => g.length)).toEqual([7, 6]);
|
||||
expect(heatGroups(rows(24)).map((g) => g.length)).toEqual([12, 12]);
|
||||
expect(heatGroups(rows(25)).map((g) => g.length)).toEqual([9, 9, 7]);
|
||||
expect(heatGroups([])).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("stepSize moves 0.15 and clamps", () => {
|
||||
expect(stepSize(1, 1)).toBe(1.15);
|
||||
expect(stepSize(1.15, 1)).toBe(1.3);
|
||||
expect(stepSize(1, -1)).toBe(0.85);
|
||||
expect(stepSize(0.6, -1)).toBe(0.6);
|
||||
expect(stepSize(0.7, -1)).toBe(0.6);
|
||||
expect(stepSize(2.1, 1)).toBe(2.1);
|
||||
expect(stepSize(2.0, 1)).toBe(2.1);
|
||||
});
|
||||
|
||||
it("fontPx follows the page width and size", () => {
|
||||
expect(fontPx(4000, 1)).toBe(30);
|
||||
expect(fontPx(4000, 2)).toBe(60);
|
||||
});
|
||||
|
||||
it("clampCorner keeps the corner on the page", () => {
|
||||
expect(clampCorner(-0.2, 1.5)).toEqual({ x: 0, y: 0.97 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// The heat table: ITEM | HEAT NO., placed on a drawing sheet. A port of the old build's heattable.js.
|
||||
// The table always shows the live BOM heats, so a heat fixed in Materials updates the drawing with no re-placing.
|
||||
|
||||
export const HT_ROWS = 12;
|
||||
export const HT_FONT = 0.0075;
|
||||
export const HT_MIN = 0.6;
|
||||
export const HT_MAX = 2.1;
|
||||
export const HT_STEP = 0.15;
|
||||
export const HEAT_COLOR = "#b07800";
|
||||
export const HEAT_MISSING = "#b00020";
|
||||
export const HEAT_NA = "#6b5a3a";
|
||||
/** The red dash a missing heat shows. */
|
||||
export const NO_HEAT = "—";
|
||||
|
||||
export interface HeatRow {
|
||||
item: string;
|
||||
/** "N/A" on an N/A line, "" when the heat is missing. */
|
||||
heat: string;
|
||||
na: boolean;
|
||||
}
|
||||
|
||||
export interface HeatBomLine {
|
||||
item: number | string;
|
||||
heat?: string | null | undefined;
|
||||
na?: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function heatRows(bom: readonly HeatBomLine[]): HeatRow[] {
|
||||
return bom.map((b) => ({ item: String(b.item), heat: b.heat || (b.na ? "N/A" : ""), na: !!b.na }));
|
||||
}
|
||||
|
||||
/** At most 12 rows per group, the groups as even as they can be. Always at least one (maybe empty) group. */
|
||||
export function heatGroups<T>(rows: readonly T[]): T[][] {
|
||||
const n = rows.length;
|
||||
const per = n > HT_ROWS ? Math.ceil(n / Math.ceil(n / HT_ROWS)) : HT_ROWS;
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < n; i += per) out.push(rows.slice(i, i + per));
|
||||
return out.length ? out : [[]];
|
||||
}
|
||||
|
||||
/** The table's font size in page pixels. */
|
||||
export function fontPx(pageWidth: number, s: number): number {
|
||||
return pageWidth * HT_FONT * s;
|
||||
}
|
||||
|
||||
/** One A- (dir -1) or A+ (dir 1) step, clamped to 0.6..2.1 and rounded to 2 decimals. */
|
||||
export function stepSize(s: number, dir: number): number {
|
||||
const v = Math.max(HT_MIN, Math.min(HT_MAX, (s || 1) + HT_STEP * Math.sign(dir)));
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
|
||||
/** The table's top-left corner stays on the page. */
|
||||
export function clampCorner(x: number, y: number): { x: number; y: number } {
|
||||
return { x: Math.max(0, Math.min(0.97, x)), y: Math.max(0, Math.min(0.97, y)) };
|
||||
}
|
||||
|
||||
/** Draws the table into a page raster (print, full-screen copy). A port of htDrawCanvas. */
|
||||
export function drawHeatTable(
|
||||
c: CanvasRenderingContext2D,
|
||||
placement: { x: number; y: number; s: number },
|
||||
rows: readonly HeatRow[],
|
||||
W: number,
|
||||
H: number,
|
||||
): void {
|
||||
const fs = fontPx(W, placement.s || 1);
|
||||
const padX = fs * 0.5;
|
||||
const padY = fs * 0.2;
|
||||
const rowH = fs * 1.15 + padY * 2;
|
||||
const bw = Math.max(1.5, fs * 0.08);
|
||||
const col = HEAT_COLOR;
|
||||
const groups = heatGroups(rows);
|
||||
c.save();
|
||||
c.textBaseline = "middle";
|
||||
const thF = `bold ${fs * 0.8}px Arial`;
|
||||
const tdF = `bold ${fs}px Arial`;
|
||||
const monoF = `bold ${fs}px "Courier New", monospace`;
|
||||
const widths = groups.map((g) => {
|
||||
c.font = thF;
|
||||
let wi = c.measureText("ITEM").width;
|
||||
let wh = c.measureText("HEAT NO.").width;
|
||||
c.font = tdF;
|
||||
for (const r of g) wi = Math.max(wi, c.measureText(r.item).width);
|
||||
c.font = monoF;
|
||||
for (const r of g) wh = Math.max(wh, c.measureText(r.heat || NO_HEAT).width);
|
||||
return [wi + padX * 2, wh + padX * 2] as const;
|
||||
});
|
||||
const totalW = widths.reduce((a, [i, h]) => a + i + h, 0);
|
||||
const capH = rowH;
|
||||
const maxRows = Math.max(...groups.map((g) => g.length));
|
||||
const totalH = capH + rowH * (maxRows + 1);
|
||||
const x0 = placement.x * W;
|
||||
const y0 = placement.y * H;
|
||||
c.fillStyle = "#fff";
|
||||
c.fillRect(x0, y0, totalW, totalH);
|
||||
c.fillStyle = col;
|
||||
c.fillRect(x0, y0, totalW, capH);
|
||||
c.fillStyle = "#fff";
|
||||
c.font = `bold ${fs * 0.85}px Arial`;
|
||||
c.textAlign = "center";
|
||||
c.fillText("HEAT NUMBERS", x0 + totalW / 2, y0 + capH / 2);
|
||||
let gx = x0;
|
||||
groups.forEach((g, gi) => {
|
||||
const [wi, wh] = widths[gi] ?? [0, 0];
|
||||
let y = y0 + capH;
|
||||
c.fillStyle = "#f3e2b8";
|
||||
c.fillRect(gx, y, wi + wh, rowH);
|
||||
c.fillStyle = "#3d2a00";
|
||||
c.font = thF;
|
||||
c.textAlign = "center";
|
||||
c.fillText("ITEM", gx + wi / 2, y + rowH / 2);
|
||||
c.fillText("HEAT NO.", gx + wi + wh / 2, y + rowH / 2);
|
||||
c.strokeStyle = col;
|
||||
c.lineWidth = bw;
|
||||
c.strokeRect(gx, y, wi, rowH);
|
||||
c.strokeRect(gx + wi, y, wh, rowH);
|
||||
y += rowH;
|
||||
for (const r of g) {
|
||||
c.font = tdF;
|
||||
c.fillStyle = "#2b1d00";
|
||||
c.textAlign = "center";
|
||||
c.fillText(r.item, gx + wi / 2, y + rowH / 2);
|
||||
c.font = monoF;
|
||||
c.textAlign = "left";
|
||||
c.fillStyle = r.heat ? (r.na ? HEAT_NA : "#2b1d00") : HEAT_MISSING;
|
||||
c.fillText(r.heat || NO_HEAT, gx + wi + padX, y + rowH / 2);
|
||||
c.strokeRect(gx, y, wi, rowH);
|
||||
c.strokeRect(gx + wi, y, wh, rowH);
|
||||
y += rowH;
|
||||
}
|
||||
gx += wi + wh;
|
||||
});
|
||||
c.lineWidth = bw * 2;
|
||||
c.strokeStyle = col;
|
||||
c.strokeRect(x0, y0, totalW, totalH);
|
||||
c.restore();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { voidText } from "./voidStamp";
|
||||
|
||||
describe("voidText", () => {
|
||||
it("names the CO, the new sheet and the date", () => {
|
||||
const at = new Date(2026, 9, 16, 12).toISOString();
|
||||
expect(voidText({ void: { co_id: 9, co_no: 2, at, new_n: 1001, new: { n: 1001, dwg: "CI-DWG-1", rev: "B" } } })).toBe(
|
||||
"VOID - superseded by CO-2 (CI-DWG-1 Rev B) 10/16/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the new sheet in the pages when the read model gives only new_n", () => {
|
||||
const at = new Date(2026, 0, 5, 12).toISOString();
|
||||
expect(voidText({ void: { co_id: 9, co_no: 3, at, new_n: 4 } }, [{ n: 4, dwg: null, rev: null }])).toBe(
|
||||
"VOID - superseded by CO-3 (new sheet Rev ?) 1/5/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("is empty for a live page", () => {
|
||||
expect(voidText({ void: null })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// The VOID stamp on a superseded drawing sheet. A port of the old build's voidText and stampVoidCanvas.
|
||||
import type { PacketPage } from "../api/types";
|
||||
|
||||
/** "10/16/2026", the shop's date format. */
|
||||
function usDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** `VOID - superseded by CO-2 (dwg Rev B) 10/16/2026`. Empty for a live page. */
|
||||
export function voidText(page: Pick<PacketPage, "void">, pages: ReadonlyArray<Pick<PacketPage, "n" | "dwg" | "rev">> = []): string {
|
||||
const v = page.void;
|
||||
if (!v) return "";
|
||||
const co = v.co_no !== undefined && v.co_no !== null ? `CO-${v.co_no}` : "a change order";
|
||||
const nw = v.new ?? (v.new_n !== null ? pages.find((x) => x.n === v.new_n) : undefined) ?? null;
|
||||
const sheet = nw ? ` (${nw.dwg || "new sheet"} Rev ${nw.rev || "?"})` : "";
|
||||
const day = usDate(v.at);
|
||||
return `VOID - superseded by ${co}${sheet}${day ? ` ${day}` : ""}`;
|
||||
}
|
||||
|
||||
/** Stamps a page raster: a big diagonal VOID at low opacity, the text under it, a red border. */
|
||||
export function stampVoid(canvas: HTMLCanvasElement, text: string): HTMLCanvasElement {
|
||||
const c = canvas.getContext("2d");
|
||||
if (!c) return canvas;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
c.save();
|
||||
c.translate(w / 2, h / 2);
|
||||
c.rotate(-Math.atan2(h, w) * 0.85);
|
||||
c.textAlign = "center";
|
||||
c.textBaseline = "middle";
|
||||
c.font = `bold ${Math.round(w / 5.5)}px Arial`;
|
||||
c.lineWidth = Math.max(4, w / 260);
|
||||
c.strokeStyle = "rgba(200,0,0,.55)";
|
||||
c.fillStyle = "rgba(220,0,0,.3)";
|
||||
c.fillText("VOID", 0, 0);
|
||||
c.strokeText("VOID", 0, 0);
|
||||
c.font = `bold ${Math.round(w / 55)}px Arial`;
|
||||
c.fillStyle = "rgba(200,0,0,.8)";
|
||||
c.fillText(text, 0, w / 9);
|
||||
c.restore();
|
||||
const lw = Math.max(6, w / 200);
|
||||
c.strokeStyle = "rgba(200,0,0,.7)";
|
||||
c.lineWidth = lw;
|
||||
c.strokeRect(lw / 2, lw / 2, w - lw, h - lw);
|
||||
return canvas;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The weld vocabulary from the old build (app.js WELD_TYPES, PROCESSES). The server checks the same lists.
|
||||
import type { PacketRead, Weld } from "../api/types";
|
||||
|
||||
export const WELD_TYPES = ["Fillet", "Groove", "Fill", "Seal", "Tack"] as const;
|
||||
export const PROCESSES = ["GMAW", "FCAW", "SMAW", "GTAW", "SAW"] as const;
|
||||
export const DEFAULT_SIZE = '5/16"';
|
||||
export const DEFAULT_TYPE = "Fillet";
|
||||
export const DEFAULT_PROCESS = "GMAW";
|
||||
export const WELD_COLOR = "#c2185b";
|
||||
|
||||
/** "W4". */
|
||||
export function weldId(w: Pick<Weld, "weld_no">): string {
|
||||
return `W${w.weld_no}`;
|
||||
}
|
||||
|
||||
/** The process of the newest weld logged in the open stage, else GMAW. */
|
||||
export function lastProcess(p: Pick<PacketRead, "welds" | "stage">): string {
|
||||
const stageId = p.stage?.id;
|
||||
const mine = p.welds.filter((w) => w.process && !w.carried && (stageId === undefined || w.stage_id == null || w.stage_id === stageId));
|
||||
const last = [...mine].sort((a, b) => b.weld_no - a.weld_no)[0];
|
||||
return last?.process ?? DEFAULT_PROCESS;
|
||||
}
|
||||
|
||||
/** `Fillet 5/16" · items 1 + 2`. */
|
||||
export function weldSummary(w: Pick<Weld, "type" | "size" | "items">): string {
|
||||
const desc = [w.type, w.size].filter(Boolean).join(" ");
|
||||
const items = w.items.length ? `items ${w.items.join(" + ")}` : "";
|
||||
return [desc, items].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The packet's work order bases, once each, with the largest lot quantity for the piece list. */
|
||||
export function woChoices(p: Pick<PacketRead, "work_orders">): Array<{ base: string; qty: number }> {
|
||||
const out = new Map<string, number>();
|
||||
for (const w of p.work_orders) {
|
||||
if (w.gone_at) continue;
|
||||
const q = Math.max(1, Math.floor(Number(w.qty) || 1));
|
||||
out.set(w.wo_base, Math.max(out.get(w.wo_base) ?? 1, q));
|
||||
}
|
||||
return [...out.entries()].map(([base, qty]) => ({ base, qty }));
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState } from "react";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { ChangeOrder, PacketRead } from "../api/types";
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { coSummary, feedOrigin } from "../lib/changeOrders";
|
||||
import { fmtTime } from "../lib/format";
|
||||
import { canVerifyDept } from "./shopUtil";
|
||||
import { ReviewPanel } from "./visual";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
export const SIGN_CO_VERIFY = "Verify a change order for the department";
|
||||
|
||||
const isCl = (roles: readonly string[]) =>
|
||||
roles.some(
|
||||
(r) => r === "clerical" || r === "clerical_supervisor" || r === "admin",
|
||||
);
|
||||
|
||||
const coLabel = (co: ChangeOrder) => co.label ?? `CO-${co.co_no}`;
|
||||
const pendingOf = (co: ChangeOrder) =>
|
||||
co.pending_depts ?? co.required.filter((d) => !co.verified[d]);
|
||||
|
||||
function coPill(co: ChangeOrder) {
|
||||
if (co.review === "rejected")
|
||||
return <span className="pill st-filed">Rejected</span>;
|
||||
if (co.review === "pending")
|
||||
return <span className="pill st-review">Waiting on Clerical</span>;
|
||||
const open = pendingOf(co);
|
||||
if (open.length)
|
||||
return (
|
||||
<span className="pill st-review">Open - {open.length} to verify</span>
|
||||
);
|
||||
return <span className="pill st-in_progress">Verified</span>;
|
||||
}
|
||||
|
||||
/** The supervisor's verify box for one department: the confirmation, a note, Verify and the signature. */
|
||||
function VerifyBox({
|
||||
p,
|
||||
co,
|
||||
dept,
|
||||
deptName,
|
||||
mutate,
|
||||
busy,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
co: ChangeOrder;
|
||||
dept: string;
|
||||
deptName: string;
|
||||
mutate: Mutate;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [ok, setOk] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const label = coLabel(co);
|
||||
const verify = () =>
|
||||
mutate(
|
||||
(g) =>
|
||||
packetsApi.coVerify(p.id, co.co_no, {
|
||||
dept,
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...g,
|
||||
}),
|
||||
{
|
||||
sign: SIGN_CO_VERIFY,
|
||||
ok: `${label} verified for ${deptName}.`,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="panel stack verifybox"
|
||||
role="group"
|
||||
aria-label={`Verify ${label} for ${deptName}`}
|
||||
>
|
||||
<h3>
|
||||
Verify {label} for {deptName}
|
||||
</h3>
|
||||
<div className="small">
|
||||
Review the new sheet and the changes with your department. Sign-offs on
|
||||
this packet stay on hold until every department verifies.
|
||||
</div>
|
||||
<label className="ckl">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ok}
|
||||
onChange={(e) => setOk(e.target.checked)}
|
||||
/>
|
||||
I reviewed this change with my department
|
||||
</label>
|
||||
<label className="f">
|
||||
Note (optional)
|
||||
<input
|
||||
aria-label="Note (optional)"
|
||||
className="in"
|
||||
value={note}
|
||||
maxLength={500}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="acts">
|
||||
<button
|
||||
className="btn pri"
|
||||
type="button"
|
||||
disabled={!ok || busy}
|
||||
onClick={() => void verify()}
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
{!ok ? (
|
||||
<span className="small muted">
|
||||
Tick the box to confirm you reviewed the change.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Changes tab: every change order, newest first. Clerical reviews the feed ones; supervisors verify here. */
|
||||
export function ChangesTab({
|
||||
p,
|
||||
roles,
|
||||
sub,
|
||||
mutate,
|
||||
busy,
|
||||
askReason,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
roles: readonly string[];
|
||||
sub: string;
|
||||
mutate: Mutate;
|
||||
busy: boolean;
|
||||
askReason: (opts: ReasonOptions) => Promise<string | null>;
|
||||
}) {
|
||||
const { departments } = useAuth();
|
||||
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
|
||||
if (!list.length)
|
||||
return <div className="empty">No change orders on this packet.</div>;
|
||||
const canReview = isCl(roles) || p.can.includes("co_approve");
|
||||
const deptName = (code: string) => {
|
||||
if (code === "WH") return "Warehouse";
|
||||
if (code === "QC") return "Q.C.";
|
||||
const d = departments.find((x) => x.code === code);
|
||||
return d ? `${d.code} ${d.name}` : code;
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((co) => {
|
||||
const feed = feedOrigin(co);
|
||||
const label = coLabel(co);
|
||||
const pending = pendingOf(co);
|
||||
const mine =
|
||||
co.review === "approved"
|
||||
? pending.filter((d) => canVerifyDept(roles, d))
|
||||
: [];
|
||||
return (
|
||||
<section
|
||||
key={co.id}
|
||||
className="sec"
|
||||
aria-label={`Change order ${label}`}
|
||||
>
|
||||
<div className="sec-h flush">
|
||||
<h2>
|
||||
{label}
|
||||
{co.ecn ? ` · ${co.ecn}` : ""}
|
||||
</h2>
|
||||
{coPill(co)}
|
||||
<span className="ct">
|
||||
{feed
|
||||
? co.origin === "drawing"
|
||||
? "from M-Files"
|
||||
: "from VISUAL"
|
||||
: `issued by ${co.issued_by?.display_name ?? "-"}`}{" "}
|
||||
{fmtTime(co.issued_at)}
|
||||
{co.hold ? " · shop packet on hold" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
<b>{coSummary(co)}</b>
|
||||
</div>
|
||||
{co.review === "pending" && feed ? (
|
||||
canReview ? (
|
||||
<ReviewPanel
|
||||
p={p}
|
||||
co={co}
|
||||
sub={sub}
|
||||
mutate={mutate}
|
||||
busy={busy}
|
||||
askReason={askReason}
|
||||
canReview={canReview}
|
||||
/>
|
||||
) : (
|
||||
<div className="callout warn flush">
|
||||
<b>Clerical is reviewing a change from VISUAL.</b> Keep
|
||||
working to the current drawing. If Clerical approves it, the
|
||||
new drawing and changes come onto this packet and you will be
|
||||
asked to verify.
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{co.review === "rejected" ? (
|
||||
<div className="callout flush">
|
||||
{co.rejected_by ? (
|
||||
<>
|
||||
<b>Rejected by Clerical</b> - {co.rejected_by.display_name}{" "}
|
||||
{fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The
|
||||
shop packet was not changed.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Withdrawn</b> {fmtTime(co.rejected_at)}:{" "}
|
||||
{co.rejected_reason ??
|
||||
"VISUAL went back to what the packet has"}
|
||||
. The shop packet was not changed.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && co.approved_by ? (
|
||||
<div className="small">
|
||||
<b>Approved by Clerical:</b> {co.approved_by.display_name}{" "}
|
||||
{fmtTime(co.approved_at)}
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && feed && co.vis?.changes?.length ? (
|
||||
<div className="small">
|
||||
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="small">
|
||||
<b>Reason:</b> {co.reason || "-"}
|
||||
</div>
|
||||
{co.items.length ? (
|
||||
<div className="tw">
|
||||
<table aria-label={`${label} sheets`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Old sheet</th>
|
||||
<th>New sheet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{co.items.map((it, i) => (
|
||||
<tr key={i}>
|
||||
<td className="small">
|
||||
{it.added ? (
|
||||
<span className="muted">Added</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="pill st-filed">VOID</span>{" "}
|
||||
{it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
|
||||
{it.old_page ? (
|
||||
<span className="muted">
|
||||
{" "}
|
||||
· page {it.old_page}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="small">
|
||||
<span className="pill st-in_progress">
|
||||
{it.added ? "ADDED" : "NEW"}
|
||||
</span>{" "}
|
||||
{it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
|
||||
{it.new_page ? (
|
||||
<span className="muted"> · page {it.new_page}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && co.required.length ? (
|
||||
<div className="tw">
|
||||
<table aria-label={`${label} verification`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th>Verified</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{co.required.map((d) => {
|
||||
const ver = co.verified[d];
|
||||
return (
|
||||
<tr key={d}>
|
||||
<td>{deptName(d)}</td>
|
||||
<td className="small">
|
||||
{ver ? (
|
||||
<>
|
||||
✓ {ver.by?.display_name ?? "-"}{" "}
|
||||
{fmtTime(ver.at)}
|
||||
{ver.signed ? (
|
||||
<span className="muted"> · signed</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="due soon">
|
||||
Pending{mine.includes(d) ? " · you" : ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="small">{ver?.note ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && !co.required.length ? (
|
||||
<div className="small muted">
|
||||
No department needs to verify this change.
|
||||
</div>
|
||||
) : null}
|
||||
{mine.map((d) => (
|
||||
<VerifyBox
|
||||
key={d}
|
||||
p={p}
|
||||
co={co}
|
||||
dept={d}
|
||||
deptName={deptName(d)}
|
||||
mutate={mutate}
|
||||
busy={busy}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import type { Write } from "../warehouse/PullTable";
|
||||
import { WeldRecord } from "./WeldRecord";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
/**
|
||||
* The stage checklist, grouped as the department's template, with the weld record beside it. Each tick is one PUT
|
||||
* for its key; ticks of different keys never clash.
|
||||
*/
|
||||
export function ChecklistTab({
|
||||
p,
|
||||
write,
|
||||
mutate,
|
||||
reload,
|
||||
onError,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
write: Write;
|
||||
mutate: Mutate;
|
||||
reload: () => Promise<void>;
|
||||
onError: (text: string) => void;
|
||||
}) {
|
||||
const editable = !!p.stage && p.can.includes("edit_checklist");
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
const state = p.stage?.checklist ?? {};
|
||||
const groups: Array<{ group: string; items: PacketRead["checklist_items"] }> = [];
|
||||
for (const it of p.checklist_items) {
|
||||
const g = groups.find((x) => x.group === it.group);
|
||||
if (g) g.items.push(it);
|
||||
else groups.push({ group: it.group, items: [it] });
|
||||
}
|
||||
|
||||
const tick = async (key: string, done: boolean) => {
|
||||
setPending((o) => ({ ...o, [key]: done }));
|
||||
const r = await write((g) => packetsApi.setChecklist(p.id, key, done, g));
|
||||
setPending((o) => {
|
||||
const n = { ...o };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
if (!r.ok && r.err !== undefined) {
|
||||
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
|
||||
onError(errorText(r.err));
|
||||
}
|
||||
};
|
||||
|
||||
const weld = p.dept.weld !== false;
|
||||
|
||||
return (
|
||||
<div className="cklay">
|
||||
<div className="ckmain">
|
||||
{groups.length ? null : <div className="empty">No checklist for this department.</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 = state[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={!editable}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
disabled={!editable || pending[i.key] !== undefined}
|
||||
onChange={(e) => void tick(i.key, e.target.checked)}
|
||||
/>
|
||||
<span className="t">{i.text}</span>
|
||||
<span className="by">{by}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!editable && groups.length ? <div className="small muted">Checklist is completed at the stall.</div> : null}
|
||||
</div>
|
||||
{weld ? (
|
||||
<div className="ckside">
|
||||
<WeldRecord p={p} mutate={mutate} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { HeatTag, PacketPage, PacketRead } from "../api/types";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { onPage } from "../lib/geometry";
|
||||
import {
|
||||
clampCorner,
|
||||
heatRows,
|
||||
NO_HEAT,
|
||||
stepSize,
|
||||
type HeatRow,
|
||||
} from "../lib/heatTable";
|
||||
import { errorText } from "../lib/format";
|
||||
import { voidText } from "../lib/voidStamp";
|
||||
import type { Write, WriteResult } from "../warehouse/PullTable";
|
||||
import { MapViewer } from "./MapViewer";
|
||||
import {
|
||||
HeatTableInner,
|
||||
HeatTableOnDrawing,
|
||||
HeatTagMark,
|
||||
type MarkMove,
|
||||
} from "./marks";
|
||||
import { landingPage, pageLabel, viewPages } from "./shopUtil";
|
||||
import { useSerial } from "./useSerial";
|
||||
import type { ViewerApi } from "./viewerContext";
|
||||
|
||||
const sheetName = (pg: PacketPage | undefined, n: number) =>
|
||||
pg?.dwg || `sheet ${n}`;
|
||||
|
||||
/** The heat table panel's grab: drag the table from here onto the drawing. */
|
||||
function HeatGrab({
|
||||
rows,
|
||||
apiRef,
|
||||
onDrop,
|
||||
onMiss,
|
||||
}: {
|
||||
rows: readonly HeatRow[];
|
||||
apiRef: MutableRefObject<ViewerApi | null>;
|
||||
onDrop: (x: number, y: number) => void;
|
||||
onMiss: () => void;
|
||||
}) {
|
||||
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const down = (e: ReactPointerEvent) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
const id = e.pointerId;
|
||||
let started = false;
|
||||
const mv = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== id) return;
|
||||
if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 8) return;
|
||||
started = true;
|
||||
setGhost({ x: ev.clientX, y: ev.clientY });
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== id) return;
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
setGhost(null);
|
||||
if (!started || ev.type !== "pointerup") return;
|
||||
const f = apiRef.current?.toFrac(ev.clientX, ev.clientY);
|
||||
if (f && f.inside && onPage(f)) {
|
||||
const c = clampCorner(f.fx, f.fy);
|
||||
onDrop(c.x, c.y);
|
||||
} else onMiss();
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="htprev htgrab"
|
||||
data-testid="heat-table-grab"
|
||||
title="Drag onto the drawing"
|
||||
onPointerDown={down}
|
||||
>
|
||||
<div className="small muted mb6">Drag me onto the drawing</div>
|
||||
<div className="htbl static">
|
||||
<HeatTableInner rows={rows} />
|
||||
</div>
|
||||
</div>
|
||||
{ghost ? (
|
||||
<div
|
||||
className="htbl htghost"
|
||||
style={{ left: ghost.x, top: ghost.y }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<HeatTableInner rows={rows} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagSheet({
|
||||
p,
|
||||
tag,
|
||||
sub,
|
||||
onSave,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
tag: HeatTag;
|
||||
sub: string;
|
||||
onSave: (item: number) => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [item, setItem] = useState(tag.item);
|
||||
return (
|
||||
<Sheet title={`Heat tag H${tag.tag_no}`} sub={sub} onClose={onClose}>
|
||||
<label className="f">
|
||||
Points to BOM item
|
||||
<select
|
||||
aria-label="Points to BOM item"
|
||||
className="in"
|
||||
value={item}
|
||||
onChange={(e) => setItem(Number(e.target.value))}
|
||||
>
|
||||
{p.bom.map((b) => (
|
||||
<option key={b.item} value={b.item}>
|
||||
{b.item} - {b.description ?? ""} ·{" "}
|
||||
{b.heat || (b.na ? "N/A" : "no heat")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="sheet-acts start">
|
||||
<button
|
||||
className="btn pri"
|
||||
type="button"
|
||||
onClick={() => (item === tag.item ? onClose() : onSave(item))}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button className="btn ghost" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button className="btn ghost pinktxt" type="button" onClick={onRemove}>
|
||||
Remove tag
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heat map: the heat table placed on each sheet (always the live BOM heats), and the older amber heat bubbles
|
||||
* where a sheet has no table. A packet with no heat map and no raw material of its own shows its sibling's map,
|
||||
* read-only.
|
||||
*/
|
||||
export function HeatMapTab({
|
||||
p,
|
||||
write,
|
||||
reload,
|
||||
sub,
|
||||
onError,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
write: Write;
|
||||
reload: () => Promise<void>;
|
||||
sub: string;
|
||||
onError: (text: string) => void;
|
||||
}) {
|
||||
const own = p.heat_table.length > 0 || p.heat_tags.length > 0;
|
||||
const src = !own && p.heat_src ? p.heat_src : null;
|
||||
const allPages = src ? src.pages : p.pages;
|
||||
const pages = viewPages(allPages);
|
||||
const [picked, setPicked] = useState<{
|
||||
n: number;
|
||||
onPurpose: boolean;
|
||||
} | null>(null);
|
||||
const page = landingPage(
|
||||
pages,
|
||||
picked?.n ?? null,
|
||||
picked?.onPurpose ?? false,
|
||||
);
|
||||
const [tagOpen, setTagOpen] = useState<number | null>(null);
|
||||
const apiRef = useRef<ViewerApi | null>(null);
|
||||
const serial = useSerial(p, write);
|
||||
|
||||
if (!page) {
|
||||
return <div className="empty">No drawing pages in this packet.</div>;
|
||||
}
|
||||
|
||||
const bom = src ? src.bom : p.bom;
|
||||
const rows = heatRows(bom);
|
||||
const missing = rows.filter((r) => !r.heat).length;
|
||||
const tables = src ? src.heat_table : p.heat_table;
|
||||
const tags = src ? src.heat_tags : p.heat_tags;
|
||||
const placement = tables.find((t) => t.page === page.n) ?? null;
|
||||
const legacyHere = tags.filter((t) => t.page === page.n);
|
||||
const shownTags = placement ? [] : legacyHere;
|
||||
const isVoid = !!page.void;
|
||||
const editTable = !src && !isVoid && p.can.includes("edit_heat_table");
|
||||
const editTags = !src && !isVoid && p.can.includes("edit_heats");
|
||||
const vText = isVoid ? voidText(page, allPages) : "";
|
||||
const heatOf = (item: number) => {
|
||||
const b = bom.find((x) => x.item === item);
|
||||
return b ? b.heat || (b.na ? "N/A" : "") : "";
|
||||
};
|
||||
const name = sheetName(page, page.n);
|
||||
const tag =
|
||||
tagOpen === null
|
||||
? null
|
||||
: (p.heat_tags.find((t) => t.tag_no === tagOpen) ?? null);
|
||||
|
||||
const done = async (r: WriteResult): Promise<boolean> => {
|
||||
if (r.ok) return true;
|
||||
if (r.err !== undefined) {
|
||||
if (
|
||||
r.err instanceof ApiError &&
|
||||
(r.err.status === 409 || r.err.status === 412)
|
||||
)
|
||||
await reload();
|
||||
onError(errorText(r.err));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cur = (c: PacketRead) =>
|
||||
c.heat_table.find((t) => t.page === page.n) ?? null;
|
||||
const put = (x: number, y: number, s?: number) =>
|
||||
serial((c, g) =>
|
||||
packetsApi.putHeatTable(
|
||||
c.id,
|
||||
page.n,
|
||||
{ x, y, s: s ?? cur(c)?.s ?? 1 },
|
||||
g,
|
||||
),
|
||||
).then(done);
|
||||
const resize = (dir: number) =>
|
||||
serial((c, g) => {
|
||||
const t = cur(c);
|
||||
if (!t) return Promise.resolve(c);
|
||||
const s = stepSize(t.s, dir);
|
||||
return s === t.s
|
||||
? Promise.resolve(c)
|
||||
: packetsApi.putHeatTable(c.id, page.n, { x: t.x, y: t.y, s }, g);
|
||||
}).then(done);
|
||||
const removeTable = () =>
|
||||
serial((c, g) => packetsApi.removeHeatTable(c.id, page.n, g)).then(done);
|
||||
|
||||
const tagRv = (c: PacketRead, t: HeatTag) =>
|
||||
c.heat_tags.find((x) => x.tag_no === t.tag_no)?.row_version ??
|
||||
t.row_version;
|
||||
const moveTag = (t: HeatTag, m: MarkMove) =>
|
||||
serial((c, g) =>
|
||||
packetsApi.patchHeatTag(
|
||||
c.id,
|
||||
t.tag_no,
|
||||
tagRv(c, t),
|
||||
m.bubble
|
||||
? { bfx: m.bfx, bfy: m.bfy }
|
||||
: { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
|
||||
g,
|
||||
),
|
||||
).then(done);
|
||||
|
||||
const tagCount = (item: number) => tags.filter((t) => t.item === item).length;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{src ? (
|
||||
<div className="callout flush" role="note">
|
||||
Heat map from{" "}
|
||||
<Link to={`/packets/${src.packet_id}`}>
|
||||
{src.op ?? "another packet"}
|
||||
</Link>{" "}
|
||||
on this unit (same drawing). Record heats there.
|
||||
</div>
|
||||
) : null}
|
||||
{isVoid ? (
|
||||
<div className="callout warn flush">
|
||||
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record
|
||||
with its heat marks.{" "}
|
||||
{page.void?.new_n ? (
|
||||
<button
|
||||
className="btn sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })
|
||||
}
|
||||
>
|
||||
Go to the current sheet
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pagesel">
|
||||
{pages.length > 1 ? (
|
||||
<div className="chips" role="group" aria-label="Sheets">
|
||||
{pages.map((x) => (
|
||||
<button
|
||||
key={x.n}
|
||||
type="button"
|
||||
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
|
||||
aria-pressed={x.n === page.n}
|
||||
onClick={() => setPicked({ n: x.n, onPurpose: true })}
|
||||
>
|
||||
{pageLabel(x)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="mono small muted">{pageLabel(page)}</span>
|
||||
)}
|
||||
{!editTable ? (
|
||||
<span className="small muted">
|
||||
{placement
|
||||
? "Heat table on this sheet"
|
||||
: "No heat table on this sheet"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dv">
|
||||
<MapViewer
|
||||
page={page}
|
||||
voidLabel={vText}
|
||||
apiRef={apiRef}
|
||||
label="Heat map"
|
||||
fsBar={<span className="fst">{pageLabel(page)}</span>}
|
||||
>
|
||||
{shownTags.map((t) => (
|
||||
<HeatTagMark
|
||||
key={t.tag_no}
|
||||
tag={t}
|
||||
heat={heatOf(t.item)}
|
||||
editable={editTags}
|
||||
onOpen={(x) => {
|
||||
if (editTags) setTagOpen(x.tag_no);
|
||||
}}
|
||||
onMove={moveTag}
|
||||
/>
|
||||
))}
|
||||
{placement ? (
|
||||
<HeatTableOnDrawing
|
||||
placement={placement}
|
||||
rows={rows}
|
||||
editable={editTable}
|
||||
onMove={(x, y) => put(x, y)}
|
||||
/>
|
||||
) : null}
|
||||
</MapViewer>
|
||||
<div className="side">
|
||||
<div className="panel" role="group" aria-label="Heat table panel">
|
||||
<h3>Heat table</h3>
|
||||
<div className="small mb10">
|
||||
{editTable
|
||||
? placement
|
||||
? "Drag the table on the drawing to move it. It always shows the heat numbers from the Materials tab."
|
||||
: "Drag the table onto the drawing, or tap Place on this sheet. Heat numbers fill in from the Materials tab."
|
||||
: placement
|
||||
? "The heat table is on this sheet."
|
||||
: "No heat table on this sheet yet."}
|
||||
</div>
|
||||
{placement?.carried_from ? (
|
||||
<div className="small amber mb10">
|
||||
Copied from the VOID copy. Check its position on the new
|
||||
revision.
|
||||
</div>
|
||||
) : null}
|
||||
{editTable ? (
|
||||
<HeatGrab
|
||||
rows={rows}
|
||||
apiRef={apiRef}
|
||||
onDrop={(x, y) => void put(x, y)}
|
||||
onMiss={() => onError("Drop the table on the drawing.")}
|
||||
/>
|
||||
) : (
|
||||
<div className="htprev">
|
||||
<div className="htbl static">
|
||||
<HeatTableInner rows={rows} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{missing ? (
|
||||
<div className="small pinktxt mt8">
|
||||
{missing} item{missing > 1 ? "s" : ""} with no heat number yet.
|
||||
</div>
|
||||
) : null}
|
||||
{editTable ? (
|
||||
<div className="acts mt10">
|
||||
{placement ? (
|
||||
<>
|
||||
<button
|
||||
className="btn sm"
|
||||
type="button"
|
||||
aria-label="Smaller table"
|
||||
title="Smaller table (A-)"
|
||||
onClick={() => void resize(-1)}
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
className="btn sm"
|
||||
type="button"
|
||||
aria-label="Larger table"
|
||||
title="Larger table (A+)"
|
||||
onClick={() => void resize(1)}
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
type="button"
|
||||
onClick={() => void removeTable()}
|
||||
>
|
||||
Remove from this sheet
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn sm pri"
|
||||
type="button"
|
||||
onClick={() => void put(0.02, 0.02, 1)}
|
||||
>
|
||||
Place on this sheet
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{legacyHere.length ? (
|
||||
<div className="small muted mt8">
|
||||
{legacyHere.length} older heat bubble
|
||||
{legacyHere.length > 1 ? "s are" : " is"} on this sheet
|
||||
{placement ? " (hidden now that the table is placed)" : ""}.
|
||||
</div>
|
||||
) : null}
|
||||
{placement ? (
|
||||
<div className="small muted mt8">
|
||||
Size {placement.s.toFixed(2)} · {name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>Items and heat numbers</h3>
|
||||
<div className="hlist" aria-label="Items and heat numbers">
|
||||
{bom.length ? (
|
||||
bom.map((b) => {
|
||||
const line = p.bom.find((x) => x.item === b.item);
|
||||
const n = tagCount(b.item);
|
||||
return (
|
||||
<div className="row" key={b.item}>
|
||||
<span className="balloon">{b.item}</span>
|
||||
<span className="d">
|
||||
{line?.description ?? ""}
|
||||
<div className="small muted mono">
|
||||
{line?.material ?? ""}
|
||||
{n ? ` · ${n} tag${n > 1 ? "s" : ""} on drawing` : ""}
|
||||
</div>
|
||||
</span>
|
||||
<span className={b.heat ? "heat" : "heat none"}>
|
||||
{b.heat || (b.na ? "N/A" : NO_HEAT)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="small muted">No BOM lines.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tag ? (
|
||||
<TagSheet
|
||||
p={p}
|
||||
tag={tag}
|
||||
sub={sub}
|
||||
onClose={() => setTagOpen(null)}
|
||||
onSave={(item) =>
|
||||
void serial((c, g) =>
|
||||
packetsApi.patchHeatTag(
|
||||
c.id,
|
||||
tag.tag_no,
|
||||
tagRv(c, tag),
|
||||
{ item },
|
||||
g,
|
||||
),
|
||||
)
|
||||
.then(done)
|
||||
.then((ok) => {
|
||||
if (ok) setTagOpen(null);
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
void serial((c, g) =>
|
||||
packetsApi.removeHeatTag(c.id, tag.tag_no, tagRv(c, tag), g),
|
||||
)
|
||||
.then(done)
|
||||
.then((ok) => {
|
||||
if (ok) setTagOpen(null);
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type MutableRefObject,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { PacketPage } from "../api/types";
|
||||
import {
|
||||
fitView,
|
||||
onPage,
|
||||
toFraction,
|
||||
zoomAt,
|
||||
type Frac,
|
||||
type View,
|
||||
} from "../lib/geometry";
|
||||
import { openPdf } from "../lib/pdf";
|
||||
import { stampVoid } from "../lib/voidStamp";
|
||||
import {
|
||||
DRAG_PX,
|
||||
TAP_PX,
|
||||
ViewerCtx as Ctx,
|
||||
renderScale,
|
||||
type DragHandlers,
|
||||
type ViewerApi,
|
||||
type ViewerInfo,
|
||||
} from "./viewerContext";
|
||||
|
||||
interface MapViewerProps {
|
||||
page: PacketPage;
|
||||
/** The text under VOID on a voided page. */
|
||||
voidLabel?: string | undefined;
|
||||
/** marking: crosshair with a pink outline; placing: crosshair with an amber outline. */
|
||||
mode?: "" | "marking" | "placing";
|
||||
/** A tap on the drawing (pointer moved no more than 6 px). */
|
||||
onTap?: ((fx: number, fy: number) => void) | undefined;
|
||||
/** The overlay: marks, leader lines, the heat table. Positioned in percent of the page. */
|
||||
children?: ReactNode;
|
||||
/** Shown at the top left in full screen, for the marking controls. */
|
||||
fsBar?: ReactNode;
|
||||
/** Filled with the viewer's api, for drags that start outside it (the heat table panel). */
|
||||
apiRef?: MutableRefObject<ViewerApi | null>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const ICON_FS = (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
const ICON_EXIT = (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* One drawing page in a pan and zoom viewer. pdf.js renders the page once to a canvas; panning and zooming are a CSS
|
||||
* transform on the stage. Mouse, pen and touch pan on every drag; two fingers pinch; the wheel zooms at the pointer.
|
||||
* Ported from the old wireViewer with its fixes: the text selection is cleared, the browser's own drag is blocked,
|
||||
* stale pointers are cleared, and moves and ups are followed on the window so a drag that leaves the drawing ends.
|
||||
*/
|
||||
export function MapViewer({
|
||||
page,
|
||||
voidLabel,
|
||||
mode = "",
|
||||
onTap,
|
||||
children,
|
||||
fsBar,
|
||||
apiRef,
|
||||
label,
|
||||
}: MapViewerProps) {
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const view = useRef<View>({ x: 0, y: 0, z: 1, w: 0, h: 0 });
|
||||
const fitZ = useRef(1);
|
||||
const atFit = useRef(true);
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [fs, setFs] = useState(false);
|
||||
const tapRef = useRef(onTap);
|
||||
const dragEnd = useRef(0);
|
||||
useEffect(() => {
|
||||
tapRef.current = onTap;
|
||||
});
|
||||
|
||||
const apply = useCallback(() => {
|
||||
const st = stageRef.current;
|
||||
if (!st) return;
|
||||
const v = view.current;
|
||||
st.style.transform = `translate(${v.x}px, ${v.y}px) scale(${v.z})`;
|
||||
st.style.setProperty("--inv", String(1 / v.z));
|
||||
}, []);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
const el = viewerRef.current;
|
||||
const v = view.current;
|
||||
if (!el || !v.w || !v.h) return;
|
||||
const f = fitView(el.clientWidth || v.w, el.clientHeight || v.h, v.w, v.h);
|
||||
fitZ.current = f.z;
|
||||
view.current = f;
|
||||
atFit.current = true;
|
||||
apply();
|
||||
}, [apply]);
|
||||
|
||||
const zoom = useCallback(
|
||||
(f: number, cx?: number, cy?: number) => {
|
||||
const el = viewerRef.current;
|
||||
if (!el || !view.current.w) return;
|
||||
const x = cx ?? el.clientWidth / 2;
|
||||
const y = cy ?? el.clientHeight / 2;
|
||||
view.current = zoomAt(view.current, f, x, y, fitZ.current * 0.6);
|
||||
atFit.current = false;
|
||||
apply();
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
// Render the page once per page.
|
||||
const url = page.url;
|
||||
const pn = page.pn || 1;
|
||||
const voided = !!page.void;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
openPdf(url)
|
||||
.then((doc) => doc.getPage(Math.max(1, Math.min(pn, doc.numPages))))
|
||||
.then(async (pg) => {
|
||||
if (cancelled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const vp = pg.getViewport({
|
||||
scale: renderScale(pg.getViewport({ scale: 1 }).width),
|
||||
});
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
task = pg.render({ canvas, viewport: vp });
|
||||
await task.promise;
|
||||
if (cancelled) return;
|
||||
if (voided) stampVoid(canvas, voidLabel ?? "VOID");
|
||||
view.current = { ...view.current, w: canvas.width, h: canvas.height };
|
||||
setSize({ w: canvas.width, h: canvas.height });
|
||||
setBusy(false);
|
||||
fit();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"name" in err &&
|
||||
(err as { name: string }).name === "RenderingCancelledException"
|
||||
)
|
||||
return;
|
||||
setBusy(false);
|
||||
setError(
|
||||
"The drawing could not be shown. Open it in a new tab instead.",
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task?.cancel();
|
||||
};
|
||||
}, [url, pn, voided, voidLabel, fit]);
|
||||
|
||||
// Refit when the frame changes size and the user has not zoomed.
|
||||
useEffect(() => {
|
||||
const el = viewerRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (atFit.current) fit();
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [fit]);
|
||||
|
||||
// Full screen: a body class; Esc leaves it.
|
||||
useEffect(() => {
|
||||
if (!fs) return;
|
||||
document.body.classList.add("fs-view");
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".scrim")) setFs(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const t = window.setTimeout(fit, 0);
|
||||
return () => {
|
||||
document.body.classList.remove("fs-view");
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.clearTimeout(t);
|
||||
window.setTimeout(fit, 0);
|
||||
};
|
||||
}, [fs, fit]);
|
||||
|
||||
const toFrac = useCallback((clientX: number, clientY: number) => {
|
||||
const el = viewerRef.current;
|
||||
if (!el) return { fx: NaN, fy: NaN, inside: false };
|
||||
const r = el.getBoundingClientRect();
|
||||
const f = toFraction(clientX, clientY, r, view.current);
|
||||
return {
|
||||
...f,
|
||||
inside:
|
||||
clientX >= r.left &&
|
||||
clientX <= r.right &&
|
||||
clientY >= r.top &&
|
||||
clientY <= r.bottom,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pan, pinch and tap. Pointers are tracked on the window.
|
||||
useEffect(() => {
|
||||
const el = viewerRef.current;
|
||||
if (!el) return;
|
||||
const pts = new Map<number, { x: number; y: number }>();
|
||||
let moved = false;
|
||||
let start: { x: number; y: number; vx: number; vy: number } | null = null;
|
||||
let pinch: { d: number; z: number } | null = null;
|
||||
|
||||
const down = (e: PointerEvent) => {
|
||||
const t = e.target as Element | null;
|
||||
if (t?.closest("[data-mark], .vtools, .fsbar")) return;
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) sel.removeAllRanges();
|
||||
} catch {
|
||||
/* nothing selected */
|
||||
}
|
||||
if (e.isPrimary) pts.clear();
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pts.size === 1) {
|
||||
moved = false;
|
||||
start = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
vx: view.current.x,
|
||||
vy: view.current.y,
|
||||
};
|
||||
pinch = null;
|
||||
el.classList.add("panning");
|
||||
}
|
||||
if (pts.size === 2) {
|
||||
const [a, b] = [...pts.values()];
|
||||
if (a && b)
|
||||
pinch = {
|
||||
d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
|
||||
z: view.current.z,
|
||||
};
|
||||
moved = true;
|
||||
}
|
||||
};
|
||||
const move = (e: PointerEvent) => {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pts.size >= 2 && pinch) {
|
||||
const [a, b] = [...pts.values()];
|
||||
if (!a || !b) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
zoom(
|
||||
(pinch.z * d) / pinch.d / view.current.z,
|
||||
(a.x + b.x) / 2 - r.left,
|
||||
(a.y + b.y) / 2 - r.top,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!start) return;
|
||||
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > TAP_PX)
|
||||
moved = true;
|
||||
if (moved && pts.size === 1) {
|
||||
view.current = {
|
||||
...view.current,
|
||||
x: start.vx + (e.clientX - start.x),
|
||||
y: start.vy + (e.clientY - start.y),
|
||||
};
|
||||
atFit.current = false;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
const end = (e: PointerEvent) => {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
pts.delete(e.pointerId);
|
||||
if (pts.size === 1) {
|
||||
// One finger lifted from a pinch: keep panning with the other.
|
||||
const [a] = [...pts.values()];
|
||||
if (a)
|
||||
start = { x: a.x, y: a.y, vx: view.current.x, vy: view.current.y };
|
||||
pinch = null;
|
||||
return;
|
||||
}
|
||||
if (pts.size === 0) {
|
||||
el.classList.remove("panning");
|
||||
if (!moved && view.current.w && e.type === "pointerup") {
|
||||
const f = toFrac(e.clientX, e.clientY);
|
||||
if (onPage(f)) tapRef.current?.(f.fx, f.fy);
|
||||
}
|
||||
start = null;
|
||||
pinch = null;
|
||||
}
|
||||
};
|
||||
const wheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const r = el.getBoundingClientRect();
|
||||
zoom(e.deltaY < 0 ? 1.15 : 0.87, e.clientX - r.left, e.clientY - r.top);
|
||||
};
|
||||
const block = (e: Event) => e.preventDefault();
|
||||
el.addEventListener("pointerdown", down);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", end);
|
||||
window.addEventListener("pointercancel", end);
|
||||
el.addEventListener("wheel", wheel, { passive: false });
|
||||
el.addEventListener("dragstart", block);
|
||||
el.addEventListener("selectstart", block);
|
||||
return () => {
|
||||
el.removeEventListener("pointerdown", down);
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", end);
|
||||
window.removeEventListener("pointercancel", end);
|
||||
el.removeEventListener("wheel", wheel);
|
||||
el.removeEventListener("dragstart", block);
|
||||
el.removeEventListener("selectstart", block);
|
||||
};
|
||||
}, [apply, zoom, toFrac]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: ReactPointerEvent, h: DragHandlers) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
const id = e.pointerId;
|
||||
let moved = false;
|
||||
let last: Frac | null = null;
|
||||
const mv = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== id) return;
|
||||
if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_PX)
|
||||
return;
|
||||
moved = true;
|
||||
const f = toFrac(ev.clientX, ev.clientY);
|
||||
last = { fx: f.fx, fy: f.fy };
|
||||
h.onMove(last);
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== id) return;
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
if (moved) dragEnd.current = Date.now();
|
||||
h.onEnd(moved && ev.type === "pointerup" ? last : null);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
},
|
||||
[toFrac],
|
||||
);
|
||||
|
||||
const justDragged = useCallback(() => Date.now() - dragEnd.current < 300, []);
|
||||
|
||||
const api = useMemo<ViewerApi>(
|
||||
() => ({ toFrac, startDrag, justDragged }),
|
||||
[toFrac, startDrag, justDragged],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!apiRef) return;
|
||||
apiRef.current = api;
|
||||
return () => {
|
||||
apiRef.current = null;
|
||||
};
|
||||
}, [api, apiRef]);
|
||||
|
||||
const info = useMemo<ViewerInfo>(
|
||||
() => ({
|
||||
...api,
|
||||
w: size.w,
|
||||
h: size.h,
|
||||
aspect: size.h ? size.w / size.h : 1.5,
|
||||
}),
|
||||
[api, size],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewerRef}
|
||||
className={`viewer${mode ? ` ${mode}` : ""}`}
|
||||
data-testid="map-viewer"
|
||||
data-ready={busy || error ? "0" : "1"}
|
||||
aria-label={label ?? `Drawing page ${page.n}`}
|
||||
role="group"
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="stage"
|
||||
style={
|
||||
size.w
|
||||
? ({
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
["--sw" as string]: `${size.w}px`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="stage-canvas"
|
||||
role="img"
|
||||
aria-label={`Drawing page ${page.n}${voided ? ", VOID" : ""}`}
|
||||
/>
|
||||
{size.w ? <Ctx.Provider value={info}>{children}</Ctx.Provider> : null}
|
||||
</div>
|
||||
{busy && !error ? (
|
||||
<div className="vload small">Drawing the page</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="vload">
|
||||
<div className="callout warn flush">{error}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="vtools">
|
||||
<button type="button" aria-label="Zoom in" onClick={() => zoom(1.25)}>
|
||||
+
|
||||
</button>
|
||||
<button type="button" aria-label="Zoom out" onClick={() => zoom(0.8)}>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fit drawing"
|
||||
className="fit"
|
||||
onClick={fit}
|
||||
>
|
||||
FIT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={fs ? "Exit full screen" : "Full screen"}
|
||||
title={fs ? "Exit full screen (Esc)" : "Full screen"}
|
||||
onClick={() => setFs((v) => !v)}
|
||||
>
|
||||
{fs ? ICON_EXIT : ICON_FS}
|
||||
</button>
|
||||
</div>
|
||||
{fsBar ? <div className="fsbar">{fsBar}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe("PacketPage", () => {
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step.");
|
||||
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
||||
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
||||
import { homePath } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { usePacketPresence } from "../auth/useEvents";
|
||||
import { useGuard } from "../components/useGuard";
|
||||
import { useChanged } from "../lib/changes";
|
||||
import { errorText } from "../lib/format";
|
||||
@@ -14,12 +15,23 @@ import { DrawingViewer } from "./DrawingViewer";
|
||||
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||
import { coSummary, coToReview } from "../lib/changeOrders";
|
||||
import { ChangesTab, VisualBox } from "./visual";
|
||||
import { VisualBox } from "./visual";
|
||||
import { ChangesTab } from "./ChangesTab";
|
||||
import { ChecklistTab } from "./ChecklistTab";
|
||||
import { HeatMapTab } from "./HeatMapTab";
|
||||
import { ShopBar } from "./ShopActions";
|
||||
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" | "materials" | "workorders" | "changes" | "docs" | "history";
|
||||
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
|
||||
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
|
||||
function isTab(v: string | null): v is Tab {
|
||||
return v !== null && (TABS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const TOAST_MS = 5000;
|
||||
|
||||
/** The rule's signature meaning for attaching a drawing by hand. */
|
||||
const SIGN_ATTACH = "Attach a drawing by hand";
|
||||
@@ -119,9 +131,13 @@ export function PacketPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<Tab | null>(null);
|
||||
const [search] = useSearchParams();
|
||||
const wantTab = search.get("tab");
|
||||
const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null);
|
||||
const [sheet, setSheet] = useState<"dept" | "move" | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
||||
const versionRef = useRef<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -140,14 +156,20 @@ export function PacketPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPacket(null);
|
||||
setTab(null);
|
||||
setTab(isTab(wantTab) ? wantTab : null);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
}, [reload, wantTab]);
|
||||
|
||||
useEffect(() => {
|
||||
versionRef.current = packet?.version ?? null;
|
||||
}, [packet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = window.setTimeout(() => setToast(null), TOAST_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
// Live refresh: another screen changed this packet.
|
||||
useChanged(() => void reload(), {
|
||||
delayMs: 500,
|
||||
@@ -195,7 +217,9 @@ export function PacketPage() {
|
||||
} catch (err) {
|
||||
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
|
||||
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
// A step someone else already took: the page shows their state and says so once, as a toast.
|
||||
if (errorCode(err) === "stale_step") setToast(errorText(err));
|
||||
else setMsg({ ok: false, text: errorText(err) });
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -293,7 +317,7 @@ export function PacketPage() {
|
||||
|
||||
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||
const review = coToReview(p);
|
||||
const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials");
|
||||
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "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 =
|
||||
@@ -322,14 +346,20 @@ export function PacketPage() {
|
||||
const hasDraftControls =
|
||||
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
|
||||
|
||||
const cos = (p.change_orders ?? []).length;
|
||||
const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length;
|
||||
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
||||
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
||||
{ id: "heat", label: "Heat map" },
|
||||
{ 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 },
|
||||
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
|
||||
{ id: "changes", label: "Changes", count: (p.change_orders ?? []).length },
|
||||
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
|
||||
{ id: "docs", label: "Documents", count: p.docs.length },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
const onTabError = (text: string) => setMsg({ ok: false, text });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -446,6 +476,12 @@ export function PacketPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{others.length ? (
|
||||
<div className="presence small" data-testid="presence" role="status">
|
||||
<span className="pdot" aria-hidden="true" />
|
||||
Also here: {others.map((u) => u.display_name).join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{msg ? (
|
||||
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
|
||||
{msg.text}
|
||||
@@ -454,7 +490,7 @@ export function PacketPage() {
|
||||
|
||||
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
|
||||
<div className="callout warn" role="status">
|
||||
<b>Change order {review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
|
||||
<b>Change order CO-{review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
|
||||
{review.hold ? <b> Shop packet on hold.</b> : null}{" "}
|
||||
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
|
||||
Review change
|
||||
@@ -615,6 +651,8 @@ export function PacketPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Packet">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
@@ -648,7 +686,12 @@ export function PacketPage() {
|
||||
<DrawingViewer pages={drawingPages} />
|
||||
</div>
|
||||
) : null}
|
||||
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
|
||||
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
|
||||
{shownTab === "checklist" ? <ChecklistTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
|
||||
{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 === "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}
|
||||
@@ -679,6 +722,11 @@ export function PacketPage() {
|
||||
) : null}
|
||||
{wh.sheet}
|
||||
{guardSheet}
|
||||
{toast ? (
|
||||
<div className="toast" role="status" data-testid="toast">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { fmtClock, isRunning, liveElapsed, useTicker } from "../lib/clock";
|
||||
import { targetHours } from "../lib/packets";
|
||||
import { useShopDepartments } from "../lib/useDepartments";
|
||||
import {
|
||||
makeStep,
|
||||
shopLevel,
|
||||
stallMissing,
|
||||
usePeople,
|
||||
type ShopTab,
|
||||
type StepFn,
|
||||
} from "./shopUtil";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
/** Signature meanings, as the rules state them. */
|
||||
export const SIGN_SUBMIT = "Submit work to the supervisor";
|
||||
export const SIGN_SEND_TO_QC = "Send to Q.C.";
|
||||
export const SIGN_ROUTE = "Route to another department";
|
||||
|
||||
/** The server's precondition texts the stall shows as links instead. */
|
||||
const STALL_PRECONDITIONS = new Set([
|
||||
"Finish the checklist first.",
|
||||
"Record at least one weld first.",
|
||||
"Enter the filler metal first.",
|
||||
]);
|
||||
|
||||
function stationsOf(
|
||||
depts: ReturnType<typeof useAuth>["departments"],
|
||||
code: string,
|
||||
): Array<{ id: number; name: string }> {
|
||||
const d = depts.find((x) => x.code === code);
|
||||
const out: Array<{ id: number; name: string }> = [];
|
||||
for (const s of d?.stations ?? []) {
|
||||
if (typeof s === "object" && s.id !== undefined && s.active !== false)
|
||||
out.push({ id: s.id, name: s.name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The clock line: HH:MM:SS, running or paused, the target, and the VISUAL ticket hours beside it. */
|
||||
export function ClockLine({ p }: { p: PacketRead }) {
|
||||
const clock = p.stage?.clock ?? null;
|
||||
const running = isRunning(clock);
|
||||
const key = `${p.id}:${p.version}:${clock?.elapsed_ms ?? 0}:${clock?.running_since ?? ""}`;
|
||||
const [fetched, setFetched] = useState(() => ({ key, at: Date.now() }));
|
||||
if (fetched.key !== key) setFetched({ key, at: Date.now() });
|
||||
const now = useTicker(running);
|
||||
const ms = liveElapsed(clock, fetched.key === key ? fetched.at : now, now);
|
||||
const target = p.target_hrs ?? targetHours(p);
|
||||
const state = running
|
||||
? "running"
|
||||
: p.status === "in_progress"
|
||||
? "paused"
|
||||
: "";
|
||||
return (
|
||||
<div className="clockrow">
|
||||
<div className="clock" data-testid="clock" aria-live="off">
|
||||
<span
|
||||
className={`cdot${running ? " run" : ""}`}
|
||||
title={running ? "Running" : "Stopped"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{fmtClock(ms)}
|
||||
<small>
|
||||
{state ? ` ${state}` : ""} · target {Number(target || 0).toFixed(1)} h
|
||||
</small>
|
||||
</div>
|
||||
{p.labor ? (
|
||||
<div
|
||||
className="labor small"
|
||||
data-testid="labor"
|
||||
title={
|
||||
p.labor.observed_at
|
||||
? `From VISUAL, read ${p.labor.observed_at}`
|
||||
: "From VISUAL"
|
||||
}
|
||||
>
|
||||
VISUAL tickets <b>{Number(p.labor.hours || 0).toFixed(1)} h</b>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The supervisor's assign sheet: station, welder (with search) and stamp. */
|
||||
export function AssignSheet({
|
||||
p,
|
||||
sub,
|
||||
onCancel,
|
||||
onSend,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
sub: string;
|
||||
onCancel: () => void;
|
||||
onSend: (stationId: number, welderId: string, stamp: string) => void;
|
||||
}) {
|
||||
const { departments } = useAuth();
|
||||
const stations = stationsOf(departments, p.dept.code);
|
||||
const { people, loaded, error: peopleError } = usePeople(p.dept.code);
|
||||
const [station, setStation] = useState<number | null>(
|
||||
p.station?.id ?? stations[0]?.id ?? null,
|
||||
);
|
||||
const [q, setQ] = useState("");
|
||||
const [welder, setWelder] = useState<string>(p.welder?.id ?? "");
|
||||
const [stamp, setStamp] = useState<string>(p.welder?.stamp ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const st = stations.find((s) => s.id === station) ?? stations[0];
|
||||
const allowed = people.filter(
|
||||
(u) =>
|
||||
u.level !== "worker" ||
|
||||
!u.station_limits.length ||
|
||||
(st ? u.station_limits.includes(st.id) : true),
|
||||
);
|
||||
const needle = q.trim().toLowerCase();
|
||||
const shown = allowed.filter(
|
||||
(u) =>
|
||||
!needle ||
|
||||
u.display_name.toLowerCase().includes(needle) ||
|
||||
(u.stamp ?? "").toLowerCase().includes(needle) ||
|
||||
u.id === welder,
|
||||
);
|
||||
|
||||
const pick = (id: string) => {
|
||||
setWelder(id);
|
||||
const u = people.find((x) => x.id === id);
|
||||
setStamp(u?.stamp ?? "");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!st) {
|
||||
setError("This department has no stations. Ask an admin to add one.");
|
||||
return;
|
||||
}
|
||||
if (!welder || !allowed.some((u) => u.id === welder)) {
|
||||
setError("Pick a welder in this department.");
|
||||
return;
|
||||
}
|
||||
onSend(st.id, welder, stamp.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title="Send to station" sub={sub} onClose={onCancel}>
|
||||
<div className="small">
|
||||
The packet appears on that station's screen right away.
|
||||
</div>
|
||||
<form className="stack" onSubmit={submit} noValidate aria-label="Assign">
|
||||
<label className="f">
|
||||
Station
|
||||
<select
|
||||
aria-label="Station"
|
||||
className="in"
|
||||
value={st?.id ?? ""}
|
||||
onChange={(e) => setStation(Number(e.target.value))}
|
||||
>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Find a welder
|
||||
<input
|
||||
aria-label="Find a welder"
|
||||
className="in"
|
||||
type="search"
|
||||
value={q}
|
||||
placeholder="Name or stamp"
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Welder
|
||||
<select
|
||||
aria-label="Welder"
|
||||
className="in"
|
||||
value={welder}
|
||||
onChange={(e) => pick(e.target.value)}
|
||||
>
|
||||
<option value="">{loaded ? "Choose" : "Loading"}</option>
|
||||
{shown.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.display_name}
|
||||
{u.stamp ? ` [${u.stamp}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{peopleError ? (
|
||||
<div className="small pinktxt">{peopleError}</div>
|
||||
) : null}
|
||||
{loaded && !allowed.length ? (
|
||||
<div className="small muted">
|
||||
No one in this department may work at this station.
|
||||
</div>
|
||||
) : null}
|
||||
<label className="f">
|
||||
Stamp
|
||||
<input
|
||||
aria-label="Stamp"
|
||||
className="in w110"
|
||||
value={stamp}
|
||||
maxLength={20}
|
||||
placeholder="ID"
|
||||
onChange={(e) => setStamp(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
Send to station
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** One step button: hidden when the user can neither do it nor is blocked from it. */
|
||||
function Step({
|
||||
p,
|
||||
action,
|
||||
busy,
|
||||
onClick,
|
||||
className = "btn",
|
||||
disabled = false,
|
||||
why,
|
||||
children,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
action: string;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
why?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const can = p.can.includes(action);
|
||||
const blocked = p.blocked[action];
|
||||
if (!can && !blocked) return null;
|
||||
const reason = blocked?.join(" ") ?? "";
|
||||
return (
|
||||
<div className="act">
|
||||
<button
|
||||
className={className}
|
||||
type="button"
|
||||
disabled={!can || busy || disabled}
|
||||
title={!can ? reason : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{why ??
|
||||
(!can && reason ? <div className="why small">{reason}</div> : null)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
|
||||
|
||||
/** The supervisor's review: send to Q.C., return to the station, or skip Q.C. and route on. */
|
||||
function ReviewBox({
|
||||
p,
|
||||
busy,
|
||||
step,
|
||||
askReason,
|
||||
sub,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
busy: boolean;
|
||||
step: StepFn;
|
||||
askReason: AskReason;
|
||||
sub: string;
|
||||
}) {
|
||||
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 ?? ""),
|
||||
);
|
||||
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 ready = book && spool && !!loc.trim();
|
||||
|
||||
const returnIt = async () => {
|
||||
let reason = ret.trim();
|
||||
if (reason.length < 8) {
|
||||
const r = await askReason({
|
||||
title: "Return to station",
|
||||
sub,
|
||||
label: "Return note",
|
||||
note: "The station sees this note. Say what needs fixing.",
|
||||
min: 8,
|
||||
submitLabel: "Return to station",
|
||||
});
|
||||
if (r === null) return;
|
||||
reason = r;
|
||||
}
|
||||
if (
|
||||
await step(
|
||||
"return_to_station",
|
||||
{ reason },
|
||||
{ ok: "Returned to the station." },
|
||||
)
|
||||
)
|
||||
setRet("");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel actbox" aria-label="Supervisor review">
|
||||
<div>
|
||||
{rejected ? (
|
||||
<>
|
||||
<b>Rejected by Q.C.</b> Return it to the station for repair, or fix
|
||||
it and send it back to Q.C.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Submitted by {p.station?.name ?? "the station"}.</b> Check the
|
||||
weld map, checklist and heats. When the data book package and the
|
||||
spool are ready, send it to Q.C.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{has(p, "send_to_qc") ? (
|
||||
<div className="revrow">
|
||||
<label className="ckl">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={book}
|
||||
onChange={(e) => setBook(e.target.checked)}
|
||||
/>
|
||||
Data book package ready
|
||||
</label>
|
||||
<label className="ckl">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={spool}
|
||||
onChange={(e) => setSpool(e.target.checked)}
|
||||
/>
|
||||
Spool ready for inspection
|
||||
</label>
|
||||
<label className="f">
|
||||
Spool location
|
||||
<input
|
||||
aria-label="Spool location"
|
||||
className="in"
|
||||
value={loc}
|
||||
maxLength={80}
|
||||
placeholder="e.g. Bay 2, rack B"
|
||||
onChange={(e) => setLoc(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f grow2">
|
||||
Note to Q.C.
|
||||
<input
|
||||
aria-label="Note to Q.C."
|
||||
className="in"
|
||||
value={note}
|
||||
maxLength={500}
|
||||
placeholder="Optional"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Step
|
||||
p={p}
|
||||
action="send_to_qc"
|
||||
busy={busy}
|
||||
className="btn pri"
|
||||
disabled={!ready}
|
||||
onClick={() =>
|
||||
void step(
|
||||
"send_to_qc",
|
||||
{
|
||||
book: true,
|
||||
spool: true,
|
||||
location: loc.trim(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
},
|
||||
{ sign: SIGN_SEND_TO_QC, ok: "Sent to Q.C." },
|
||||
)
|
||||
}
|
||||
>
|
||||
Send to Q.C.
|
||||
</Step>
|
||||
</div>
|
||||
) : null}
|
||||
{has(p, "return_to_station") ? (
|
||||
<div className="revrow sep">
|
||||
<label className="f grow2">
|
||||
Return note
|
||||
<input
|
||||
aria-label="Return note"
|
||||
className="in"
|
||||
value={ret}
|
||||
maxLength={500}
|
||||
placeholder="What needs fixing"
|
||||
onChange={(e) => setRet(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Step
|
||||
p={p}
|
||||
action="return_to_station"
|
||||
busy={busy}
|
||||
className="btn pink"
|
||||
onClick={() => void returnIt()}
|
||||
>
|
||||
Return to station
|
||||
</Step>
|
||||
</div>
|
||||
) : null}
|
||||
{has(p, "route_from_review") ? (
|
||||
<div className="revrow sep">
|
||||
<label className="f grow2">
|
||||
Skip Q.C. - send to department
|
||||
<select
|
||||
aria-label="Skip Q.C. - send to department"
|
||||
className="in"
|
||||
value={routeTo}
|
||||
onChange={(e) => setRoute(e.target.value)}
|
||||
>
|
||||
{depts.map((d) => (
|
||||
<option key={d.code} value={d.code}>
|
||||
{d.code} {d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Step
|
||||
p={p}
|
||||
action="route_from_review"
|
||||
busy={busy}
|
||||
disabled={!routeTo}
|
||||
onClick={() =>
|
||||
void step(
|
||||
"route_from_review",
|
||||
{ dept: routeTo },
|
||||
{ sign: SIGN_ROUTE, ok: "Sent to the department." },
|
||||
)
|
||||
}
|
||||
>
|
||||
Send to department
|
||||
</Step>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* The shop action bars on the packet page, per status and what `can` allows: assign, pull back, the review, and
|
||||
* the stall's start, pause, resume and submit. The server decides; this only shapes the buttons.
|
||||
*/
|
||||
export function ShopBar({
|
||||
p,
|
||||
busy,
|
||||
mutate,
|
||||
askReason,
|
||||
sub,
|
||||
goTab,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
busy: boolean;
|
||||
mutate: Mutate;
|
||||
askReason: AskReason;
|
||||
sub: string;
|
||||
goTab: (t: ShopTab) => void;
|
||||
}) {
|
||||
const { me } = useAuth();
|
||||
const roles = me?.roles ?? [];
|
||||
const [assign, setAssign] = useState(false);
|
||||
const step = makeStep(p, mutate);
|
||||
const st = p.status;
|
||||
const level = shopLevel(roles, p.dept.code);
|
||||
const deptLabel = `${p.dept.code} ${p.dept.name}`;
|
||||
|
||||
const reasonStep = async (
|
||||
action: string,
|
||||
title: string,
|
||||
note: string,
|
||||
ok: string,
|
||||
) => {
|
||||
const reason = await askReason({
|
||||
title,
|
||||
sub,
|
||||
note,
|
||||
min: 8,
|
||||
submitLabel: title,
|
||||
});
|
||||
if (reason === null) return;
|
||||
await step(action, { reason }, { ok });
|
||||
};
|
||||
|
||||
if (st === "ready") {
|
||||
if (
|
||||
!has(p, "assign") &&
|
||||
!has(p, "send_back_to_warehouse") &&
|
||||
!has(p, "return_to_clerical")
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<section className="panel actbox" aria-label="Assign">
|
||||
<div>
|
||||
{p.return_note ? (
|
||||
<>
|
||||
<b>Returned.</b> Assign it again when it is ready.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Assign this packet</b> to a station and welder. It appears on
|
||||
that station's screen right away.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="acts">
|
||||
<Step
|
||||
p={p}
|
||||
action="assign"
|
||||
busy={busy}
|
||||
className="btn pri"
|
||||
onClick={() => setAssign(true)}
|
||||
>
|
||||
Send to station
|
||||
</Step>
|
||||
<Step
|
||||
p={p}
|
||||
action="send_back_to_warehouse"
|
||||
busy={busy}
|
||||
className="btn ghost"
|
||||
onClick={() =>
|
||||
void reasonStep(
|
||||
"send_back_to_warehouse",
|
||||
"Send back to Warehouse",
|
||||
"The warehouse gets it back for the parts. Say what is missing.",
|
||||
"Sent back to the warehouse.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Send back to Warehouse
|
||||
</Step>
|
||||
<Step
|
||||
p={p}
|
||||
action="return_to_clerical"
|
||||
busy={busy}
|
||||
className="btn ghost"
|
||||
onClick={() =>
|
||||
void reasonStep(
|
||||
"return_to_clerical",
|
||||
"Send back to Clerical",
|
||||
"The packet goes back to Clerical as a draft. Say what needs fixing.",
|
||||
"Sent back to Clerical.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Send back to Clerical
|
||||
</Step>
|
||||
</div>
|
||||
{assign ? (
|
||||
<AssignSheet
|
||||
p={p}
|
||||
sub={sub}
|
||||
onCancel={() => setAssign(false)}
|
||||
onSend={(station_id, welder_id, stamp) => {
|
||||
setAssign(false);
|
||||
void step(
|
||||
"assign",
|
||||
{ station_id, welder_id, ...(stamp ? { stamp } : {}) },
|
||||
{ ok: "Sent to the station." },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (st === "assigned" || st === "in_progress") {
|
||||
const sup = has(p, "unassign") || has(p, "pull_back");
|
||||
const stall = has(p, "start") || has(p, "submit");
|
||||
if (!sup && !stall) return null;
|
||||
const running = isRunning(p.stage?.clock);
|
||||
const miss = st === "in_progress" ? stallMissing(p) : [];
|
||||
const other = (p.blocked.submit ?? []).filter(
|
||||
(m) => !STALL_PRECONDITIONS.has(m),
|
||||
);
|
||||
return (
|
||||
<section className="panel actbox" aria-label="Station">
|
||||
<div>
|
||||
{stall && st === "assigned" ? (
|
||||
<>
|
||||
<b>Ready to start.</b> Clock on to the work order in VISUAL (Work
|
||||
orders tab) and start the timer.
|
||||
</>
|
||||
) : stall && st === "in_progress" ? (
|
||||
miss.length ? (
|
||||
<span data-testid="submit-missing">
|
||||
<b>Before submitting:</b>{" "}
|
||||
{miss.map((m, i) => (
|
||||
<span key={m.t}>
|
||||
{i ? " · " : ""}
|
||||
<button
|
||||
className="lnk"
|
||||
type="button"
|
||||
onClick={() => goTab(m.tab)}
|
||||
>
|
||||
{m.t}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<b>Everything is filled in.</b> Submit when the parts are done.
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
In <b>{p.station?.name ?? "a station"}</b> with{" "}
|
||||
{p.welder?.display_name ?? "-"}. You can edit the heat map, weld
|
||||
map, materials and checklist while it is out.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{other.length && stall && st === "in_progress" ? (
|
||||
<div className="why small">{other.join(" ")}</div>
|
||||
) : null}
|
||||
<div className="acts center">
|
||||
<ClockLine p={p} />
|
||||
{stall && st === "assigned" ? (
|
||||
<Step
|
||||
p={p}
|
||||
action="start"
|
||||
busy={busy}
|
||||
className="btn pri"
|
||||
onClick={() => void step("start", {}, { ok: "Work started." })}
|
||||
>
|
||||
Start work
|
||||
</Step>
|
||||
) : null}
|
||||
{stall && st === "in_progress" && p.can.includes("clock") ? (
|
||||
<div className="act">
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate((g) =>
|
||||
packetsApi.clock(p.id, running ? "pause" : "resume", g),
|
||||
)
|
||||
}
|
||||
>
|
||||
{running ? "Pause" : "Resume"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{stall && st === "in_progress" ? (
|
||||
<Step
|
||||
p={p}
|
||||
action="submit"
|
||||
busy={busy}
|
||||
className="btn pri"
|
||||
why={<span />}
|
||||
onClick={() =>
|
||||
void step(
|
||||
"submit",
|
||||
{},
|
||||
{ sign: SIGN_SUBMIT, ok: "Submitted to the supervisor." },
|
||||
)
|
||||
}
|
||||
>
|
||||
Submit to supervisor
|
||||
</Step>
|
||||
) : null}
|
||||
<Step
|
||||
p={p}
|
||||
action={st === "assigned" ? "unassign" : "pull_back"}
|
||||
busy={busy}
|
||||
onClick={() =>
|
||||
void step(
|
||||
st === "assigned" ? "unassign" : "pull_back",
|
||||
{},
|
||||
{ ok: "Pulled back to the queue." },
|
||||
)
|
||||
}
|
||||
>
|
||||
Pull back to queue
|
||||
</Step>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (st === "review") {
|
||||
if (
|
||||
has(p, "send_to_qc") ||
|
||||
has(p, "return_to_station") ||
|
||||
has(p, "route_from_review")
|
||||
) {
|
||||
return (
|
||||
<ReviewBox
|
||||
p={p}
|
||||
busy={busy}
|
||||
step={step}
|
||||
askReason={askReason}
|
||||
sub={sub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (level && level !== "supervisor") {
|
||||
return (
|
||||
<div className="callout" role="note" data-testid="review-readonly">
|
||||
<b>Submitted by {p.station?.name ?? "the station"}.</b> A supervisor
|
||||
for {deptLabel} reviews it and sends it on.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead, Weld, WeldBody } from "../api/types";
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { errorText } from "../lib/format";
|
||||
import { voidText } from "../lib/voidStamp";
|
||||
import { weldId, weldSummary } from "../lib/weldDefaults";
|
||||
import type { Write, WriteResult } from "../warehouse/PullTable";
|
||||
import { MapViewer } from "./MapViewer";
|
||||
import { WeldMark, type MarkMove } from "./marks";
|
||||
import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil";
|
||||
import { useSerial } from "./useSerial";
|
||||
import { WeldRecord } from "./WeldRecord";
|
||||
import { WeldSheet, type NewWeldAt } from "./WeldSheet";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
const rv = (cur: PacketRead, no: number, fallback: number) => cur.welds.find((w) => w.weld_no === no)?.row_version ?? fallback;
|
||||
|
||||
function WeldRow({ w, onOpen }: { w: Weld; onOpen: (w: Weld) => void }) {
|
||||
return (
|
||||
<button type="button" className="row rowbtn" onClick={() => onOpen(w)} aria-label={`Open weld ${weldId(w)}`}>
|
||||
<span className={`balloon pinkb${w.carried ? " carried" : ""}`}>{w.weld_no}</span>
|
||||
<span className="d">
|
||||
{weldSummary(w)}
|
||||
<div className="small muted mono">
|
||||
{w.wo_base ?? "-"} #{w.piece ?? 1} · {w.process ?? "-"} · {w.stamp ?? ""}
|
||||
</div>
|
||||
</span>
|
||||
<span />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The weld map: the drawing with the welds of the open sheet as pink leader marks. "Mark a weld" turns on
|
||||
* marking; a tap on the joint opens the weld sheet. Bubbles and arrows drag when the user may edit welds.
|
||||
*/
|
||||
export function WeldMapTab({
|
||||
p,
|
||||
write,
|
||||
mutate,
|
||||
reload,
|
||||
askReason,
|
||||
sub,
|
||||
myId,
|
||||
onError,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
write: Write;
|
||||
mutate: Mutate;
|
||||
reload: () => Promise<void>;
|
||||
askReason: (o: ReasonOptions) => Promise<string | null>;
|
||||
sub: string;
|
||||
myId: string | null;
|
||||
onError: (text: string) => void;
|
||||
}) {
|
||||
const pages = viewPages(p.pages);
|
||||
const [picked, setPicked] = useState<{ n: number; onPurpose: boolean } | null>(null);
|
||||
const page = landingPage(pages, picked?.n ?? null, picked?.onPurpose ?? false);
|
||||
const [marking, setMarking] = useState(false);
|
||||
const [sheet, setSheet] = useState<{ weldNo: number | null; at: NewWeldAt | null } | null>(null);
|
||||
const serial = useSerial(p, write);
|
||||
const { people } = usePeople(p.dept.code);
|
||||
const myStamp = people.find((x) => x.id === myId)?.stamp ?? null;
|
||||
|
||||
if (!page) return <div className="empty">No drawing pages in this packet.</div>;
|
||||
|
||||
const isVoid = !!page.void;
|
||||
const canEdit = p.can.includes("edit_welds");
|
||||
const editable = canEdit && !isVoid;
|
||||
const onSheet = p.welds.filter((w) => w.page === page.n);
|
||||
const current = onSheet.filter((w) => !w.carried);
|
||||
const carried = onSheet.filter((w) => w.carried);
|
||||
const vText = isVoid ? voidText(page, p.pages) : "";
|
||||
const open = sheet ? (sheet.weldNo !== null ? (p.welds.find((w) => w.weld_no === sheet.weldNo) ?? null) : null) : null;
|
||||
|
||||
const done = async (r: WriteResult): Promise<boolean> => {
|
||||
if (r.ok) return true;
|
||||
if (r.err !== undefined) {
|
||||
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
|
||||
onError(errorText(r.err));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const add = async (body: WeldBody) => {
|
||||
if (await done(await serial((cur, g) => packetsApi.addWeld(cur.id, body, g)))) {
|
||||
setSheet(null);
|
||||
setMarking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (w: Weld, body: WeldBody) => {
|
||||
if (await done(await serial((cur, g) => packetsApi.patchWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), body, g)))) setSheet(null);
|
||||
};
|
||||
|
||||
const remove = async (w: Weld) => {
|
||||
const reason = await askReason({
|
||||
title: `Remove weld ${weldId(w)}`,
|
||||
sub,
|
||||
note: "The weld leaves the weld map. It stays in the record with your reason.",
|
||||
min: 3,
|
||||
danger: true,
|
||||
submitLabel: "Remove weld",
|
||||
});
|
||||
if (reason === null) return;
|
||||
if (await done(await serial((cur, g) => packetsApi.removeWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), { reason, ...g })))) setSheet(null);
|
||||
};
|
||||
|
||||
const move = (w: Weld, m: MarkMove) =>
|
||||
serial((cur, g) =>
|
||||
packetsApi.patchWeld(
|
||||
cur.id,
|
||||
w.weld_no,
|
||||
rv(cur, w.weld_no, w.row_version),
|
||||
m.bubble ? { bfx: m.bfx, bfy: m.bfy } : { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
|
||||
g,
|
||||
),
|
||||
).then(done);
|
||||
|
||||
const markBtn = editable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={marking ? "btn pink" : "btn pri"}
|
||||
aria-pressed={marking}
|
||||
onClick={() => setMarking((v) => !v)}
|
||||
>
|
||||
{marking ? "Cancel marking" : "Mark a weld"}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{isVoid ? (
|
||||
<div className="callout warn flush">
|
||||
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record with its weld marks.{" "}
|
||||
{page.void?.new_n ? (
|
||||
<button className="btn sm" type="button" onClick={() => setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })}>
|
||||
Go to the current sheet
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pagesel">
|
||||
{pages.length > 1 ? (
|
||||
<div className="chips" role="group" aria-label="Sheets">
|
||||
{pages.map((x) => (
|
||||
<button
|
||||
key={x.n}
|
||||
type="button"
|
||||
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
|
||||
aria-pressed={x.n === page.n}
|
||||
onClick={() => setPicked({ n: x.n, onPurpose: true })}
|
||||
>
|
||||
{pageLabel(x)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="mono small muted">{pageLabel(page)}</span>
|
||||
)}
|
||||
{markBtn}
|
||||
{marking ? <span className="small">Tap the weld location on the drawing.</span> : null}
|
||||
{!canEdit ? (
|
||||
<span className="small muted">
|
||||
{p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dv">
|
||||
<MapViewer
|
||||
page={page}
|
||||
voidLabel={vText}
|
||||
mode={marking && editable ? "marking" : ""}
|
||||
label="Weld map"
|
||||
onTap={(fx, fy) => {
|
||||
if (marking && editable) setSheet({ weldNo: null, at: { page: page.n, fx, fy } });
|
||||
}}
|
||||
fsBar={
|
||||
<>
|
||||
<span className="fst">{pageLabel(page)}</span>
|
||||
{markBtn}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{onSheet.map((w) => (
|
||||
<WeldMark
|
||||
key={w.weld_no}
|
||||
weld={w}
|
||||
editable={editable && !w.carried}
|
||||
onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })}
|
||||
onMove={move}
|
||||
/>
|
||||
))}
|
||||
</MapViewer>
|
||||
<div className="side">
|
||||
<div className="panel">
|
||||
<h3>
|
||||
Welds on this sheet ({current.length})
|
||||
{current.length !== p.welds.filter((w) => !w.carried).length ? <span className="small muted"> of {p.welds.filter((w) => !w.carried).length}</span> : null}
|
||||
</h3>
|
||||
{editable && current.length ? <div className="small muted mb8">Drag a bubble to move it off the linework. Drag the arrow to fix the weld location.</div> : null}
|
||||
<div className="hlist">
|
||||
{current.length ? (
|
||||
current.map((w) => <WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />)
|
||||
) : (
|
||||
<div className="small muted">{editable ? "Tap Mark a weld, then tap the joint on the drawing." : "No welds recorded yet."}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{carried.length ? (
|
||||
<div className="panel">
|
||||
<h3>Carried from an earlier department</h3>
|
||||
<div className="hlist">
|
||||
{carried.map((w) => (
|
||||
<WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<WeldRecord p={p} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
{sheet && (sheet.weldNo === null || open) ? (
|
||||
<WeldSheet
|
||||
key={sheet.weldNo ?? "new"}
|
||||
p={p}
|
||||
weld={open}
|
||||
at={sheet.at}
|
||||
editable={canEdit && !(open ? !!p.pages.find((x) => x.n === open.page)?.void : isVoid) && !open?.carried}
|
||||
myStamp={myStamp}
|
||||
sub={sub}
|
||||
onSave={(body) => void (open ? save(open, body) : add(body))}
|
||||
onRemove={() => {
|
||||
if (open) void remove(open);
|
||||
}}
|
||||
onClose={() => setSheet(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketRead, StageFields } from "../api/types";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
const FIELDS: ReadonlyArray<{
|
||||
k: keyof StageFields;
|
||||
label: string;
|
||||
ph: string;
|
||||
max: number;
|
||||
}> = [
|
||||
{ k: "filler", label: "Filler metal", ph: "e.g. ER70S-6", max: 40 },
|
||||
{ k: "filler_lot", label: "Filler lot / heat", ph: "Lot no.", max: 40 },
|
||||
{ k: "preheat", label: "Preheat °F", ph: "e.g. 50 min", max: 40 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The weld record of the open stage: filler, filler lot, preheat, notes. Each field saves on blur (or Enter) with
|
||||
* a field-level base, so two people editing different fields never clash; the same field answers field_conflict.
|
||||
*/
|
||||
export function WeldRecord({ p, mutate }: { p: PacketRead; mutate: Mutate }) {
|
||||
const st = p.stage;
|
||||
const editable = !!st && p.can.includes("edit_stage_fields");
|
||||
const [draft, setDraft] = useState<
|
||||
Partial<Record<keyof StageFields, string>>
|
||||
>({});
|
||||
|
||||
const server = (k: keyof StageFields) => (st ? (st[k] ?? "") : "");
|
||||
const shown = (k: keyof StageFields) => draft[k] ?? server(k);
|
||||
|
||||
const saving = useRef(new Set<keyof StageFields>());
|
||||
|
||||
const save = async (k: keyof StageFields) => {
|
||||
const now = draft[k];
|
||||
if (now === undefined || saving.current.has(k)) return;
|
||||
const was = st ? st[k] : null;
|
||||
const value = now.trim() || null;
|
||||
if (value === (was ?? null)) {
|
||||
setDraft((d) => ({ ...d, [k]: undefined }));
|
||||
return;
|
||||
}
|
||||
saving.current.add(k);
|
||||
try {
|
||||
await mutate((g) =>
|
||||
packetsApi.patchStage(p.id, {
|
||||
set: { [k]: value },
|
||||
base: { [k]: was ?? null },
|
||||
...g,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
saving.current.delete(k);
|
||||
}
|
||||
// On a conflict the page reloads; the field then shows the value someone else saved.
|
||||
setDraft((d) => ({ ...d, [k]: undefined }));
|
||||
};
|
||||
|
||||
if (!st) {
|
||||
return (
|
||||
<div className="panel stack">
|
||||
<h3>Weld record</h3>
|
||||
<div className="small muted">
|
||||
The weld record opens when the packet is at a station.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel stack" aria-label="Weld record" role="group">
|
||||
<h3>Weld record</h3>
|
||||
{FIELDS.map((f) =>
|
||||
editable ? (
|
||||
<label key={f.k} className="f">
|
||||
{f.label}
|
||||
<input
|
||||
aria-label={f.label}
|
||||
className="in"
|
||||
value={shown(f.k)}
|
||||
maxLength={f.max}
|
||||
placeholder={f.ph}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, [f.k]: e.target.value }))
|
||||
}
|
||||
onBlur={() => void save(f.k)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void save(f.k);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div key={f.k} className="small">
|
||||
<span className="muted">{f.label}: </span>
|
||||
<b className="near">{server(f.k) || "-"}</b>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{editable ? (
|
||||
<label className="f">
|
||||
Notes
|
||||
<textarea
|
||||
aria-label="Notes"
|
||||
className="in"
|
||||
value={shown("notes")}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
|
||||
onBlur={() => void save("notes")}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="small">
|
||||
<span className="muted">Notes: </span>
|
||||
{server("notes") || "-"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { PacketRead, Weld, WeldBody } from "../api/types";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { bpos } from "../lib/geometry";
|
||||
import { fmtTime } from "../lib/format";
|
||||
import {
|
||||
DEFAULT_SIZE,
|
||||
DEFAULT_TYPE,
|
||||
PROCESSES,
|
||||
WELD_TYPES,
|
||||
lastProcess,
|
||||
weldId,
|
||||
woChoices,
|
||||
} from "../lib/weldDefaults";
|
||||
|
||||
/** The new weld's spot, from the tap on the drawing. */
|
||||
export interface NewWeldAt {
|
||||
page: number;
|
||||
fx: number;
|
||||
fy: number;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
wo_base: string;
|
||||
piece: number;
|
||||
type: string;
|
||||
size: string;
|
||||
process: string;
|
||||
stamp: string;
|
||||
items: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The weld sheet: work order, piece, type, size, process, stamp and the BOM items the weld joins (at least one).
|
||||
* A new weld sends its point and the default label position; an existing one sends only what changed.
|
||||
*/
|
||||
export function WeldSheet({
|
||||
p,
|
||||
weld,
|
||||
at,
|
||||
editable,
|
||||
myStamp,
|
||||
sub,
|
||||
onSave,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
/** The weld to edit, or null for a new one. */
|
||||
weld: Weld | null;
|
||||
at: NewWeldAt | null;
|
||||
editable: boolean;
|
||||
/** The signed-in user's welder stamp, when known. */
|
||||
myStamp: string | null;
|
||||
sub: string;
|
||||
onSave: (body: WeldBody) => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const wos = woChoices(p);
|
||||
const [d, setD] = useState<Draft>(() =>
|
||||
weld
|
||||
? {
|
||||
wo_base: weld.wo_base ?? wos[0]?.base ?? "",
|
||||
piece: weld.piece ?? 1,
|
||||
type: weld.type ?? DEFAULT_TYPE,
|
||||
size: weld.size ?? DEFAULT_SIZE,
|
||||
process: weld.process ?? lastProcess(p),
|
||||
stamp: weld.stamp ?? "",
|
||||
items: [...weld.items],
|
||||
}
|
||||
: {
|
||||
wo_base: wos[0]?.base ?? "",
|
||||
piece: 1,
|
||||
type: DEFAULT_TYPE,
|
||||
size: DEFAULT_SIZE,
|
||||
process: lastProcess(p),
|
||||
stamp: myStamp || p.welder?.stamp || "",
|
||||
items: [],
|
||||
},
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qty = wos.find((w) => w.base === d.wo_base)?.qty ?? 1;
|
||||
const title = weld ? `Weld ${weldId(weld)}` : "New weld";
|
||||
const dis = !editable;
|
||||
|
||||
const toggle = (item: number) =>
|
||||
setD((o) => ({
|
||||
...o,
|
||||
items: o.items.includes(item)
|
||||
? o.items.filter((x) => x !== item)
|
||||
: [...o.items, item].sort((a, b) => a - b),
|
||||
}));
|
||||
|
||||
const heats = d.items
|
||||
.map((i) => {
|
||||
const b = p.bom.find((x) => x.item === i);
|
||||
return b ? `${i}: ${b.heat || (b.na ? "N/A" : "-")}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editable) return;
|
||||
if (!d.items.length) {
|
||||
setError("Pick at least one BOM item this weld joins.");
|
||||
return;
|
||||
}
|
||||
const size = d.size.trim() || DEFAULT_SIZE;
|
||||
const stamp = d.stamp.trim() || null;
|
||||
if (!weld) {
|
||||
if (!at) return;
|
||||
const b = bpos({ fx: at.fx, fy: at.fy });
|
||||
onSave({
|
||||
page: at.page,
|
||||
fx: at.fx,
|
||||
fy: at.fy,
|
||||
bfx: b.bfx,
|
||||
bfy: b.bfy,
|
||||
...(d.wo_base ? { wo_base: d.wo_base } : {}),
|
||||
piece: d.piece,
|
||||
items: d.items,
|
||||
type: d.type,
|
||||
size,
|
||||
process: d.process,
|
||||
...(stamp ? { stamp } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body: WeldBody = {};
|
||||
if ((weld.wo_base ?? "") !== d.wo_base && d.wo_base)
|
||||
body.wo_base = d.wo_base;
|
||||
if ((weld.piece ?? 1) !== d.piece) body.piece = d.piece;
|
||||
if ((weld.type ?? "") !== d.type) body.type = d.type;
|
||||
if ((weld.size ?? "") !== size) body.size = size;
|
||||
if ((weld.process ?? "") !== d.process) body.process = d.process;
|
||||
if ((weld.stamp ?? null) !== stamp) body.stamp = stamp;
|
||||
if ([...weld.items].sort((a, b) => a - b).join(",") !== d.items.join(","))
|
||||
body.items = d.items;
|
||||
if (!Object.keys(body).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onSave(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={title}
|
||||
sub={weld ? `${sub} · recorded ${fmtTime(weld.at)}` : sub}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
{weld?.carried ? (
|
||||
<div className="callout flush">
|
||||
Logged in an earlier department. Shown for the record.
|
||||
</div>
|
||||
) : null}
|
||||
<form className="stack" onSubmit={submit} noValidate aria-label={title}>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Work order
|
||||
<select
|
||||
aria-label="Work order"
|
||||
className="in"
|
||||
value={d.wo_base}
|
||||
disabled={dis}
|
||||
onChange={(e) =>
|
||||
setD((o) => ({ ...o, wo_base: e.target.value, piece: 1 }))
|
||||
}
|
||||
>
|
||||
{wos.map((w) => (
|
||||
<option key={w.base} value={w.base}>
|
||||
{w.base}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Piece
|
||||
<select
|
||||
aria-label="Piece"
|
||||
className="in"
|
||||
value={d.piece}
|
||||
disabled={dis}
|
||||
onChange={(e) =>
|
||||
setD((o) => ({ ...o, piece: Number(e.target.value) }))
|
||||
}
|
||||
>
|
||||
{Array.from({ length: Math.max(qty, d.piece) }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{i + 1} of {qty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Type
|
||||
<select
|
||||
aria-label="Type"
|
||||
className="in"
|
||||
value={d.type}
|
||||
disabled={dis}
|
||||
onChange={(e) => setD((o) => ({ ...o, type: e.target.value }))}
|
||||
>
|
||||
{WELD_TYPES.map((t) => (
|
||||
<option key={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Size
|
||||
<input
|
||||
aria-label="Size"
|
||||
className="in"
|
||||
value={d.size}
|
||||
maxLength={40}
|
||||
disabled={dis}
|
||||
onChange={(e) => setD((o) => ({ ...o, size: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Process
|
||||
<select
|
||||
aria-label="Process"
|
||||
className="in"
|
||||
value={d.process}
|
||||
disabled={dis}
|
||||
onChange={(e) => setD((o) => ({ ...o, process: e.target.value }))}
|
||||
>
|
||||
{PROCESSES.map((t) => (
|
||||
<option key={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Welder stamp
|
||||
<input
|
||||
aria-label="Welder stamp"
|
||||
className="in"
|
||||
value={d.stamp}
|
||||
maxLength={40}
|
||||
disabled={dis}
|
||||
onChange={(e) => setD((o) => ({ ...o, stamp: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div role="group" aria-label="Mating BOM items">
|
||||
<div className="flabel">Mating BOM items</div>
|
||||
<div className="chips">
|
||||
{p.bom.map((b) => (
|
||||
<button
|
||||
key={b.item}
|
||||
type="button"
|
||||
className="ichip"
|
||||
aria-pressed={d.items.includes(b.item)}
|
||||
aria-label={`Item ${b.item}`}
|
||||
disabled={dis}
|
||||
onClick={() => {
|
||||
toggle(b.item);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{b.item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{d.items.length
|
||||
? `Selected: item ${d.items.join(" + ")}${heats ? ` · heats ${heats}` : ""}`
|
||||
: "Tap each BOM item number this weld joins."}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sheet-acts start">
|
||||
{editable ? (
|
||||
<button className="btn pri" type="submit">
|
||||
{weld ? "Save changes" : "Log weld"}
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn ghost" type="button" onClick={onClose}>
|
||||
{editable ? "Cancel" : "Close"}
|
||||
</button>
|
||||
{editable && weld ? (
|
||||
<>
|
||||
<span className="grow" />
|
||||
<button
|
||||
className="btn ghost pinktxt"
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
>
|
||||
Remove weld
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import type { HeatTablePlacement, HeatTag, Weld } from "../api/types";
|
||||
import { arrowRotation, bpos, clamp01 } from "../lib/geometry";
|
||||
import { clampCorner, heatGroups, NO_HEAT, type HeatRow } from "../lib/heatTable";
|
||||
import { weldId } from "../lib/weldDefaults";
|
||||
import { useViewer } from "./viewerContext";
|
||||
|
||||
/** A mark's position while it is dragged, before the server has it. */
|
||||
interface Pos {
|
||||
fx: number;
|
||||
fy: number;
|
||||
bfx: number;
|
||||
bfy: number;
|
||||
}
|
||||
|
||||
export interface MarkMove {
|
||||
/** Only the bubble moved. */
|
||||
bubble: boolean;
|
||||
fx: number;
|
||||
fy: number;
|
||||
bfx: number;
|
||||
bfy: number;
|
||||
}
|
||||
|
||||
const pct = (v: number) => `${v * 100}%`;
|
||||
|
||||
/**
|
||||
* One leader mark: the line, the arrowhead at the point and the bubble. Shared by welds (pink) and the older heat
|
||||
* bubbles (amber). The bubble and the arrowhead drag when editable; a tap on the bubble opens it.
|
||||
*/
|
||||
function Leader({
|
||||
kind,
|
||||
at,
|
||||
editable,
|
||||
carried,
|
||||
label,
|
||||
bubble,
|
||||
bubbleClass,
|
||||
onOpen,
|
||||
onMove,
|
||||
}: {
|
||||
kind: "w" | "h";
|
||||
at: Pos;
|
||||
editable: boolean;
|
||||
carried: boolean;
|
||||
label: string;
|
||||
bubble: ReactNode;
|
||||
bubbleClass: string;
|
||||
onOpen: () => void;
|
||||
onMove: (m: MarkMove) => Promise<boolean>;
|
||||
}) {
|
||||
const v = useViewer();
|
||||
const key = `${at.fx},${at.fy},${at.bfx},${at.bfy}`;
|
||||
// The dragged spot shows until the server's answer moves the mark (a new key) or the write fails.
|
||||
const [drag, setDrag] = useState<{ key: string; pos: Pos } | null>(null);
|
||||
const pos = drag && drag.key === key ? drag.pos : at;
|
||||
const rot = arrowRotation(pos.fx, pos.fy, pos.bfx, pos.bfy, v.aspect);
|
||||
|
||||
const begin = (e: ReactPointerEvent, part: "anchor" | "bubble") => {
|
||||
if (!editable) return;
|
||||
let last: Pos | null = null;
|
||||
v.startDrag(e, {
|
||||
onMove: (f) => {
|
||||
const x = clamp01(f.fx);
|
||||
const y = clamp01(f.fy);
|
||||
last = part === "anchor" ? { ...at, fx: x, fy: y } : { ...at, bfx: x, bfy: y };
|
||||
setDrag({ key, pos: last });
|
||||
},
|
||||
onEnd: (f) => {
|
||||
const done = f ? last : null;
|
||||
if (!done) {
|
||||
setDrag(null);
|
||||
return;
|
||||
}
|
||||
void onMove({ bubble: part === "bubble", ...done }).then((ok) => {
|
||||
if (!ok) setDrag(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg className="lead" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line className={`ld ${kind}${carried ? " carried" : ""}`} x1={pos.fx} y1={pos.fy} x2={pos.bfx} y2={pos.bfy} />
|
||||
</svg>
|
||||
<span
|
||||
className={`arw ${kind}${editable ? " drag" : ""}`}
|
||||
data-mark={editable ? "anchor" : undefined}
|
||||
style={{ left: pct(pos.fx), top: pct(pos.fy), ["--rot" as string]: `${rot}deg` } as CSSProperties}
|
||||
title={editable ? "Drag to move where the arrow points" : undefined}
|
||||
aria-hidden="true"
|
||||
onPointerDown={editable ? (e) => begin(e, "anchor") : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`${bubbleClass}${editable ? " drag" : ""}${carried ? " carried" : ""}`}
|
||||
data-mark="bubble"
|
||||
style={{ left: pct(pos.bfx), top: pct(pos.bfy) }}
|
||||
aria-label={label}
|
||||
onPointerDown={editable ? (e) => begin(e, "bubble") : undefined}
|
||||
onClick={() => {
|
||||
if (!v.justDragged()) onOpen();
|
||||
}}
|
||||
>
|
||||
{bubble}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function posOf(m: { fx: number | null; fy: number | null; bfx?: number | null | undefined; bfy?: number | null | undefined }): Pos {
|
||||
const b = bpos(m);
|
||||
return { fx: m.fx ?? 0.5, fy: m.fy ?? 0.5, ...b };
|
||||
}
|
||||
|
||||
export function WeldMark({
|
||||
weld,
|
||||
editable,
|
||||
onOpen,
|
||||
onMove,
|
||||
}: {
|
||||
weld: Weld;
|
||||
editable: boolean;
|
||||
onOpen: (w: Weld) => void;
|
||||
onMove: (w: Weld, m: MarkMove) => Promise<boolean>;
|
||||
}) {
|
||||
const id = weldId(weld);
|
||||
return (
|
||||
<Leader
|
||||
kind="w"
|
||||
at={posOf(weld)}
|
||||
editable={editable}
|
||||
carried={!!weld.carried}
|
||||
label={`Weld ${id}${weld.carried ? ", carried" : ""}`}
|
||||
bubble={id}
|
||||
bubbleClass="mk"
|
||||
onOpen={() => onOpen(weld)}
|
||||
onMove={(m) => onMove(weld, m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeatTagMark({
|
||||
tag,
|
||||
heat,
|
||||
editable,
|
||||
onOpen,
|
||||
onMove,
|
||||
}: {
|
||||
tag: HeatTag;
|
||||
heat: string;
|
||||
editable: boolean;
|
||||
onOpen: (t: HeatTag) => void;
|
||||
onMove: (t: HeatTag, m: MarkMove) => Promise<boolean>;
|
||||
}) {
|
||||
return (
|
||||
<Leader
|
||||
kind="h"
|
||||
at={posOf(tag)}
|
||||
editable={editable}
|
||||
carried={!!tag.carried}
|
||||
label={`Heat tag H${tag.tag_no}, item ${tag.item}, ${heat || "no heat"}`}
|
||||
bubble={
|
||||
<>
|
||||
<span className="hn">{tag.item}</span>
|
||||
<span>{heat || "no heat"}</span>
|
||||
</>
|
||||
}
|
||||
bubbleClass={heat ? "ht" : "ht none"}
|
||||
onOpen={() => onOpen(tag)}
|
||||
onMove={(m) => onMove(tag, m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The table body: the caption and the groups side by side. */
|
||||
export function HeatTableInner({ rows }: { rows: readonly HeatRow[] }) {
|
||||
return (
|
||||
<>
|
||||
<div className="htcap">HEAT NUMBERS</div>
|
||||
<div className="htcols">
|
||||
{heatGroups(rows).map((g, i) => (
|
||||
<table key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ITEM</th>
|
||||
<th>HEAT NO.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.map((r) => (
|
||||
<tr key={r.item}>
|
||||
<td className="i">{r.item}</td>
|
||||
<td className={`h${r.heat ? "" : " none"}${r.na ? " na" : ""}`}>{r.heat || NO_HEAT}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The heat table on the drawing. Its font follows the page width (--sw) and the size (--hs). Drags when editable. */
|
||||
export function HeatTableOnDrawing({
|
||||
placement,
|
||||
rows,
|
||||
editable,
|
||||
onMove,
|
||||
}: {
|
||||
placement: HeatTablePlacement;
|
||||
rows: readonly HeatRow[];
|
||||
editable: boolean;
|
||||
onMove: (x: number, y: number) => Promise<boolean>;
|
||||
}) {
|
||||
const v = useViewer();
|
||||
const key = `${placement.x},${placement.y}`;
|
||||
const [drag, setDrag] = useState<{ key: string; x: number; y: number } | null>(null);
|
||||
const at = drag && drag.key === key ? drag : placement;
|
||||
|
||||
const down = (e: ReactPointerEvent) => {
|
||||
if (!editable) return;
|
||||
const f0 = v.toFrac(e.clientX, e.clientY);
|
||||
let last: { x: number; y: number } | null = null;
|
||||
v.startDrag(e, {
|
||||
onMove: (f) => {
|
||||
last = clampCorner(placement.x + (f.fx - f0.fx), placement.y + (f.fy - f0.fy));
|
||||
setDrag({ key, ...last });
|
||||
},
|
||||
onEnd: (f) => {
|
||||
if (!f || !last) {
|
||||
setDrag(null);
|
||||
return;
|
||||
}
|
||||
void onMove(last.x, last.y).then((ok) => {
|
||||
if (!ok) setDrag(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`htbl${editable ? " drag" : ""}${drag && drag.key === key ? " moving" : ""}`}
|
||||
data-mark={editable ? "table" : undefined}
|
||||
data-testid="heat-table"
|
||||
role="group"
|
||||
aria-label="Heat table on the drawing"
|
||||
style={{ left: pct(at.x), top: pct(at.y), ["--hs" as string]: String(placement.s || 1) } as CSSProperties}
|
||||
title={editable ? "Drag to move the heat table" : "Heat numbers from the Materials tab"}
|
||||
onPointerDown={editable ? down : undefined}
|
||||
>
|
||||
<HeatTableInner rows={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PacketRead, Stage } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
import { WeldSheet } from "./WeldSheet";
|
||||
|
||||
const DEPTS = DEPARTMENTS.map((d) => (d.code === "03" ? { ...d, weld: true, stations: [{ id: 1, name: "Stall 1" }, { id: 2, name: "Stall 2" }] } : d));
|
||||
|
||||
const CHECK = [
|
||||
{ group: "Before welding", key: "dwg", text: "Drawing CI-DWG-1 Rev 0 matches the packet" },
|
||||
{ group: "Before welding", key: "heat", text: "Heat numbers on material match the Materials tab" },
|
||||
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
|
||||
];
|
||||
|
||||
function stage(over: Partial<Stage> = {}): Stage {
|
||||
return {
|
||||
id: 5,
|
||||
dept_code: "03",
|
||||
station: { id: 1, name: "Stall 1" },
|
||||
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
|
||||
checklist: {},
|
||||
filler: null,
|
||||
filler_lot: null,
|
||||
preheat: null,
|
||||
notes: null,
|
||||
clock: { elapsed_ms: 3_723_000, running: true, running_since: "2026-10-16T08:00:00Z", started_at: "2026-10-16T08:00:00Z" },
|
||||
opened_at: "2026-10-16T07:00:00Z",
|
||||
row_version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function shopPacket(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return makePacket({
|
||||
status: "in_progress",
|
||||
holder: "Stall 1",
|
||||
station: { id: 1, name: "Stall 1" },
|
||||
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
|
||||
heat_map: true,
|
||||
bom: [
|
||||
{ item: 1, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H1", row_version: 1 },
|
||||
{ item: 2, qty: 2, part_id: "P-2", description: "Flange", dwg_ref: null, length: null, material: "A105", heat: null, row_version: 1 },
|
||||
],
|
||||
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 3, target_hrs: 4.5, row_version: 1 }],
|
||||
checklist_items: CHECK,
|
||||
stage: stage(),
|
||||
can: [],
|
||||
blocked: {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function base(packet: PacketRead, roles: string[], name = "Wes Super") {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor", "stall"], user: { id: name === "Andy Welder" ? "andy" : "wes", display_name: name } }) },
|
||||
"/api/departments": { body: DEPTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
"/api/departments/03/people": { body: [{ id: "andy", display_name: "Andy Welder", stamp: "75", level: "worker", station_limits: [] }] },
|
||||
};
|
||||
}
|
||||
|
||||
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("Checklist tab", () => {
|
||||
it("each tick PUTs its key and the answer replaces the packet", async () => {
|
||||
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
|
||||
const after = shopPacket({ version: 4, can: ["edit_checklist", "edit_stage_fields"], stage: stage({ checklist: { dwg: { done: true, at: "2026-10-16T09:00:00Z", stamp: "75" } } }) });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PUT /api/packets/1/stage/checklist/dwg": { body: after } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
const box = screen.getByRole("checkbox", { name: /Drawing CI-DWG-1 Rev 0 matches the packet/ });
|
||||
expect(box).toBeEnabled();
|
||||
fireEvent.click(box);
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: /Drawing CI-DWG-1/ })).toBeChecked());
|
||||
expect(callsTo(fetch, "PUT", "/api/packets/1/stage/checklist/dwg")).toEqual([{ done: true }]);
|
||||
expect(screen.getByText(/^75 · /)).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Weld record" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is read-only without edit_checklist", async () => {
|
||||
mockFetch(base(shopPacket({ can: [] }), ["shop:03:lead"]));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
|
||||
expect(screen.getByText("Checklist is completed at the stall.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weld record", () => {
|
||||
it("saves a field on blur with its base", async () => {
|
||||
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PATCH /api/packets/1/stage": { body: shopPacket({ version: 4, stage: stage({ filler: "ER70S-6" }) }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
const filler = screen.getByLabelText("Filler metal");
|
||||
fireEvent.change(filler, { target: { value: "ER70S-6" } });
|
||||
fireEvent.blur(filler);
|
||||
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/stage")).toEqual([{ set: { filler: "ER70S-6" }, base: { filler: null } }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weld sheet", () => {
|
||||
it("needs at least one item, then sends the point and the default label position", () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<WeldSheet
|
||||
p={shopPacket()}
|
||||
weld={null}
|
||||
at={{ page: 1, fx: 0.5, fy: 0.5 }}
|
||||
editable
|
||||
myStamp="75"
|
||||
sub="L999001 · WELD-PART"
|
||||
onSave={onSave}
|
||||
onRemove={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Size")).toHaveValue('5/16"');
|
||||
expect(screen.getByLabelText("Type")).toHaveValue("Fillet");
|
||||
expect(screen.getByLabelText("Process")).toHaveValue("GMAW");
|
||||
expect(screen.getByLabelText("Welder stamp")).toHaveValue("75");
|
||||
expect(screen.getByLabelText("Piece")).toHaveTextContent("1 of 3");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Pick at least one BOM item this weld joins.");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Item 2" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Item 1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const body = onSave.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(body).toMatchObject({ page: 1, fx: 0.5, fy: 0.5, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75" });
|
||||
expect(body.bfx).toBeCloseTo(0.55);
|
||||
expect(body.bfy).toBeCloseTo(0.43);
|
||||
});
|
||||
|
||||
it("is read-only without edit rights", () => {
|
||||
render(
|
||||
<WeldSheet
|
||||
p={shopPacket()}
|
||||
weld={{ weld_no: 4, page: 1, fx: 0.2, fy: 0.3, wo_base: "L999001", piece: 1, items: [1], type: "Groove", size: "1/4\"", process: "GTAW", stamp: "75", at: null, row_version: 1, welder: null }}
|
||||
at={null}
|
||||
editable={false}
|
||||
myStamp={null}
|
||||
sub=""
|
||||
onSave={() => undefined}
|
||||
onRemove={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Weld W4" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Type")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Packet page action bars", () => {
|
||||
it("sup.wes on a review packet: Send to Q.C. waits for both boxes and a location, then signs", async () => {
|
||||
const p = shopPacket({ status: "review", holder: "Supervisor", stage: stage({ clock: { elapsed_ms: 1000, running: false, running_since: null, started_at: null } }), can: ["send_to_qc", "return_to_station", "route_from_review"] });
|
||||
const fetch = mockFetch({
|
||||
...base(p, ["shop:03:supervisor"]),
|
||||
"POST /api/packets/1/transitions": { body: shopPacket({ status: "qc", version: 5, holder: "Q.C.", can: [], blocked: {} }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const box = await screen.findByRole("region", { name: "Supervisor review" });
|
||||
const send = within(box).getByRole("button", { name: "Send to Q.C." });
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("Data book package ready"));
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("Spool ready for inspection"));
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.change(within(box).getByLabelText("Spool location"), { target: { value: "Bay 2" } });
|
||||
expect(send).toBeEnabled();
|
||||
expect(within(box).getByRole("button", { name: "Return to station" })).toBeEnabled();
|
||||
expect(within(box).getByRole("button", { name: "Send to department" })).toBeEnabled();
|
||||
fireEvent.click(send);
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Send to Q.C.")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "send_to_qc", expect: { status: "review", dept: "03" }, book: true, spool: true, location: "Bay 2", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Q.C. inspection"));
|
||||
});
|
||||
|
||||
it("a lead sees the review read-only", async () => {
|
||||
mockFetch(base(shopPacket({ status: "review", holder: "Supervisor", can: [] }), ["shop:03:lead"], "Lou Lead"));
|
||||
renderApp("/packets/1");
|
||||
expect(await screen.findByTestId("review-readonly")).toHaveTextContent("A supervisor for 03 Weld Parts reviews it and sends it on.");
|
||||
expect(screen.queryByRole("button", { name: "Send to Q.C." })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("weld.andy in progress: the clock, Pause, and Submit with the missing steps as links", async () => {
|
||||
const p = shopPacket({
|
||||
can: ["clock", "edit_checklist", "edit_welds", "edit_stage_fields"],
|
||||
blocked: { submit: ["Finish the checklist first.", "Record at least one weld first.", "Enter the filler metal first."] },
|
||||
labor: { hours: 3.24, tickets: 2, per_wo: [], observed_at: null },
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:worker"], "Andy Welder"));
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Station" });
|
||||
expect(within(bar).getByRole("button", { name: "Submit to supervisor" })).toBeDisabled();
|
||||
expect(within(bar).getByTestId("submit-missing")).toHaveTextContent("Before submitting: 3 checklist items · no welds marked on the drawing · filler metal");
|
||||
expect(within(bar).getByRole("button", { name: "Pause" })).toBeEnabled();
|
||||
expect(within(bar).getByTestId("clock")).toHaveTextContent(/01:02:0\d running · target 4.5 h/);
|
||||
expect(within(bar).getByTestId("labor")).toHaveTextContent("VISUAL tickets 3.2 h");
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "no welds marked on the drawing" }));
|
||||
expect(screen.getByRole("tab", { name: /Weld map/ })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("Submit signs and sends the expect guard; a stale step reloads and toasts the server text", async () => {
|
||||
let fresh = false;
|
||||
const p = shopPacket({ can: ["clock", "submit"], stage: stage({ filler: "ER70S-6", checklist: { dwg: { done: true }, heat: { done: true }, vt: { done: true } } }), welds: [{ weld_no: 1, page: 1, fx: 0.4, fy: 0.4, wo_base: "L999001", piece: 1, items: [1], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null, dept_code: "03" }] });
|
||||
const back = shopPacket({ status: "ready", version: 9, station: null, welder: null, stage: null, holder: "Supervisor", can: [], blocked: {} });
|
||||
const fetch = mockFetch({
|
||||
...base(p, ["shop:03:worker"], "Andy Welder"),
|
||||
"/api/packets/1": () => ({ body: fresh ? back : p }),
|
||||
"POST /api/packets/1/transitions": () => {
|
||||
fresh = true;
|
||||
return { status: 409, body: { detail: "Someone else already took this step.", code: "stale_step" } };
|
||||
},
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Station" });
|
||||
expect(within(bar).getByText(/Everything is filled in/)).toBeInTheDocument();
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Submit to supervisor" }));
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "submit", expect: { status: "in_progress", dept: "03" }, sign: { factor: "password", secret: "pw-1" } }]);
|
||||
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
|
||||
});
|
||||
|
||||
it("the assign sheet sends the station, the welder and the stamp", async () => {
|
||||
const p = shopPacket({ status: "ready", station: null, welder: null, stage: null, holder: "Supervisor", can: ["assign", "send_back_to_warehouse"] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/transitions": { body: shopPacket({ status: "assigned", version: 5, can: [] }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Send to station" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Send to station" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Station"), { target: { value: "2" } });
|
||||
await within(sheet).findByRole("option", { name: "Andy Welder [75]" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Find a welder"), { target: { value: "andy" } });
|
||||
fireEvent.change(within(sheet).getByLabelText("Welder"), { target: { value: "andy" } });
|
||||
expect(within(sheet).getByLabelText("Stamp")).toHaveValue("75");
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Send to station" }));
|
||||
await waitFor(() =>
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "assign", expect: { status: "ready", dept: "03" }, station_id: 2, welder_id: "andy", stamp: "75" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Changes tab, verify", () => {
|
||||
it("a supervisor of a pending department ticks, verifies and signs", async () => {
|
||||
const co = {
|
||||
id: 9,
|
||||
co_no: 2,
|
||||
label: "CO-2",
|
||||
origin: "manual",
|
||||
review: "approved",
|
||||
minor: false,
|
||||
hold: false,
|
||||
ecn: null,
|
||||
reason: "New rev",
|
||||
issued_at: "2026-10-16T08:00:00Z",
|
||||
issued_by: { id: "clara", display_name: "Clara Clerk" },
|
||||
items: [],
|
||||
required: ["03", "09"],
|
||||
verified: { "09": { by: { id: "x", display_name: "Hal Hydro" }, at: "2026-10-16T09:00:00Z", note: null, signed: true } },
|
||||
pending_depts: ["03"],
|
||||
open: true,
|
||||
} as const;
|
||||
const p = shopPacket({ change_orders: [{ ...co, items: [], verified: { ...co.verified }, required: [...co.required], pending_depts: [...co.pending_depts] }] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/change-orders/2/verify": { body: shopPacket({ version: 6 }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
|
||||
const box = await screen.findByRole("group", { name: "Verify CO-2 for 03 Weld Parts" });
|
||||
const verify = within(box).getByRole("button", { name: "Verify" });
|
||||
expect(verify).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("I reviewed this change with my department"));
|
||||
fireEvent.change(within(box).getByLabelText("Note (optional)"), { target: { value: "Checked with the crew" } });
|
||||
fireEvent.click(verify);
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Verify a change order for the department")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/verify")).toEqual([
|
||||
{ dept: "03", note: "Checked with the crew", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a lead gets no verify box", async () => {
|
||||
const p = shopPacket({
|
||||
change_orders: [
|
||||
{ id: 9, co_no: 2, origin: "manual", review: "approved", minor: false, hold: false, ecn: null, reason: null, issued_at: null, issued_by: null, items: [], required: ["03"], verified: {} },
|
||||
],
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:lead"], "Lou Lead"));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
|
||||
await screen.findByRole("region", { name: "Change order CO-2" });
|
||||
expect(screen.queryByRole("button", { name: "Verify" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 { DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, 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 });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Row writes on the packet page run one after another. Each takes the row version from the newest packet seen
|
||||
// (the prop, or an earlier write's own answer before React re-rendered), and a row_conflict reads the packet
|
||||
// again and retries once. The phase 4 pull table found these races at full speed.
|
||||
import { useCallback, useRef } from "react";
|
||||
import { errorCode } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { Guarded, PacketRead } from "../api/types";
|
||||
import type { Write, WriteResult } from "../warehouse/PullTable";
|
||||
|
||||
export type RowCall = (cur: PacketRead, g: Guarded) => Promise<PacketRead>;
|
||||
|
||||
export function useSerial(p: PacketRead, write: Write): (call: RowCall) => Promise<WriteResult> {
|
||||
const latest = useRef(p);
|
||||
if (latest.current.id !== p.id || p.version >= latest.current.version) latest.current = p;
|
||||
const chain = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const id = p.id;
|
||||
return useCallback(
|
||||
(call: RowCall) => {
|
||||
const next = chain.current.then(async () => {
|
||||
const send = () =>
|
||||
write((g) =>
|
||||
call(latest.current, g).then((r) => {
|
||||
if (r && r.version >= latest.current.version) latest.current = r;
|
||||
return r;
|
||||
}),
|
||||
);
|
||||
let r = await send();
|
||||
if (!r.ok && r.err !== undefined && errorCode(r.err) === "row_conflict") {
|
||||
const fresh = await packetsApi.get(id);
|
||||
if (fresh.version >= latest.current.version) latest.current = fresh;
|
||||
r = await send();
|
||||
}
|
||||
return r;
|
||||
});
|
||||
chain.current = next.catch(() => undefined);
|
||||
return next;
|
||||
},
|
||||
[write, id],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// The viewer context the drawing overlay reads: pointer to page fractions, mark drags, the page size.
|
||||
import { createContext, useContext, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { Frac } from "../lib/geometry";
|
||||
|
||||
/** The bitmap: at most 2.2 times the PDF size and at most 4200 px wide. */
|
||||
export function renderScale(pdfWidth: number): number {
|
||||
return Math.min(2.2, 4200 / Math.max(1, pdfWidth));
|
||||
}
|
||||
|
||||
/** A pointer that moved less than this is a tap. */
|
||||
export const TAP_PX = 6;
|
||||
/** A mark drag starts after this. */
|
||||
export const DRAG_PX = 5;
|
||||
|
||||
export interface DragHandlers {
|
||||
/** The pointer is at f (page fractions, not clamped). */
|
||||
onMove: (f: Frac) => void;
|
||||
/** The drag ended at f, or null when it never moved (a tap). */
|
||||
onEnd: (f: Frac | null) => void;
|
||||
}
|
||||
|
||||
export interface ViewerApi {
|
||||
/** A client point as page fractions, and whether it lies inside the viewer frame. */
|
||||
toFrac: (clientX: number, clientY: number) => Frac & { inside: boolean };
|
||||
/** Starts a mark drag from a pointerdown. Moves and ends follow the window. */
|
||||
startDrag: (e: ReactPointerEvent, h: DragHandlers) => void;
|
||||
/** True for a short while after a drag ended, so the click that follows it is ignored. */
|
||||
justDragged: () => boolean;
|
||||
}
|
||||
|
||||
export interface ViewerInfo extends ViewerApi {
|
||||
/** Page size in bitmap pixels; 0 until the page is drawn. */
|
||||
w: number;
|
||||
h: number;
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export const ViewerCtx = createContext<ViewerInfo | null>(null);
|
||||
|
||||
/** The viewer the overlay lives in. Marks use it to turn pointers into page fractions. */
|
||||
export function useViewer(): ViewerInfo {
|
||||
const v = useContext(ViewerCtx);
|
||||
if (!v) throw new Error("useViewer must be used inside MapViewer");
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -167,9 +167,9 @@ describe("Packet page, Changes tab", () => {
|
||||
"POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
expect(await screen.findByText(/Change order 2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Change order CO-2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review change" }));
|
||||
const sec = await screen.findByRole("region", { name: "Change order 2" });
|
||||
const sec = await screen.findByRole("region", { name: "Change order CO-2" });
|
||||
expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument();
|
||||
expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument();
|
||||
expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2");
|
||||
@@ -177,8 +177,8 @@ describe("Packet page, Changes tab", () => {
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
|
||||
expect(await screen.findByText("Change order 2 approved. The shop packet is updated.")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Change order CO-2 approved. The shop packet is updated.")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("table", { name: "CO-2 verification" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => {
|
||||
@@ -190,7 +190,7 @@ describe("Packet page, Changes tab", () => {
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
|
||||
const sec = await screen.findByRole("region", { name: "Change order 2" });
|
||||
const sec = await screen.findByRole("region", { name: "Change order CO-2" });
|
||||
expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled();
|
||||
expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
|
||||
expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument();
|
||||
@@ -200,7 +200,7 @@ describe("Packet page, Changes tab", () => {
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]);
|
||||
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Reject" }));
|
||||
const ask = await screen.findByRole("dialog", { name: "Reject change order 2" });
|
||||
const ask = await screen.findByRole("dialog", { name: "Reject change order CO-2" });
|
||||
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } });
|
||||
fireEvent.click(within(ask).getByRole("button", { name: "Reject" }));
|
||||
await sign();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The VISUAL box and the Changes tab on the packet page (phase 3).
|
||||
// The VISUAL box and Clerical's change order review panel on the packet page (phase 3). The Changes tab is ChangesTab.tsx.
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { assetsApi, blobUrl } from "../api/assets";
|
||||
import { packetsApi } from "../api/packets";
|
||||
@@ -6,7 +6,6 @@ import type { ChangeOrder, FoldedOp, HandUpload, PacketRead, WhoRef } from "../a
|
||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import { coSummary, feedOrigin } from "../lib/changeOrders";
|
||||
import { DRAWING_FLAGS } from "../lib/packets";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
@@ -307,15 +306,7 @@ export function VisualBox({
|
||||
|
||||
/* ---------- Change orders ---------- */
|
||||
|
||||
function coPill(co: ChangeOrder) {
|
||||
if (co.review === "rejected") return <span className="pill st-filed">Rejected</span>;
|
||||
if (co.review === "pending") return <span className="pill st-review">Waiting on Clerical</span>;
|
||||
const open = co.required.filter((d) => !co.verified[d]);
|
||||
if (open.length) return <span className="pill st-review">Open - {open.length} to verify</span>;
|
||||
return <span className="pill st-in_progress">Verified</span>;
|
||||
}
|
||||
|
||||
function ReviewPanel({
|
||||
export function ReviewPanel({
|
||||
p,
|
||||
co,
|
||||
sub,
|
||||
@@ -339,11 +330,11 @@ function ReviewPanel({
|
||||
const old = p.pages.find((x) => x.type === "drawing" && !x.void);
|
||||
|
||||
const approve = () =>
|
||||
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order ${co.co_no} approved. The shop packet is updated.` });
|
||||
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order CO-${co.co_no} approved. The shop packet is updated.` });
|
||||
|
||||
const reject = async () => {
|
||||
const reason = await askReason({
|
||||
title: `Reject change order ${co.co_no}`,
|
||||
title: `Reject change order CO-${co.co_no}`,
|
||||
sub,
|
||||
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
|
||||
min: 3,
|
||||
@@ -352,7 +343,7 @@ function ReviewPanel({
|
||||
submitLabel: "Reject",
|
||||
});
|
||||
if (reason === null) return;
|
||||
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order ${co.co_no} rejected.` });
|
||||
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order CO-${co.co_no} rejected.` });
|
||||
};
|
||||
|
||||
const hold = () =>
|
||||
@@ -421,142 +412,3 @@ function ReviewPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Changes tab: every change order, newest first. Clerical reviews the ones from VISUAL and M-Files here. */
|
||||
export function ChangesTab({
|
||||
p,
|
||||
roles,
|
||||
sub,
|
||||
mutate,
|
||||
busy,
|
||||
askReason,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
roles: readonly string[];
|
||||
sub: string;
|
||||
mutate: Mutate;
|
||||
busy: boolean;
|
||||
askReason: AskReason;
|
||||
}) {
|
||||
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
|
||||
if (!list.length) return <div className="empty">No change orders on this packet.</div>;
|
||||
const canReview = isCl(roles) || p.can.includes("co_approve");
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((co) => {
|
||||
const feed = feedOrigin(co);
|
||||
return (
|
||||
<section key={co.id} className="sec" aria-label={`Change order ${co.co_no}`}>
|
||||
<div className="sec-h flush">
|
||||
<h2>
|
||||
Change order {co.co_no}
|
||||
{co.ecn ? ` · ${co.ecn}` : ""}
|
||||
</h2>
|
||||
{coPill(co)}
|
||||
<span className="ct">
|
||||
{feed ? (co.origin === "drawing" ? "from M-Files" : "from VISUAL") : `issued by ${co.issued_by?.display_name ?? "-"}`} {fmtTime(co.issued_at)}
|
||||
{co.hold ? " · shop packet on hold" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
<b>{coSummary(co)}</b>
|
||||
</div>
|
||||
{co.review === "pending" && feed ? (
|
||||
canReview ? (
|
||||
<ReviewPanel p={p} co={co} sub={sub} mutate={mutate} busy={busy} askReason={askReason} canReview={canReview} />
|
||||
) : (
|
||||
<div className="callout warn flush">
|
||||
<b>Clerical is reviewing a change from VISUAL.</b> Keep working to the current drawing. If Clerical approves it, the new drawing and changes come onto
|
||||
this packet and you will be asked to verify.
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{co.review === "rejected" ? (
|
||||
<div className="callout flush">
|
||||
{co.rejected_by ? (
|
||||
<>
|
||||
<b>Rejected by Clerical</b> - {co.rejected_by.display_name} {fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The shop packet was not changed.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Withdrawn</b> {fmtTime(co.rejected_at)}: {co.rejected_reason ?? "VISUAL went back to what the packet has"}. The shop packet was not changed.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && co.approved_by ? (
|
||||
<div className="small">
|
||||
<b>Approved by Clerical:</b> {co.approved_by.display_name} {fmtTime(co.approved_at)}
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && feed && co.vis?.changes?.length ? (
|
||||
<div className="small">
|
||||
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="small">
|
||||
<b>Reason:</b> {co.reason || "-"}
|
||||
</div>
|
||||
{co.items.length ? (
|
||||
<div className="tw">
|
||||
<table aria-label={`Change order ${co.co_no} sheets`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Old sheet</th>
|
||||
<th>New sheet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{co.items.map((it, i) => (
|
||||
<tr key={i}>
|
||||
<td className="small">
|
||||
{it.added ? (
|
||||
<span className="muted">Added</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="pill st-filed">VOID</span> {it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
|
||||
{it.old_page ? <span className="muted"> · page {it.old_page}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="small">
|
||||
<span className="pill st-in_progress">{it.added ? "ADDED" : "NEW"}</span> {it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
|
||||
{it.new_page ? <span className="muted"> · page {it.new_page}</span> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{co.review === "approved" && co.required.length ? (
|
||||
<div className="tw">
|
||||
<table aria-label={`Change order ${co.co_no} verification`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th>Verified</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{co.required.map((d) => {
|
||||
const ver = co.verified[d];
|
||||
return (
|
||||
<tr key={d}>
|
||||
<td className="mono">{d}</td>
|
||||
<td className="small">{ver ? `${ver.by?.display_name ?? "-"} ${fmtTime(ver.at)}` : <span className="due soon">Pending</span>}</td>
|
||||
<td className="small">{ver?.note ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { ClericalHome } from "./clerical/ClericalHome";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
import { StallHome } from "./stall/StallHome";
|
||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||
import { WarehouseHome } from "./warehouse/WarehouseHome";
|
||||
|
||||
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
|
||||
@@ -25,5 +27,7 @@ export function ScreenRoute() {
|
||||
if (screen === "admin") return <AdminPage />;
|
||||
if (screen === "clerical") return <ClericalHome />;
|
||||
if (screen === "warehouse") return <WarehouseHome />;
|
||||
if (screen === "supervisor") return <SupervisorHome />;
|
||||
if (screen === "stall") return <StallHome />;
|
||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShopCard, StallAnswer, StationOption } from "../../api/shop";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { makeCard } from "../../test/packets";
|
||||
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||
import { NO_STATION, STALL_KEY } from "./StallHome";
|
||||
|
||||
const ANDY = makeMe({ roles: ["shop:03:worker"], screens: ["stall"], user: { sam_account: "weld.andy", display_name: "Andy Welder" } });
|
||||
|
||||
const STATIONS: StationOption[] = [
|
||||
{ dept_code: "03", dept_name: "Weld Parts", id: 1, name: "Stall 1" },
|
||||
{ dept_code: "03", dept_name: "Weld Parts", id: 2, name: "Stall 2" },
|
||||
];
|
||||
|
||||
function shopCard(id: number, over: Partial<ShopCard> = {}): ShopCard {
|
||||
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, holder: "Stall 1" });
|
||||
return { ...base, open_cos: [], ...over };
|
||||
}
|
||||
|
||||
function stall(id: number): StallAnswer {
|
||||
const st = STATIONS.find((s) => s.id === id) as StationOption;
|
||||
return {
|
||||
station: { id, name: st.name, dept_code: "03", dept_name: "Weld Parts" },
|
||||
mine:
|
||||
id === 1
|
||||
? [shopCard(1, { status: "in_progress", station: { id: 1, name: "Stall 1" }, clock: { elapsed_ms: 3_600_000, running: false }, target_hrs: 4.5 })]
|
||||
: [],
|
||||
submitted: id === 1 ? [shopCard(2, { status: "review", station: { id: 1, name: "Stall 1" } })] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function base(stations: StationOption[] = STATIONS) {
|
||||
return {
|
||||
"/api/auth/me": { body: ANDY },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/shop/stations": { body: stations },
|
||||
"/api/shop/stall?station_id=1": { body: stall(1) },
|
||||
"/api/shop/stall?station_id=2": { body: stall(2) },
|
||||
};
|
||||
}
|
||||
|
||||
const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("StallHome", () => {
|
||||
it("shows the hero, the picker and the two boards", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/stall");
|
||||
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("03 Weld Parts")).toHaveClass("eyebrow");
|
||||
expect(screen.getByText("Packets your supervisor sends to this station appear here.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Station" })).toHaveValue("1");
|
||||
|
||||
const mine = await waitFor(() => column("My packets"));
|
||||
const card = within(mine).getByRole("link", { name: /^L662001/ });
|
||||
expect(card).toHaveTextContent("01:00:00 paused · target 4.5 h");
|
||||
expect(card).toHaveTextContent("Working");
|
||||
expect(within(column("Submitted, waiting on supervisor")).getByRole("link", { name: /^L662002/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches station and remembers it", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/stall");
|
||||
const pick = await screen.findByRole("combobox", { name: "Station" });
|
||||
fireEvent.change(pick, { target: { value: "2" } });
|
||||
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/stall?station_id=2")).toBe(true));
|
||||
expect(window.localStorage.getItem(STALL_KEY)).toBe("2");
|
||||
expect(await screen.findByText("No work assigned to this stall right now.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing waiting.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts on the remembered station", async () => {
|
||||
window.localStorage.setItem(STALL_KEY, "2");
|
||||
mockFetch(base());
|
||||
renderApp("/stall");
|
||||
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the picker with one station", async () => {
|
||||
mockFetch(base([STATIONS[0] as StationOption]));
|
||||
renderApp("/stall");
|
||||
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: "Station" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when the user has no station", async () => {
|
||||
mockFetch(base([]));
|
||||
renderApp("/stall");
|
||||
expect(await screen.findByText(NO_STATION)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
getStall,
|
||||
getStations,
|
||||
type ShopCard,
|
||||
type StallAnswer,
|
||||
type StationOption,
|
||||
} from "../../api/shop";
|
||||
import { Card } from "../../components/Card";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import {
|
||||
asCard,
|
||||
fmtClock,
|
||||
fmtHrs,
|
||||
liveMs,
|
||||
lsGet,
|
||||
lsSet,
|
||||
useNow,
|
||||
} from "../supervisor/shopView";
|
||||
|
||||
/** localStorage key for the picked station id (the old ps.stall). */
|
||||
export const STALL_KEY = "ps.stall";
|
||||
|
||||
export const NO_STATION =
|
||||
"You aren't assigned to a station. Ask an admin to add you to a department.";
|
||||
|
||||
type LoadedStall = StallAnswer & { fetchedAt: number };
|
||||
|
||||
function StallCard({
|
||||
p,
|
||||
fetchedAt,
|
||||
now,
|
||||
}: {
|
||||
p: ShopCard;
|
||||
fetchedAt: number;
|
||||
now: number;
|
||||
}) {
|
||||
const running = !!p.clock?.running;
|
||||
const state = running
|
||||
? "running"
|
||||
: p.status === "in_progress"
|
||||
? "paused"
|
||||
: "";
|
||||
const extra = (
|
||||
<>
|
||||
<div className="small mono stclock">
|
||||
<span className={running ? "sdot run" : "sdot"} aria-hidden="true" />
|
||||
{fmtClock(liveMs(p.clock, fetchedAt, now))}
|
||||
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)}{" "}
|
||||
h
|
||||
</div>
|
||||
{p.return_note && p.status === "assigned" ? (
|
||||
<div className="small due soon">Returned: {p.return_note}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return <Card p={asCard(p)} extra={extra} />;
|
||||
}
|
||||
|
||||
function StallColumn({
|
||||
title,
|
||||
cards,
|
||||
empty,
|
||||
fetchedAt,
|
||||
now,
|
||||
}: {
|
||||
title: string;
|
||||
cards: ShopCard[];
|
||||
empty: string;
|
||||
fetchedAt: number;
|
||||
now: number;
|
||||
}) {
|
||||
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) => (
|
||||
<StallCard key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
|
||||
))
|
||||
) : (
|
||||
<div className="empty">{empty}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function stationLabel(s: StationOption): string {
|
||||
return `${s.dept_code} ${s.dept_name} · ${s.name}`;
|
||||
}
|
||||
|
||||
/** /stall (the old homeStall): the station picker, the hero and the two boards. */
|
||||
export function StallHome() {
|
||||
const [stations] = useLoad<StationOption[]>(
|
||||
(s) => getStations(s),
|
||||
"stations",
|
||||
);
|
||||
const list = useMemo(
|
||||
() => (stations.state === "ok" ? stations.data : []),
|
||||
[stations],
|
||||
);
|
||||
const [picked, setPicked] = useState<string | null>(() => lsGet(STALL_KEY));
|
||||
const station = list.find((s) => String(s.id) === picked) ?? list[0] ?? null;
|
||||
const choose = (v: string) => {
|
||||
setPicked(v);
|
||||
lsSet(STALL_KEY, v);
|
||||
};
|
||||
|
||||
const [stall, reload] = useLoad<LoadedStall | null>(
|
||||
async (s) =>
|
||||
station
|
||||
? { ...(await getStall(station.id, s)), fetchedAt: Date.now() }
|
||||
: null,
|
||||
String(station?.id ?? ""),
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const data = stall.state === "ok" ? stall.data : null;
|
||||
const running = data ? data.mine.some((p) => p.clock?.running) : false;
|
||||
const now = useNow(running);
|
||||
|
||||
if (stations.state === "loading")
|
||||
return <div className="empty slim">Loading</div>;
|
||||
if (stations.state === "error")
|
||||
return <div className="callout warn">{stations.message}</div>;
|
||||
if (!station) return <div className="empty stnone">{NO_STATION}</div>;
|
||||
|
||||
const deptLabel = `${station.dept_code} ${station.dept_name}`;
|
||||
return (
|
||||
<>
|
||||
<div className="stallhero">
|
||||
<div className="big">{station.name.toUpperCase()}</div>
|
||||
<div className="grow">
|
||||
<div className="eyebrow">{deptLabel}</div>
|
||||
<div className="small muted">
|
||||
Packets your supervisor sends to this station appear here.
|
||||
</div>
|
||||
</div>
|
||||
{list.length > 1 ? (
|
||||
<label className="f stpick">
|
||||
<span>Station</span>
|
||||
<select
|
||||
aria-label="Station"
|
||||
className="in"
|
||||
value={String(station.id)}
|
||||
onChange={(e) => choose(e.target.value)}
|
||||
>
|
||||
{list.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{stationLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{stall.state === "loading" ? (
|
||||
<div className="empty slim">Loading</div>
|
||||
) : null}
|
||||
{stall.state === "error" ? (
|
||||
<div className="callout warn">{stall.message}</div>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<div className="board">
|
||||
<StallColumn
|
||||
title="My packets"
|
||||
cards={data.mine}
|
||||
empty="No work assigned to this stall right now."
|
||||
fetchedAt={data.fetchedAt}
|
||||
now={now}
|
||||
/>
|
||||
<StallColumn
|
||||
title="Submitted, waiting on supervisor"
|
||||
cards={data.submitted}
|
||||
empty="Nothing waiting."
|
||||
fetchedAt={data.fetchedAt}
|
||||
now={now}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import type { ShopCoRow } from "../../api/shop";
|
||||
import { coHref, deptShort } from "./shopView";
|
||||
|
||||
/**
|
||||
* "Change orders on hold" (the old coDeptSection): the open change orders in my departments that the
|
||||
* banner does not already ask me to verify, such as one waiting on Clerical or on another department.
|
||||
*/
|
||||
export function CoTable({
|
||||
rows,
|
||||
names,
|
||||
canVerify,
|
||||
}: {
|
||||
rows: readonly ShopCoRow[];
|
||||
names: ReadonlyMap<string, string>;
|
||||
canVerify: (dept: string) => boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section className="sec cosec" aria-labelledby="cosec-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="cosec-h">Change orders on hold</h2>
|
||||
<span className="ct">
|
||||
{rows.length} in your department{rows.length === 1 ? "" : "s"} · sign-offs on these packets are on hold
|
||||
</span>
|
||||
</div>
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CO</th>
|
||||
<th>Packet</th>
|
||||
<th>What</th>
|
||||
<th>Waiting on</th>
|
||||
<th>
|
||||
<span className="sr">Open</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const job = r.op ? `${r.unit_number} · ${r.op}` : r.unit_number;
|
||||
const clerical = r.review !== "approved";
|
||||
return (
|
||||
<tr key={`${r.packet_id}-${r.co_no}`}>
|
||||
<td>
|
||||
<b>{r.label}</b>
|
||||
{r.ecn ? <div className="small muted">{r.ecn}</div> : null}
|
||||
</td>
|
||||
<td>
|
||||
<b>{job}</b>
|
||||
<div className="small muted">
|
||||
{deptShort(r.dept_code, names)}
|
||||
{r.title ? ` · ${r.title}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="small">
|
||||
{clerical ? "A change from VISUAL" : "Drawing changed"}
|
||||
{r.reason ? <div className="muted">{r.reason}</div> : null}
|
||||
</td>
|
||||
<td className="small">
|
||||
{clerical ? (
|
||||
<div>○ Clerical review{r.hold ? " · packet held" : ""}</div>
|
||||
) : (
|
||||
r.pending_depts.map((d) => (
|
||||
<div key={d}>
|
||||
○ {deptShort(d, names)}
|
||||
{canVerify(d) ? <b className="due soon"> you</b> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn sm" type="button" aria-label={`Open ${r.label} on ${job}`} onClick={() => navigate(coHref(r.packet_id))}>
|
||||
Open
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import type { BoardLevel, ShopCard, ShopDept } from "../../api/shop";
|
||||
import { jobName, statusLabel } from "../../lib/packets";
|
||||
import { coHref, coTagText, deptShort, fmtClock, fmtHrs, liveMs } from "./shopView";
|
||||
|
||||
const LEVEL_LABEL: Record<BoardLevel, string> = { lead: "Lead", supervisor: "Supervisor", admin: "Admin" };
|
||||
|
||||
function StationJob({ p, fetchedAt, now }: { p: ShopCard; fetchedAt: number; now: number }) {
|
||||
const navigate = useNavigate();
|
||||
const ms = liveMs(p.clock, fetchedAt, now);
|
||||
const target = Number(p.target_hrs) || 0;
|
||||
const hrs = ms / 3_600_000;
|
||||
const stamp = p.welder_stamp ?? p.welder?.stamp ?? null;
|
||||
return (
|
||||
<div className="stjob">
|
||||
<button type="button" className="stopen" onClick={() => navigate(`/packets/${p.id}`)}>
|
||||
{jobName(p)}
|
||||
</button>
|
||||
{p.open_cos.length ? (
|
||||
<button type="button" className="cotag" onClick={() => navigate(coHref(p.id))}>
|
||||
{coTagText(p.open_cos)}
|
||||
</button>
|
||||
) : null}
|
||||
{p.welder || stamp ? (
|
||||
<div className="small">
|
||||
{p.welder?.display_name ?? ""} {stamp ? <span className="mono muted">[{stamp}]</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="small mono muted stclock">
|
||||
{fmtClock(ms)} / {fmtHrs(target)} h · {statusLabel(p.status)}
|
||||
</div>
|
||||
<div className="bar" title={`${fmtHrs(hrs)} of ${fmtHrs(target)} h`}>
|
||||
<i className={target && hrs > target ? "over" : undefined} style={{ width: `${target ? Math.min(100, (hrs / target) * 100) : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The station grid (the old homeSupervisor grid): one section per department, one tile per station. */
|
||||
export function StationGrid({
|
||||
depts,
|
||||
names,
|
||||
fetchedAt,
|
||||
now,
|
||||
}: {
|
||||
depts: readonly ShopDept[];
|
||||
names: ReadonlyMap<string, string>;
|
||||
fetchedAt: number;
|
||||
now: number;
|
||||
}) {
|
||||
// With several departments, a department with nothing at its stations is left out.
|
||||
const shown = depts.filter((d) => depts.length === 1 || d.stations.some((s) => s.packets.length));
|
||||
if (!shown.length) return <div className="empty slim stempty">No packets at stations right now.</div>;
|
||||
return (
|
||||
<>
|
||||
{shown.map((d) => {
|
||||
const id = `stations-${d.code}`;
|
||||
return (
|
||||
<section key={d.code} className="sec" aria-labelledby={id}>
|
||||
<div className="sec-h">
|
||||
<h2 id={id}>{deptShort(d.code, names)}</h2>
|
||||
<span className="ct">{LEVEL_LABEL[d.level] ?? ""}</span>
|
||||
</div>
|
||||
{d.stations.length ? (
|
||||
<div className="stalls">
|
||||
{d.stations.map((st) => {
|
||||
const n = st.packets.length;
|
||||
const hot = st.packets.some((p) => p.open_cos.length);
|
||||
const cls = ["stall", n ? "busy" : "idle", hot ? "cohot" : ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div key={st.id} className={cls} role="group" aria-label={`${st.name}, ${n ? `${n} job` : "idle"}`} data-station={st.id}>
|
||||
<div className="sn">
|
||||
{st.name.toUpperCase()}
|
||||
<span className="mono small muted">{n ? `${n} job` : "idle"}</span>
|
||||
</div>
|
||||
{st.packets.map((p) => (
|
||||
<StationJob key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty slim">No stations set up for this department.</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShopBoard, ShopCard } from "../../api/shop";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { makeCard } from "../../test/packets";
|
||||
import { memoryStorage } from "./memoryStorage";
|
||||
import { SUP_DEPT_KEY } from "./SupervisorHome";
|
||||
|
||||
const WES = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor", "stall"], user: { sam_account: "sup.wes", display_name: "Wes Super" } });
|
||||
const WELD = { code: "03", name: "Weld Parts" };
|
||||
|
||||
function shopCard(id: number, over: Partial<ShopCard> = {}): ShopCard {
|
||||
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, dept: WELD, holder: "Weld Parts" });
|
||||
return { ...base, open_cos: [], ...over };
|
||||
}
|
||||
|
||||
const AT_STALL = shopCard(1, {
|
||||
status: "in_progress",
|
||||
station: { id: 1, name: "Stall 1" },
|
||||
welder: { id: "u-andy", display_name: "Andy Welder", stamp: "75" },
|
||||
welder_stamp: "75",
|
||||
clock: { elapsed_ms: 5_400_000, running: true },
|
||||
target_hrs: 2,
|
||||
open_cos: [{ label: "CO-2", review: "approved" }],
|
||||
});
|
||||
|
||||
function board(over: Partial<ShopBoard> = {}): ShopBoard {
|
||||
return {
|
||||
depts: [
|
||||
{
|
||||
code: "03",
|
||||
name: "Weld Parts",
|
||||
weld: true,
|
||||
level: "supervisor",
|
||||
stations: [
|
||||
{ id: 1, name: "Stall 1", packets: [AT_STALL] },
|
||||
{ id: 2, name: "Stall 2", packets: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
kpis: { ready: 2, at_stations: 1, review: 0, elapsed_ms: 16_200_000, target_hrs: 12 },
|
||||
cos: [
|
||||
{
|
||||
packet_id: 1,
|
||||
unit_number: "L662001",
|
||||
op: "WELD-PART",
|
||||
title: "CI spool",
|
||||
dept_code: "03",
|
||||
co_no: 2,
|
||||
label: "CO-2",
|
||||
review: "approved",
|
||||
hold: false,
|
||||
pending_depts: ["03"],
|
||||
mine: true,
|
||||
reason: "Nozzle moved",
|
||||
ecn: null,
|
||||
issued_at: "2026-10-16T08:00:00Z",
|
||||
},
|
||||
{
|
||||
packet_id: 4,
|
||||
unit_number: "L662004",
|
||||
op: "WELD-PART",
|
||||
title: "Other spool",
|
||||
dept_code: "03",
|
||||
co_no: 1,
|
||||
label: "CO-1",
|
||||
review: "pending",
|
||||
hold: true,
|
||||
pending_depts: [],
|
||||
mine: false,
|
||||
reason: null,
|
||||
ecn: null,
|
||||
issued_at: "2026-10-16T09:00:00Z",
|
||||
},
|
||||
],
|
||||
columns: {
|
||||
ready: [shopCard(2, { status: "ready" }), shopCard(3, { status: "ready", title: "Elbow" })],
|
||||
review: [],
|
||||
qc: [],
|
||||
warehouse: [],
|
||||
complete: [],
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function base(b: ShopBoard = board(), me = WES) {
|
||||
return {
|
||||
"/api/auth/me": { body: me },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/shop/board": { body: b },
|
||||
"/api/projects": { body: [] },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("SupervisorHome", () => {
|
||||
it("shows the page head, the KPIs, the station grid and the five boards", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/supervisor");
|
||||
expect(await screen.findByRole("heading", { name: "Dispatch, review, route" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Supervisor · 03 Weld Parts")).toBeInTheDocument();
|
||||
await screen.findByRole("heading", { name: /^Ready to assign/ });
|
||||
|
||||
expect(screen.getByLabelText("Ready to assign: 2")).toHaveClass("hot");
|
||||
expect(screen.getByLabelText("At stations: 1")).not.toHaveClass("hot");
|
||||
expect(screen.getByLabelText("Waiting on review: 0")).toHaveClass("zero");
|
||||
expect(screen.getByLabelText("Station hours vs target: 4.5 / 12.0 h")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "03 Weld Parts" })).toBeInTheDocument();
|
||||
const busy = screen.getByRole("group", { name: "Stall 1, 1 job" });
|
||||
expect(busy).toHaveClass("stall", "busy", "cohot");
|
||||
expect(within(busy).getByRole("button", { name: "L662001 · WELD-PART" })).toBeInTheDocument();
|
||||
expect(within(busy).getByRole("button", { name: "CHANGE ORDER CO-2 - drawing changed, verify" })).toHaveClass("cotag");
|
||||
expect(within(busy).getByText("Andy Welder", { exact: false })).toBeInTheDocument();
|
||||
expect(within(busy).getByText("[75]")).toBeInTheDocument();
|
||||
expect(within(busy).getByText(/^01:30:0\d \/ 2\.0 h · Working$/)).toBeInTheDocument();
|
||||
const idle = screen.getByRole("group", { name: "Stall 2, idle" });
|
||||
expect(idle).toHaveClass("idle");
|
||||
expect(idle).not.toHaveClass("cohot");
|
||||
|
||||
for (const t of ["Ready to assign", "Waiting on review", "At Q.C.", "With warehouse", "Routed on"]) {
|
||||
expect(screen.getByRole("heading", { name: new RegExp(`^${t.replace(/\./g, "\\.")}`) })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText("Stations submit finished packets here.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Approved packets show their next stop here.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /^L662002 · WELD-PART/ })).toHaveAttribute("href", "/packets/2");
|
||||
});
|
||||
|
||||
it("shows the CO banner without a close button, and the held CO in the table", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/supervisor");
|
||||
const banner = await screen.findByRole("alert", { name: "Change orders to verify" });
|
||||
expect(banner).toHaveTextContent("Change order: your verification is required");
|
||||
expect(banner).toHaveTextContent("Change order CO-2 on L662001 · WELD-PART needs your verification");
|
||||
expect(banner).toHaveTextContent("for 03 Weld Parts");
|
||||
expect(within(banner).getAllByRole("button")).toHaveLength(1);
|
||||
expect(within(banner).queryByRole("button", { name: /close|dismiss/i })).not.toBeInTheDocument();
|
||||
|
||||
const table = screen.getByRole("heading", { name: "Change orders on hold" }).closest("section") as HTMLElement;
|
||||
expect(table).toHaveTextContent("CO-1");
|
||||
expect(table).toHaveTextContent("L662004 · WELD-PART");
|
||||
expect(table).toHaveTextContent("○ Clerical review · packet held");
|
||||
expect(table).not.toHaveTextContent("CO-2");
|
||||
|
||||
fireEvent.click(within(banner).getByRole("button", { name: "Review & verify CO-2 on L662001 · WELD-PART" }));
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u).startsWith("/api/packets/1"))).toBe(true));
|
||||
});
|
||||
|
||||
it("the CO tag on a held packet says Clerical is reviewing", async () => {
|
||||
const held = { ...AT_STALL, open_cos: [{ label: "CO-3", review: "pending" }] };
|
||||
const b = board({ cos: [] });
|
||||
b.depts[0]!.stations[0]!.packets = [held];
|
||||
mockFetch(base(b));
|
||||
renderApp("/supervisor");
|
||||
expect(await screen.findByRole("button", { name: "CHANGE ORDER CO-3 - Clerical reviewing a change, on hold" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert", { name: "Change orders to verify" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Change orders on hold" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows department chips to a user who leads more than one, and remembers the pick", async () => {
|
||||
const mia = makeMe({ roles: ["shop:03:supervisor", "shop:14:lead"], screens: ["supervisor", "stall"] });
|
||||
const fetch = mockFetch(base(board(), mia));
|
||||
renderApp("/supervisor");
|
||||
const chips = await screen.findByRole("group", { name: "Department" });
|
||||
expect(within(chips).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("Supervisor · 2 departments")).toBeInTheDocument();
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "14 Electrical" }));
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=14")).toBe(true));
|
||||
expect(window.localStorage.getItem(SUP_DEPT_KEY)).toBe("14");
|
||||
expect(within(chips).getByRole("button", { name: "14 Electrical" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("Supervisor · 14 Electrical")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts on the remembered department", async () => {
|
||||
window.localStorage.setItem(SUP_DEPT_KEY, "03");
|
||||
const mia = makeMe({ roles: ["shop:03:supervisor", "shop:14:lead"], screens: ["supervisor", "stall"] });
|
||||
const fetch = mockFetch(base(board(), mia));
|
||||
renderApp("/supervisor");
|
||||
await screen.findByRole("group", { name: "Department" });
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=03")).toBe(true));
|
||||
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=all")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the chips with one department and filters the boards with the find box", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/supervisor");
|
||||
await screen.findByRole("link", { name: /^L662003/ });
|
||||
expect(screen.queryByRole("group", { name: "Department" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||
expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /^L662003/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's refusal", async () => {
|
||||
mockFetch({ ...base(), "/api/shop/board": { status: 403, body: { detail: "You do not lead a department.", code: "forbidden" } } });
|
||||
renderApp("/supervisor");
|
||||
expect(await screen.findByText("You do not lead a department.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getBoard, type ShopBoard, type ShopCard, type ShopColumn } from "../../api/shop";
|
||||
import { levelIn, myShopDepts } from "../../auth/roles";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { Board, type ColumnDef } from "../../components/Board";
|
||||
import { CoBanner, type CoBannerItem } from "../../components/CoBanner";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { fmtTime } from "../../lib/format";
|
||||
import { useShopDepartments } from "../../lib/useDepartments";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { CoTable } from "./CoTable";
|
||||
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, matches, useNow } from "./shopView";
|
||||
import { StationGrid } from "./StationGrid";
|
||||
|
||||
/** localStorage key for the department scope (the old ps.supdept). */
|
||||
export const SUP_DEPT_KEY = "ps.supDept";
|
||||
|
||||
const COLUMNS: ReadonlyArray<{ key: ShopColumn; title: string; empty: string }> = [
|
||||
{ key: "ready", title: "Ready to assign", empty: "Warehouse releases packets here, and other departments route them here." },
|
||||
{ key: "review", title: "Waiting on review", empty: "Stations submit finished packets here." },
|
||||
{ key: "qc", title: "At Q.C.", empty: "Packets you send for inspection wait here." },
|
||||
{ key: "warehouse", title: "With warehouse", empty: "None." },
|
||||
{ key: "complete", title: "Routed on", empty: "Approved packets show their next stop here." },
|
||||
];
|
||||
|
||||
type Loaded = ShopBoard & { fetchedAt: number };
|
||||
|
||||
/** /supervisor (the old homeSupervisor): the change order headline, numbers, stations and the five boards. */
|
||||
export function SupervisorHome() {
|
||||
const { me } = useAuth();
|
||||
const roles = useMemo(() => me?.roles ?? [], [me]);
|
||||
const shop = useShopDepartments();
|
||||
const isAdmin = roles.includes("admin");
|
||||
const mine = useMemo(() => myShopDepts(roles, "supervisor", shop.map((d) => d.code)), [roles, shop]);
|
||||
|
||||
const [picked, setPicked] = useState<string>(() => lsGet(SUP_DEPT_KEY) ?? "all");
|
||||
const dept = picked !== "all" && mine.includes(picked) ? picked : "all";
|
||||
const choose = (v: string) => {
|
||||
setPicked(v);
|
||||
lsSet(SUP_DEPT_KEY, v);
|
||||
};
|
||||
|
||||
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<Loaded>(
|
||||
async (s) => {
|
||||
const b = await getBoard({ dept, ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s);
|
||||
return { ...b, fetchedAt: Date.now() };
|
||||
},
|
||||
`${dept}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const data = load.state === "ok" ? load.data : null;
|
||||
const atStations = useMemo(() => (data ? data.depts.flatMap((d) => d.stations.flatMap((s) => s.packets)) : []), [data]);
|
||||
const running = atStations.filter((p) => p.clock?.running).length;
|
||||
const now = useNow(running > 0);
|
||||
|
||||
const names = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
shop.forEach((d) => m.set(d.code, d.name));
|
||||
data?.depts.forEach((d) => m.set(d.code, d.name));
|
||||
return m;
|
||||
}, [shop, data]);
|
||||
|
||||
// Keep the scope chip valid when the role list changes under it.
|
||||
useEffect(() => {
|
||||
if (picked !== "all" && mine.length && !mine.includes(picked)) setPicked("all");
|
||||
}, [picked, mine]);
|
||||
|
||||
const scope = dept !== "all" ? [dept] : mine.length ? mine : (data?.depts.map((d) => d.code) ?? []);
|
||||
const title = scope.length === 1 && scope[0] ? deptShort(scope[0], names) : `${scope.length} departments`;
|
||||
|
||||
const canVerify = (d: string) => isAdmin || levelIn(roles, d) === "supervisor";
|
||||
const cos = data?.cos ?? [];
|
||||
const toVerify = cos.filter((c) => c.review === "approved" && c.mine);
|
||||
const onHold = cos.filter((c) => !(c.review === "approved" && c.mine));
|
||||
const banner: CoBannerItem[] = toVerify.map((c) => {
|
||||
const job = c.op ? `${c.unit_number} · ${c.op}` : c.unit_number;
|
||||
const forDepts = c.pending_depts.filter(canVerify).map((d) => deptShort(d, names));
|
||||
const sub = [c.issued_at ? `Issued ${fmtTime(c.issued_at)}` : "", forDepts.length ? `for ${forDepts.join(", ")}` : "", c.reason ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return { key: `${c.packet_id}-${c.co_no}`, label: c.ecn ? `${c.label} (${c.ecn})` : c.label, job, sub, href: coHref(c.packet_id) };
|
||||
});
|
||||
|
||||
const k = data?.kpis;
|
||||
const elapsedMs = (k?.elapsed_ms ?? 0) + (data ? running * Math.max(0, now - data.fetchedAt) : 0);
|
||||
|
||||
const filtered = (list: ShopCard[]) => list.filter((c) => matches(c, q));
|
||||
const columns: ColumnDef[] = COLUMNS.map((c) => {
|
||||
const all = data?.columns[c.key] ?? [];
|
||||
const shown = filtered(all);
|
||||
return {
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
cards: shown.map(asCard),
|
||||
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 (
|
||||
<>
|
||||
<CoBanner items={banner} it={isAdmin} />
|
||||
|
||||
<header className="phead">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="eyebrow">Supervisor · {title}</div>
|
||||
<h1>Dispatch, review, route</h1>
|
||||
</div>
|
||||
{mine.length > 1 ? (
|
||||
<div className="pacts">
|
||||
<div className="chips supchips" role="group" aria-label="Department">
|
||||
<button type="button" className="chip sm" aria-pressed={dept === "all"} onClick={() => choose("all")}>
|
||||
All
|
||||
</button>
|
||||
{mine.map((c) => (
|
||||
<button key={c} type="button" className="chip sm" aria-pressed={dept === c} onClick={() => choose(c)}>
|
||||
{deptShort(c, names)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true },
|
||||
{ n: k?.at_stations ?? 0, label: "At stations" },
|
||||
{ n: k?.review ?? 0, label: "Waiting on review", alert: true },
|
||||
{ n: `${fmtHrs(elapsedMs / 3_600_000)} / ${fmtHrs(Number(k?.target_hrs) || 0)} h`, label: "Station hours vs target" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
|
||||
{data ? <CoTable rows={onHold} names={names} canVerify={canVerify} /> : null}
|
||||
{data ? <StationGrid depts={data.depts} names={names} fetchedAt={data.fetchedAt} now={now} /> : null}
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
||||
|
||||
{data ? <Board columns={columns} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// An in-memory Storage for tests. Node's own localStorage global can hide jsdom's and throw.
|
||||
export function memoryStorage(): Storage {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
key: (i: number) => [...m.keys()][i] ?? null,
|
||||
removeItem: (k: string) => {
|
||||
m.delete(k);
|
||||
},
|
||||
setItem: (k: string, v: string) => {
|
||||
m.set(k, String(v));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Clock, hours and search helpers shared by the Supervisor and Stall screens.
|
||||
// The server owns every stored clock value. The screens only add the time since the answer while a clock runs.
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ShopCard, ShopClock } from "../../api/shop";
|
||||
import type { PacketCard } from "../../api/types";
|
||||
import { jobName } from "../../lib/packets";
|
||||
|
||||
/** "HH:MM:SS" (the old fmtClock). */
|
||||
export function fmtClock(ms: number): string {
|
||||
const t = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
/** Hours with one decimal: "4.5". */
|
||||
export function fmtHrs(h: number): string {
|
||||
return (Number.isFinite(h) ? h : 0).toFixed(1);
|
||||
}
|
||||
|
||||
/** The display value of a clock: the server's elapsed plus the time since the fetch while it runs. */
|
||||
export function liveMs(clock: ShopClock | null | undefined, fetchedAt: number, now: number): number {
|
||||
if (!clock) return 0;
|
||||
const base = Number(clock.elapsed_ms) || 0;
|
||||
return clock.running ? base + Math.max(0, now - fetchedAt) : base;
|
||||
}
|
||||
|
||||
/** The time now, refreshed every second while `active`. */
|
||||
export function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(Date.now());
|
||||
const t = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/** "03 Weld Parts" from a code and the names known (the old deptShort). */
|
||||
export function deptShort(code: string, names: ReadonlyMap<string, string>): string {
|
||||
const n = names.get(code);
|
||||
return n ? `${code} ${n}` : code;
|
||||
}
|
||||
|
||||
/** The board card shape for the shared Card component. */
|
||||
export function asCard(c: ShopCard): PacketCard {
|
||||
return { ...c, open_cos: c.open_cos.length } as PacketCard;
|
||||
}
|
||||
|
||||
/** Whether a card matches the find box: unit, op, title, drawing, customer, project, station or welder. */
|
||||
export function matches(c: ShopCard, 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.station?.name,
|
||||
c.welder?.display_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return t.split(/\s+/).every((w) => hay.includes(w));
|
||||
}
|
||||
|
||||
/** "CHANGE ORDER CO-2 - drawing changed, verify" (the old station tile tag). */
|
||||
export function coTagText(cos: ShopCard["open_cos"]): string {
|
||||
const labels = cos.map((c) => c.label).join(", ");
|
||||
const held = cos.some((c) => c.review === "pending");
|
||||
return `CHANGE ORDER ${labels} - ${held ? "Clerical reviewing a change, on hold" : "drawing changed, verify"}`;
|
||||
}
|
||||
|
||||
/** The packet page on its Change orders tab. */
|
||||
export function coHref(packetId: number): string {
|
||||
return `/packets/${packetId}?tab=changes`;
|
||||
}
|
||||
|
||||
/** localStorage, never throwing. */
|
||||
export function lsGet(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function lsSet(key: string, value: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage blocked: the choice lasts for this page only.
|
||||
}
|
||||
}
|
||||
@@ -404,3 +404,152 @@ td.late { color: var(--red); }
|
||||
.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; }
|
||||
.bar.usage { position: relative; height: 12px; border-radius: 6px; }
|
||||
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
|
||||
|
||||
/* Phase 5: shop screens. From .stalls, .stall, .sn, .cotag, .cohot, .stallhero, .cobanner, .cob-h, .cob-t, .cob-row, .cosec. */
|
||||
.stalls { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; align-items: start; }
|
||||
.stall { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 5px; min-height: 112px; }
|
||||
.stall .sn { font-family: var(--f-disp); font-weight: 700; font-size: 18px; letter-spacing: .08em; color: var(--near); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.stall.idle { opacity: .55; min-height: 0; padding-block: 8px; }
|
||||
.stall.idle .sn { font-size: 15px; }
|
||||
.stall .stjob { display: flex; flex-direction: column; gap: 4px; }
|
||||
.stall .stjob + .stjob { border-top: 1px solid var(--line); padding-top: 6px; }
|
||||
.stall button.stopen { all: unset; cursor: pointer; color: var(--cyan); font-weight: 600; font-size: 14px; }
|
||||
.stall button.stopen:focus-visible, .cotag:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
||||
.stall.cohot { border: 2px solid var(--pink); box-shadow: 0 0 0 3px rgba(229, 88, 159, .18); }
|
||||
.cotag, .stall button.cotag { all: unset; cursor: pointer; display: block; margin: 6px 0; background: var(--pink); color: var(--white); font-weight: 700; font-size: 11.5px; letter-spacing: .04em; padding: 5px 8px; border-radius: 6px; line-height: 1.25; text-transform: uppercase; }
|
||||
.stempty { margin-bottom: 22px; }
|
||||
.stclock { display: flex; align-items: center; flex-wrap: wrap; gap: 0 4px; }
|
||||
.sdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); margin-right: 6px; }
|
||||
.sdot.run { background: var(--green); animation: sdotpulse 1.6s infinite; }
|
||||
@keyframes sdotpulse { 0% { box-shadow: 0 0 0 0 rgba(95, 201, 138, .55); } 70% { box-shadow: 0 0 0 8px rgba(95, 201, 138, 0); } 100% { box-shadow: 0 0 0 0 rgba(95, 201, 138, 0); } }
|
||||
.stallhero { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-block: 6px 18px; }
|
||||
.stallhero .big { font-family: var(--f-disp); font-weight: 700; font-size: 64px; line-height: .9; color: var(--white); letter-spacing: .04em; }
|
||||
.stallhero .stpick { min-width: 220px; }
|
||||
.stnone { margin-top: 30px; }
|
||||
.cobanner { border: 3px solid var(--pink); background: linear-gradient(180deg, rgba(229, 88, 159, .22), rgba(229, 88, 159, .08)); border-radius: 14px; padding: 14px 16px; margin: 4px 0 18px; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 0 0 4px rgba(229, 88, 159, .12); animation: copulse 2.2s ease-in-out infinite; }
|
||||
@keyframes copulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(229, 88, 159, .12), 0 0 0 0 rgba(229, 88, 159, 0); border-color: var(--pink); } 50% { box-shadow: 0 0 0 6px rgba(229, 88, 159, .30), 0 0 34px 6px rgba(229, 88, 159, .38); border-color: #ff8cc4; } }
|
||||
.cob-h { display: flex; gap: 12px; align-items: center; color: var(--near); }
|
||||
.cob-h svg { color: var(--pink); flex: none; animation: cobpulse 1.6s ease-in-out infinite; }
|
||||
@keyframes cobpulse { 50% { opacity: .35; } }
|
||||
.cob-t { font-family: var(--f-disp); font-size: 26px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--white); line-height: 1.1; }
|
||||
.cob-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; background: rgba(13, 22, 27, .7); border-radius: 10px; padding: 10px 12px; }
|
||||
.cob-txt { min-width: 0; }
|
||||
.cosec { border-left: 4px solid var(--pink); padding-left: 12px; }
|
||||
.supchips { justify-content: flex-end; }
|
||||
@media (max-width: 640px) {
|
||||
.stalls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stall.busy { grid-column: 1 / -1; }
|
||||
.cob-t { font-size: 20px; }
|
||||
.stallhero .big { font-size: 48px; }
|
||||
}
|
||||
|
||||
/* Phase 5: packet page. From .dv, .viewer, .stage, .mk, .ld, .arw, .ht, .htbl, .htprev, .vtools, .fsbar, body.fs-view,
|
||||
.pagesel, .side, .hlist, .ichip, .ck, .ck-item, .cklay, .clock, .cdot. The drawing stays on white.
|
||||
Appended after the shop screens block because that block was written first; no selector overlaps it. */
|
||||
.dv { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; align-items: start; }
|
||||
.dv.solo { grid-template-columns: minmax(0, 1fr); }
|
||||
@media (max-width: 980px) { .dv { grid-template-columns: minmax(0, 1fr); } }
|
||||
.viewer { position: relative; background: var(--white); border-radius: 10px; overflow: hidden; height: min(68vh, 720px); touch-action: none; border: 1px solid var(--line2); cursor: grab; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
||||
.viewer.panning { cursor: grabbing; }
|
||||
.viewer.marking { cursor: crosshair; outline: 3px solid var(--pink); outline-offset: -3px; }
|
||||
.viewer.placing { cursor: crosshair; outline: 3px solid var(--amber); outline-offset: -3px; }
|
||||
.stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
||||
.stage-canvas { display: block; max-width: none; pointer-events: none; }
|
||||
.vload { position: absolute; inset: 0; display: grid; place-items: center; color: #44535a; padding: 16px; pointer-events: none; }
|
||||
.vload .callout { pointer-events: auto; }
|
||||
.lead { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||
.ld { stroke-width: 2.5; vector-effect: non-scaling-stroke; fill: none; }
|
||||
.ld.h { stroke: #b07800; }
|
||||
.ld.w { stroke: #c2185b; }
|
||||
.ld.carried { stroke-dasharray: 6 4; }
|
||||
.mk { position: absolute; z-index: 2; transform: translate(-50%, -50%) scale(var(--inv, 1)); transform-origin: center; width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #c2185b; border: 3px solid #c2185b; font-family: var(--f-mono); font-size: 13px; font-weight: 700; display: grid; place-items: center; box-shadow: 0 2px 6px rgba(0, 0, 0, .45); cursor: pointer; padding: 0; }
|
||||
.mk.drag { cursor: move; touch-action: none; }
|
||||
.mk.carried { border-style: dashed; opacity: .85; }
|
||||
.arw { position: absolute; z-index: 1; width: 20px; height: 14px; transform: translate(-100%, -50%) rotate(var(--rot, 0deg)) scale(var(--inv, 1)); transform-origin: 100% 50%; pointer-events: none; }
|
||||
.arw::before { content: ""; position: absolute; inset: 0; clip-path: polygon(0 0, 100% 50%, 0 100%); }
|
||||
.arw.h::before { background: #b07800; }
|
||||
.arw.w::before { background: #c2185b; }
|
||||
.arw.drag { pointer-events: auto; cursor: move; touch-action: none; width: 30px; height: 22px; }
|
||||
.ht { position: absolute; z-index: 2; transform: translate(-50%, -50%) scale(var(--inv, 1)); display: inline-flex; align-items: center; gap: 7px; background: #fff8e6; border: 2px solid #b07800; color: #3d2a00; border-radius: 999px; padding: 3px 12px 3px 3px; font-family: var(--f-mono); font-weight: 700; font-size: 13px; white-space: nowrap; box-shadow: 0 2px 6px rgba(0, 0, 0, .35); cursor: pointer; line-height: 1.2; min-height: 34px; }
|
||||
.ht .hn { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: #b07800; color: #fff; font-size: 12px; flex: none; }
|
||||
.ht.none { border-style: dashed; color: #7a5a20; }
|
||||
.ht.drag { cursor: move; touch-action: none; }
|
||||
.ht.carried { opacity: .85; }
|
||||
.htbl { position: absolute; z-index: 3; font-size: calc(var(--sw, 3000px) * 0.0075 * var(--hs, 1)); background: #fff; border: .16em solid #b07800; color: #2b1d00; font-family: Arial, Helvetica, sans-serif; line-height: 1.15; box-shadow: 0 .1em .4em rgba(0, 0, 0, .35); user-select: none; -webkit-user-select: none; }
|
||||
.htbl.drag { cursor: move; touch-action: none; }
|
||||
.htbl.drag:hover, .htbl.moving { outline: .12em dashed var(--cyan); outline-offset: .25em; }
|
||||
.htbl .htcap { background: #b07800; color: #fff; font-weight: 700; font-size: .85em; text-align: center; padding: .2em .5em; letter-spacing: .04em; }
|
||||
.htbl .htcols { display: flex; align-items: flex-start; }
|
||||
.htbl table { border-collapse: collapse; margin: 0; width: auto; font-size: inherit; }
|
||||
.htbl th, .htbl td { border: .08em solid #b07800; padding: .2em .5em; white-space: nowrap; background: #fff; color: #2b1d00; font-size: 1em; }
|
||||
.htbl th { background: #f3e2b8; color: #3d2a00; font-size: .8em; font-weight: 700; text-align: center; letter-spacing: .03em; text-transform: none; }
|
||||
.htbl td.i { text-align: center; font-weight: 700; }
|
||||
.htbl td.h { font-family: "Courier New", monospace; font-weight: 700; }
|
||||
.htbl td.h.none { color: #b00020; }
|
||||
.htbl td.h.na { color: #6b5a3a; }
|
||||
.htbl.static { position: static; font-size: 13px; box-shadow: none; display: inline-block; max-width: 100%; overflow: auto; }
|
||||
.htbl.htghost { position: fixed; z-index: 70; pointer-events: none; font-size: 13px; opacity: .92; transform: translate(-10px, -10px); }
|
||||
.htprev { border: 1px dashed var(--line2); border-radius: 10px; padding: 10px; }
|
||||
.htprev.htgrab { cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }
|
||||
.htprev.htgrab:hover { border-color: var(--cyan); }
|
||||
.vtools { position: absolute; right: 10px; top: 10px; display: flex; flex-direction: column; gap: 6px; z-index: 4; }
|
||||
.vtools button { width: 44px; height: 44px; border-radius: 9px; border: 1px solid #c9d3d8; background: rgba(255, 255, 255, .94); color: #1a252b; font-size: 20px; font-weight: 700; cursor: pointer; display: grid; place-items: center; padding: 0; }
|
||||
.vtools button.fit { font-size: 13px; }
|
||||
.vtools button svg { display: block; margin: auto; }
|
||||
.fsbar { display: none; }
|
||||
body.fs-view { overflow: hidden; }
|
||||
body.fs-view .viewer { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; z-index: 80; border-radius: 0; border: 0; }
|
||||
body.fs-view .fsbar { display: flex; position: absolute; left: 10px; top: 10px; max-width: calc(100% - 80px); z-index: 4; gap: 6px; flex-wrap: wrap; align-items: center; background: rgba(13, 22, 27, .9); padding: 6px 8px; border-radius: 10px; color: var(--near); cursor: default; }
|
||||
.fsbar .fst { font-size: 13px; margin-right: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }
|
||||
body.fs-view .scrim { z-index: 95; }
|
||||
body.fs-view .toast { z-index: 99; }
|
||||
.pagesel { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.hlist { display: flex; flex-direction: column; gap: 7px; font-size: 13.5px; }
|
||||
.hlist .row { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; }
|
||||
.hlist .row .d { min-width: 0; color: var(--near); overflow-wrap: anywhere; }
|
||||
.hlist .rowbtn { all: unset; display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; cursor: pointer; min-height: 44px; border-radius: 8px; padding: 0 4px; }
|
||||
.hlist .rowbtn:hover { background: var(--panel2); }
|
||||
.hlist .rowbtn:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
||||
.balloon.pinkb { border-color: var(--pink); color: var(--pink); }
|
||||
.balloon.carried { border-style: dashed; }
|
||||
.flabel { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
|
||||
.ichip { width: 56px; height: 56px; border-radius: 50%; border: 2px solid var(--line2); background: var(--panel); color: var(--near); font-family: var(--f-mono); font-size: 20px; font-weight: 700; cursor: pointer; transition: background .12s, border-color .12s, transform .12s; }
|
||||
.ichip:hover { border-color: var(--cyan); }
|
||||
.ichip[aria-pressed="true"] { background: var(--cyan); border-color: var(--cyan); color: var(--on-cyan); box-shadow: 0 0 0 4px rgba(73, 192, 222, .28); transform: scale(1.06); }
|
||||
.ichip:disabled { cursor: default; }
|
||||
.ck { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ck-item { display: flex; gap: 14px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; min-height: 58px; }
|
||||
.ck-item input { width: 28px; height: 28px; accent-color: var(--green); flex: none; }
|
||||
.ck-item .t { color: var(--near); font-weight: 500; flex: 1; min-width: 0; }
|
||||
.ck-item .by { font-size: 12px; color: var(--muted); text-align: right; }
|
||||
.ck-item.done { border-color: #2d5a41; }
|
||||
.ck-item[aria-disabled="true"] { cursor: default; }
|
||||
.cklay { display: grid; grid-template-columns: minmax(0, 1fr) 360px; grid-template-areas: "main side"; gap: 18px; align-items: start; }
|
||||
.cklay .ckmain { grid-area: main; min-width: 0; }
|
||||
.cklay .ckside { grid-area: side; position: sticky; top: calc(var(--toph, 64px) + 64px); }
|
||||
@media (max-width: 980px) { .cklay { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "side"; } .cklay .ckside { position: static; } }
|
||||
.clockrow { display: flex; gap: 16px; align-items: baseline; flex-wrap: wrap; margin-right: auto; }
|
||||
.clock { font-family: var(--f-disp); font-weight: 700; font-size: 40px; color: var(--white); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.clock small { font-size: 18px; color: var(--muted); }
|
||||
.cdot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: var(--muted); margin-right: 10px; vertical-align: middle; }
|
||||
.cdot.run { background: var(--green); animation: cdotpulse 1.6s infinite; }
|
||||
@keyframes cdotpulse { 0% { box-shadow: 0 0 0 0 rgba(95, 201, 138, .6); } 70% { box-shadow: 0 0 0 10px rgba(95, 201, 138, 0); } 100% { box-shadow: 0 0 0 0 rgba(95, 201, 138, 0); } }
|
||||
.labor { color: var(--body); }
|
||||
.labor b { color: var(--amber); font-family: var(--f-mono); }
|
||||
.acts.center { align-items: center; }
|
||||
.revrow { display: flex; gap: 10px 16px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.revrow.sep { border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.revrow .f { flex: 0 1 220px; }
|
||||
.revrow .f.grow2 { flex: 1 1 260px; }
|
||||
.presence { display: flex; gap: 8px; align-items: center; color: var(--body); margin: 6px 0; }
|
||||
.pdot { width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); flex: none; }
|
||||
.verifybox { border-color: var(--pink); }
|
||||
.pinktxt { color: var(--pink); }
|
||||
.amber { color: var(--amber); }
|
||||
.near { color: var(--near); }
|
||||
.mb6 { margin-bottom: 6px; }
|
||||
.mb8 { margin-bottom: 8px; }
|
||||
.mb10 { margin-bottom: 10px; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
.mt10 { margin-top: 10px; }
|
||||
|
||||
Reference in New Issue
Block a user