The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
743 lines
21 KiB
TypeScript
743 lines
21 KiB
TypeScript
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;
|
|
}
|