Files
PacketStation/frontend/src/packet/ShopActions.tsx
T

743 lines
21 KiB
TypeScript
Raw Normal View History

import { useState, type FormEvent, type ReactNode } from "react";
import type { PacketRead } from "../api/types";
import { packetsApi } from "../api/packets";
import { useAuth } from "../auth/useAuth";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { fmtClock, isRunning, liveElapsed, useTicker } from "../lib/clock";
import { targetHours } from "../lib/packets";
import { useShopDepartments } from "../lib/useDepartments";
import {
makeStep,
shopLevel,
stallMissing,
usePeople,
type ShopTab,
type StepFn,
} from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_SUBMIT = "Submit work to the supervisor";
export const SIGN_SEND_TO_QC = "Send to Q.C.";
export const SIGN_ROUTE = "Route to another department";
/** The server's precondition texts the stall shows as links instead. */
const STALL_PRECONDITIONS = new Set([
"Finish the checklist first.",
"Record at least one weld first.",
"Enter the filler metal first.",
]);
function stationsOf(
depts: ReturnType<typeof useAuth>["departments"],
code: string,
): Array<{ id: number; name: string }> {
const d = depts.find((x) => x.code === code);
const out: Array<{ id: number; name: string }> = [];
for (const s of d?.stations ?? []) {
if (typeof s === "object" && s.id !== undefined && s.active !== false)
out.push({ id: s.id, name: s.name });
}
return out;
}
/** The clock line: HH:MM:SS, running or paused, the target, and the VISUAL ticket hours beside it. */
export function ClockLine({ p }: { p: PacketRead }) {
const clock = p.stage?.clock ?? null;
const running = isRunning(clock);
const key = `${p.id}:${p.version}:${clock?.elapsed_ms ?? 0}:${clock?.running_since ?? ""}`;
const [fetched, setFetched] = useState(() => ({ key, at: Date.now() }));
if (fetched.key !== key) setFetched({ key, at: Date.now() });
const now = useTicker(running);
const ms = liveElapsed(clock, fetched.key === key ? fetched.at : now, now);
const target = p.target_hrs ?? targetHours(p);
const state = running
? "running"
: p.status === "in_progress"
? "paused"
: "";
return (
<div className="clockrow">
<div className="clock" data-testid="clock" aria-live="off">
<span
className={`cdot${running ? " run" : ""}`}
title={running ? "Running" : "Stopped"}
aria-hidden="true"
/>
{fmtClock(ms)}
<small>
{state ? ` ${state}` : ""} · target {Number(target || 0).toFixed(1)} h
</small>
</div>
{p.labor ? (
<div
className="labor small"
data-testid="labor"
title={
p.labor.observed_at
? `From VISUAL, read ${p.labor.observed_at}`
: "From VISUAL"
}
>
VISUAL tickets <b>{Number(p.labor.hours || 0).toFixed(1)} h</b>
</div>
) : null}
</div>
);
}
/** The supervisor's assign sheet: station, welder (with search) and stamp. */
export function AssignSheet({
p,
sub,
onCancel,
onSend,
}: {
p: PacketRead;
sub: string;
onCancel: () => void;
onSend: (stationId: number, welderId: string, stamp: string) => void;
}) {
const { departments } = useAuth();
const stations = stationsOf(departments, p.dept.code);
const { people, loaded, error: peopleError } = usePeople(p.dept.code);
const [station, setStation] = useState<number | null>(
p.station?.id ?? stations[0]?.id ?? null,
);
const [q, setQ] = useState("");
const [welder, setWelder] = useState<string>(p.welder?.id ?? "");
const [stamp, setStamp] = useState<string>(p.welder?.stamp ?? "");
const [error, setError] = useState<string | null>(null);
const st = stations.find((s) => s.id === station) ?? stations[0];
const allowed = people.filter(
(u) =>
u.level !== "worker" ||
!u.station_limits.length ||
(st ? u.station_limits.includes(st.id) : true),
);
const needle = q.trim().toLowerCase();
const shown = allowed.filter(
(u) =>
!needle ||
u.display_name.toLowerCase().includes(needle) ||
(u.stamp ?? "").toLowerCase().includes(needle) ||
u.id === welder,
);
const pick = (id: string) => {
setWelder(id);
const u = people.find((x) => x.id === id);
setStamp(u?.stamp ?? "");
setError(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (!st) {
setError("This department has no stations. Ask an admin to add one.");
return;
}
if (!welder || !allowed.some((u) => u.id === welder)) {
setError("Pick a welder in this department.");
return;
}
onSend(st.id, welder, stamp.trim());
};
return (
<Sheet title="Send to station" sub={sub} onClose={onCancel}>
<div className="small">
The packet appears on that station's screen right away.
</div>
<form className="stack" onSubmit={submit} noValidate aria-label="Assign">
<label className="f">
Station
<select
aria-label="Station"
className="in"
value={st?.id ?? ""}
onChange={(e) => setStation(Number(e.target.value))}
>
{stations.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="f">
Find a welder
<input
aria-label="Find a welder"
className="in"
type="search"
value={q}
placeholder="Name or stamp"
onChange={(e) => setQ(e.target.value)}
/>
</label>
<label className="f">
Welder
<select
aria-label="Welder"
className="in"
value={welder}
onChange={(e) => pick(e.target.value)}
>
<option value="">{loaded ? "Choose" : "Loading"}</option>
{shown.map((u) => (
<option key={u.id} value={u.id}>
{u.display_name}
{u.stamp ? ` [${u.stamp}]` : ""}
</option>
))}
</select>
</label>
{peopleError ? (
<div className="small pinktxt">{peopleError}</div>
) : null}
{loaded && !allowed.length ? (
<div className="small muted">
No one in this department may work at this station.
</div>
) : null}
<label className="f">
Stamp
<input
aria-label="Stamp"
className="in w110"
value={stamp}
maxLength={20}
placeholder="ID"
onChange={(e) => setStamp(e.target.value)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit">
Send to station
</button>
</div>
</form>
</Sheet>
);
}
/** One step button: hidden when the user can neither do it nor is blocked from it. */
function Step({
p,
action,
busy,
onClick,
className = "btn",
disabled = false,
why,
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
disabled?: boolean;
why?: ReactNode;
children: ReactNode;
}) {
const can = p.can.includes(action);
const blocked = p.blocked[action];
if (!can && !blocked) return null;
const reason = blocked?.join(" ") ?? "";
return (
<div className="act">
<button
className={className}
type="button"
disabled={!can || busy || disabled}
title={!can ? reason : undefined}
onClick={onClick}
>
{children}
</button>
{why ??
(!can && reason ? <div className="why small">{reason}</div> : null)}
</div>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/** The supervisor's review: send to Q.C., return to the station, or skip Q.C. and route on. */
function ReviewBox({
p,
busy,
step,
askReason,
sub,
}: {
p: PacketRead;
busy: boolean;
step: StepFn;
askReason: AskReason;
sub: string;
}) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState(
String((p.qc as { location?: string } | null)?.location ?? ""),
);
const [note, setNote] = useState("");
const [ret, setRet] = useState("");
const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route)
? route
: (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected";
const ready = book && spool && !!loc.trim();
const returnIt = async () => {
let reason = ret.trim();
if (reason.length < 8) {
const r = await askReason({
title: "Return to station",
sub,
label: "Return note",
note: "The station sees this note. Say what needs fixing.",
min: 8,
submitLabel: "Return to station",
});
if (r === null) return;
reason = r;
}
if (
await step(
"return_to_station",
{ reason },
{ ok: "Returned to the station." },
)
)
setRet("");
};
return (
<section className="panel actbox" aria-label="Supervisor review">
<div>
{rejected ? (
<>
<b>Rejected by Q.C.</b> Return it to the station for repair, or fix
it and send it back to Q.C.
</>
) : (
<>
<b>Submitted by {p.station?.name ?? "the station"}.</b> Check the
weld map, checklist and heats. When the data book package and the
spool are ready, send it to Q.C.
</>
)}
</div>
{has(p, "send_to_qc") ? (
<div className="revrow">
<label className="ckl">
<input
type="checkbox"
checked={book}
onChange={(e) => setBook(e.target.checked)}
/>
Data book package ready
</label>
<label className="ckl">
<input
type="checkbox"
checked={spool}
onChange={(e) => setSpool(e.target.checked)}
/>
Spool ready for inspection
</label>
<label className="f">
Spool location
<input
aria-label="Spool location"
className="in"
value={loc}
maxLength={80}
placeholder="e.g. Bay 2, rack B"
onChange={(e) => setLoc(e.target.value)}
/>
</label>
<label className="f grow2">
Note to Q.C.
<input
aria-label="Note to Q.C."
className="in"
value={note}
maxLength={500}
placeholder="Optional"
onChange={(e) => setNote(e.target.value)}
/>
</label>
<Step
p={p}
action="send_to_qc"
busy={busy}
className="btn pri"
disabled={!ready}
onClick={() =>
void step(
"send_to_qc",
{
book: true,
spool: true,
location: loc.trim(),
...(note.trim() ? { note: note.trim() } : {}),
},
{ sign: SIGN_SEND_TO_QC, ok: "Sent to Q.C." },
)
}
>
Send to Q.C.
</Step>
</div>
) : null}
{has(p, "return_to_station") ? (
<div className="revrow sep">
<label className="f grow2">
Return note
<input
aria-label="Return note"
className="in"
value={ret}
maxLength={500}
placeholder="What needs fixing"
onChange={(e) => setRet(e.target.value)}
/>
</label>
<Step
p={p}
action="return_to_station"
busy={busy}
className="btn pink"
onClick={() => void returnIt()}
>
Return to station
</Step>
</div>
) : null}
{has(p, "route_from_review") ? (
<div className="revrow sep">
<label className="f grow2">
Skip Q.C. - send to department
<select
aria-label="Skip Q.C. - send to department"
className="in"
value={routeTo}
onChange={(e) => setRoute(e.target.value)}
>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<Step
p={p}
action="route_from_review"
busy={busy}
disabled={!routeTo}
onClick={() =>
void step(
"route_from_review",
{ dept: routeTo },
{ sign: SIGN_ROUTE, ok: "Sent to the department." },
)
}
>
Send to department
</Step>
</div>
) : null}
</section>
);
}
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
/**
* The shop action bars on the packet page, per status and what `can` allows: assign, pull back, the review, and
* the stall's start, pause, resume and submit. The server decides; this only shapes the buttons.
*/
export function ShopBar({
p,
busy,
mutate,
askReason,
sub,
goTab,
}: {
p: PacketRead;
busy: boolean;
mutate: Mutate;
askReason: AskReason;
sub: string;
goTab: (t: ShopTab) => void;
}) {
const { me } = useAuth();
const roles = me?.roles ?? [];
const [assign, setAssign] = useState(false);
const step = makeStep(p, mutate);
const st = p.status;
const level = shopLevel(roles, p.dept.code);
const deptLabel = `${p.dept.code} ${p.dept.name}`;
const reasonStep = async (
action: string,
title: string,
note: string,
ok: string,
) => {
const reason = await askReason({
title,
sub,
note,
min: 8,
submitLabel: title,
});
if (reason === null) return;
await step(action, { reason }, { ok });
};
if (st === "ready") {
if (
!has(p, "assign") &&
!has(p, "send_back_to_warehouse") &&
!has(p, "return_to_clerical")
)
return null;
return (
<section className="panel actbox" aria-label="Assign">
<div>
{p.return_note ? (
<>
<b>Returned.</b> Assign it again when it is ready.
</>
) : (
<>
<b>Assign this packet</b> to a station and welder. It appears on
that station's screen right away.
</>
)}
</div>
<div className="acts">
<Step
p={p}
action="assign"
busy={busy}
className="btn pri"
onClick={() => setAssign(true)}
>
Send to station
</Step>
<Step
p={p}
action="send_back_to_warehouse"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"send_back_to_warehouse",
"Send back to Warehouse",
"The warehouse gets it back for the parts. Say what is missing.",
"Sent back to the warehouse.",
)
}
>
Send back to Warehouse
</Step>
<Step
p={p}
action="return_to_clerical"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"return_to_clerical",
"Send back to Clerical",
"The packet goes back to Clerical as a draft. Say what needs fixing.",
"Sent back to Clerical.",
)
}
>
Send back to Clerical
</Step>
</div>
{assign ? (
<AssignSheet
p={p}
sub={sub}
onCancel={() => setAssign(false)}
onSend={(station_id, welder_id, stamp) => {
setAssign(false);
void step(
"assign",
{ station_id, welder_id, ...(stamp ? { stamp } : {}) },
{ ok: "Sent to the station." },
);
}}
/>
) : null}
</section>
);
}
if (st === "assigned" || st === "in_progress") {
const sup = has(p, "unassign") || has(p, "pull_back");
const stall = has(p, "start") || has(p, "submit");
if (!sup && !stall) return null;
const running = isRunning(p.stage?.clock);
const miss = st === "in_progress" ? stallMissing(p) : [];
const other = (p.blocked.submit ?? []).filter(
(m) => !STALL_PRECONDITIONS.has(m),
);
return (
<section className="panel actbox" aria-label="Station">
<div>
{stall && st === "assigned" ? (
<>
<b>Ready to start.</b> Clock on to the work order in VISUAL (Work
orders tab) and start the timer.
</>
) : stall && st === "in_progress" ? (
miss.length ? (
<span data-testid="submit-missing">
<b>Before submitting:</b>{" "}
{miss.map((m, i) => (
<span key={m.t}>
{i ? " · " : ""}
<button
className="lnk"
type="button"
onClick={() => goTab(m.tab)}
>
{m.t}
</button>
</span>
))}
</span>
) : (
<>
<b>Everything is filled in.</b> Submit when the parts are done.
</>
)
) : (
<>
In <b>{p.station?.name ?? "a station"}</b> with{" "}
{p.welder?.display_name ?? "-"}. You can edit the heat map, weld
map, materials and checklist while it is out.
</>
)}
</div>
{other.length && stall && st === "in_progress" ? (
<div className="why small">{other.join(" ")}</div>
) : null}
<div className="acts center">
<ClockLine p={p} />
{stall && st === "assigned" ? (
<Step
p={p}
action="start"
busy={busy}
className="btn pri"
onClick={() => void step("start", {}, { ok: "Work started." })}
>
Start work
</Step>
) : null}
{stall && st === "in_progress" && p.can.includes("clock") ? (
<div className="act">
<button
className="btn"
type="button"
disabled={busy}
onClick={() =>
void mutate((g) =>
packetsApi.clock(p.id, running ? "pause" : "resume", g),
)
}
>
{running ? "Pause" : "Resume"}
</button>
</div>
) : null}
{stall && st === "in_progress" ? (
<Step
p={p}
action="submit"
busy={busy}
className="btn pri"
why={<span />}
onClick={() =>
void step(
"submit",
{},
{ sign: SIGN_SUBMIT, ok: "Submitted to the supervisor." },
)
}
>
Submit to supervisor
</Step>
) : null}
<Step
p={p}
action={st === "assigned" ? "unassign" : "pull_back"}
busy={busy}
onClick={() =>
void step(
st === "assigned" ? "unassign" : "pull_back",
{},
{ ok: "Pulled back to the queue." },
)
}
>
Pull back to queue
</Step>
</div>
</section>
);
}
if (st === "review") {
if (
has(p, "send_to_qc") ||
has(p, "return_to_station") ||
has(p, "route_from_review")
) {
return (
<ReviewBox
p={p}
busy={busy}
step={step}
askReason={askReason}
sub={sub}
/>
);
}
if (level && level !== "supervisor") {
return (
<div className="callout" role="note" data-testid="review-readonly">
<b>Submitted by {p.station?.name ?? "the station"}.</b> A supervisor
for {deptLabel} reviews it and sends it on.
</div>
);
}
}
return null;
}