Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ describe("routes", () => {
mockFetch({ "/api/auth/me": { body: makeMe() } });
renderApp("/admin");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
expect(screen.getByText("This screen arrives in phase 2.")).toBeInTheDocument();
expect(screen.getByText("Projects and job packets")).toBeInTheDocument();
});
});
+21 -1
View File
@@ -4,10 +4,13 @@ import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
import { AboutPage } from "./pages/AboutPage";
import { HomePage } from "./pages/HomePage";
import { PacketPage } from "./packet/PacketPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { SignInPage } from "./pages/SignInPage";
import { AccountPage } from "./screens/account/AccountPage";
import { ScreenRoute } from "./screens/ScreenRoute";
import { ProjectPage } from "./screens/clerical/ProjectPage";
import { UnitPage } from "./screens/clerical/UnitPage";
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
export function AppRoutes() {
@@ -20,6 +23,23 @@ export function AppRoutes() {
<Route element={<RequireAuth />}>
<Route path="account" element={<AccountPage />} />
<Route path=":screen" element={<ScreenRoute />} />
<Route
path="clerical/projects/:projectId"
element={
<RequireScreen screen="clerical">
<ProjectPage />
</RequireScreen>
}
/>
<Route
path="clerical/units/:unitId"
element={
<RequireScreen screen="clerical">
<UnitPage />
</RequireScreen>
}
/>
<Route path="packets/:id" element={<PacketPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
+19
View File
@@ -0,0 +1,19 @@
// Asset upload and the blob URL. The upload is multipart through the shared request.
import { api, request } from "./client";
import type { Asset } from "./types";
/** Same-origin URL of a stored file. */
export function blobUrl(assetId: string, download = false): string {
return `/_blob/${encodeURIComponent(assetId)}${download ? "?download=1" : ""}`;
}
export const assetsApi = {
upload: (file: File, opts: { packet_id?: number; purpose?: string } = {}) => {
const fd = new FormData();
fd.append("file", file, file.name);
if (opts.packet_id !== undefined) fd.append("packet_id", String(opts.packet_id));
if (opts.purpose) fd.append("purpose", opts.purpose);
return request<Asset>("/api/assets", { method: "POST", body: fd });
},
get: (id: string, signal?: AbortSignal) => api.get<Asset>(`/api/assets/${encodeURIComponent(id)}`, signal),
};
+46
View File
@@ -0,0 +1,46 @@
// Packet endpoints from the phase 2 contract.
import { api, request } from "./client";
import { asList, ifMatch, qs } from "./query";
import type {
AttachPagesBody,
BomLine,
FieldCas,
Guarded,
LogRow,
PacketCard,
PacketCreate,
PacketHeader,
PacketListQuery,
PacketRead,
TransitionBody,
WorkOrder,
} from "./types";
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
export const packetsApi = {
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
create: (body: PacketCreate) => api.post<PacketRead>("/api/packets", body),
patch: (id: number, body: FieldCas<PacketHeader>) => api.patch<PacketRead>(base(id), body),
transition: (id: number, body: TransitionBody) => api.post<PacketRead>(`${base(id)}/transitions`, body),
putWorkOrders: (id: number, version: number, work_orders: WorkOrder[], extra: Guarded = {}) =>
request<PacketRead>(`${base(id)}/work-orders`, { method: "PUT", body: { work_orders, ...extra }, headers: ifMatch(version) }),
putBom: (id: number, version: number, bom: BomLine[], extra: Guarded = {}) =>
request<PacketRead>(`${base(id)}/bom`, { method: "PUT", body: { bom, ...extra }, headers: ifMatch(version) }),
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/docs`, body),
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
};
+44
View File
@@ -0,0 +1,44 @@
// Project and unit endpoints from the phase 2 contract.
import { api } from "./client";
import { asList, qs } from "./query";
import type { FieldCas, Guarded, NumberLookup, Project, ProjectCreate, Unit, UnitsCreate } from "./types";
export type ProjectShow = "active" | "open" | "hold" | "closed" | "all";
export interface ProjectFields {
name: string | null;
customer: string | null;
po: string | null;
due: string | null;
status: Project["status"];
notes: string | null;
}
export interface UnitFields {
name: string | null;
due: string | null;
status: Unit["status"];
notes: string | null;
}
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
const uid = (id: number) => `/api/units/${encodeURIComponent(String(id))}`;
export const projectsApi = {
list: async (q: string, status: ProjectShow = "active", signal?: AbortSignal): Promise<Project[]> =>
asList<Project>(await api.get<unknown>(`/api/projects${qs({ q, status })}`, signal), "projects"),
get: (id: number, signal?: AbortSignal) => api.get<Project>(pid(id), signal),
create: (body: ProjectCreate & Guarded) => api.post<Project>("/api/projects", body),
patch: (id: number, body: FieldCas<ProjectFields>) => api.patch<Project>(pid(id), body),
renumber: (id: number, body: { number: string; reason: string } & Guarded) => api.post<Project>(`${pid(id)}/renumber`, body),
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(pid(id), body),
addUnits: async (id: number, body: UnitsCreate & Guarded): Promise<Unit[]> =>
asList<Unit>(await api.post<unknown>(`${pid(id)}/units`, body), "units"),
units: async (projectId: number, q = "", signal?: AbortSignal): Promise<Unit[]> =>
asList<Unit>(await api.get<unknown>(`/api/units${qs({ project_id: projectId, q })}`, signal), "units"),
unit: (id: number, signal?: AbortSignal) => api.get<Unit>(uid(id), signal),
patchUnit: (id: number, body: FieldCas<UnitFields>) => api.patch<Unit>(uid(id), body),
renumberUnit: (id: number, body: { lot: string; reason: string } & Guarded) => api.post<Unit>(`${uid(id)}/renumber`, body),
removeUnit: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(uid(id), body),
lookup: (n: string, signal?: AbortSignal) => api.get<NumberLookup>(`/api/lookup/number${qs({ n })}`, signal),
};
+26
View File
@@ -0,0 +1,26 @@
// Query strings and list answers shared by the API modules.
/** "?a=1&b=x" from the defined, non-empty values. Empty when nothing is set. */
export function qs(params: Record<string, string | number | boolean | null | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
/** A list answer may come bare or wrapped as {key: [...]}. Both give the array. */
export function asList<T>(raw: unknown, key: string): T[] {
if (Array.isArray(raw)) return raw as T[];
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
return (raw as Record<string, T[]>)[key] as T[];
}
return [];
}
/** The If-Match header for a version check. */
export function ifMatch(version: number): Record<string, string> {
return { "If-Match": String(version) };
}
+396
View File
@@ -0,0 +1,396 @@
// API shapes from the phase 2 design contract. IDs stay text where the contract says so.
// Work order, lot, split, sub and part IDs are always strings. Timestamps are ISO strings.
import type { Factor } from "../auth/types";
export const STATUSES = ["draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed"] as const;
export type PacketStatus = (typeof STATUSES)[number];
export type ProjectStatus = "open" | "hold" | "closed";
export type RouteKind = "databook" | "shipping";
/** Who references in packet answers. The frontend never gets emp_no or email here. */
export interface WhoRef {
id: string;
display_name: string;
}
export interface WelderRef extends WhoRef {
stamp: string | null;
}
export interface StationRef {
id: number;
name: string;
}
export interface DeptRef {
code: string;
name: string;
weld?: boolean;
}
/** A signature in a request body. The server checks it against the session user. */
export interface SignBody {
factor: Factor;
secret: string;
}
/** Packet counts per stage on project and unit rows. */
export interface StageCounts {
draft: number;
warehouse: number;
shop: number;
qc: number;
complete: number;
filed: number;
total: number;
}
/* ---------- Projects and units ---------- */
export interface ProjectRef {
id: number;
number: string;
name: string | null;
customer?: string | null;
po?: string | null;
status?: ProjectStatus;
due?: string | null;
}
/** One row of GET /api/units?project_id= and of a project's `units`. */
export interface Unit {
id: number;
project_id?: number;
number: string;
lot: string;
name: string | null;
due: string | null;
due_effective: string | null;
status: ProjectStatus;
notes?: string | null;
packets?: StageCounts;
version: number;
project?: ProjectRef;
}
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
export interface Project {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
due: string | null;
status: ProjectStatus;
notes: string | null;
source?: string;
needs_details?: boolean;
/** A count on list rows, the unit rows on GET /api/projects/{id}. */
units: number | Unit[];
packets?: StageCounts;
aliases?: Array<string | { alias: string; kind?: string }>;
version: number;
}
export interface ProjectCreate {
number: string;
name: string;
customer: string;
po: string;
due: string | null;
notes: string;
}
export interface UnitsCreate {
lot?: string;
lots?: string[];
name: string;
due: string | null;
notes: string;
}
export interface FieldCas<T> {
set: Partial<T>;
base: Partial<T>;
it_reason?: string;
}
export interface NumberLookup {
kind: "project" | "unit";
id: number;
number: string;
alias: boolean;
}
/* ---------- Packets ---------- */
export interface VclosedRef {
at: string;
by: WhoRef | null;
}
/** One packet on a board. GET /api/packets -> {packets: [card...]}. */
export interface PacketCard {
id: number;
version: number;
unit: { id: number; number: string };
project: { id: number; number: string; name: string | null };
dept: DeptRef;
op: string | null;
title: string | null;
customer: string | null;
drawing: string | null;
rev: string | null;
status: PacketStatus;
status_at: string | null;
station: StationRef | null;
welder: WelderRef | null;
routed_to: RouteKind | null;
vclosed: VclosedRef | null;
source_kind: "visual" | "hand";
flag: string | null;
/** CO numbers, or a count. Both are handled. */
open_cos: number[] | number;
open_shorts: number;
wh_stage: string | null;
due_effective: string | null;
updated_at: string | null;
holder: string;
can: string[];
blocked: Record<string, string[]>;
/** Not in the contract's card shape. Shown when the server sends it. */
wo_count?: number;
}
export interface PacketList {
packets: PacketCard[];
}
export interface PacketListQuery {
status?: string;
dept?: string;
unit_id?: number;
project_id?: number;
q?: string;
flag?: string;
limit?: number;
}
export interface WorkOrder {
id?: number;
wo_base: string;
lot: string;
split: string;
sub: string;
seq?: number | null;
qty: number | string | null;
target_hrs: number | string | null;
op?: string | null;
description?: string | null;
visual_status?: string | null;
gone_at?: string | null;
row_version?: number;
}
export interface PacketPage {
n: number;
type: "drawing" | "info" | "overlay" | string;
asset_id: string | null;
pn: number;
dwg: string | null;
rev: string | null;
void: { co_id: number; at: string; new_n: number | null } | null;
url: string;
}
export interface BomShort {
id: number;
qty: number | string;
qty_open: number | string;
reason: string | null;
ref: string | null;
eta: string | null;
note: string | null;
opened_at: string | null;
receipts?: unknown[];
}
export interface BomLine {
id?: number;
item: number;
qty: number | string | null;
part_id: string | null;
description: string | null;
dwg_ref: string | null;
cl_item?: string | null;
length: string | null;
material: string | null;
heat?: string | null;
na?: boolean;
issued?: number | string | null;
loc?: string | null;
pulled?: boolean;
mtr_asset_id?: string | null;
src?: string | null;
short?: BomShort | null;
row_version?: number;
}
export interface PacketDoc {
id: number;
asset_id: string;
kind: string;
title: string | null;
added_at: string | null;
added_by: WhoRef | null;
url: string;
}
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
export interface PacketRead {
id: number;
version: number;
unit: {
id: number;
number: string;
lot: string;
name: string | null;
project: { id: number; number: string; name: string | null; customer: string | null; po: string | null; status: ProjectStatus };
due_effective: string | null;
};
dept: DeptRef;
op: string | null;
title: string | null;
customer: string | null;
drawing: string | null;
rev: string | null;
wps: string | null;
status: PacketStatus;
status_at: string | null;
station: StationRef | null;
welder: WelderRef | null;
for_sup: WhoRef | null;
heat_map: boolean | null;
wh_skipped: boolean;
wh_sent_at: string | null;
routed_to: RouteKind | null;
return_note: string | null;
vclosed: VclosedRef | null;
databook_at: string | null;
source_kind: "visual" | "hand";
source: Record<string, unknown>;
drawing_state: string;
flags: string[];
visual: Record<string, unknown>;
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
work_orders: WorkOrder[];
pages: PacketPage[];
bom: BomLine[];
welds: unknown[];
heat_tags: unknown[];
heat_table: unknown[];
stage: Record<string, unknown> | null;
stages: unknown[];
wh: Record<string, unknown> | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
change_orders: unknown[];
open_cos: number[];
open_shorts: number;
docs: PacketDoc[];
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null;
can: string[];
blocked: Record<string, string[]>;
holder: string;
checklist_items: Array<{ group: string; key: string; text: string }>;
qc_items: unknown[];
flag: string | null;
}
/** One row of GET /api/packets/{id}/log, newest first. */
export interface LogRow {
id: number;
at: string;
by: WhoRef | null;
role: string | null;
action: string;
note: string | null;
it_reason: string | null;
signed: boolean;
}
/** POST /api/packets: a hand draft. */
export interface PacketCreate {
unit_id: number;
dept_code: string;
op: string;
title: string;
customer: string;
drawing: string;
rev: string;
wps: string;
heat_map?: boolean | null;
work_orders: Array<Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "qty" | "target_hrs"> & { op?: string; description?: string }>;
bom: Array<Pick<BomLine, "item" | "qty" | "part_id" | "description" | "material" | "length" | "dwg_ref">>;
}
/** The header fields PATCH /api/packets/{id} takes. */
export interface PacketHeader {
title: string | null;
customer: string | null;
wps: string | null;
op: string | null;
heat_map: boolean | null;
for_sup_id: string | null;
drawing: string | null;
rev: string | null;
dept_code: string;
}
/** POST /api/packets/{id}/transitions. A transition carries only its own fields. */
export interface TransitionBody {
action: string;
expect: { status: PacketStatus; dept: string };
reason?: string;
note?: string;
dept?: string;
station_id?: number;
welder_id?: string;
stamp?: string;
routed_to?: RouteKind;
book?: boolean;
spool?: boolean;
location?: string;
for_sup_id?: string;
sign?: SignBody;
it_reason?: string;
}
export interface AttachPagesBody {
asset_id: string;
type: "drawing";
dwg?: string;
rev?: string;
sign?: SignBody;
it_reason?: string;
}
/* ---------- Assets ---------- */
/** POST /api/assets and GET /api/assets/{id}. */
export interface Asset {
id: string;
mime: string;
size: number;
sha256: string;
pages: number | null;
original_name: string | null;
url: string;
}
/** Extra fields the guard adds on a retry: the IT reason and the signature. */
export interface Guarded {
it_reason?: string;
sign?: SignBody;
}
+2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ApiError, errorCode, onApiError } from "../api/client";
import { emitChanged } from "../lib/changes";
import { authApi, departmentsApi } from "./api";
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
import { roleLabel as labelFor } from "./roles";
@@ -203,6 +204,7 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
const presence = useEvents(sessionId, {
onRevoked: () => endSession("This session was signed out from another place."),
onChanged: emitChanged,
});
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
+3 -2
View File
@@ -173,8 +173,8 @@ export interface SigninAttempt {
reason: string | null;
}
/** Stations may come as names or as rows. */
export type StationEntry = string | { name: string; active?: boolean };
/** Stations come as rows with their ids (ASSIGN needs the id). Older answers were plain names. */
export type StationEntry = string | { id?: number; name: string; active?: boolean };
/** One row of GET /api/departments. */
export interface Department {
@@ -182,6 +182,7 @@ export interface Department {
slug: string;
name: string;
kind: "support" | "shop";
weld?: boolean;
stations: StationEntry[];
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import type { PacketCard } from "../api/types";
import { Card } from "./Card";
export interface ColumnDef {
key: string;
title: string;
cards: PacketCard[];
/** Shown when the column is empty. */
empty: ReactNode;
}
/** The old column(): a titled column of cards with a count, or the empty note. */
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
<h3 className="col-h" id={id}>
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
</section>
);
}
/** A row of columns. */
export function Board({ columns }: { columns: ColumnDef[] }) {
return (
<div className="board">
{columns.map((c) => (
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PacketCard } from "../api/types";
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
const shorts = p.open_shorts || 0;
const cos = openCoCount(p);
return (
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
<div className="r1">
<span className="job">{jobName(p)}</span>
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
</div>
<div className="tt">{p.title || "Untitled"}</div>
<div className="small dept">{p.dept.name}</div>
<div className="meta">
<span>
Dwg <b className="mono">{p.drawing || "-"}</b>
{p.rev ? <> R{p.rev}</> : null}
</span>
{p.wo_count !== undefined ? (
<span>
<b>{p.wo_count}</b> WO
</span>
) : null}
{p.station ? (
<span>
<b>{p.station.name}</b>
{p.welder ? ` · ${p.welder.display_name}` : ""}
</span>
) : null}
{p.status === "complete" && p.routed_to ? (
<span>
To <b>{p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"}</b>
</span>
) : null}
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{extra}
</Link>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { PickUnit, type UnitPick } from "./PickUnit";
export type BoardView = "board" | "list";
interface FindBarProps {
q: string;
onQ: (q: string) => void;
pick: UnitPick;
onPick: (v: UnitPick) => void;
view: BoardView;
onView: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
<label className="f fltq">
<span>Find a job</span>
<input
className="in"
type="search"
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
autoComplete="off"
spellCheck={false}
value={q}
onChange={(e) => onQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && q) {
e.stopPropagation();
onQ("");
}
}}
/>
</label>
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
Board
</button>
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
List
</button>
</div>
</div>
</div>
{count !== null ? (
<div className="fltrow2">
<span className="grow" />
<span className="small muted">
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
</span>
</div>
) : null}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
export interface Kpi {
n: ReactNode;
label: string;
/** Turns pink when above zero. */
alert?: boolean;
}
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
return (
<div className="kpis" role="group" aria-label={label}>
{items.map((k) => {
const zero = String(k.n) === "0";
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
return (
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
<div className="n">{k.n}</div>
<div className="l">{k.label}</div>
</div>
);
})}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useId, useState } from "react";
import { projectsApi } from "../api/projects";
import type { Project, ProjectRef, Unit } from "../api/types";
import { errorText } from "../lib/format";
import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
export interface UnitPick {
project: ProjectRef | null;
unit: Unit | null;
}
interface PickUnitProps {
value: UnitPick;
onChange: (v: UnitPick) => void;
/** A packet must go in a unit: no "All units" choice, and closed units are left out. */
requireUnit?: boolean;
/** Label of the first step. */
label?: string;
}
/** Two steps: find the project, then pick one of its units. */
export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) {
const id = useId();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Project[] | null>(null);
const [units, setUnits] = useState<Unit[] | null>(null);
const [error, setError] = useState<string | null>(null);
const projectId = value.project?.id ?? null;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
// Step one: projects matching the search, while no project is chosen.
useEffect(() => {
if (projectId !== null) return;
const ctl = new AbortController();
projectsApi
.list(query, "active", ctl.signal)
.then((list) => {
if (!ctl.signal.aborted) setHits(list);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
return () => ctl.abort();
}, [query, projectId]);
// Step two: the chosen project's units.
useEffect(() => {
if (projectId === null) {
setUnits(null);
return;
}
const ctl = new AbortController();
projectsApi
.units(projectId, "", ctl.signal)
.then((list) => {
if (!ctl.signal.aborted) setUnits(list);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
return () => ctl.abort();
}, [projectId]);
const choose = (p: Project) => {
setError(null);
onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null });
};
if (!value.project) {
const shown = (hits ?? []).slice(0, 8);
return (
<div className="pick">
<label className="f">
{label}
<input
className="in"
type="search"
placeholder="Find a project: number, name, customer, PO"
autoComplete="off"
spellCheck={false}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</label>
{error ? <div className="small due over">{error}</div> : null}
{hits === null ? null : shown.length ? (
<div className="projhits" role="group" aria-label="Projects found">
{shown.map((p) => (
<button key={p.id} type="button" className="projhit" onClick={() => choose(p)}>
<b className="mono">{p.number}</b> {p.name ?? ""}
<span className="muted small">{p.customer ?? ""}</span>
<span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span>
</button>
))}
</div>
) : (
<div className="small muted">{query ? `Nothing matches "${query}".` : "No open projects yet."}</div>
)}
</div>
);
}
const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id);
return (
<div className="pick">
<div className="pickp">
<span className="small muted">{label}</span>
<b className="mono">{value.project.number}</b> <span>{value.project.name ?? ""}</span>
<button className="lnk small" type="button" onClick={() => onChange({ project: null, unit: null })}>
Change
</button>
</div>
<label className="f" htmlFor={`${id}-unit`}>
Unit
</label>
{units === null ? (
<div className="small muted">Loading units</div>
) : list.length === 0 ? (
<div className="small muted">This project has no open units. Add one from the project page first.</div>
) : (
<select
id={`${id}-unit`}
className="in"
value={value.unit ? String(value.unit.id) : ""}
onChange={(e) => {
const u = list.find((x) => String(x.id) === e.target.value) ?? null;
onChange({ project: value.project, unit: u });
}}
>
<option value="">{requireUnit ? "Choose a unit" : "All units"}</option>
{list.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.number}
{u.name ? ` · ${u.name}` : ""}
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
</option>
))}
</select>
)}
{error ? <div className="small due over">{error}</div> : null}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, type FormEvent } from "react";
import { Sheet } from "./Sheet";
export interface ReasonOptions {
title: string;
sub?: string | undefined;
/** The field label. */
label?: string;
/** A line under the title. */
note?: string | undefined;
placeholder?: string | undefined;
/** Shortest reason accepted. 0 makes the field optional. */
min?: number;
max?: number;
submitLabel?: string;
danger?: boolean;
}
interface ReasonSheetProps extends ReasonOptions {
onSubmit: (reason: string) => void;
onCancel: () => void;
}
/** Asks for a reason, or an optional note, before a step. */
export function ReasonSheet({
title,
sub,
label = "Reason",
note,
placeholder,
min = 1,
max = 500,
submitLabel = "Continue",
danger = false,
onSubmit,
onCancel,
}: ReasonSheetProps) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
const t = text.trim();
if (t.length < min) {
setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`);
return;
}
onSubmit(t);
};
return (
<Sheet title={title} sub={sub} onClose={onCancel}>
{note ? <div className="small">{note}</div> : null}
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{label}
<input className="in" value={text} maxLength={max} placeholder={placeholder} onChange={(e) => setText(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={danger ? "btn pink" : "btn pri"} type="submit">
{submitLabel}
</button>
</div>
</form>
</Sheet>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
interface SheetProps {
title: string;
/** Small text next to the title, like the job name. */
sub?: string | undefined;
onClose: () => void;
children: ReactNode;
/** The old sheets were 560 px for short forms and 720 px or more for long ones. */
wide?: boolean;
}
/** Open sheets, innermost last. Only the innermost one answers Escape. */
const open: string[] = [];
/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */
export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) {
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
});
useEffect(() => {
const el = ref.current;
const first = el?.querySelector<HTMLElement>("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea");
(first ?? el)?.focus();
open.push(id);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && open[open.length - 1] === id) {
e.stopPropagation();
closeRef.current();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
const i = open.lastIndexOf(id);
if (i >= 0) open.splice(i, 1);
};
}, [id]);
return (
<div
className="scrim"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div ref={ref} className={wide ? "sheet" : "sheet narrow"} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
<div className="sec-h flush">
<h2 id={id}>{title}</h2>
{sub ? <span className="ct">{sub}</span> : null}
</div>
{children}
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { ApiError, errorCode } from "../api/client";
import type { SignBody } from "../api/types";
import type { Factor } from "../auth/types";
import { useAuth } from "../auth/useAuth";
import { errorText } from "../lib/format";
import { applyKey, type KeypadKey } from "../lib/keypad";
import { Keypad } from "./Keypad";
import { Sheet } from "./Sheet";
const PIN_MAX = 12;
interface SignatureSheetProps {
/** What the signature means, from the rule. */
meaning: string;
sub?: string | undefined;
/** Sends the step with the signature. A wrong secret keeps the sheet open. */
onSign: (sign: SignBody) => Promise<void>;
/** Any other failure. The caller closes the sheet. */
onFail: (err: unknown) => void;
onCancel: () => void;
}
function attemptsLeft(err: unknown): number | null {
if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) {
const n = Number((err.body as { attempts_left: unknown }).attempts_left);
return Number.isFinite(n) ? n : null;
}
return null;
}
/** Signs a step with the user's PIN or password. */
export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) {
const auth = useAuth();
const me = auth.me;
const hasPin = me ? me.pin.set && !me.pin.disabled : false;
const [factor, setFactor] = useState<Factor>(hasPin ? "pin" : "password");
const [secret, setSecret] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [left, setLeft] = useState<number | null>(null);
const [missed, setMissed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, [factor]);
const switchTo = (f: Factor) => {
setFactor(f);
setSecret("");
setError(null);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!secret || busy) return;
setBusy(true);
setError(null);
try {
await onSign({ factor, secret });
} catch (err) {
const code = errorCode(err);
if (code === "session_ended") {
// The auth layer signs out on this 401 too. Make sure, with the server's words.
auth.endSession(errorText(err));
return;
}
if (code === "wrong_secret" || code === "bad_secret") {
setError(errorText(err));
setLeft(attemptsLeft(err));
setMissed(true);
setSecret("");
setBusy(false);
inputRef.current?.focus();
return;
}
onFail(err);
}
};
const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX));
return (
<Sheet title="Sign" sub={sub} onClose={onCancel}>
<div className="sigmean">
<div className="eyebrow">This signature means</div>
<div className="tt">{meaning}</div>
</div>
<div className="small">
You are signing as <b>{me?.user.display_name ?? "yourself"}</b>.
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
{left !== null ? <div className="small">{left === 1 ? "1 attempt left." : `${left} attempts left.`}</div> : null}
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
</div>
) : null}
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
{factor === "pin" ? (
<>
<label className="f">
PIN
<input
ref={inputRef}
className="in big"
name="sign-pin"
type="password"
inputMode="numeric"
autoComplete="off"
maxLength={PIN_MAX}
value={secret}
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
/>
</label>
<Keypad onKey={onKey} disabled={busy} label="Keypad, signature PIN" />
</>
) : (
<label className="f">
Password
<input
ref={inputRef}
className="in"
name="sign-password"
type="password"
autoComplete="current-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</label>
)}
<div className="sheet-acts">
{factor === "pin" ? (
<button className="lnk small" type="button" onClick={() => switchTo("password")}>
Use your password instead
</button>
) : hasPin ? (
<button className="lnk small" type="button" onClick={() => switchTo("pin")}>
Use your PIN instead
</button>
) : null}
<span className="grow" />
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy || !secret}>
{busy ? "Signing" : "Sign"}
</button>
</div>
</form>
</Sheet>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { ApiError, errorCode } from "../api/client";
import type { Guarded, SignBody } from "../api/types";
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
import { SignatureSheet } from "./SignatureSheet";
type Ask =
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
| {
kind: "sign";
meaning: string;
sub: string | undefined;
attempt: (sign: SignBody) => Promise<unknown>;
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
reject: (err: unknown) => void;
};
export interface RunOptions {
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
sign?: string | undefined;
/** Shown next to the sheet titles, like the job name. */
sub?: string | undefined;
}
export interface Guard {
/**
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
* On 400 signature_required it opens the signature sheet and retries with `sign`.
* Resolves undefined when the user cancels. Other errors are thrown.
*/
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
/** Asks for a reason. Null when cancelled. */
askReason: (opts: ReasonOptions) => Promise<string | null>;
/** The open sheet, if any. Render it once in the screen. */
sheet: ReactNode;
}
export const IT_REASON: ReasonOptions = {
title: "IT reason",
label: "Why IT is making this change",
note: "You are changing this as IT. The reason is recorded with the change.",
min: 8,
submitLabel: "Continue",
};
function meaningOf(err: unknown): string {
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
const m = (err.body as { meaning: unknown }).meaning;
if (typeof m === "string" && m) return m;
}
return "Sign this step";
}
/** The IT reason and signature retries, as sheets. */
export function useGuard(): Guard {
const [ask, setAsk] = useState<Ask | null>(null);
const askReason = useCallback(
(opts: ReasonOptions) =>
new Promise<string | null>((resolve) => {
setAsk({ kind: "reason", opts, resolve });
}),
[],
);
const askSign = useCallback(
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
setAsk({
kind: "sign",
meaning,
sub,
attempt,
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
reject,
});
}),
[],
);
const run = useCallback(
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
let itReason: string | undefined;
let meaning = opts.sign;
for (let round = 0; round < 4; round++) {
const call = (sign?: SignBody) => {
const g: Guarded = {};
if (itReason) g.it_reason = itReason;
if (sign) g.sign = sign;
return fn(g);
};
try {
if (meaning) {
const r = await askSign(meaning, opts.sub, call);
return r.ok ? r.value : undefined;
}
return await call();
} catch (err) {
const code = errorCode(err);
if (code === "it_reason_required" && !itReason) {
const r = await askReason({ ...IT_REASON, sub: opts.sub });
if (r === null) return undefined;
itReason = r;
continue;
}
if (code === "signature_required" && !meaning) {
meaning = meaningOf(err);
continue;
}
throw err;
}
}
return undefined;
},
[askReason, askSign],
);
let sheet: ReactNode = null;
if (ask?.kind === "reason") {
sheet = (
<ReasonSheet
{...ask.opts}
onSubmit={(r) => {
setAsk(null);
ask.resolve(r);
}}
onCancel={() => {
setAsk(null);
ask.resolve(null);
}}
/>
);
} else if (ask?.kind === "sign") {
sheet = (
<SignatureSheet
meaning={ask.meaning}
sub={ask.sub}
onSign={async (sign) => {
const value = await ask.attempt(sign);
setAsk(null);
ask.resolve({ ok: true, value });
}}
onFail={(err) => {
setAsk(null);
ask.reject(err);
}}
onCancel={() => {
setAsk(null);
ask.resolve({ ok: false });
}}
/>
);
}
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
}
+11
View File
@@ -0,0 +1,11 @@
// What the screens offer. The server decides on every request; this only hides what cannot work.
/** Clerical (or a Clerical supervisor) sets up projects and units. Admin can, with an IT reason. */
export function canEditProjects(roles: readonly string[]): boolean {
return roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
}
/** Renumbering a project or unit needs a Clerical supervisor, or admin. */
export function canRenumber(roles: readonly string[]): boolean {
return roles.some((r) => r === "clerical_supervisor" || r === "admin");
}
+53
View File
@@ -0,0 +1,53 @@
// The `changed` events from /api/events, fanned out to the open screens.
// AuthProvider owns the one EventSource and feeds every payload in here.
import { useEffect, useRef } from "react";
export interface ChangedEvent {
event?: string;
packet_id?: number;
version?: number;
status?: string;
dept?: string;
[key: string]: unknown;
}
type Listener = (ev: ChangedEvent) => void;
const listeners = new Set<Listener>();
export function emitChanged(payload: unknown): void {
const ev: ChangedEvent = payload && typeof payload === "object" ? (payload as ChangedEvent) : {};
for (const fn of listeners) fn(ev);
}
export function onChanged(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/**
* Calls `fn` after `changed` events. With `delayMs`, a burst of events gives one call
* `delayMs` after the last one. `filter` drops events the screen does not care about.
*/
export function useChanged(fn: () => void, opts: { delayMs?: number; filter?: (ev: ChangedEvent) => boolean } = {}): void {
const fnRef = useRef(fn);
const filterRef = useRef(opts.filter);
useEffect(() => {
fnRef.current = fn;
filterRef.current = opts.filter;
});
const delay = opts.delayMs ?? 500;
useEffect(() => {
let t: number | undefined;
const off = onChanged((ev) => {
if (filterRef.current && !filterRef.current(ev)) return;
window.clearTimeout(t);
t = window.setTimeout(() => fnRef.current(), delay);
});
return () => {
off();
window.clearTimeout(t);
};
}, [delay]);
}
+114
View File
@@ -0,0 +1,114 @@
// Status labels, pills, job names and due dates. Copy from the old build where it had one.
import type { PacketCard, PacketRead, PacketStatus, ProjectStatus, StageCounts } from "../api/types";
export const STATUS_LABEL: Record<PacketStatus, string> = {
draft: "Ready to release",
warehouse: "With warehouse",
ready: "Ready to assign",
assigned: "Sent to station",
in_progress: "Working",
review: "Supervisor review",
qc: "Q.C. inspection",
complete: "Routed",
filed: "Filed in data book",
};
export function statusLabel(s: string): string {
return (STATUS_LABEL as Record<string, string>)[s] ?? s;
}
/** The pill class for a status: "pill st-<status>". */
export function pillClass(s: string): string {
return `pill st-${s}`;
}
export const SHOP: readonly PacketStatus[] = ["ready", "assigned", "in_progress", "review"];
export const IN_SHOP_OR_QC: readonly PacketStatus[] = [...SHOP, "qc"];
/** "L999001 · WELD-PART". */
export function jobName(p: Pick<PacketCard, "unit" | "op"> | Pick<PacketRead, "unit" | "op">): string {
return p.op ? `${p.unit.number} · ${p.op}` : p.unit.number;
}
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = { open: "Open", hold: "On hold", closed: "Closed" };
/** The old projPill colours: open green, on hold pink, closed grey. */
export function projectPillClass(s: ProjectStatus): string {
return `pill ${s === "open" ? "st-in_progress" : s === "hold" ? "st-review" : "st-filed"}`;
}
/** A flag text that names a hard problem shows in pink, the rest in amber. */
export function flagClass(flag: string): string {
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
export function dueInfo(due: string | null | undefined, closed = false, today: Date = new Date()): { txt: string; cls: string } {
if (!due) return { txt: "No due date", cls: "due muted" };
const d = new Date(`${due.slice(0, 10)}T12:00:00`);
if (Number.isNaN(d.getTime())) return { txt: `Due ${due}`, cls: "due" };
const noon = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12);
const days = Math.round((d.getTime() - noon.getTime()) / 86_400_000);
const lab = `${MONTHS[d.getMonth()] ?? ""} ${d.getDate()} '${String(d.getFullYear()).slice(2)}`;
if (closed) return { txt: `Due ${lab}`, cls: "due muted" };
if (days < 0) return { txt: `Due ${lab} · ${-days} d overdue`, cls: "due over" };
if (days <= 7) return { txt: `Due ${lab} · ${days === 0 ? "today" : `${days} d left`}`, cls: "due soon" };
return { txt: `Due ${lab} · ${days} d left`, cls: "due" };
}
/** The open change orders on a card, as a count. */
export function openCoCount(p: Pick<PacketCard, "open_cos">): number {
return Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
}
/** Sum of the work orders' target hours. */
export function targetHours(p: Pick<PacketRead, "work_orders">): number {
return p.work_orders.reduce((a, w) => a + (Number(w.target_hrs) || 0), 0);
}
/** "001" from "1", "7A" stays "7A". The server does the same. */
export function normLot(v: string): string {
const t = v.trim().toUpperCase();
return /^\d{1,3}$/.test(t) ? t.padStart(3, "0") : t;
}
export const RX_LOT = /^[A-Z0-9]{1,3}$/;
export const RX_PROJECT = /^[A-Z0-9][A-Z0-9-]{0,19}$/;
/** Lots counting up from a numeric start: ("008", 3) -> 008, 009, 010. Null when the start is not digits. */
export function lotSequence(start: string, count: number): string[] | null {
const s = start.trim();
if (!/^\d{1,3}$/.test(s) || !Number.isInteger(count) || count < 1) return null;
const b = Number(s);
if (b + count - 1 > 999) return null;
return Array.from({ length: count }, (_, i) => String(b + i).padStart(3, "0"));
}
/** The next free numeric lot after the ones in use. */
export function nextLot(lots: readonly string[]): string {
const nums = lots.filter((l) => /^\d+$/.test(l)).map(Number);
const n = nums.length ? Math.max(...nums) + 1 : 1;
return String(Math.min(n, 999)).padStart(3, "0");
}
/** Packet counts with every stage present. */
export function stageCounts(c: Partial<StageCounts> | undefined | null): StageCounts {
return { draft: 0, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 0, ...(c ?? {}) };
}
/** Counts of a list of cards, the same way. */
export function countCards(list: readonly Pick<PacketCard, "status">[]): StageCounts {
const c = stageCounts(null);
for (const p of list) {
c.total++;
if (p.status === "draft") c.draft++;
else if (p.status === "warehouse") c.warehouse++;
else if ((SHOP as readonly string[]).includes(p.status)) c.shop++;
else if (p.status === "qc") c.qc++;
else if (p.status === "complete") c.complete++;
else if (p.status === "filed") c.filed++;
}
return c;
}
+54
View File
@@ -0,0 +1,54 @@
// pdf.js, loaded on first use. The worker is a same-origin file from the build (CSP worker-src 'self').
import type { PDFDocumentProxy } from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
type PdfJs = typeof import("pdfjs-dist");
let lib: Promise<PdfJs> | null = null;
export function loadPdfJs(): Promise<PdfJs> {
if (!lib) {
lib = import("pdfjs-dist").then((m) => {
m.GlobalWorkerOptions.workerSrc = workerUrl;
return m;
});
lib.catch(() => {
lib = null;
});
}
return lib;
}
interface Entry {
promise: Promise<PDFDocumentProxy>;
destroy: () => void;
}
const docs = new Map<string, Entry>();
const MAX_DOCS = 6;
/**
* Opens a PDF from a same-origin URL, once per URL. The session cookie rides along.
* WebAssembly decoders stay off: the CSP does not allow wasm, and drawings rarely need them.
*/
export function openPdf(url: string): Promise<PDFDocumentProxy> {
const hit = docs.get(url);
if (hit) return hit.promise;
let destroy = () => undefined as void;
const promise = loadPdfJs().then((m) => {
const task = m.getDocument({ url, withCredentials: true, useWasm: false });
destroy = () => void task.destroy().catch(() => undefined);
return task.promise;
});
const entry: Entry = { promise, destroy: () => destroy() };
docs.set(url, entry);
promise.catch(() => docs.delete(url));
// Keep a few documents open; drop the oldest.
while (docs.size > MAX_DOCS) {
const first = docs.keys().next().value;
if (first === undefined) break;
docs.get(first)?.destroy();
docs.delete(first);
}
return promise;
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { departmentsApi } from "../auth/api";
import type { Department } from "../auth/types";
import { useAuth } from "../auth/useAuth";
/** The shop departments from /api/departments, in code order. */
export function useShopDepartments(): Department[] {
const { departments } = useAuth();
const [own, setOwn] = useState<Department[] | null>(null);
useEffect(() => {
if (departments.length) return;
const ctl = new AbortController();
departmentsApi
.list(ctl.signal)
.then((d) => {
if (!ctl.signal.aborted) setOwn(Array.isArray(d) ? d : []);
})
.catch(() => undefined);
return () => ctl.abort();
}, [departments.length]);
const list = departments.length ? departments : (own ?? []);
return list.filter((d) => d.kind === "shop").sort((a, b) => a.code.localeCompare(b.code));
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from "react";
import type { PacketPage } from "../api/types";
import { openPdf } from "../lib/pdf";
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
const [sel, setSel] = useState(0);
const [zi, setZi] = useState(2);
const [width, setWidth] = useState(0);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const page = pages[Math.min(sel, pages.length - 1)];
const zoom = ZOOMS[zi] ?? 1;
const url = page?.url ?? "";
const pn = page?.pn ?? 1;
// Fit to width: follow the frame's width.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const measure = () => setWidth(el.clientWidth);
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!url || !width) return;
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 || 1, doc.numPages))))
.then(async (pg) => {
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const base = pg.getViewport({ scale: 1 });
const scale = (Math.max(200, width - 2) / base.width) * zoom;
const ratio = window.devicePixelRatio || 1;
const vp = pg.getViewport({ scale: scale * ratio });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
task = pg.render({ canvas, viewport: vp });
await task.promise;
if (!cancelled) setBusy(false);
})
.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, width, zoom]);
if (!page) return <div className="empty">No drawing attached yet.</div>;
return (
<div className="pdfv">
<div className="pdfv-bar">
<div className="seg" role="group" aria-label="Zoom">
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
−
</button>
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
Fit
</button>
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
+
</button>
</div>
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
<span className="grow" />
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
Open in a new tab
</a>
</div>
<div className="pdfv-body">
<ol className="pdfv-pages" aria-label="Pages">
{pages.map((p, i) => (
<li key={p.n}>
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
<span className="mono">p.{p.n}</span>
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
{p.void ? <span className="pill st-bad">Void</span> : null}
</button>
</li>
))}
</ol>
<div className="pdfv-frame" ref={wrapRef}>
{error ? <div className="callout warn flush">{error}</div> : null}
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
function base(packet = makePacket()) {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
describe("PacketPage", () => {
it("shows Send to warehouse disabled with the blocked reason", async () => {
mockFetch(base());
renderApp("/packets/1");
const send = await screen.findByRole("button", { name: "Send to warehouse" });
expect(send).toBeDisabled();
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
});
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
const fetch = mockFetch({
...base(ready),
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
});
renderApp("/packets/1");
const send = await screen.findByRole("button", { name: "Send to warehouse" });
expect(send).toBeEnabled();
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
fireEvent.click(send);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
expect(screen.getByText("View only.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
});
it("heat map chips PATCH with the base value", async () => {
const fetch = mockFetch({
...base(),
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
});
renderApp("/packets/1");
const group = await screen.findByRole("group", { name: "Heat map required?" });
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
});
it("attaching a drawing uploads it, then signs and sends sign", async () => {
const fetch = mockFetch({
...base(),
"POST /api/assets": {
status: 201,
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
},
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
});
renderApp("/packets/1");
const input = await screen.findByLabelText("Attach drawing (PDF)");
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
fireEvent.change(input, { target: { files: [file] } });
const sheet = await screen.findByRole("dialog", { name: "Sign" });
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
]);
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
});
it("a wrong signature keeps the sheet open with the attempts left", async () => {
mockFetch({
...base(),
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
});
renderApp("/packets/1");
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
});
it("a stale step shows the server message and reloads the packet", async () => {
let gets = 0;
const fetch = mockFetch({
...base(),
"/api/packets/1": () => {
gets++;
return gets === 1
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
},
"POST /api/packets/1/transitions": {
status: 409,
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
},
});
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.");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
});
it("asks an admin for the IT reason and retries with it", async () => {
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
let tries = 0;
const fetch = mockFetch({
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
"/api/auth/me": { body: admin },
"POST /api/packets/1/transitions": () =>
++tries === 1
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
]);
});
it("shows View only with the holder when the user has no actions", async () => {
mockFetch({
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
});
renderApp("/packets/1");
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
expect(note).toHaveTextContent("This packet is with Clerical.");
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
});
it("reloads when a changed event names this packet", async () => {
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
class FakeEventSource {
listeners = new Map<string, (ev: MessageEvent) => void>();
constructor() {
sources.push(this);
}
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
this.listeners.set(type, fn);
}
close() {}
emit(type: string, data: unknown) {
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
}
}
vi.stubGlobal("EventSource", FakeEventSource);
const fetch = mockFetch(base());
renderApp("/packets/1");
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
vi.useFakeTimers();
try {
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
} finally {
vi.useRealTimers();
}
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
});
});
+554
View File
@@ -0,0 +1,554 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { assetsApi } from "../api/assets";
import { ApiError } 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 { useGuard } from "../components/useGuard";
import { useChanged } from "../lib/changes";
import { errorText } from "../lib/format";
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand";
const SIGN_FILE = "File in the data book";
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
function Act({
p,
action,
busy,
onClick,
className = "btn",
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
children: ReactNode;
}) {
const can = p.can.includes(action);
const why = p.blocked[action];
if (!can && !why) return null;
const reason = why?.join(" ") ?? "";
return (
<div className="act">
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
{children}
</button>
{!can && reason ? <div className="why small">{reason}</div> : null}
</div>
);
}
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
const head = p.can.includes("edit_header");
const dwg = p.can.includes("edit_drawing_ref");
const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" });
const field = (k: keyof typeof v, label: string, max: number, on: boolean) =>
on ? (
<label className="f">
{label}
<input className="in" value={v[k]} maxLength={max} onChange={(e) => setV((o) => ({ ...o, [k]: e.target.value }))} />
</label>
) : null;
const save = async (e: FormEvent) => {
e.preventDefault();
const set: Partial<PacketHeader> = {};
const base: Partial<PacketHeader> = {};
for (const k of Object.keys(v) as Array<keyof typeof v>) {
const now = v[k].trim() || null;
const was = p[k] ?? null;
if (now !== was) {
set[k] = now;
base[k] = was;
}
}
if (Object.keys(set).length === 0) {
onDone();
return;
}
if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone();
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-label="Edit details">
<div className="frow">
{field("title", "Title", 200, head)}
{field("customer", "Customer", 120, head)}
{field("wps", "WPS", 40, head)}
{field("drawing", "Drawing no.", 60, dwg)}
{field("rev", "Rev", 10, dwg)}
</div>
<div className="sheet-acts start">
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
<button className="btn ghost" type="button" onClick={onDone}>
Cancel
</button>
</div>
</form>
);
}
/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */
export function PacketPage() {
const params = useParams();
const id = Number(params.id);
const navigate = useNavigate();
const { me } = useAuth();
const { run, askReason, sheet: guardSheet } = useGuard();
const [packet, setPacket] = useState<PacketRead | null>(null);
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 [sheet, setSheet] = useState<"dept" | "move" | null>(null);
const [editing, setEditing] = useState(false);
const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const reload = useCallback(async () => {
try {
const p = await packetsApi.get(id);
setPacket(p);
setLoadError(null);
} catch (err) {
setLoadError(errorText(err));
}
}, [id]);
useEffect(() => {
setPacket(null);
setTab(null);
void reload();
}, [reload]);
useEffect(() => {
versionRef.current = packet?.version ?? null;
}, [packet]);
// Live refresh: another screen changed this packet.
useChanged(() => void reload(), {
delayMs: 500,
filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current),
});
const job = packet ? jobName(packet) : "";
const mutate: Mutate = useCallback(
async (fn, opts = {}) => {
setBusy(true);
setMsg(null);
try {
const r = await run(fn, { sign: opts.sign, sub: job });
if (r === undefined) return false;
if (r && typeof r === "object" && "id" in r) setPacket(r);
else await reload();
if (opts.ok) setMsg({ ok: true, text: opts.ok });
return true;
} 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) });
return false;
} finally {
setBusy(false);
}
},
[run, reload, job],
);
if (!packet) {
return loadError ? <div className="callout warn">{loadError}</div> : <div className="empty slim">Loading</div>;
}
const p = packet;
const can = (a: string) => p.can.includes(a);
const draft = p.status === "draft";
const transition = (action: string, extra: Omit<TransitionBody, "action" | "expect"> = {}, ok?: string) =>
mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok });
const setHeatMap = (v: boolean) =>
mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g }));
const attach = async (file: File) => {
setBusy(true);
setMsg(null);
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
setBusy(false);
return;
} finally {
if (fileRef.current) fileRef.current.value = "";
}
setBusy(false);
if (asset.mime !== "application/pdf") {
setMsg({ ok: false, text: "The drawing must be a PDF." });
return;
}
const pages = asset.pages ?? 0;
await mutate(
(g) =>
packetsApi.attachPages(p.id, {
asset_id: asset.id,
type: "drawing",
...(p.drawing ? { dwg: p.drawing } : {}),
...(p.rev ? { rev: p.rev } : {}),
...g,
}),
{ sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." },
);
};
const sendToQc = async () => {
const note = await askReason({
title: "Send straight to Q.C.",
sub: job,
label: "Note (optional, Q.C. will see it)",
note: "Skips the warehouse and the shop. Q.C. gets it for inspection.",
min: 0,
submitLabel: "Send to Q.C.",
});
if (note === null) return;
await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C.");
};
const remove = async () => {
const reason = await askReason({
title: "Remove packet",
sub: job,
note: "The packet leaves every board. It stays in the record with your reason.",
min: 8,
danger: true,
submitLabel: "Remove packet",
});
if (reason === null) return;
setBusy(true);
setMsg(null);
try {
const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job });
if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`);
} catch (err) {
if (err instanceof ApiError && err.status === 409) await reload();
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const removeDrawing = async (n: number) => {
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
};
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const shownTab: Tab = 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 =
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
const project = p.unit.project;
const di = dueInfo(p.unit.due_effective, project.status === "closed");
const target = targetHours(p);
const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0));
const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []);
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 tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" },
];
return (
<>
<Link className="back" to={backTo}>
← Back
</Link>
<div className="dhead">
<div className="grow0">
<div className="eyebrow">
{p.op ?? ""} · {statusLabel(p.status)}
</div>
<h1>{job}</h1>
{p.title ? <div className="dtitle">{p.title}</div> : null}
<div className="small crumbs2">
Project <b>{project.number}</b> {project.name ?? ""} <span className="muted">›</span> Unit <b>{p.unit.number}</b> {p.unit.name ?? ""}
{project.po ? (
<>
{" "}
· PO <span className="mono">{project.po}</span>
</>
) : null}{" "}
· <span className={di.cls}>{di.txt}</span>
</div>
<div className="facts">
{lotQty ? (
<div>
<span>Lot qty </span>
<b>{lotQty}</b>
</div>
) : null}
<div>
<span>Customer </span>
<b>{p.customer || "-"}</b>
</div>
<div>
<span>Drawing </span>
<b>{p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}</b>
</div>
<div>
<span>WPS </span>
<b>{p.wps || "-"}</b>
</div>
<div>
<span>Department </span>
<b>
{p.dept.code} {p.dept.name}
</b>
</div>
<div>
<span>Target </span>
<b>{target.toFixed(1)} h</b>
</div>
<div>
<span>Heat map </span>
<b>{p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}</b>
</div>
{p.for_sup ? (
<div>
<span>For </span>
<b>{p.for_sup.display_name}</b>
</div>
) : null}
{p.station ? (
<div>
<span>Station </span>
<b>{p.station.name}</b>
</div>
) : null}
{p.welder ? (
<div>
<span>Welder </span>
<b>
{p.welder.display_name}
{p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
</b>
</div>
) : null}
</div>
</div>
<div className="pacts">
<span className={pillClass(p.status)} data-testid="status-pill">
{statusLabel(p.status)}
</span>
{can("edit_header") || can("edit_drawing_ref") ? (
<button className="btn sm" type="button" onClick={() => setEditing((e) => !e)} aria-expanded={editing}>
Edit details
</button>
) : null}
{can("move_unit") ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => setSheet("move")}>
Move
</button>
) : null}
</div>
</div>
{p.flag ? <div className={`small flagline ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.removed ? (
<div className="callout warn">
<b>Removed</b>
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div>
) : null}
{p.return_note ? (
<div className="callout warn">
<b>Returned:</b> {p.return_note}
</div>
) : null}
{viewOnly && !p.removed ? (
<div className="viewonly">
<span aria-hidden="true">●</span>
<div>
<b>View only.</b> This packet is with <b>{p.holder}</b>. You can follow its progress. Changes open up only when it is sent to you or sent back.
</div>
</div>
) : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
{hasDraftControls ? (
<section className="panel actbox" aria-label="Draft steps">
<div>
<b>Ready to release.</b> Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the
parts pull, or straight to a department or Q.C.
</div>
{can("edit_header") ? (
<div className="hmq" role="group" aria-label="Heat map required?">
<b>Heat map required?</b>
<button type="button" className="chip sm" aria-pressed={p.heat_map === true} disabled={busy} onClick={() => void setHeatMap(true)}>
Yes
</button>
<button type="button" className="chip sm" aria-pressed={p.heat_map === false} disabled={busy} onClick={() => void setHeatMap(false)}>
No
</button>
{p.heat_map === null ? <span className="due soon small">choose before sending</span> : null}
</div>
) : null}
<div className="acts">
{can("edit_document_set") ? (
<div className="act">
<label className={busy ? "btn dim" : "btn"}>
{drawingPages.length ? "Attach another drawing" : "Attach drawing"}
<input
ref={fileRef}
className="sr"
type="file"
accept="application/pdf"
aria-label="Attach drawing (PDF)"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void attach(f);
}}
/>
</label>
</div>
) : null}
<Act p={p} action="send_to_warehouse" busy={busy} className="btn pri" onClick={() => void transition("send_to_warehouse", {}, "Sent to the warehouse.")}>
Send to warehouse
</Act>
<Act p={p} action="release_to_shop" busy={busy} onClick={() => setSheet("dept")}>
Send to a department
</Act>
<Act p={p} action="send_draft_to_qc" busy={busy} onClick={() => void sendToQc()}>
Send to Q.C.
</Act>
<Act p={p} action="remove" busy={busy} className="btn rejb" onClick={() => void remove()}>
Remove
</Act>
</div>
</section>
) : null}
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
<section className="panel actbox" aria-label="Close and file">
<div>
{p.vclosed ? (
<>
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
</>
) : (
<>
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
</>
)}
</div>
<div className="acts">
{!p.vclosed ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
) : (
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
Not closed in VISUAL, move back
</Act>
)}
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
Mark filed
</Act>
</div>
</section>
) : null}
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`ptab-${t.id}`}
aria-selected={shownTab === t.id}
aria-controls={`ppanel-${t.id}`}
onClick={() => setTab(t.id)}
>
{t.label}
{t.count !== undefined ? <span className="cnt">{t.count}</span> : null}
</button>
))}
</div>
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
{shownTab === "drawing" ? (
<div className="stack">
{draft && can("edit_document_set") && drawingPages.length ? (
<div className="sec-h flush">
<span className="small muted">
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void removeDrawing(drawingPages[0]?.n ?? 1)}>
Remove drawing
</button>
</div>
) : null}
<DrawingViewer pages={drawingPages} />
</div>
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
{sheet === "dept" ? (
<SendToDeptSheet
p={p}
sub={job}
onCancel={() => setSheet(null)}
onSend={(dept, note) => {
setSheet(null);
void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department.");
}}
/>
) : null}
{sheet === "move" ? (
<MoveSheet
p={p}
sub={job}
onCancel={() => setSheet(null)}
onMove={(unitId, reason) => {
setSheet(null);
void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." });
}}
/>
) : null}
{guardSheet}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, type FormEvent } from "react";
import type { PacketRead } from "../api/types";
import { PickUnit, type UnitPick } from "../components/PickUnit";
import { Sheet } from "../components/Sheet";
import { useShopDepartments } from "../lib/useDepartments";
/** Clerical: send a draft straight to a shop department. Skips the warehouse parts pull. */
export function SendToDeptSheet({
p,
sub,
onSend,
onCancel,
}: {
p: PacketRead;
sub: string;
onSend: (dept: string, note: string) => void;
onCancel: () => void;
}) {
const depts = useShopDepartments();
const [dept, setDept] = useState(p.dept.code);
const [note, setNote] = useState("");
const value = depts.some((d) => d.code === dept) ? dept : (depts[0]?.code ?? "");
const submit = (e: FormEvent) => {
e.preventDefault();
if (value) onSend(value, note.trim());
};
return (
<Sheet title="Send straight to a department" sub={sub} onClose={onCancel}>
<div className="small">Skips the warehouse parts pull. The department's lead or supervisor gets it ready to assign.</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
Department
<select className="in" value={value} onChange={(e) => setDept(e.target.value)}>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<label className="f">
Note (optional, they will see it)
<input className="in" value={note} maxLength={500} placeholder="e.g. parts already staged at the station" onChange={(e) => setNote(e.target.value)} />
</label>
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={!value}>
Send
</button>
</div>
</form>
</Sheet>
);
}
/** Clerical: move a packet to another unit, through the two-step picker. */
export function MoveSheet({
p,
sub,
onMove,
onCancel,
}: {
p: PacketRead;
sub: string;
onMove: (unitId: number, reason: string) => void;
onCancel: () => void;
}) {
const [pick, setPick] = useState<UnitPick>({
project: { id: p.unit.project.id, number: p.unit.project.number, name: p.unit.project.name, customer: p.unit.project.customer, po: p.unit.project.po, status: p.unit.project.status },
unit: null,
});
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!pick.unit) {
setError("Pick the unit to move it to.");
return;
}
if (pick.unit.id === p.unit.id) {
setError("The packet is in that unit already.");
return;
}
onMove(pick.unit.id, reason.trim());
};
return (
<Sheet title="Move to another unit" sub={sub} onClose={onCancel}>
<form className="stack" onSubmit={submit} noValidate>
<PickUnit value={pick} onChange={setPick} requireUnit />
<label className="f">
Reason (optional)
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="small muted">The move is recorded in the packet history and the access log.</div>
{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">
Move packet
</button>
</div>
</form>
</Sheet>
);
}
+466
View File
@@ -0,0 +1,466 @@
import QRCode from "qrcode";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { assetsApi } from "../api/assets";
import { packetsApi } from "../api/packets";
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { useLoad } from "../lib/useLoad";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
opts?: { sign?: string | undefined; ok?: string | undefined },
) => Promise<boolean>;
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
/* ---------- Materials ---------- */
interface LineDraft {
qty: string;
part_id: string;
description: string;
material: string;
length: string;
dwg_ref: string;
}
const toDraft = (b: BomLine): LineDraft => ({
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
part_id: b.part_id ?? "",
description: b.description ?? "",
material: b.material ?? "",
length: b.length ?? "",
dwg_ref: b.dwg_ref ?? "",
});
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */
export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
const [lines, setLines] = useState<LineDraft[] | null>(null);
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const save = async (e: FormEvent) => {
e.preventDefault();
if (!lines) return;
const bom: BomLine[] = lines
.filter((l) => l.part_id.trim() || l.description.trim())
.map((l, i) => ({
item: i + 1,
qty: l.qty.trim() === "" ? null : Number(l.qty),
part_id: l.part_id.trim() || null,
description: l.description.trim() || null,
material: l.material.trim() || null,
length: l.length.trim() || null,
dwg_ref: l.dwg_ref.trim() || null,
}));
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
if (ok) setLines(null);
};
if (lines) {
return (
<form className="stack" onSubmit={save} noValidate>
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Dwg ref</th>
<th />
</tr>
</thead>
<tbody>
{lines.map((l, i) => (
<tr key={i}>
<td className="mono">{i + 1}</td>
<td>
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sheet-acts start">
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
Add line
</button>
<span className="grow" />
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save BOM
</button>
</div>
</form>
);
}
return (
<div className="stack">
{editable ? (
<div className="sec-h flush">
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
<span className="grow" />
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
Edit lines
</button>
</div>
) : null}
{p.bom.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Heat</th>
<th>Pulled</th>
</tr>
</thead>
<tbody>
{p.bom.map((b) => (
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
<td>
<span className="balloon">{b.item}</span>
</td>
<td className="num">{show(b.qty)}</td>
<td className="mono small">{show(b.part_id)}</td>
<td>
{show(b.description)}
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
</td>
<td className="small">{show(b.material)}</td>
<td className="small">{show(b.length)}</td>
<td className="mono small">{show(b.heat)}</td>
<td>
{b.na ? (
<span className="pill st-filed">N/A</span>
) : b.short ? (
<span className="pill st-review">{String(b.short.qty_open)} short</span>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (
<span className="muted small">Not yet</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty">No BOM lines on this packet.</div>
)}
</div>
);
}
/* ---------- Work orders ---------- */
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
}
function Qr({ text }: { text: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const c = ref.current;
if (!c) return;
let ok = true;
try {
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
if (ok) setFailed(true);
});
} catch {
setFailed(true);
}
return () => {
ok = false;
};
}, [text]);
return (
<div className="code">
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
</div>
);
}
/** The work orders, and one QR code per work order key. */
export function WorkOrdersTab({ p }: { p: PacketRead }) {
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
return (
<div className="stack">
<div className="tw">
<table>
<thead>
<tr>
<th>WO</th>
<th>Lot</th>
<th>Split</th>
<th>Sub</th>
<th>Seq</th>
<th>Operation</th>
<th className="num">Qty</th>
<th className="num">Target hrs</th>
</tr>
</thead>
<tbody>
{p.work_orders.map((w) => (
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
<td className="mono">{w.wo_base}</td>
<td className="mono">{w.lot}</td>
<td className="mono">{w.split}</td>
<td className="mono">{w.sub}</td>
<td className="mono">{show(w.seq)}</td>
<td>{show(w.op)}</td>
<td className="num">{show(w.qty)}</td>
<td className="num">{show(w.target_hrs)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="qrs">
{p.work_orders.map((w) => {
const k = woKey(w);
return (
<div key={w.id ?? k} className="qr">
<Qr text={k} />
<div>
<div className="wo">{w.wo_base}</div>
<div className="mono small">{k}</div>
</div>
</div>
);
})}
</div>
</div>
);
}
/* ---------- Documents ---------- */
const DOC_KINDS: ReadonlyArray<[string, string]> = [
["mtr", "Material test report"],
["nde", "NDE report"],
["procedure", "Procedure (WPS / PQR)"],
["photo", "Photo"],
["other", "Other"],
];
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
const canAdd = p.can.includes("doc_add");
const canRemove = p.can.includes("doc_remove");
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState("mtr");
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirm, setConfirm] = useState<number | null>(null);
const add = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!file) {
setError("Choose a file.");
return;
}
setUploading(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
if (ok) {
setTitle("");
setFile(null);
if (fileRef.current) fileRef.current.value = "";
}
} catch (err) {
setError(errorText(err));
} finally {
setUploading(false);
}
};
return (
<div className="stack">
{p.docs.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Title</th>
<th>Kind</th>
<th>Added</th>
<th>By</th>
<th />
</tr>
</thead>
<tbody>
{p.docs.map((d) => (
<tr key={d.id}>
<td>
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
{d.title || "Untitled"}
</a>
</td>
<td className="small">{kindLabel(d.kind)}</td>
<td className="small mono">{fmtTime(d.added_at)}</td>
<td className="small">{d.added_by?.display_name ?? "-"}</td>
<td className="nowrap">
{canRemove ? (
confirm === d.id ? (
<>
<button
className="btn sm rejb"
type="button"
disabled={busy}
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
>
Confirm remove
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Remove
</button>
)
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty slim">No documents on this packet.</div>
)}
{canAdd ? (
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
<h3 id="docadd-h">Add a document</h3>
<div className="frow">
<label className="f">
File (PDF, PNG or JPEG)
<input
ref={fileRef}
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Kind
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
{DOC_KINDS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
<label className="f">
Title
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
</label>
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div>
<button className="btn pri" type="submit" disabled={busy || uploading}>
{uploading ? "Uploading" : "Add document"}
</button>
</div>
</form>
) : null}
</div>
);
}
/* ---------- History ---------- */
/** GET /log, newest first. IT edits and signatures are marked. */
export function HistoryTab({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const rows = load.data;
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
return (
<div className="tw">
<table>
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>What</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td className="small">
{r.by?.display_name ?? "System"}
{r.role ? <div className="sub">{r.role}</div> : null}
</td>
<td>
{r.action}
{r.signed ? <span className="pill st-ok">Signed</span> : null}
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
</td>
<td className="small">
{r.note ?? ""}
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+12 -1
View File
@@ -1,9 +1,19 @@
import type { ReactNode } from "react";
import { Navigate, useParams } from "react-router";
import { homePath, isScreen, orderScreens } from "../auth/roles";
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen";
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
export function RequireScreen({ screen, children }: { screen: Screen; children: ReactNode }) {
const { me } = useAuth();
if (!me) return null;
if (!orderScreens(me.screens).includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
return <>{children}</>;
}
/** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */
export function ScreenRoute() {
const { screen } = useParams();
@@ -12,5 +22,6 @@ export function ScreenRoute() {
const allowed = orderScreens(me.screens);
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
if (screen === "admin") return <AdminPage />;
if (screen === "clerical") return <ClericalHome />;
return <PlaceholderScreen key={screen} screen={screen} />;
}
@@ -0,0 +1,108 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeCard, makeProject, makeUnit } from "../../test/packets";
const CARDS = [
makeCard({ id: 1, flag: "Made by hand - check it and send it on" }),
makeCard({ id: 2, unit: { id: 12, number: "L999002" }, op: "FIT-UP" }),
makeCard({ id: 3, status: "warehouse", holder: "Warehouse" }),
makeCard({ id: 4, status: "in_progress" }),
makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }),
makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }),
makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }),
];
function base() {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets": { body: { packets: CARDS } },
"/api/projects": { body: [makeProject()] },
};
}
function column(title: string): HTMLElement {
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
}
describe("ClericalHome", () => {
it("renders the boards and the numbers from the list answer", async () => {
mockFetch(base());
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
await screen.findByRole("heading", { name: /^Flagged/ });
expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot");
expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument();
expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument();
expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
const flagged = column("Flagged");
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
expect(within(column("Going to the warehouse")).getByText("L999002 · FIT-UP")).toBeInTheDocument();
expect(within(column("Ready to close")).getByText("L999003 · WELD-PART")).toBeInTheDocument();
expect(within(column("Ready for data book")).getByText("L999004 · WELD-PART")).toBeInTheDocument();
const filed = column("Filed");
expect(within(filed).getByText("L999005 · WELD-PART")).toBeInTheDocument();
expect(within(filed).getByText("Filed in data book")).toHaveClass("pill", "st-filed");
// The list view holds the same packets.
fireEvent.click(screen.getByRole("button", { name: "List" }));
const table = await screen.findByRole("table");
expect(within(table).getAllByRole("row")).toHaveLength(1 + 5);
});
it("the picker loads the units after a project is chosen", async () => {
const fetch = mockFetch({
...base(),
"/api/units": { body: [makeUnit(), makeUnit({ id: 12, number: "L999002", lot: "002" })] },
});
renderApp("/clerical");
const bar = await screen.findByRole("region", { name: "Find jobs" });
expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0);
fireEvent.click(await within(bar).findByRole("button", { name: /L999/ }));
const unit = await within(bar).findByLabelText("Unit");
await within(unit).findByRole("option", { name: "L999002" });
const urls = fetch.mock.calls.map(([u]) => String(u));
expect(urls).toContain("/api/units?project_id=7");
// Choosing the project narrows the boards.
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7"));
fireEvent.change(unit, { target: { value: "12" } });
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
});
it("the new project sheet posts and then opens Add units", async () => {
const fetch = mockFetch({
...base(),
"POST /api/projects": { status: 201, body: makeProject({ id: 9, number: "L998", name: "New one", units: 0 }) },
"POST /api/projects/9/units": { status: 201, body: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] },
"/api/projects/9": { body: makeProject({ id: 9, number: "L998", name: "New one", units: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }) },
});
renderApp("/clerical");
fireEvent.click(await screen.findByRole("button", { name: "New project" }));
const sheet = await screen.findByRole("dialog", { name: "New project" });
fireEvent.change(within(sheet).getByLabelText("Project number"), { target: { value: "l998" } });
fireEvent.change(within(sheet).getByLabelText("Project name"), { target: { value: "New one" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Create project" }));
const add = await screen.findByRole("dialog", { name: "Add units" });
expect(callsTo(fetch, "POST", "/api/projects")).toEqual([{ number: "L998", name: "New one", customer: "", po: "", due: null, notes: "" }]);
expect(within(add).getByLabelText("Lot")).toHaveValue("001");
// Several units: a preview of the numbers.
fireEvent.click(within(add).getByRole("button", { name: "Several units" }));
fireEvent.change(within(add).getByLabelText("How many"), { target: { value: "3" } });
expect(within(add).getByText("Numbers: L998001, L998002, L998003")).toBeInTheDocument();
fireEvent.click(within(add).getByRole("button", { name: "Add" }));
expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]);
});
});
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { useNavigate } from "react-router";
import { packetsApi } from "../../api/packets";
import type { PacketCard } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Board } from "../../components/Board";
import { FindBar, type BoardView } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { canEditProjects } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { fmtTime } from "../../lib/format";
import { STATUSES } from "../../api/types";
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { byTime, clericalBoards } from "./boards";
import { ProjectsSection } from "./ProjectsSection";
import { NewPacketSheet } from "./sheets";
type SortKey = "updated" | "unit" | "due" | "status" | "dept";
const SORTS: ReadonlyArray<[SortKey, string]> = [
["updated", "Last updated"],
["unit", "Unit"],
["due", "Due date"],
["status", "Status"],
["dept", "Department"],
];
function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
switch (k) {
case "unit":
return (a, b) => a.unit.number.localeCompare(b.unit.number, undefined, { numeric: true }) || (a.op ?? "").localeCompare(b.op ?? "");
case "due":
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999");
case "status":
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status);
case "dept":
return (a, b) => a.dept.code.localeCompare(b.dept.code);
default:
return (a, b) => byTime(b.updated_at, a.updated_at);
}
}
function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; onSort: (k: SortKey) => void }) {
const navigate = useNavigate();
const sorted = useMemo(() => [...rows].sort(sorter(sort)), [rows, sort]);
const onKey = (e: KeyboardEvent, id: number) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/packets/${id}`);
}
};
return (
<section className="sec" aria-labelledby="jobs-h">
<div className="sec-h">
<h2 id="jobs-h">Jobs</h2>
<span className="ct">
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
</span>
<span className="grow" />
<label className="f">
Sort by
<select className="in" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
{SORTS.map(([k, l]) => (
<option key={k} value={k}>
{l}
</option>
))}
</select>
</label>
</div>
<div className="tw">
<table className="joblist">
<thead>
<tr>
<th>Job</th>
<th>Operation</th>
<th>Department</th>
<th>Status</th>
<th>Drawing</th>
<th>Due</th>
<th>Updated</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => {
const di = dueInfo(p.due_effective);
return (
<tr key={p.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}>
<td>
<b>{jobName(p)}</b>
<div className="sub">{p.project.name ?? ""}</div>
</td>
<td>
{p.op ?? ""}
<div className="sub">{p.title ?? ""}</div>
</td>
<td className="small">{p.dept.name}</td>
<td>
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
</td>
<td className="mono small">
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
</td>
<td className="small">
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
</td>
<td className="small mono">{fmtTime(p.updated_at)}</td>
<td>
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
</td>
</tr>
);
})}
{sorted.length === 0 ? (
<tr>
<td colSpan={8} className="muted">
No packets match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
);
}
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
export function ClericalHome() {
const { me } = useAuth();
const navigate = useNavigate();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [view, setView] = useState<BoardView>("board");
const [sort, setSort] = useState<SortKey>("updated");
const [drafting, setDrafting] = useState(false);
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const [load, reload] = useLoad(
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
const b = useMemo(() => clericalBoards(all), [all]);
const shown = useMemo(() => {
const ids = new Set<number>();
const out: PacketCard[] = [];
for (const c of b.columns) {
for (const p of c.cards) {
if (ids.has(p.id)) continue;
ids.add(p.id);
out.push(p);
}
}
return out;
}, [b]);
const editable = canEditProjects(me?.roles ?? []);
return (
<>
<header className="phead">
<div>
<div className="eyebrow">Projects and job packets</div>
<h1>Clerical</h1>
</div>
{editable ? (
<div className="pacts">
<button className="btn pri" type="button" onClick={() => setDrafting(true)}>
New packet
</button>
</div>
) : null}
</header>
<Kpis
items={[
{ n: 0, label: "Changes to approve", alert: true },
{ n: b.flagged, label: "Flagged", alert: true },
{ n: b.warehouse, label: "With warehouse" },
{ n: b.shop, label: "In the shop" },
{ n: b.toClose, label: "Ready to close", alert: true },
]}
/>
{b.flagged || b.toClose ? (
<div className="attn" role="status">
{b.flagged ? (
<div className="callout warn flush">
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
</div>
) : null}
{b.toClose ? (
<div className="callout flush">
<b>{b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
</div>
) : null}
</div>
) : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? view === "board" ? <Board columns={b.columns} /> : <JobList rows={shown} sort={sort} onSort={setSort} /> : null}
<ProjectsSection />
{drafting ? (
<NewPacketSheet
initial={pick.unit ? pick : { project: pick.project, unit: null }}
onCancel={() => setDrafting(false)}
onCreated={(p) => {
setDrafting(false);
navigate(`/packets/${p.id}`);
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,290 @@
import { useEffect, useState, type KeyboardEvent } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { projectsApi } from "../../api/projects";
import type { Project, Unit } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Kpis } from "../../components/Kpis";
import { useGuard } from "../../components/useGuard";
import { canEditProjects, canRenumber } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProgressBar, ProjectPill } from "./common";
import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
type UnitShow = "active" | "hold" | "closed" | "all";
const UNIT_SHOWS: ReadonlyArray<[UnitShow, string]> = [
["active", "Active"],
["hold", "On hold"],
["closed", "Closed"],
["all", "All"],
];
function unitsOf(p: Project): Unit[] {
return Array.isArray(p.units) ? [...p.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })) : [];
}
function sumCounts(units: Unit[]) {
const c = stageCounts(null);
for (const u of units) {
const x = stageCounts(u.packets);
c.draft += x.draft;
c.warehouse += x.warehouse;
c.shop += x.shop;
c.qc += x.qc;
c.complete += x.complete;
c.filed += x.filed;
c.total += x.total;
}
return c;
}
function UnitTable({ project, units }: { project: Project; units: Unit[] }) {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [show, setShow] = useState<UnitShow>("active");
const t = q.trim().toLowerCase();
const rows = units.filter(
(u) =>
(show === "all" || (show === "active" ? u.status !== "closed" : u.status === show)) &&
(!t || [u.number, u.lot, u.name ?? "", u.notes ?? ""].join(" ").toLowerCase().includes(t)),
);
const onKey = (e: KeyboardEvent, id: number) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/clerical/units/${id}`);
}
};
return (
<>
<div className="unitbar">
<label className="f grow">
<span className="sr">Find a unit</span>
<input className="in" type="search" placeholder="Find a unit: number or description" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<div className="chips" role="group" aria-label="Show units">
{UNIT_SHOWS.map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
<span className="small muted">
{rows.length} of {units.length} unit{units.length === 1 ? "" : "s"}
</span>
</div>
<div className="tw">
<table className="joblist unitlist">
<thead>
<tr>
<th>Unit</th>
<th>Description</th>
<th>Status</th>
<th>Due</th>
<th>Packets</th>
<th className="wide">Progress</th>
</tr>
</thead>
<tbody>
{rows.map((u) => {
const c = stageCounts(u.packets);
const own = !!u.due;
const di = dueInfo(u.due_effective ?? project.due, u.status === "closed" || project.status === "closed");
return (
<tr key={u.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/clerical/units/${u.id}`)} onKeyDown={(e) => onKey(e, u.id)}>
<td className="mono">
<b>{u.number}</b>
</td>
<td>{u.name || "-"}</td>
<td>
<ProjectPill status={u.status} />
</td>
<td className="small">
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
{own ? null : <span className="muted"> (project)</span>}
</td>
<td className="small">
<b>{c.total}</b>
{c.total ? <span className="muted"> · {c.filed} filed</span> : null}
</td>
<td>
<ProgressBar c={c} />
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No units match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
);
}
/** /clerical/projects/:projectId. */
export function ProjectPage() {
const { projectId } = useParams();
const id = Number(projectId);
const { me } = useAuth();
const navigate = useNavigate();
const guard = useGuard();
const [load, reload] = useLoad((s) => projectsApi.get(id, s), String(id));
const [sheet, setSheet] = useState<"edit" | "units" | "renumber" | null>(null);
const [toast, setToast] = useState<string | null>(null);
const roles = me?.roles ?? [];
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
const back = (
<Link className="back" to="/clerical">
← All projects
</Link>
);
if (load.state === "loading") return <>{back}<div className="empty slim">Loading</div></>;
if (load.state === "error") return <>{back}<div className="callout warn">{load.message}</div></>;
const p = load.data;
const units = unitsOf(p);
const c = p.packets ? stageCounts(p.packets) : sumCounts(units);
const di = dueInfo(p.due, p.status === "closed");
const editable = canEditProjects(roles);
const aliases = (p.aliases ?? []).map((a) => (typeof a === "string" ? a : a.alias)).filter((a) => a && a !== p.number);
return (
<>
{back}
<div className="dhead">
<div className="grow0">
<div className="eyebrow">Project · {PROJECT_STATUS_LABEL[p.status]}</div>
<h1>
{p.number}
{p.name ? ` · ${p.name}` : ""}
</h1>
<div className="facts">
<div>
<span>Customer </span>
<b>{p.customer || "-"}</b>
</div>
<div>
<span>Customer PO </span>
<b>{p.po || "-"}</b>
</div>
<div>
<span>Due </span>
<b className={di.cls}>{di.txt.replace(/^Due /, "")}</b>
</div>
<div>
<span>Units </span>
<b>{units.length}</b>
</div>
<div>
<span>Packets </span>
<b>{c.total}</b>
</div>
{aliases.length ? (
<div>
<span>Old numbers </span>
<b>{aliases.join(", ")}</b>
</div>
) : null}
</div>
{p.notes ? <div className="small pre">{p.notes}</div> : null}
</div>
<div className="pacts">
<ProjectPill status={p.status} />
{editable ? (
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
Edit project
</button>
) : null}
{canRenumber(roles) ? (
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
Renumber
</button>
) : null}
</div>
</div>
<Kpis
items={[
{ n: c.draft, label: "Ready to release" },
{ n: c.warehouse, label: "With warehouse" },
{ n: c.shop + c.qc, label: "In the shop / Q.C." },
{ n: c.complete, label: "Back for data book" },
{ n: c.filed, label: "Filed" },
]}
/>
<ProgressBar c={c} />
<section className="sec" aria-labelledby="units-h">
<div className="sec-h">
<h2 id="units-h">Units</h2>
<span className="ct">each unit holds the packets for that piece of equipment</span>
<span className="grow" />
{editable && p.status !== "closed" ? (
<button className="btn pri" type="button" onClick={() => setSheet("units")}>
Add units
</button>
) : null}
</div>
{p.status === "closed" ? <div className="callout">This project is closed. Reopen it (Edit project) to add units or packets.</div> : null}
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
</section>
{sheet === "edit" ? (
<ProjectSheet
project={p}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setToast("Project saved");
reload();
}}
/>
) : null}
{sheet === "units" ? (
<AddUnitsSheet
project={p}
lots={units.map((u) => u.lot)}
onCancel={() => setSheet(null)}
onDone={(added) => {
setSheet(null);
setToast(added.length === 1 ? `Unit ${added[0]?.number ?? ""} added` : `${added.length} units added`);
reload();
}}
/>
) : null}
{sheet === "renumber" ? (
<RenumberSheet
kind="project"
current={p.number}
onCancel={() => setSheet(null)}
onSave={async (number, reason) => {
const r = await guard.run((g) => projectsApi.renumber(p.id, { number, reason, ...g }), { sub: p.number });
if (r === undefined) return;
setSheet(null);
setToast(`Project renumbered to ${number}`);
reload();
if (r.id && r.id !== p.id) navigate(`/clerical/projects/${r.id}`);
}}
/>
) : null}
{guard.sheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { projectsApi, type ProjectShow } from "../../api/projects";
import type { Project } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { canEditProjects } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { useLoad } from "../../lib/useLoad";
import { ProjectCard } from "./common";
import { AddUnitsSheet, ProjectSheet } from "./sheets";
const SHOWS: ReadonlyArray<[ProjectShow, string]> = [
["active", "Active"],
["open", "Open"],
["hold", "On hold"],
["closed", "Closed"],
["all", "All"],
];
/** Projects on the Clerical home: find box, status chips, cards, New project then Add units. */
export function ProjectsSection() {
const { me } = useAuth();
const navigate = useNavigate();
const [show, setShow] = useState<ProjectShow>("active");
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const [adding, setAdding] = useState<Project | null>(null);
const [load, reload] = useLoad((s) => projectsApi.list(query, show, s), `${show}|${query}`);
const editable = canEditProjects(me?.roles ?? []);
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
useChanged(reload, { delayMs: 500 });
const list = load.state === "ok" ? load.data : [];
return (
<section className="sec" aria-labelledby="projects-h">
<div className="sec-h">
<h2 id="projects-h">Projects</h2>
<span className="ct">project, then units, then packets</span>
<span className="grow" />
<div className="chips" role="group" aria-label="Show projects">
{SHOWS.map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
{editable ? (
<button className="btn pri" type="button" onClick={() => setCreating(true)}>
New project
</button>
) : null}
</div>
<div className="unitbar">
<label className="f grow">
<span className="sr">Find a project</span>
<input
className="in"
type="search"
placeholder="Find a project: L670, customer, PO"
autoComplete="off"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</label>
</div>
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? (
list.length ? (
<div className="projgrid">
{list.map((p) => (
<ProjectCard key={p.id} p={p} />
))}
</div>
) : (
<div className="empty">{query ? `Nothing matches "${query}".` : "No projects in this view. Set one up, add its units, then add packets into each unit."}</div>
)
) : null}
{creating ? (
<ProjectSheet
onCancel={() => setCreating(false)}
onSaved={(p) => {
setCreating(false);
setAdding(p);
reload();
}}
/>
) : null}
{adding ? (
<AddUnitsSheet
project={adding}
lots={[]}
onCancel={() => setAdding(null)}
onDone={() => {
const id = adding.id;
setAdding(null);
navigate(`/clerical/projects/${id}`);
}}
/>
) : null}
</section>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { packetsApi } from "../../api/packets";
import { projectsApi } from "../../api/projects";
import { useAuth } from "../../auth/useAuth";
import { Board } from "../../components/Board";
import { Kpis } from "../../components/Kpis";
import { useGuard } from "../../components/useGuard";
import { canEditProjects, canRenumber } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { countCards, dueInfo, IN_SHOP_OR_QC, PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProgressBar, ProjectPill } from "./common";
import { NewPacketSheet, RenumberSheet, UnitSheet } from "./sheets";
/** /clerical/units/:unitId: the unit's facts and its packets. */
export function UnitPage() {
const { unitId } = useParams();
const id = Number(unitId);
const { me } = useAuth();
const navigate = useNavigate();
const guard = useGuard();
const [unitLoad, reloadUnit] = useLoad((s) => projectsApi.unit(id, s), String(id));
const [packetsLoad, reloadPackets] = useLoad((s) => packetsApi.list({ unit_id: id }, s), String(id));
const [sheet, setSheet] = useState<"edit" | "renumber" | "packet" | null>(null);
const [toast, setToast] = useState<string | null>(null);
const roles = me?.roles ?? [];
useChanged(
() => {
reloadUnit();
reloadPackets();
},
{ delayMs: 500 },
);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
if (unitLoad.state === "loading") return <div className="empty slim">Loading</div>;
if (unitLoad.state === "error")
return (
<>
<Link className="back" to="/clerical">
← All projects
</Link>
<div className="callout warn">{unitLoad.message}</div>
</>
);
const u = unitLoad.data;
const t = u.project ?? null;
const list = packetsLoad.state === "ok" ? packetsLoad.data : [];
const c = countCards(list);
const di = dueInfo(u.due_effective, u.status === "closed" || t?.status === "closed");
const open = u.status !== "closed" && t?.status !== "closed";
const editable = canEditProjects(roles);
const grp = (st: string) => list.filter((p) => p.status === st);
return (
<>
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/clerical">
All projects
</Link>
<span>›</span>
{t ? (
<>
<Link className="back" to={`/clerical/projects/${t.id}`}>
{t.number} {t.name ?? ""}
</Link>
<span>›</span>
</>
) : null}
<b>{u.number}</b>
</nav>
<div className="dhead">
<div className="grow0">
<div className="eyebrow">
Unit{t ? ` of ${t.number}` : ""} · {PROJECT_STATUS_LABEL[u.status]}
</div>
<h1>
{u.number}
{u.name ? ` · ${u.name}` : ""}
</h1>
<div className="facts">
<div>
<span>Project </span>
<b>{t ? `${t.number} ${t.name ?? ""}` : "-"}</b>
</div>
<div>
<span>Customer </span>
<b>{t?.customer || "-"}</b>
</div>
<div>
<span>Customer PO </span>
<b>{t?.po || "-"}</b>
</div>
<div>
<span>Due </span>
<b className={di.cls}>
{di.txt.replace(/^Due /, "")}
{u.due ? "" : " (project)"}
</b>
</div>
<div>
<span>Packets </span>
<b>{c.total}</b>
</div>
</div>
{u.notes ? <div className="small pre">{u.notes}</div> : null}
</div>
<div className="pacts">
<ProjectPill status={u.status} />
{editable ? (
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
Edit unit
</button>
) : null}
{canRenumber(roles) ? (
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
Renumber
</button>
) : null}
{editable && open ? (
<button className="btn pri" type="button" onClick={() => setSheet("packet")}>
Add packet
</button>
) : null}
</div>
</div>
<Kpis
items={[
{ n: c.draft, label: "Ready to release" },
{ n: c.warehouse, label: "With warehouse" },
{ n: list.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length, label: "In the shop / Q.C." },
{ n: c.complete, label: "Back for data book" },
{ n: c.filed, label: "Filed" },
]}
/>
<ProgressBar c={c} />
{!open ? <div className="callout">This {u.status === "closed" ? "unit" : "project"} is closed. Reopen it to add packets.</div> : null}
{packetsLoad.state === "error" ? <div className="callout warn">{packetsLoad.message}</div> : null}
<Board
columns={[
{ key: "flagged", title: "Flagged", cards: grp("draft").filter((p) => p.flag), empty: "Nothing flagged." },
{ key: "draft", title: "Ready to release", cards: grp("draft").filter((p) => !p.flag), empty: "Nothing ready to release in this unit." },
{
key: "prog",
title: "In progress",
cards: list.filter((p) => p.status === "warehouse" || IN_SHOP_OR_QC.includes(p.status)),
empty: "Nothing in progress.",
},
{ key: "back", title: "Back for data book", cards: grp("complete"), empty: "Nothing accepted yet." },
{ key: "filed", title: "Filed", cards: grp("filed"), empty: "Nothing filed yet." },
]}
/>
{sheet === "edit" ? (
<UnitSheet
unit={u}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setToast("Unit saved");
reloadUnit();
}}
/>
) : null}
{sheet === "renumber" ? (
<RenumberSheet
kind="unit"
current={u.lot}
prefix={t?.number ?? ""}
onCancel={() => setSheet(null)}
onSave={async (lot, reason) => {
const r = await guard.run((g) => projectsApi.renumberUnit(u.id, { lot, reason, ...g }), { sub: u.number });
if (r === undefined) return;
setSheet(null);
setToast(`Unit renumbered to ${r.number ?? lot}`);
reloadUnit();
reloadPackets();
}}
/>
) : null}
{sheet === "packet" ? (
<NewPacketSheet
initial={{ project: t, unit: u }}
onCancel={() => setSheet(null)}
onCreated={(p) => {
setSheet(null);
navigate(`/packets/${p.id}`);
}}
/>
) : null}
{guard.sheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
// The Clerical home boards, from one list answer.
import type { ColumnDef } from "../../components/Board";
import type { PacketCard } from "../../api/types";
import { IN_SHOP_OR_QC } from "../../lib/packets";
export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? "");
/** The boards of the Clerical home, from one list answer. */
export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } {
const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at));
const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook");
const flagged = drafts.filter((p) => p.flag);
const going = drafts.filter((p) => !p.flag);
const toClose = back.filter((p) => !p.vclosed).sort((a, b) => byTime(a.status_at, b.status_at));
const toBook = back.filter((p) => p.vclosed).sort((a, b) => byTime(a.vclosed?.at ?? null, b.vclosed?.at ?? null));
const filed = all
.filter((p) => p.status === "filed")
.sort((a, b) => byTime(b.status_at, a.status_at))
.slice(0, 25);
const columns: ColumnDef[] = [
{ key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." },
{ key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
{
key: "close",
title: "Ready to close",
cards: toClose,
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
},
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
];
return {
columns,
flagged: flagged.length,
warehouse: all.filter((p) => p.status === "warehouse").length,
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,
toClose: toClose.length,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { Link } from "react-router";
import type { Project, StageCounts } from "../../api/types";
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
/** The old stageChips(): how many packets sit at each stage. */
export function StageChips({ c }: { c: StageCounts }) {
const items: Array<[string, number]> = [
["Ready to release", c.draft],
["Warehouse", c.warehouse],
["Shop", c.shop],
["Q.C.", c.qc],
["Accepted", c.complete],
["Filed", c.filed],
];
return (
<div className="stg">
{items.map(([l, n]) => (
<span key={l} className={n ? undefined : "z"}>
<b>{n}</b> {l}
</span>
))}
</div>
);
}
/** The old progressBar(): filed in cyan, accepted in green. */
export function ProgressBar({ c }: { c: StageCounts }) {
const n = c.total || 0;
const f = n ? (c.filed / n) * 100 : 0;
const a = n ? (c.complete / n) * 100 : 0;
return (
<div className="pbar" title={`${c.filed} filed, ${c.complete} accepted, ${Math.max(0, n - c.filed - c.complete)} in progress of ${n}`}>
<i className="f" style={{ width: `${f}%` }} />
<i className="c" style={{ width: `${a}%` }} />
</div>
);
}
export function ProjectPill({ status }: { status: Project["status"] }) {
return <span className={projectPillClass(status)}>{PROJECT_STATUS_LABEL[status]}</span>;
}
/** The old projCard(). */
export function ProjectCard({ p }: { p: Project }) {
const c = stageCounts(p.packets);
const di = dueInfo(p.due, p.status === "closed");
const units = typeof p.units === "number" ? p.units : p.units.length;
return (
<Link className="card pcard" to={`/clerical/projects/${p.id}`}>
<div className="r1">
<span className="job">{p.number}</span>
<ProjectPill status={p.status} />
</div>
<div className="tt">{p.name || "-"}</div>
<div className="small muted">
{p.customer ?? ""}
{p.po ? (
<>
{" "}
· PO <span className="mono">{p.po}</span>
</>
) : null}
</div>
<div className={`small ${di.cls}`}>{di.txt}</div>
<StageChips c={c} />
<ProgressBar c={c} />
<div className="small muted">
{units} unit{units === 1 ? "" : "s"} · {c.total} packet{c.total === 1 ? "" : "s"} · {c.filed} filed
</div>
</Link>
);
}
+735
View File
@@ -0,0 +1,735 @@
import { useState, type FormEvent } from "react";
import { packetsApi } from "../../api/packets";
import { projectsApi, type ProjectFields, type UnitFields } from "../../api/projects";
import type { PacketCreate, PacketRead, Project, ProjectRef, ProjectStatus, Unit } from "../../api/types";
import { PickUnit, type UnitPick } from "../../components/PickUnit";
import { Sheet } from "../../components/Sheet";
import { useGuard } from "../../components/useGuard";
import { errorText } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { lotSequence, nextLot, normLot, PROJECT_STATUS_LABEL, RX_LOT, RX_PROJECT } from "../../lib/packets";
const STATUSES: ProjectStatus[] = ["open", "hold", "closed"];
function Problem({ text }: { text: string | null }) {
if (!text) return null;
return (
<div className="callout warn flush" role="alert">
{text}
</div>
);
}
/** The keys whose value changed, with the old values as the base. */
function diff<T extends object>(before: T, after: T): { set: Partial<T>; base: Partial<T> } {
const set: Partial<T> = {};
const base: Partial<T> = {};
for (const k of Object.keys(after) as Array<keyof T>) {
if ((before[k] ?? null) !== (after[k] ?? null)) {
set[k] = after[k];
base[k] = before[k];
}
}
return { set, base };
}
const orNull = (s: string) => (s.trim() === "" ? null : s.trim());
/* ---------- Project: new or edit ---------- */
export function ProjectSheet({ project, onSaved, onCancel }: { project?: Project; onSaved: (p: Project) => void; onCancel: () => void }) {
const guard = useGuard();
const editing = !!project;
const [number, setNumber] = useState(project?.number ?? "");
const [name, setName] = useState(project?.name ?? "");
const [customer, setCustomer] = useState(project?.customer ?? "");
const [po, setPo] = useState(project?.po ?? "");
const [due, setDue] = useState(project?.due ?? "");
const [status, setStatus] = useState<ProjectStatus>(project?.status ?? "open");
const [notes, setNotes] = useState(project?.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const num = number.trim().toUpperCase();
if (!editing && !RX_PROJECT.test(num)) {
setError("Project number: 1 to 20 letters, numbers or dashes, for example L670.");
return;
}
if (!name.trim()) {
setError("Give the project a name.");
return;
}
setBusy(true);
try {
let saved: Project | undefined;
if (project) {
const before: ProjectFields = { name: project.name, customer: project.customer, po: project.po, due: project.due, status: project.status, notes: project.notes };
const after: ProjectFields = { name: orNull(name), customer: orNull(customer), po: orNull(po), due: orNull(due), status, notes: orNull(notes) };
const cas = diff(before, after);
if (Object.keys(cas.set).length === 0) {
onCancel();
return;
}
saved = await guard.run((g) => projectsApi.patch(project.id, { ...cas, ...g }), { sub: project.number });
} else {
saved = await guard.run((g) =>
projectsApi.create({ number: num, name: name.trim(), customer: customer.trim(), po: po.trim(), due: orNull(due), notes: notes.trim(), ...g }),
);
}
if (saved) onSaved(saved);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title={editing ? "Edit project" : "New project"} sub={project?.number} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
{editing ? null : (
<label className="f">
Project number
<input className="in" value={number} maxLength={20} placeholder="e.g. L670" autoCapitalize="characters" onChange={(e) => setNumber(e.target.value)} />
</label>
)}
<label className="f">
Project name
<input className="in" value={name} maxLength={120} placeholder="e.g. BPX separator package" onChange={(e) => setName(e.target.value)} />
</label>
</div>
<div className="frow">
<label className="f">
Customer
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
</label>
<label className="f">
Customer PO
<input className="in" value={po} maxLength={60} onChange={(e) => setPo(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
{editing ? (
<label className="f">
Status
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{PROJECT_STATUS_LABEL[s]}
</option>
))}
</select>
</label>
) : null}
</div>
<label className="f">
Notes
<textarea className="in" rows={3} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
{editing ? null : <div className="small muted">After creating the project, add its units. Packets go into a unit.</div>}
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
{editing ? "Save changes" : "Create project"}
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Add units: one, or several counting up ---------- */
export function AddUnitsSheet({
project,
lots,
onDone,
onCancel,
}: {
project: ProjectRef;
/** Lots already in use, for the next free number. */
lots: string[];
onDone: (units: Unit[]) => void;
onCancel: () => void;
}) {
const guard = useGuard();
const first = nextLot(lots);
const [many, setMany] = useState(false);
const [lot, setLot] = useState(first);
const [count, setCount] = useState("");
const [start, setStart] = useState(first);
const [name, setName] = useState("");
const [due, setDue] = useState("");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const seq = many ? lotSequence(start, Number(count)) : null;
const preview = seq?.map((l) => `${project.number}${l}`) ?? [];
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
let body: { lot?: string; lots?: string[] };
if (many) {
const n = Number(count);
if (!Number.isInteger(n) || n < 1 || n > 200) {
setError("How many: 1 to 200.");
return;
}
if (!seq) {
setError("The starting lot has to be digits, for example 001, and the last one 999 or less.");
return;
}
body = { lots: seq };
} else {
const l = normLot(lot);
if (!RX_LOT.test(l)) {
setError("Lot: 1 to 3 letters or digits, for example 001 or 7A.");
return;
}
body = { lot: l };
}
setBusy(true);
try {
const units = await guard.run((g) => projectsApi.addUnits(project.id, { ...body, name: name.trim(), due: orNull(due), notes: notes.trim(), ...g }), { sub: project.number });
if (units) onDone(units);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="Add units" sub={`Project ${project.number}${project.name ? ` · ${project.name}` : ""}`} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="seg" role="group" aria-label="One or several">
<button type="button" aria-pressed={!many} onClick={() => setMany(false)}>
One unit
</button>
<button type="button" aria-pressed={many} onClick={() => setMany(true)}>
Several units
</button>
</div>
{many ? (
<>
<div className="frow">
<label className="f">
How many
<input className="in" type="number" min={1} max={200} inputMode="numeric" placeholder="e.g. 12" value={count} onChange={(e) => setCount(e.target.value)} />
</label>
<label className="f">
Starting lot
<input className="in mono" value={start} maxLength={3} onChange={(e) => setStart(e.target.value)} />
</label>
</div>
<div className="small muted" aria-live="polite">
{preview.length
? preview.length <= 12
? `Numbers: ${preview.join(", ")}`
: `Numbers: ${preview.slice(0, 6).join(", ")} and ${preview.length - 6} more, up to ${preview[preview.length - 1] ?? ""}`
: "Numbers count up from the starting lot."}
</div>
</>
) : (
<div className="frow">
<label className="f">
Lot
<input className="in mono" value={lot} maxLength={3} autoCapitalize="characters" onChange={(e) => setLot(e.target.value)} />
</label>
<div className="end small muted">
Unit number <b className="mono">{`${project.number}${normLot(lot)}`}</b>
</div>
</div>
)}
<div className="frow">
<label className="f">
{many ? "Description (all of them)" : "Description"}
<input className="in" value={name} maxLength={120} placeholder='e.g. 48" horizontal separator' onChange={(e) => setName(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
</div>
<div className="small muted">Leave the date blank to use the project's date.</div>
<label className="f">
Notes
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Add
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Edit a unit ---------- */
export function UnitSheet({ unit, onSaved, onCancel }: { unit: Unit; onSaved: (u: Unit) => void; onCancel: () => void }) {
const guard = useGuard();
const [name, setName] = useState(unit.name ?? "");
const [due, setDue] = useState(unit.due ?? "");
const [status, setStatus] = useState<ProjectStatus>(unit.status);
const [notes, setNotes] = useState(unit.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const before: UnitFields = { name: unit.name, due: unit.due, status: unit.status, notes: unit.notes ?? null };
const cas = diff(before, { name: orNull(name), due: orNull(due), status, notes: orNull(notes) });
if (Object.keys(cas.set).length === 0) {
onCancel();
return;
}
setBusy(true);
setError(null);
try {
const saved = await guard.run((g) => projectsApi.patchUnit(unit.id, { ...cas, ...g }), { sub: unit.number });
if (saved) onSaved(saved);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="Edit unit" sub={unit.number} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
<label className="f">
Description
<input className="in" value={name} maxLength={120} onChange={(e) => setName(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
<label className="f">
Status
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{PROJECT_STATUS_LABEL[s]}
</option>
))}
</select>
</label>
</div>
<label className="f">
Notes
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save changes
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Renumber a project or a unit ---------- */
export function RenumberSheet({
kind,
current,
prefix,
onSave,
onCancel,
}: {
kind: "project" | "unit";
/** The current project number, or the unit's lot. */
current: string;
/** For a unit: the project number, to show the new unit number. */
prefix?: string;
onSave: (value: string, reason: string) => Promise<void>;
onCancel: () => void;
}) {
const [value, setValue] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const v = kind === "project" ? value.trim().toUpperCase() : normLot(value);
if (kind === "project" ? !RX_PROJECT.test(v) : !RX_LOT.test(v)) {
setError(kind === "project" ? "Project number: 1 to 20 letters, numbers or dashes." : "Lot: 1 to 3 letters or digits.");
return;
}
if (v === current) {
setError("That is the current number.");
return;
}
if (!reason.trim()) {
setError("Give a reason.");
return;
}
setBusy(true);
try {
await onSave(v, reason.trim());
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
const title = kind === "project" ? "Renumber project" : "Renumber unit";
return (
<Sheet title={title} sub={kind === "project" ? current : `${prefix ?? ""}${current}`} onClose={onCancel}>
<div className="small">
{kind === "project"
? "The old number stays as an alias, so prints with it still find this project. Its units keep their lots and move with it."
: "The old number stays as an alias. The unit's packets move with it."}
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{kind === "project" ? "New project number" : "New lot"}
<input className="in mono" value={value} maxLength={kind === "project" ? 20 : 3} autoCapitalize="characters" onChange={(e) => setValue(e.target.value)} />
</label>
<label className="f">
Reason
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Change number
</button>
</div>
</form>
</Sheet>
);
}
/* ---------- New packet: a hand draft ---------- */
interface WoRow {
wo_base: string;
lot: string;
split: string;
sub: string;
qty: string;
target_hrs: string;
}
interface BomRow {
qty: string;
part_id: string;
description: string;
material: string;
length: string;
dwg_ref: string;
}
const EMPTY_WO: WoRow = { wo_base: "", lot: "", split: "", sub: "", qty: "", target_hrs: "" };
const EMPTY_BOM: BomRow = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
const num = (s: string): number | null => {
const t = s.trim();
if (t === "") return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
};
export function NewPacketSheet({
initial,
onCreated,
onCancel,
}: {
initial: UnitPick;
onCreated: (p: PacketRead) => void;
onCancel: () => void;
}) {
const guard = useGuard();
const depts = useShopDepartments();
const [pick, setPick] = useState<UnitPick>(initial);
const [dept, setDept] = useState("");
const [op, setOp] = useState("");
const [title, setTitle] = useState("");
const [customer, setCustomer] = useState(initial.project?.customer ?? "");
const [drawing, setDrawing] = useState("");
const [rev, setRev] = useState("");
const [wps, setWps] = useState("");
const [wos, setWos] = useState<WoRow[]>([{ ...EMPTY_WO }]);
const [bom, setBom] = useState<BomRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const deptValue = dept || depts[0]?.code || "";
const onPick = (v: UnitPick) => {
setPick(v);
if (v.project?.customer && !customer) setCustomer(v.project.customer);
};
const setWo = (i: number, k: keyof WoRow, v: string) => setWos((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const setBl = (i: number, k: keyof BomRow, v: string) => setBom((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!pick.unit) {
setError("Pick the project and the unit first.");
return;
}
if (!deptValue) {
setError("Pick the first department.");
return;
}
if (!op.trim()) {
setError("Give the operation, for example WELD-PART.");
return;
}
const work_orders = wos
.filter((w) => w.wo_base.trim())
.map((w) => ({
wo_base: w.wo_base.trim().toUpperCase(),
lot: w.lot.trim().toUpperCase(),
split: w.split.trim().toUpperCase(),
sub: w.sub.trim().toUpperCase(),
qty: num(w.qty),
target_hrs: num(w.target_hrs),
}));
const lines = bom
.filter((b) => b.part_id.trim() || b.description.trim())
.map((b, i) => ({
item: i + 1,
qty: num(b.qty),
part_id: b.part_id.trim() || null,
description: b.description.trim() || null,
material: b.material.trim() || null,
length: b.length.trim() || null,
dwg_ref: b.dwg_ref.trim() || null,
}));
const body: PacketCreate = {
unit_id: pick.unit.id,
dept_code: deptValue,
op: op.trim().toUpperCase(),
title: title.trim(),
customer: customer.trim(),
drawing: drawing.trim(),
rev: rev.trim(),
wps: wps.trim(),
work_orders,
bom: lines,
};
setBusy(true);
try {
const created = await guard.run((g) => packetsApi.create({ ...body, ...g } as PacketCreate));
if (created) onCreated(created);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="New packet" sub="Made by hand" onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<PickUnit value={pick} onChange={onPick} requireUnit />
<div className="frow">
<label className="f">
First department
<select className="in" value={deptValue} onChange={(e) => setDept(e.target.value)}>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<label className="f">
Operation
<input className="in" value={op} maxLength={40} placeholder="e.g. WELD-PART" autoCapitalize="characters" onChange={(e) => setOp(e.target.value)} />
</label>
<label className="f">
Title
<input className="in" value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Customer
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
</label>
</div>
<div className="frow">
<label className="f">
Drawing no.
<input className="in" value={drawing} maxLength={60} onChange={(e) => setDrawing(e.target.value)} />
</label>
<label className="f">
Rev
<input className="in" value={rev} maxLength={10} onChange={(e) => setRev(e.target.value)} />
</label>
<label className="f">
WPS
<input className="in" value={wps} maxLength={40} onChange={(e) => setWps(e.target.value)} />
</label>
</div>
<h3>
Work orders <span className="muted mono small">({wos.filter((w) => w.wo_base.trim()).length})</span>
</h3>
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>WO</th>
<th>Lot</th>
<th>Split</th>
<th>Sub</th>
<th className="num">Qty</th>
<th className="num">Target hrs</th>
<th />
</tr>
</thead>
<tbody>
{wos.map((w, i) => (
<tr key={i}>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1}`} value={w.wo_base} maxLength={30} onChange={(e) => setWo(i, "wo_base", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} lot`} value={w.lot} maxLength={10} onChange={(e) => setWo(i, "lot", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} split`} value={w.split} maxLength={10} onChange={(e) => setWo(i, "split", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} sub`} value={w.sub} maxLength={10} onChange={(e) => setWo(i, "sub", e.target.value)} />
</td>
<td>
<input className="in sm num" aria-label={`Work order ${i + 1} quantity`} inputMode="decimal" value={w.qty} onChange={(e) => setWo(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm num" aria-label={`Work order ${i + 1} target hours`} inputMode="decimal" value={w.target_hrs} onChange={(e) => setWo(i, "target_hrs", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove work order ${i + 1}`} onClick={() => setWos((rows) => rows.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<button className="btn sm" type="button" onClick={() => setWos((rows) => [...rows, { ...EMPTY_WO }])}>
Add work order
</button>
</div>
<h3>
Bill of materials <span className="muted mono small">(optional, {bom.length} lines)</span>
</h3>
{bom.length ? (
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Dwg ref</th>
<th />
</tr>
</thead>
<tbody>
{bom.map((b, i) => (
<tr key={i}>
<td className="mono">{i + 1}</td>
<td>
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={b.qty} onChange={(e) => setBl(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} value={b.part_id} maxLength={60} onChange={(e) => setBl(i, "part_id", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} description`} value={b.description} maxLength={240} onChange={(e) => setBl(i, "description", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} material`} value={b.material} maxLength={120} onChange={(e) => setBl(i, "material", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} length`} value={b.length} maxLength={40} onChange={(e) => setBl(i, "length", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} value={b.dwg_ref} maxLength={60} onChange={(e) => setBl(i, "dwg_ref", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setBom((rows) => rows.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<div>
<button className="btn sm" type="button" onClick={() => setBom((rows) => [...rows, { ...EMPTY_BOM }])}>
Add BOM line
</button>
</div>
<div className="small muted">The packet is saved as a draft. Attach the drawing and say whether it needs a heat map before you send it on.</div>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Discard
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save packet as draft
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
+130
View File
@@ -0,0 +1,130 @@
// Synthetic packet answers for the screen tests. No real job data.
import type { PacketCard, PacketRead, Project, Unit } from "../api/types";
export function makeCard(over: Partial<PacketCard> = {}): PacketCard {
return {
id: 1,
version: 1,
unit: { id: 11, number: "L999001" },
project: { id: 7, number: "L999", name: "CI project" },
dept: { code: "03", name: "Weld Parts" },
op: "WELD-PART",
title: "CI spool",
customer: "Test Co",
drawing: "CI-DWG-1",
rev: "0",
status: "draft",
status_at: "2026-10-16T08:00:00Z",
station: null,
welder: null,
routed_to: null,
vclosed: null,
source_kind: "hand",
flag: null,
open_cos: [],
open_shorts: 0,
wh_stage: null,
due_effective: null,
updated_at: "2026-10-16T08:00:00Z",
holder: "Clerical",
can: [],
blocked: {},
...over,
};
}
export function makePacket(over: Partial<PacketRead> = {}): PacketRead {
return {
id: 1,
version: 3,
unit: {
id: 11,
number: "L999001",
lot: "001",
name: null,
project: { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: null, status: "open" },
due_effective: null,
},
dept: { code: "03", name: "Weld Parts", weld: true },
op: "WELD-PART",
title: "CI spool",
customer: "Test Co",
drawing: "CI-DWG-1",
rev: "0",
wps: null,
status: "draft",
status_at: "2026-10-16T08:00:00Z",
station: null,
welder: null,
for_sup: null,
heat_map: null,
wh_skipped: false,
wh_sent_at: null,
routed_to: null,
return_note: null,
vclosed: null,
databook_at: null,
source_kind: "hand",
source: { kind: "hand" },
drawing_state: "none",
flags: ["hand"],
visual: {},
removed: null,
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2, row_version: 1 }],
pages: [],
bom: [],
welds: [],
heat_tags: [],
heat_table: [],
stage: null,
stages: [],
wh: null,
qc: null,
qc_history: [],
change_orders: [],
open_cos: [],
open_shorts: 0,
docs: [],
seal: null,
can: ["edit_header", "edit_drawing_ref", "edit_document_set", "move_unit", "remove"],
blocked: { send_to_warehouse: ["Say whether this packet needs a heat map."] },
holder: "Clerical",
checklist_items: [],
qc_items: [],
flag: null,
...over,
};
}
export function makeProject(over: Partial<Project> = {}): Project {
return {
id: 7,
number: "L999",
name: "CI project",
customer: "Test Co",
po: null,
due: null,
status: "open",
notes: null,
units: 1,
packets: { draft: 1, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 1 },
version: 1,
...over,
};
}
export function makeUnit(over: Partial<Unit> = {}): Unit {
return {
id: 11,
project_id: 7,
number: "L999001",
lot: "001",
name: null,
due: null,
due_effective: null,
status: "open",
notes: null,
version: 1,
...over,
};
}
+87
View File
@@ -263,3 +263,90 @@ tr.rowlink.on td { background: var(--sel); }
tr.rowlink:focus-visible { outline: 2px solid var(--cyan); outline-offset: -2px; }
td .pill + .pill { margin-left: 4px; }
.lock .auth-card { max-height: calc(100dvh - 32px); overflow: auto; }
/* ===== New in the rebuild (phase 2) ===== */
/* Clerical home, projects and units, the packet page. Ported from the old .dhead, .facts, .projgrid, .pcard,
.stg, .pbar, .unitbar, .projhits, .crumbs, .due, .fltbar, .qrs, .balloon blocks, plus a few new helpers. */
.sheet.narrow { width: min(560px, 100%); }
.sec-h.flush { margin: 0; }
.chip.sm { min-height: 34px; padding: 4px 12px; font-size: 13.5px; }
.grow0 { min-width: 0; flex: 1 1 420px; }
.pre { white-space: pre-wrap; margin-top: 6px; }
.dhead { display: flex; gap: 18px; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; padding-block: 6px 14px; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
.dhead .facts { display: flex; gap: 6px 22px; flex-wrap: wrap; margin-top: 8px; font-size: 13.5px; }
.dhead .facts span { color: var(--muted); }
.dhead .facts b { color: var(--near); font-weight: 600; font-family: var(--f-mono); font-size: 13px; }
.dhead .pacts { align-items: center; }
.dtitle { color: var(--near); font-weight: 500; font-size: 17px; margin-top: 2px; }
.crumbs2 { margin: 4px 0 6px; }
.crumbs2 b { color: var(--near); }
.crumbs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; font-size: 14px; }
.crumbs span { color: var(--muted); }
.crumbs b { color: var(--near); }
.due { font-weight: 600; }
.due.over { color: var(--pink); font-weight: 700; }
.due.soon { color: var(--amber); font-weight: 700; }
.due.muted { opacity: .7; }
.flagline { margin: 4px 0 8px; }
.card .dept { color: var(--cyan); font-weight: 600; }
.attn { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.fltbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; margin: 8px 0 16px; display: flex; flex-direction: column; gap: 10px; }
.fltrow { display: flex; gap: 8px 12px; flex-wrap: wrap; align-items: flex-end; }
.fltrow .f { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.fltrow .f > span { font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.fltrow .fltq { flex: 3 1 320px; }
.fltrow .fltp { flex: 1.4 1 260px; }
.fltbtns { display: flex; gap: 8px; align-items: flex-end; flex: none; }
.fltrow2 { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.pick { display: flex; flex-direction: column; gap: 6px; }
.pickp { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; color: var(--near); }
.pickp .small { font-weight: 700; letter-spacing: .06em; text-transform: uppercase; font-size: 11.5px; }
.projhits { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow: auto; }
.projhit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; color: var(--near); cursor: pointer; font: inherit; }
.projhit:hover, .projhit:focus-visible { border-color: var(--cyan); }
.projhit .pill { margin-left: auto; }
.projgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.pcard .job { font-size: 20px; }
.stg { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; margin-top: 6px; }
.stg span { color: var(--near); }
.stg span.z { color: var(--muted); opacity: .6; }
.pbar { height: 8px; border-radius: 5px; background: var(--line2); overflow: hidden; display: flex; margin: 6px 0 2px; }
.pbar i { display: block; height: 100%; }
.pbar i.f { background: var(--cyan); }
.pbar i.c { background: var(--green); }
.unitbar { display: flex; gap: 10px 14px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.unitlist .pbar { margin: 0; }
th.wide { min-width: 160px; }
table.edit td { padding: 6px; }
input.in.sm { min-height: 36px; padding: 6px 8px; font-size: 14px; }
input.in.num { text-align: right; font-family: var(--f-mono); }
.sheet-acts.start { justify-content: flex-start; align-items: center; }
.sigmean { border-left: 3px solid var(--amber); background: var(--panel); border-radius: 0 10px 10px 0; padding: 8px 12px; }
.sigmean .tt { color: var(--near); font-weight: 600; font-size: 16px; margin-top: 2px; }
.actbox { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.hmq { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.hmq b { color: var(--near); }
.acts { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-start; }
.act { display: flex; flex-direction: column; gap: 4px; max-width: 280px; }
.act .why { color: var(--amber); }
label.btn input.sr { pointer-events: none; }
.balloon { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--cyan); color: var(--cyan); font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; }
.qrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.qr { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; gap: 16px; align-items: center; }
.qr .code { background: var(--white); padding: 10px; border-radius: 8px; flex: none; width: 148px; height: 148px; display: grid; place-items: center; color: var(--bg); }
.qr .code canvas { width: 128px !important; height: 128px !important; }
.qr .wo { font-family: var(--f-disp); font-weight: 700; font-size: 26px; color: var(--white); letter-spacing: .03em; }
.pdfv { display: flex; flex-direction: column; gap: 10px; }
.pdfv-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.pdfv-bar .seg button { min-width: 44px; font-weight: 700; }
.pdfv-body { display: grid; grid-template-columns: 150px 1fr; gap: 12px; align-items: start; }
.pdfv-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pdfv-pages button { width: 100%; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; color: var(--near); cursor: pointer; font: inherit; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.pdfv-pages button[aria-current="page"] { border-color: var(--cyan); background: var(--sel); }
.pdfv-frame { overflow: auto; background: var(--col); border: 1px solid var(--line); border-radius: 10px; padding: 1px; min-height: 240px; max-height: calc(100dvh - 160px); }
.pdfv-canvas { display: block; background: var(--white); }
@media (max-width: 700px) {
.pdfv-body { grid-template-columns: 1fr; }
.pdfv-pages { flex-direction: row; flex-wrap: wrap; }
.pdfv-pages button { width: auto; }
}