Phase 8: Project Management, find bar, presence and motion
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s

The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+28 -7
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useMemo, useRef, type ReactNode } from "react";
import type { PacketCard } from "../api/types";
import { sigMap, useFlip } from "../lib/useFlip";
import { Card } from "./Card";
export interface ColumnDef {
@@ -10,8 +11,17 @@ export interface ColumnDef {
empty: ReactNode;
}
/** A card in a column, wrapped so motion can find and move it by data-card-id. */
export function CardSlot({ id, children }: { id: number; children: ReactNode }) {
return (
<div className="cardw" data-card-id={id}>
{children}
</div>
);
}
/** The old column(): a titled column of cards with a count, or the empty note. */
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
export function Column({ title, cards, empty, render }: Omit<ColumnDef, "key"> & { render?: (p: PacketCard) => ReactNode }) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
@@ -19,17 +29,28 @@ export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
{cards.length ? (
cards.map((p) => (
<CardSlot key={p.id} id={p.id}>
{render ? render(p) : <Card p={p} />}
</CardSlot>
))
) : (
<div className="empty">{empty}</div>
)}
</section>
);
}
/** A row of columns. */
export function Board({ columns }: { columns: ColumnDef[] }) {
/** A row of columns. Cards glide to their new column, new ones fade in, and ones changed elsewhere glow. */
export function Board({ columns, render }: { columns: ColumnDef[]; render?: (p: PacketCard, col: string) => ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const sigs = useMemo(() => sigMap(columns.flatMap((c) => c.cards)), [columns]);
useFlip(ref, "[data-card-id]", [columns], sigs);
return (
<div className="board">
<div className="board" ref={ref}>
{columns.map((c) => (
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} {...(render ? { render: (p: PacketCard) => render(p, c.key) } : {})} />
))}
</div>
);
+157
View File
@@ -0,0 +1,157 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { Facets } from "../api/types";
import type { Screen } from "../auth/roles";
import type { Department } from "../auth/types";
import { FLT_DEFAULT, type Filters } from "../lib/filters";
import { mockFetch } from "../test/fetchMock";
import { FindBar } from "./FindBar";
import type { UnitPick } from "./PickUnit";
const DEPTS: Department[] = [
{ code: "CL", slug: "Clerical", name: "Clerical", kind: "support", stations: [] },
{ code: "03", slug: "WeldParts", name: "Weld Parts", kind: "shop", stations: [] },
];
const FACETS: Facets = {
stations: [
{ id: 1, name: "Stall 1", dept_code: "03" },
{ id: 2, name: "Stall 1", dept_code: "14" },
],
workers: [{ id: "u-andy", display_name: "Andy Welder" }],
};
/** The find bar with its own state, as a screen holds it. */
function Harness({ screen: scr, initial = {}, facets = FACETS, onView }: { screen: Screen; initial?: Partial<Filters>; facets?: Facets | null; onView?: boolean }) {
const [f, setF] = useState<Filters>({ ...FLT_DEFAULT, ...initial });
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [view, setView] = useState<"board" | "list">("board");
return (
<>
<FindBar
f={f}
onChange={(p) => setF((x) => ({ ...x, ...p }))}
onClear={() => setF({ ...FLT_DEFAULT })}
pick={pick}
onPick={setPick}
view={view}
{...(onView === false ? {} : { onView: setView })}
count={3}
total={10}
facets={facets}
depts={DEPTS}
screen={scr}
/>
<output data-testid="state">{JSON.stringify(f)}</output>
<output data-testid="view">{view}</output>
</>
);
}
const state = () => JSON.parse(screen.getByTestId("state").textContent ?? "{}") as Filters;
describe("FindBar", () => {
it("Esc clears the search and keeps the focus", () => {
mockFetch({ "/api/projects": { body: [] } });
render(<Harness screen="clerical" />);
const box = screen.getByLabelText("Find a job");
expect(box).toHaveAttribute("type", "search");
expect(box).toHaveAttribute("placeholder", "Job, lot, drawing, WO, heat no., welder, customer, PO, ECN…");
box.focus();
fireEvent.change(box, { target: { value: "4471" } });
expect(box).toHaveValue("4471");
const outer = vi.fn();
document.addEventListener("keydown", outer);
fireEvent.keyDown(box, { key: "Escape" });
document.removeEventListener("keydown", outer);
expect(box).toHaveValue("");
expect(box).toHaveFocus();
expect(outer).not.toHaveBeenCalled();
});
it("the chips toggle quick flags, the count shows, and Clear filters counts what is active", () => {
mockFetch({ "/api/projects": { body: [] } });
render(<Harness screen="warehouse" />);
const chips = screen.getByRole("group", { name: "Quick filters" });
expect(within(chips).getAllByRole("button").map((b) => b.textContent)).toEqual([
"Change order open",
"Shorts open",
"Project overdue",
"Due within 7 days",
"Mine",
]);
expect(screen.getByTestId("flt-count")).toHaveTextContent("Showing 3 of 10 packets");
expect(screen.queryByRole("button", { name: /^Clear filters/ })).not.toBeInTheDocument();
fireEvent.click(within(chips).getByRole("button", { name: "Shorts open" }));
fireEvent.click(within(chips).getByRole("button", { name: "Mine" }));
expect(within(chips).getByRole("button", { name: "Shorts open" })).toHaveAttribute("aria-pressed", "true");
expect(state().flags).toEqual(["short", "mine"]);
fireEvent.click(screen.getByRole("button", { name: "Clear filters (2)" }));
expect(state().flags).toEqual([]);
});
it("Filters opens the advanced row; closing it resets the selects", () => {
mockFetch({ "/api/projects": { body: [] } });
render(<Harness screen="supervisor" />);
const btn = screen.getByRole("button", { name: /^Filters/ });
expect(btn).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByLabelText("Status")).not.toBeInTheDocument();
fireEvent.click(btn);
expect(btn).toHaveAttribute("aria-expanded", "true");
const dept = screen.getByLabelText("Department");
expect(within(dept).getAllByRole("option").map((o) => o.textContent)).toEqual(["All departments", "03 Weld Parts"]);
expect(within(screen.getByLabelText("Status")).getAllByRole("option")[0]).toHaveTextContent("Any status");
// Two stations share a name: the department tells them apart.
expect(within(screen.getByLabelText("Station")).getAllByRole("option").map((o) => o.textContent)).toEqual([
"Any station",
"Stall 1 (03)",
"Stall 1 (14)",
]);
expect(within(screen.getByLabelText("Worker")).getAllByRole("option").map((o) => o.textContent)).toEqual(["Anyone", "Andy Welder"]);
expect(screen.queryByLabelText("Flag")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Status"), { target: { value: "station" } });
fireEvent.change(screen.getByLabelText("Station"), { target: { value: "2" } });
expect(btn).toHaveTextContent("Filters 2");
expect(screen.getByRole("button", { name: "Filters 2" })).toBe(btn);
expect(state()).toMatchObject({ status: "station", stall: "2" });
fireEvent.click(btn);
expect(state()).toMatchObject({ status: "all", stall: "all", more: false });
expect(screen.queryByLabelText("Status")).not.toBeInTheDocument();
});
it("the advanced row stays open while a select is set", () => {
mockFetch({ "/api/projects": { body: [] } });
render(<Harness screen="clerical" initial={{ flag: "rev" }} />);
expect(screen.getByRole("button", { name: /^Filters/ })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByLabelText("Flag")).toHaveValue("rev");
});
it("hides the parts each screen does not use", () => {
mockFetch({ "/api/projects": { body: [] } });
const { unmount } = render(<Harness screen="stall" initial={{ more: true }} />);
expect(screen.queryByLabelText("Project")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Department")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Station")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Worker")).not.toBeInTheDocument();
expect(screen.getByLabelText("Status")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "List" })).toBeInTheDocument();
unmount();
render(<Harness screen="pm" initial={{ more: true }} facets={null} onView={false} />);
expect(screen.getByLabelText("Project")).toBeInTheDocument();
expect(screen.getByLabelText("Flag")).toBeInTheDocument();
expect(screen.queryByLabelText("Station")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Worker")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "List" })).not.toBeInTheDocument();
});
it("Board | List switches the view", () => {
mockFetch({ "/api/projects": { body: [] } });
render(<Harness screen="qc" />);
expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "List" }));
expect(screen.getByTestId("view")).toHaveTextContent("list");
});
});
+158 -45
View File
@@ -1,63 +1,153 @@
import { FLAG_FILTERS } from "../lib/packets";
import type { ReactNode } from "react";
import type { Facets } from "../api/types";
import type { Department } from "../auth/types";
import type { Screen } from "../auth/roles";
import { ALL, countActive, countAdvanced, FLT_FLAGKIND, FLT_FLAGS, FLT_STATUS, partsFor, type Filters, type ViewKind } from "../lib/filters";
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
import { PickUnit, type UnitPick } from "./PickUnit";
export type BoardView = "board" | "list";
export type BoardView = ViewKind;
interface FindBarProps {
q: string;
onQ: (q: string) => void;
export interface FindBarProps {
f: Filters;
onChange: (patch: Partial<Filters>) => void;
onClear: () => void;
pick: UnitPick;
onPick: (v: UnitPick) => void;
/** The Board | List toggle. Hidden when onView is not given. */
view?: BoardView;
onView?: (v: BoardView) => void;
/** "Showing n packets". */
onPick: (p: UnitPick) => void;
/** The Board | List toggle. Hidden when onView is not given (as on PM). */
view?: ViewKind;
onView?: (v: ViewKind) => void;
/** "Showing n of total packets". Null while loading. */
count: number | null;
/** The flag kind filter. Shown when onFlag is given. */
flag?: string;
onFlag?: (v: string) => void;
total?: number | null;
/** The Station and Worker choices. Their selects hide without them. */
facets: Facets | null;
/** The department list. Clerical (CL) is left out here. */
depts: readonly Department[];
screen: Screen;
/** The project summary line. Without it a short line for the picked project shows. */
summary?: ReactNode;
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, flag = "", onFlag }: FindBarProps) {
const ADV_RESET: Partial<Filters> = { more: false, dept: ALL, status: ALL, stall: ALL, who: ALL, flag: ALL };
function Select({
label,
value,
allLabel,
options,
onChange,
}: {
label: string;
value: string;
allLabel: string;
options: ReadonlyArray<readonly [string, string]>;
onChange: (v: string) => void;
}) {
const known = value === ALL || options.some(([v]) => v === value);
return (
<section className="fltbar" aria-label="Find jobs">
<label className="f">
<span>{label}</span>
<select className="in" aria-label={label} value={value} onChange={(e) => onChange(e.target.value)}>
<option value={ALL}>{allLabel}</option>
{known ? null : <option value={value}>{value}</option>}
{options.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
);
}
/** The old fltProjSummary, short: the picked project or unit, customer, PO and due date. */
function PickSummary({ pick }: { pick: UnitPick }) {
const p = pick.project;
if (!p) return null;
const di = dueInfo(pick.unit?.due_effective ?? p.due ?? null);
const bits = [p.customer ?? "", p.po ? `PO ${p.po}` : ""].filter(Boolean);
return (
<div className="projsum" data-testid="projsum">
<div>
{pick.unit ? <span className="muted">{p.number} › </span> : null}
<b className="mono">{pick.unit ? pick.unit.number : p.number}</b> {pick.unit ? (pick.unit.name ?? "") : (p.name ?? "")}{" "}
{p.status ? <span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span> : null}
</div>
<div className="small muted">
{bits.length ? `${bits.join(" · ")} · ` : ""}
<span className={di.cls}>{di.txt}</span>
</div>
</div>
);
}
/**
* Find a job (the old fltBar). Row one: the search box, the project then unit picker, Filters and Board | List.
* Row two, while Filters is open or any of its selects is set: Department, Status, Station, Worker, Flag.
* Then the project summary and the chips row with the count and Clear filters.
*/
export function FindBar({ f, onChange, onClear, pick, onPick, view = "board", onView, count, total, facets, depts, screen, summary }: FindBarProps) {
const parts = partsFor(screen);
const adv = countAdvanced(f, screen);
const n = countActive(f, parts.project && !!pick.project, screen);
const open = f.more || adv > 0;
const deptOpts = depts.filter((d) => d.code !== "CL").map((d) => [d.code, `${d.code} ${d.name}`] as const);
const stations = facets?.stations ?? [];
const dupNames = new Set(stations.map((s) => s.name).filter((nm, i, a) => a.indexOf(nm) !== i));
const stationOpts = stations.map((s) => [String(s.id), dupNames.has(s.name) ? `${s.name} (${s.dept_code})` : s.name] as const);
const workerOpts = (facets?.workers ?? []).map((w) => [w.id, w.display_name] as const);
const chips = FLT_FLAGS.filter(([k]) => k !== "mine" || parts.mine);
const showStations = parts.stall && (stationOpts.length > 0 || f.stall !== ALL);
const showWorkers = parts.who && (workerOpts.length > 0 || f.who !== ALL);
const toggleFlag = (k: string) => onChange({ flags: f.flags.includes(k) ? f.flags.filter((x) => x !== k) : [...f.flags, k] });
return (
<section className="fltbar" aria-label="Find jobs" data-testid="findbar">
<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"
placeholder="Job, lot, drawing, WO, heat no., welder, customer, PO, ECN…"
autoComplete="off"
spellCheck={false}
value={q}
onChange={(e) => onQ(e.target.value)}
value={f.q}
onChange={(e) => onChange({ q: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Escape" && q) {
if (e.key === "Escape" && f.q) {
e.preventDefault();
e.stopPropagation();
onQ("");
onChange({ q: "" });
}
}}
/>
</label>
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
{onFlag ? (
<label className="f">
<span>Flags</span>
<select className="in" value={flag} onChange={(e) => onFlag(e.target.value)}>
{FLAG_FILTERS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
{parts.project ? (
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
) : null}
{onView ? (
<div className="fltbtns">
<div className="fltbtns">
<button
type="button"
className={open ? "btn on" : "btn"}
aria-expanded={open}
onClick={() => onChange(open ? ADV_RESET : { more: true })}
>
Filters
{adv ? (
<>
{" "}
<b className="fltn">{adv}</b>
</>
) : null}
<span aria-hidden="true">{open ? " ▴" : " ▾"}</span>
</button>
{onView && parts.view ? (
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
Board
@@ -66,17 +156,40 @@ export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, f
List
</button>
</div>
</div>
) : null}
) : null}
</div>
</div>
{count !== null ? (
<div className="fltrow2">
<span className="grow" />
<span className="small muted">
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
</span>
{open ? (
<div className="fltrow fltadv" data-testid="fltadv">
{parts.dept ? <Select label="Department" value={f.dept} allLabel="All departments" options={deptOpts} onChange={(v) => onChange({ dept: v })} /> : null}
<Select label="Status" value={f.status} allLabel="Any status" options={FLT_STATUS} onChange={(v) => onChange({ status: v })} />
{showStations ? <Select label="Station" value={f.stall} allLabel="Any station" options={stationOpts} onChange={(v) => onChange({ stall: v })} /> : null}
{showWorkers ? <Select label="Worker" value={f.who} allLabel="Anyone" options={workerOpts} onChange={(v) => onChange({ who: v })} /> : null}
{parts.flag ? <Select label="Flag" value={f.flag} allLabel="Any" options={FLT_FLAGKIND} onChange={(v) => onChange({ flag: v })} /> : null}
</div>
) : null}
{summary ?? (parts.project ? <PickSummary pick={pick} /> : null)}
<div className="fltrow2">
<div className="chips" role="group" aria-label="Quick filters">
{chips.map(([k, l]) => (
<button key={k} type="button" className="chip" aria-pressed={f.flags.includes(k)} onClick={() => toggleFlag(k)}>
{l}
</button>
))}
</div>
<span className="grow" />
{count !== null ? (
<span className="small muted" data-testid="flt-count">
Showing <b>{count}</b>
{total !== undefined && total !== null ? ` of ${total}` : ""} packet{(total ?? count) === 1 ? "" : "s"}
</span>
) : null}
{n ? (
<button type="button" className="btn sm ghost" onClick={onClear}>
Clear filters ({n})
</button>
) : null}
</div>
</section>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useState } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import type { SortKey } from "../lib/filters";
import { makeCard } from "../test/packets";
import { JobList } from "./JobList";
const ROWS = [
makeCard({ id: 1, updated_at: "2026-10-10T00:00:00Z", project: { id: 1, number: "L10", name: "Ten" }, unit: { id: 1, number: "L10001" } }),
makeCard({
id: 2,
updated_at: "2026-10-12T00:00:00Z",
project: { id: 2, number: "L9", name: "Nine" },
unit: { id: 2, number: "L9001" },
status: "in_progress",
station: { id: 4, name: "Stall 1" },
welder: { id: "u", display_name: "Andy Welder", stamp: "75" },
open_shorts: 2,
open_cos: [3],
}),
];
function Harness({ onSort, serverSorted = false }: { onSort?: (k: SortKey) => void; serverSorted?: boolean }) {
const [sort, setSort] = useState<SortKey>("updated");
return (
<MemoryRouter initialEntries={["/supervisor"]}>
<Routes>
<Route
path="/supervisor"
element={
<JobList
rows={ROWS}
sort={sort}
serverSorted={serverSorted}
onSort={(k) => {
setSort(k);
onSort?.(k);
}}
/>
}
/>
<Route path="/packets/:id" element={<h1>Packet page</h1>} />
</Routes>
</MemoryRouter>
);
}
const jobs = () =>
within(screen.getByRole("table", { name: "Jobs" }))
.getAllByRole("row")
.slice(1)
.map((r) => within(r).getAllByRole("cell")[0]?.querySelector("b")?.textContent);
describe("JobList", () => {
it("has the old columns and sorts by the select", () => {
const onSort = vi.fn();
render(<Harness onSort={onSort} />);
expect(screen.getByRole("heading", { name: "Jobs" })).toBeInTheDocument();
const heads = within(screen.getByRole("table", { name: "Jobs" }))
.getAllByRole("columnheader")
.map((h) => h.textContent);
expect(heads).toEqual(["Job", "Operation", "Department", "Status", "Station / worker", "Drawing", "Project due", "Updated", "Flags"]);
expect(jobs()).toEqual(["L9001 · WELD-PART", "L10001 · WELD-PART"]);
const sel = screen.getByLabelText("Sort by");
expect(within(sel).getAllByRole("option").map((o) => o.textContent)).toEqual(["Last updated", "Project / lot", "Due date", "Status", "Department"]);
fireEvent.change(sel, { target: { value: "status" } });
expect(onSort).toHaveBeenCalledWith("status");
expect(jobs()).toEqual(["L10001 · WELD-PART", "L9001 · WELD-PART"]);
const row = screen.getByText("L9001 · WELD-PART").closest("tr") as HTMLElement;
expect(row).toHaveTextContent("Andy Welder");
expect(row).toHaveTextContent("2 short");
expect(row).toHaveTextContent("CO 1");
});
it("keeps the server's order when told the rows are sorted there", () => {
render(<Harness serverSorted />);
expect(jobs()).toEqual(["L10001 · WELD-PART", "L9001 · WELD-PART"]);
});
it("Enter on a row opens the packet", () => {
render(<Harness />);
const row = screen.getByText("L10001 · WELD-PART").closest("tr") as HTMLElement;
expect(row).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(row, { key: "Enter" });
expect(screen.getByRole("heading", { name: "Packet page" })).toBeInTheDocument();
});
it("shows the empty row", () => {
render(
<MemoryRouter>
<JobList rows={[]} sort="updated" onSort={() => undefined} />
</MemoryRouter>,
);
expect(screen.getByText("No packets match these filters.")).toBeInTheDocument();
});
});
+128
View File
@@ -0,0 +1,128 @@
import { useId, useMemo, type KeyboardEvent, type ReactNode } from "react";
import { useNavigate } from "react-router";
import type { PacketCard } from "../api/types";
import { fmtTime } from "../lib/format";
import { FLT_SORT, sorter, type SortKey } from "../lib/filters";
import { dueInfo, flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
import { pillClassFor, pillText, whStage } from "../warehouse/stages";
export interface JobListProps {
rows: readonly PacketCard[];
sort: SortKey;
onSort: (k: SortKey) => void;
/** The rows already come in the server's sort= order. Otherwise they are sorted here the same way. */
serverSorted?: boolean;
/** The section heading. */
title?: string;
/** Buttons at the right of the heading, like Excel (CSV) on PM. */
actions?: ReactNode;
}
/** The status pill; a warehouse packet shows its stage, as on the cards. */
function Pill({ p }: { p: PacketCard }) {
if (p.status === "warehouse" && p.wh_stage !== undefined) {
const s = whStage(p);
return <span className={pillClassFor(s)}>{pillText(s)}</span>;
}
return <span className={pillClass(p.status)}>{statusLabel(p.status)}</span>;
}
/** The list view (the old fltListView): one row per packet. Enter or a click opens it. */
export function JobList({ rows, sort, onSort, serverSorted = false, title = "Jobs", actions }: JobListProps) {
const navigate = useNavigate();
const id = useId();
const sorted = useMemo(() => (serverSorted ? [...rows] : [...rows].sort(sorter(sort))), [rows, sort, serverSorted]);
const open = (pid: number) => navigate(`/packets/${pid}`);
const onKey = (e: KeyboardEvent, pid: number) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open(pid);
}
};
return (
<section className="sec joblist-sec" aria-labelledby={id} data-testid="joblist">
<div className="sec-h">
<h2 id={id}>{title}</h2>
<span className="ct">
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
</span>
<span className="grow" />
{actions}
<label className="f jlsort">
<span>Sort by</span>
<select className="in" aria-label="Sort by" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
{FLT_SORT.map(([k, l]) => (
<option key={k} value={k}>
{l}
</option>
))}
</select>
</label>
</div>
<div className="tw">
<table className="joblist" aria-label={title}>
<thead>
<tr>
<th>Job</th>
<th>Operation</th>
<th>Department</th>
<th>Status</th>
<th>Station / worker</th>
<th>Drawing</th>
<th>Project due</th>
<th>Updated</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => {
const di = dueInfo(p.due_effective);
const cos = openCoCount(p);
return (
<tr key={p.id} className="rowlink" tabIndex={0} data-card-id={p.id} onClick={() => open(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.code} {p.dept.name}
</td>
<td>
<Pill p={p} />
</td>
<td className="small">
{p.station?.name ?? "-"}
{p.welder ? <div>{p.welder.display_name}</div> : null}
</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>
{cos ? <span className="pill st-review">CO {cos}</span> : null}{" "}
{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={9} className="muted">
No packets match these filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoryStorage } from "../screens/supervisor/memoryStorage";
import { Kpis } from "./Kpis";
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
});
describe("Kpis", () => {
it("a tile with a filter is a button; a tap calls onFilter, aria-pressed follows on", () => {
const onFilter = vi.fn();
const { rerender } = render(
<Kpis
items={[
{ n: 3, label: "Ready to assign", alert: true, filter: { key: "status", value: "ready" }, on: false },
{ n: 0, label: "Station hours" },
]}
onFilter={onFilter}
/>,
);
const tile = screen.getByRole("button", { name: "Ready to assign: 3" });
expect(tile).toHaveClass("kpi", "hot");
expect(tile).toHaveAttribute("aria-pressed", "false");
expect(tile).toHaveAttribute("title", "Show only these below");
expect(screen.queryByRole("button", { name: /Station hours/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Station hours: 0")).toHaveClass("zero");
fireEvent.click(tile);
expect(onFilter).toHaveBeenCalledWith({ key: "status", value: "ready" });
rerender(<Kpis items={[{ n: 3, label: "Ready to assign", filter: { key: "status", value: "ready" }, on: true }]} onFilter={onFilter} />);
const on = screen.getByRole("button", { name: "Ready to assign: 3" });
expect(on).toHaveAttribute("aria-pressed", "true");
expect(on).toHaveClass("on");
expect(on).toHaveAttribute("title", "Show everything again");
});
it("without onFilter the tiles stay plain", () => {
render(<Kpis items={[{ n: 2, label: "Waiting", filter: { key: "status", value: "qc" } }]} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByLabelText("Waiting: 2")).toBeInTheDocument();
});
it("counts up to a new number under motion and bumps the tile", () => {
let now = 0;
const frames: FrameRequestCallback[] = [];
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
vi.stubGlobal("cancelAnimationFrame", () => undefined);
const { rerender } = render(<Kpis items={[{ n: 10, label: "Waiting" }]} />);
rerender(<Kpis items={[{ n: 20, label: "Waiting" }]} />);
const tile = screen.getByLabelText("Waiting: 20");
expect(tile).toHaveClass("m-bump");
expect(tile.querySelector(".n")).toHaveTextContent("10");
now = 210;
act(() => frames.shift()?.(now));
const mid = Number(tile.querySelector(".n")?.textContent);
expect(mid).toBeGreaterThan(10);
expect(mid).toBeLessThan(20);
now = 500;
act(() => frames.shift()?.(now));
expect(tile.querySelector(".n")).toHaveTextContent("20");
});
it("does not count with motion off", () => {
window.localStorage.setItem("ps.motion", "off");
const { rerender } = render(<Kpis items={[{ n: 10, label: "Waiting" }]} />);
rerender(<Kpis items={[{ n: 20, label: "Waiting" }]} />);
const tile = screen.getByLabelText("Waiting: 20");
expect(tile.querySelector(".n")).toHaveTextContent("20");
expect(tile).not.toHaveClass("m-bump");
});
});
+100 -13
View File
@@ -1,26 +1,113 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { KpiKey } from "../lib/filters";
import { BUMP_MS, cancelFrame, COUNT_MS, countStep, nextFrame, useMotion } from "../lib/motion";
export interface KpiFilter {
key: KpiKey;
value: string;
}
export interface Kpi {
n: ReactNode;
label: string;
/** Turns pink when above zero. */
alert?: boolean;
/** Tapping the tile filters the boards below. */
filter?: KpiFilter;
/** The tile's filter is on. */
on?: 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 }) {
export interface KpisProps {
items: Kpi[];
label?: string;
/** With this, tiles that carry a filter are buttons. */
onFilter?: (f: KpiFilter) => void;
}
/** A whole number counts from its last value to the new one over 420 ms, under motion. Anything else shows as is. */
function Num({ n, motion, onBump }: { n: ReactNode; motion: boolean; onBump: () => void }) {
const whole = typeof n === "number" && Number.isInteger(n) ? n : typeof n === "string" && /^\d+$/.test(n.trim()) ? Number(n) : null;
const prev = useRef<number | null>(whole);
const [shown, setShown] = useState<number | null>(null);
const bump = useRef(onBump);
useEffect(() => {
bump.current = onBump;
});
useEffect(() => {
const from = prev.current;
prev.current = whole;
if (whole === null || from === null || from === whole || !motion) {
setShown(null);
return;
}
bump.current();
const t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const f = (t - t0) / COUNT_MS;
if (f >= 1) {
setShown(null);
return;
}
setShown(countStep(from, whole, f));
raf = nextFrame(step);
};
setShown(from);
raf = nextFrame(step);
return () => cancelFrame(raf);
}, [whole, motion]);
return <div className="n">{shown !== null ? shown : n}</div>;
}
function Tile({ k, motion, onFilter }: { k: Kpi; motion: boolean; onFilter: KpisProps["onFilter"] }) {
const [bumped, setBumped] = useState(false);
useEffect(() => {
if (!bumped) return;
const t = window.setTimeout(() => setBumped(false), BUMP_MS);
return () => window.clearTimeout(t);
}, [bumped]);
const zero = String(k.n) === "0";
const tap = k.filter && onFilter ? k.filter : null;
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : "", tap && k.on ? "on" : "", bumped ? "m-bump" : ""].filter(Boolean).join(" ");
const inner = (
<>
<Num n={k.n} motion={motion} onBump={() => setBumped(true)} />
<div className="l">{k.label}</div>
</>
);
const name = `${k.label}: ${String(k.n)}`;
if (tap && onFilter) {
return (
<button
type="button"
className={cls}
aria-label={name}
aria-pressed={!!k.on}
title={k.on ? "Show everything again" : "Show only these below"}
onClick={() => onFilter(tap)}
>
{inner}
</button>
);
}
return (
<div className={cls} aria-label={name}>
{inner}
</div>
);
}
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. A tile with a filter is a button. */
export function Kpis({ items, label = "Numbers", onFilter }: KpisProps) {
const motion = useMotion();
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>
);
})}
{items.map((k) => (
<Tile key={k.label} k={k} motion={motion} onFilter={onFilter} />
))}
</div>
);
}
+15 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { Link, Outlet, useLocation } from "react-router";
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { replayClass } from "../lib/motion";
import { useScrollRestore } from "../lib/scrollRestore";
import { LockScreen } from "./LockScreen";
import { RoleSwitcher } from "./RoleSwitcher";
import { TopBar } from "./TopBar";
@@ -25,6 +27,18 @@ export function Layout() {
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
const shown = active ?? firstScreen(screens);
// Back lands on the same spot; a new page starts at the top.
useScrollRestore();
// A new screen eases in (the old m-enter), only while body.motion is set; the CSS keys off both.
const lastPath = useRef<string | null>(null);
useLayoutEffect(() => {
const was = lastPath.current;
lastPath.current = pathname;
if (was === null || was === pathname) return;
return replayClass(mainRef.current, "m-enter", 700);
}, [pathname]);
// Keep the page mounted behind the lock, but out of reach of the keyboard and screen readers.
useEffect(() => {
mainRef.current?.toggleAttribute("inert", locked);
+36 -1
View File
@@ -1,6 +1,40 @@
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router";
import logo from "../assets/logo.png";
import { setMotion, useMotion } from "../lib/motion";
/** The Motion on / Motion off switch (the old motionBtn), with a short toast on toggle. */
export function MotionButton() {
const on = useMotion();
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2500);
return () => window.clearTimeout(t);
}, [toast]);
return (
<>
<button
className="btn sm ghost mbtn"
type="button"
aria-pressed={on}
title={on ? "Turn animations off" : "Turn animations on"}
onClick={() => {
setMotion(!on);
setToast(on ? "Motion off" : "Motion on");
}}
>
<span className="mdot" aria-hidden="true" />
{on ? "Motion on" : "Motion off"}
</button>
{toast ? (
<div className="toast" role="status" data-testid="motion-toast">
{toast}
</div>
) : null}
</>
);
}
interface TopBarProps {
/** The role switcher, drawn between the brand and the spacer. */
@@ -21,6 +55,7 @@ export function TopBar({ nav, children }: TopBarProps) {
</div>
</Link>
{nav}
<MotionButton />
<div className="sp" />
{children ? <div className="who">{children}</div> : null}
</div>