Phase 8: Project Management, find bar, presence and motion
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:
@@ -293,7 +293,8 @@ describe("events", () => {
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
const es = sources.at(-1);
|
||||
expect(es?.url).toBe("/api/events");
|
||||
// Phase 8: the stream names the screen in view.
|
||||
expect(es?.url).toBe("/api/events?screen=clerical");
|
||||
|
||||
act(() => es?.emit("session", { session_id: "other", action: "revoked" }));
|
||||
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PacketCard,
|
||||
PacketCreate,
|
||||
PacketHeader,
|
||||
PacketList,
|
||||
PacketListQuery,
|
||||
PacketRead,
|
||||
PrintKind,
|
||||
@@ -28,12 +29,19 @@ import type {
|
||||
WhStageBody,
|
||||
WorkOrder,
|
||||
} from "./types";
|
||||
import { normaliseFacets } 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"),
|
||||
/** Phase 8: the find bar's list, with the facets for the Station and Worker choices. */
|
||||
find: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketList> => {
|
||||
const raw = await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q, facets: 1 })}`, signal);
|
||||
const facets = normaliseFacets((raw as { facets?: unknown } | null)?.facets);
|
||||
return { packets: asList<PacketCard>(raw, "packets"), ...(facets ? { facets } : {}) };
|
||||
},
|
||||
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"),
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// The Project Management reports (phase 8 contract, api/pm.py, prefix /api/pm).
|
||||
// Reads only. Every call takes the scope: the find bar's filters plus the history period.
|
||||
// These types belong to the PM screen. The packet card type stays in types.ts.
|
||||
import { api } from "./client";
|
||||
import { qs } from "./query";
|
||||
import type { Facets, PacketCard } from "./types";
|
||||
|
||||
export type PmPeriod = "30" | "90" | "365" | "all";
|
||||
export type PmTabKey = "overview" | "projects" | "aging" | "hours" | "quality" | "find";
|
||||
export type HealthKey = "ok" | "risk" | "late" | "done" | "empty";
|
||||
|
||||
/** The scope params every PM call takes, as query string values. Built by pmView.scopeParams. */
|
||||
export type PmQuery = Record<string, string>;
|
||||
|
||||
export interface PmScope {
|
||||
label: string;
|
||||
count: number;
|
||||
period: { value: PmPeriod | string; label: string; start: string | null };
|
||||
project: { id: number; number: string; name: string | null } | null;
|
||||
unit: { id: number; number: string } | null;
|
||||
}
|
||||
|
||||
export interface PmRow {
|
||||
cells: string[];
|
||||
pid: number | null;
|
||||
project_id: number | null;
|
||||
unit_id: number | null;
|
||||
lvl: 0 | 1;
|
||||
health: HealthKey | null;
|
||||
flag: boolean;
|
||||
}
|
||||
|
||||
export interface PmTable {
|
||||
key: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
head: string[];
|
||||
rows: PmRow[];
|
||||
}
|
||||
|
||||
export interface PmKpi {
|
||||
key: string;
|
||||
n: number | string;
|
||||
label: string;
|
||||
tab: PmTabKey | string;
|
||||
alert: boolean;
|
||||
}
|
||||
|
||||
export interface PmStep {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
avg_s: number;
|
||||
max_s: number;
|
||||
oldest: { pid: number; job: string } | null;
|
||||
slowest: boolean;
|
||||
}
|
||||
|
||||
export interface PmOverview {
|
||||
scope: PmScope;
|
||||
kpis: PmKpi[];
|
||||
summary: string[];
|
||||
pipeline: PmStep[];
|
||||
attention: PmTable;
|
||||
}
|
||||
|
||||
export interface PmTables {
|
||||
scope: PmScope;
|
||||
tables: PmTable[];
|
||||
}
|
||||
|
||||
export interface PmAging extends PmTables {
|
||||
pipeline: PmStep[];
|
||||
}
|
||||
|
||||
export interface PmFind {
|
||||
scope: PmScope;
|
||||
packets: PacketCard[];
|
||||
}
|
||||
|
||||
/** The find bar's Station and Worker choices. */
|
||||
export type PmFacets = Facets;
|
||||
|
||||
/* ---------- normalising ---------- */
|
||||
|
||||
function list<T>(raw: unknown): T[] {
|
||||
return Array.isArray(raw) ? (raw as T[]) : [];
|
||||
}
|
||||
|
||||
const EMPTY_SCOPE: PmScope = { label: "", count: 0, period: { value: "90", label: "Last 90 days", start: null }, project: null, unit: null };
|
||||
|
||||
function scopeOf(raw: Partial<PmScope> | null | undefined): PmScope {
|
||||
return { ...EMPTY_SCOPE, ...(raw ?? {}), period: { ...EMPTY_SCOPE.period, ...(raw?.period ?? {}) } };
|
||||
}
|
||||
|
||||
function rowOf(raw: Partial<PmRow>): PmRow {
|
||||
return {
|
||||
cells: list<unknown>(raw.cells).map((c) => (c === null || c === undefined ? "" : String(c))),
|
||||
pid: raw.pid ?? null,
|
||||
project_id: raw.project_id ?? null,
|
||||
unit_id: raw.unit_id ?? null,
|
||||
lvl: raw.lvl === 1 ? 1 : 0,
|
||||
health: raw.health ?? null,
|
||||
flag: !!raw.flag,
|
||||
};
|
||||
}
|
||||
|
||||
export function tableOf(raw: Partial<PmTable> | null | undefined): PmTable {
|
||||
return {
|
||||
key: raw?.key ?? "",
|
||||
title: raw?.title ?? "",
|
||||
note: raw?.note ?? null,
|
||||
head: list<string>(raw?.head),
|
||||
rows: list<Partial<PmRow>>(raw?.rows).map(rowOf),
|
||||
};
|
||||
}
|
||||
|
||||
function tablesOf(raw: Partial<PmTables> | null): PmTables {
|
||||
return { scope: scopeOf(raw?.scope), tables: list<Partial<PmTable>>(raw?.tables).map(tableOf) };
|
||||
}
|
||||
|
||||
/* ---------- calls ---------- */
|
||||
|
||||
const get = <T>(path: string, q: PmQuery, signal?: AbortSignal, extra: PmQuery = {}) => api.get<T>(`${path}${qs({ ...q, ...extra })}`, signal);
|
||||
|
||||
export async function overview(q: PmQuery, signal?: AbortSignal): Promise<PmOverview> {
|
||||
const raw = await get<Partial<PmOverview> | null>("/api/pm/overview", q, signal);
|
||||
return {
|
||||
scope: scopeOf(raw?.scope),
|
||||
kpis: list<PmKpi>(raw?.kpis),
|
||||
summary: list<string>(raw?.summary),
|
||||
pipeline: list<PmStep>(raw?.pipeline),
|
||||
attention: tableOf(raw?.attention),
|
||||
};
|
||||
}
|
||||
|
||||
/** `expand` is "all" or the opened project ids. Without it, only the picked project shows its units. */
|
||||
export async function projects(q: PmQuery, expand: string, signal?: AbortSignal): Promise<PmTables> {
|
||||
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/projects", q, signal, expand ? { expand } : {}));
|
||||
}
|
||||
|
||||
export async function aging(q: PmQuery, signal?: AbortSignal): Promise<PmAging> {
|
||||
const raw = await get<Partial<PmAging> | null>("/api/pm/aging", q, signal);
|
||||
return { ...tablesOf(raw), pipeline: list<PmStep>(raw?.pipeline) };
|
||||
}
|
||||
|
||||
export async function hours(q: PmQuery, signal?: AbortSignal): Promise<PmTables> {
|
||||
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/hours", q, signal));
|
||||
}
|
||||
|
||||
export async function quality(q: PmQuery, signal?: AbortSignal): Promise<PmTables> {
|
||||
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/quality", q, signal));
|
||||
}
|
||||
|
||||
export async function find(q: PmQuery, sort: string, signal?: AbortSignal): Promise<PmFind> {
|
||||
const raw = await get<Partial<PmFind> | null>("/api/pm/find", q, signal, sort ? { sort } : {});
|
||||
return { scope: scopeOf(raw?.scope), packets: list<PacketCard>(raw?.packets) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The find bar's Station and Worker choices. The PM answers carry no facets, so this asks
|
||||
* GET /api/packets for them with the project and unit only (facets ignore the other filters).
|
||||
*/
|
||||
export async function facets(q: PmQuery, signal?: AbortSignal): Promise<PmFacets> {
|
||||
const raw = await api.get<{ facets?: Partial<PmFacets> } | null>(
|
||||
`/api/packets${qs({ project_id: q.project_id, unit_id: q.unit_id, facets: 1, limit: 1 })}`,
|
||||
signal,
|
||||
);
|
||||
return { stations: list(raw?.facets?.stations), workers: list(raw?.facets?.workers) };
|
||||
}
|
||||
|
||||
/** One table (by key), "jobs", or "all", as a CSV download. */
|
||||
export const csvUrl = (table: string, q: PmQuery) => `/api/pm/export.csv${qs({ table, ...q })}`;
|
||||
|
||||
/** The production status report. */
|
||||
export const pdfUrl = (q: PmQuery) => `/api/pm/report.pdf${qs({ ...q })}`;
|
||||
|
||||
export const pmApi = { overview, projects, aging, hours, quality, find, facets, csvUrl, pdfUrl };
|
||||
@@ -2,7 +2,7 @@
|
||||
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
|
||||
import { api, request } from "./client";
|
||||
import { qs } from "./query";
|
||||
import type { Guarded, PacketCard, ProjectStatus, SignBody } from "./types";
|
||||
import { normaliseFacets, type BoardQuery, type Facets, type Guarded, type PacketCard, type ProjectStatus, type SignBody } from "./types";
|
||||
|
||||
/** A person on a record. */
|
||||
export interface QcWho {
|
||||
@@ -52,12 +52,12 @@ export interface QcHome {
|
||||
kpis: QcKpis;
|
||||
columns: Record<QcColumn, QcCard[]>;
|
||||
folders: QcFolderCard[];
|
||||
/** Phase 8: the find bar's Station and Worker choices. */
|
||||
facets?: Facets;
|
||||
}
|
||||
|
||||
export interface QcHomeQuery {
|
||||
project_id?: number;
|
||||
unit_id?: number;
|
||||
}
|
||||
/** Phase 8: the find bar's query. KPIs follow the project and unit only. */
|
||||
export type QcHomeQuery = BoardQuery;
|
||||
|
||||
/* ---------- Q.C. folders ---------- */
|
||||
|
||||
@@ -298,10 +298,12 @@ function list<T>(raw: unknown): T[] {
|
||||
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
|
||||
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
|
||||
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
|
||||
const facets = normaliseFacets(raw?.facets);
|
||||
return {
|
||||
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
|
||||
columns,
|
||||
folders: list<QcFolderCard>(raw?.folders),
|
||||
...(facets ? { facets } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts.
|
||||
import { api } from "./client";
|
||||
import { asList, qs } from "./query";
|
||||
import type { PacketCard } from "./types";
|
||||
import { normaliseFacets, type BoardQuery, type Facets, type PacketCard } from "./types";
|
||||
|
||||
export interface ShopClock {
|
||||
/** Live on the server at the time of the answer: stored plus the running time. */
|
||||
@@ -76,14 +76,12 @@ export interface ShopBoard {
|
||||
kpis: ShopKpis;
|
||||
cos: ShopCoRow[];
|
||||
columns: Record<ShopColumn, ShopCard[]>;
|
||||
/** Phase 8: the find bar's Station and Worker choices. */
|
||||
facets?: Facets;
|
||||
}
|
||||
|
||||
export interface ShopBoardQuery {
|
||||
/** A department code, or "all". */
|
||||
dept?: string;
|
||||
project_id?: number;
|
||||
unit_id?: number;
|
||||
}
|
||||
/** The find bar's query plus the department scope. dept is a code, or "all". */
|
||||
export type ShopBoardQuery = BoardQuery;
|
||||
|
||||
export interface StationOption {
|
||||
dept_code: string;
|
||||
@@ -96,6 +94,7 @@ export interface StallAnswer {
|
||||
station: { id: number; name: string; dept_code: string; dept_name: string };
|
||||
mine: ShopCard[];
|
||||
submitted: ShopCard[];
|
||||
facets?: Facets;
|
||||
}
|
||||
|
||||
export interface DeptPerson {
|
||||
@@ -123,11 +122,13 @@ export function normaliseBoard(raw: Partial<ShopBoard> | null): ShopBoard {
|
||||
...d,
|
||||
stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })),
|
||||
}));
|
||||
const facets = normaliseFacets(raw?.facets);
|
||||
return {
|
||||
depts,
|
||||
kpis: { ready: 0, at_stations: 0, review: 0, elapsed_ms: 0, target_hrs: 0, ...(raw?.kpis ?? {}) },
|
||||
cos: Array.isArray(raw?.cos) ? raw.cos.map((c) => ({ ...c, pending_depts: Array.isArray(c.pending_depts) ? c.pending_depts : [] })) : [],
|
||||
columns,
|
||||
...(facets ? { facets } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,12 +140,15 @@ export async function getStations(signal?: AbortSignal): Promise<StationOption[]
|
||||
return asList<StationOption>(await api.get<unknown>("/api/shop/stations", signal), "stations");
|
||||
}
|
||||
|
||||
export async function getStall(stationId: number, signal?: AbortSignal): Promise<StallAnswer> {
|
||||
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId })}`, signal);
|
||||
/** The stall's two columns. Phase 8: the find bar's query applies to the station's cards. */
|
||||
export async function getStall(stationId: number, signal?: AbortSignal, q: BoardQuery = {}): Promise<StallAnswer> {
|
||||
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId, ...q })}`, signal);
|
||||
const facets = normaliseFacets(raw?.facets);
|
||||
return {
|
||||
station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" },
|
||||
mine: cards(raw?.mine),
|
||||
submitted: cards(raw?.submitted),
|
||||
...(facets ? { facets } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,20 +188,61 @@ export interface PacketCard {
|
||||
released_at?: string | null;
|
||||
}
|
||||
|
||||
/** Phase 8: the find bar's Station and Worker choices, from the packets in the project and unit scope. */
|
||||
export interface FacetStation {
|
||||
id: number;
|
||||
name: string;
|
||||
dept_code: string;
|
||||
}
|
||||
|
||||
export interface FacetWorker {
|
||||
id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface Facets {
|
||||
stations: FacetStation[];
|
||||
workers: FacetWorker[];
|
||||
}
|
||||
|
||||
/** The facets of an answer, with both lists present, or null when the server sent none. */
|
||||
export function normaliseFacets(raw: unknown): Facets | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const r = raw as Partial<Facets>;
|
||||
return { stations: Array.isArray(r.stations) ? r.stations : [], workers: Array.isArray(r.workers) ? r.workers : [] };
|
||||
}
|
||||
|
||||
export interface PacketList {
|
||||
packets: PacketCard[];
|
||||
/** Phase 8: present when asked with facets=1. */
|
||||
facets?: Facets;
|
||||
}
|
||||
|
||||
export interface PacketListQuery {
|
||||
/** A find bar status word (draft, warehouse, shop, station, open, ...) or a comma list of raw statuses. */
|
||||
status?: string;
|
||||
dept?: string;
|
||||
unit_id?: number;
|
||||
project_id?: number;
|
||||
q?: string;
|
||||
/** A flag kind (any, rev, unconf, hand, none, vault, read, wait, no) or an older raw value. */
|
||||
flag?: string;
|
||||
/** "pending": packets with a pending feed change order. */
|
||||
co?: string;
|
||||
limit?: number;
|
||||
/* Phase 8: the find bar. */
|
||||
station_id?: number;
|
||||
/** The assigned welder's user id (uuid text). */
|
||||
welder_id?: string;
|
||||
/** Quick flags as a comma list: co, short, overdue, week, mine. */
|
||||
flags?: string;
|
||||
/** updated (default), project, due, status, dept. */
|
||||
sort?: string;
|
||||
/** 1 adds facets to the answer. */
|
||||
facets?: number;
|
||||
/** The screen that decides what Mine means. */
|
||||
mine_screen?: string;
|
||||
mine_station_id?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
@@ -1164,14 +1205,12 @@ export interface BoardResponse {
|
||||
kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number };
|
||||
columns: Record<BoardColumn, PacketCard[]>;
|
||||
shorts: ShortRow[];
|
||||
/** Phase 8: the find bar's Station and Worker choices. */
|
||||
facets?: Facets;
|
||||
}
|
||||
|
||||
export interface BoardQuery {
|
||||
q?: string;
|
||||
project_id?: number;
|
||||
unit_id?: number;
|
||||
dept?: string;
|
||||
}
|
||||
/** Phase 8: every board takes the find bar's query. KPIs follow the project and unit only. */
|
||||
export type BoardQuery = Omit<PacketListQuery, "limit" | "co" | "facets">;
|
||||
|
||||
/* ---------- Phase 4: storage and alerts (Admin) ---------- */
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The warehouse board (phase 4 contract, api/warehouse.py).
|
||||
import { api } from "./client";
|
||||
import { qs } from "./query";
|
||||
import type { BoardQuery, BoardResponse, PacketCard } from "./types";
|
||||
import { normaliseFacets, type BoardQuery, type BoardResponse, type PacketCard } from "./types";
|
||||
|
||||
const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
|
||||
|
||||
@@ -9,10 +9,12 @@ const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
|
||||
function normalise(raw: Partial<BoardResponse> | null): BoardResponse {
|
||||
const cols = (raw?.columns ?? {}) as Partial<Record<(typeof COLUMNS)[number], PacketCard[]>>;
|
||||
const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"];
|
||||
const facets = normaliseFacets(raw?.facets);
|
||||
return {
|
||||
kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) },
|
||||
columns,
|
||||
shorts: Array.isArray(raw?.shorts) ? raw.shorts : [],
|
||||
...(facets ? { facets } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { authApi, departmentsApi } from "./api";
|
||||
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
||||
import { roleLabel as labelFor } from "./roles";
|
||||
import type { Department, Factor, Me } from "./types";
|
||||
import { useEvents } from "./useEvents";
|
||||
import { useLocation } from "react-router";
|
||||
import { screenOf, useEvents } from "./useEvents";
|
||||
|
||||
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */
|
||||
export const IDLE_LOCK_MS = 3 * 60_000;
|
||||
@@ -202,10 +203,17 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
|
||||
}, [status, idleLockMs, idleCheckMs, lock]);
|
||||
|
||||
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,
|
||||
});
|
||||
// Phase 8: the stream names the screen in view, so presence can show it as the role.
|
||||
const { pathname } = useLocation();
|
||||
const presence = useEvents(
|
||||
sessionId,
|
||||
{
|
||||
onRevoked: () => endSession("This session was signed out from another place."),
|
||||
onChanged: emitChanged,
|
||||
onLockChange: () => void refresh(),
|
||||
},
|
||||
{ screen: screenOf(pathname), locked: status === "locked" },
|
||||
);
|
||||
|
||||
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
||||
|
||||
|
||||
@@ -191,4 +191,8 @@ export interface PresenceUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
screen: string | null;
|
||||
/** Phase 8: the screen label of the stream (Clerical, Weld Stall, ...), else the person's primary role label. */
|
||||
role?: string | null;
|
||||
/** Phase 8: when this stream joined (ISO). */
|
||||
since?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eventsUrl, othersOn } from "./useEvents";
|
||||
import { eventsUrl, othersOn, presenceText, screenOf } from "./useEvents";
|
||||
|
||||
describe("packet presence", () => {
|
||||
it("opens the stream with the packet in view", () => {
|
||||
expect(eventsUrl(null)).toBe("/api/events");
|
||||
expect(eventsUrl(42)).toBe("/api/events?packet_id=42");
|
||||
expect(eventsUrl(42, "supervisor")).toBe("/api/events?screen=supervisor&packet_id=42");
|
||||
expect(eventsUrl(null, "qc")).toBe("/api/events?screen=qc");
|
||||
});
|
||||
|
||||
it("names the screen from the first path segment", () => {
|
||||
expect(screenOf("/supervisor")).toBe("supervisor");
|
||||
expect(screenOf("/clerical/units/4")).toBe("clerical");
|
||||
expect(screenOf("/packets/7")).toBeNull();
|
||||
expect(screenOf("/")).toBeNull();
|
||||
});
|
||||
|
||||
it("the callout names each person with the role, and is empty when alone", () => {
|
||||
expect(presenceText([])).toBe("");
|
||||
const users = [
|
||||
{ user_id: "a", display_name: "Andy Welder", screen: "stall", role: "Weld Stall", packet_id: 7 },
|
||||
{ user_id: "w", display_name: "Wes Super", screen: "supervisor", packet_id: 7 },
|
||||
{ user_id: "x", display_name: "Xena", screen: null, packet_id: 7 },
|
||||
];
|
||||
expect(presenceText(othersOn(users, 7, "me"))).toBe("Also in this packet now: Andy Welder (Weld Stall) · Wes Super (Supervisor) · Xena");
|
||||
});
|
||||
|
||||
it("prefers the entry that carries a role", () => {
|
||||
const users = [
|
||||
{ user_id: "a", display_name: "Andy Welder", screen: null, packet_id: 7 },
|
||||
{ user_id: "a", display_name: "Andy Welder", screen: "stall", role: "Weld Stall", packet_id: 7 },
|
||||
];
|
||||
expect(othersOn(users, 7, null)[0]?.role).toBe("Weld Stall");
|
||||
});
|
||||
|
||||
it("lists the other people on the packet once each, sorted, without me", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { AuthCtx } from "./context";
|
||||
import { isScreen, SCREEN_LABELS } from "./roles";
|
||||
import type { PresenceUser } from "./types";
|
||||
|
||||
interface EventHandlers {
|
||||
@@ -7,6 +8,15 @@ interface EventHandlers {
|
||||
onRevoked: () => void;
|
||||
/** Called for every `changed` event. Later phases refresh their data from it. */
|
||||
onChanged?: (payload: unknown) => void;
|
||||
/** Phase 8: this browser's own session was locked or unlocked, here or in another tab. */
|
||||
onLockChange?: (action: "locked" | "unlocked") => void;
|
||||
}
|
||||
|
||||
export interface EventsOptions {
|
||||
/** The screen in view (the first path segment when it is a screen). The server shows it as the role. */
|
||||
screen?: string | null;
|
||||
/** While locked the stream stays open but takes no new screen or packet. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
/** Phase 5: presence carries the packet each user has open. */
|
||||
@@ -45,9 +55,19 @@ function subscribeFocus(fn: () => void): () => void {
|
||||
|
||||
const getFocus = () => focusPacket;
|
||||
|
||||
/** The stream URL for the packet in view. */
|
||||
export function eventsUrl(packetId: number | null): string {
|
||||
return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events";
|
||||
/** The stream URL for the screen and the packet in view. */
|
||||
export function eventsUrl(packetId: number | null, screen: string | null = null): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (screen) sp.set("screen", screen);
|
||||
if (packetId) sp.set("packet_id", String(packetId));
|
||||
const q = sp.toString();
|
||||
return q ? `/api/events?${q}` : "/api/events";
|
||||
}
|
||||
|
||||
/** The screen of a path: its first segment when that is a screen. */
|
||||
export function screenOf(pathname: string): string | null {
|
||||
const seg = pathname.split("/")[1];
|
||||
return isScreen(seg) ? seg : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,23 +75,30 @@ export function eventsUrl(packetId: number | null): string {
|
||||
* A `session` event with this session id and action "revoked" calls onRevoked.
|
||||
* `presence` is kept in state. The stream reopens when the packet in view changes.
|
||||
*/
|
||||
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
|
||||
export function useEvents(sessionId: string | null, handlers: EventHandlers, opts: EventsOptions = {}): PresenceUser[] {
|
||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
||||
const focus = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
||||
const ref = useRef(handlers);
|
||||
useEffect(() => {
|
||||
ref.current = handlers;
|
||||
});
|
||||
// While locked the stream keeps what it had: nothing new is sent about where this browser is.
|
||||
const [held, setHeld] = useState<{ packetId: number | null; screen: string | null }>({ packetId: focus, screen: opts.screen ?? null });
|
||||
const wantScreen = opts.screen ?? null;
|
||||
if (!opts.locked && (held.packetId !== focus || held.screen !== wantScreen)) setHeld({ packetId: focus, screen: wantScreen });
|
||||
const { packetId, screen } = held;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof EventSource === "undefined") return;
|
||||
const es = new EventSource(eventsUrl(packetId), { withCredentials: true });
|
||||
const es = new EventSource(eventsUrl(packetId, screen), { withCredentials: true });
|
||||
|
||||
es.addEventListener("session", (ev) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
if (p && typeof p === "object") {
|
||||
const o = p as { session_id?: unknown; action?: unknown };
|
||||
if (o.session_id === sessionId && o.action === "revoked") ref.current.onRevoked();
|
||||
if (o.session_id !== sessionId) return;
|
||||
if (o.action === "revoked") ref.current.onRevoked();
|
||||
else if (o.action === "locked" || o.action === "unlocked") ref.current.onLockChange?.(o.action);
|
||||
}
|
||||
});
|
||||
es.addEventListener("presence", (ev) => {
|
||||
@@ -93,23 +120,37 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
||||
es.close();
|
||||
setPresence([]);
|
||||
};
|
||||
}, [sessionId, packetId]);
|
||||
}, [sessionId, packetId, screen]);
|
||||
|
||||
return presence;
|
||||
}
|
||||
|
||||
/** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */
|
||||
/** The other people on `packetId`, from the presence list. One entry per user (one with a role first), sorted by name. */
|
||||
export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] {
|
||||
const seen = new Set<string>();
|
||||
const out: PacketPresenceUser[] = [];
|
||||
const byUser = new Map<string, PacketPresenceUser>();
|
||||
for (const u of users) {
|
||||
if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue;
|
||||
if (myId && u.user_id === myId) continue;
|
||||
if (seen.has(u.user_id)) continue;
|
||||
seen.add(u.user_id);
|
||||
out.push(u);
|
||||
const had = byUser.get(u.user_id);
|
||||
if (!had || (!presenceRole(had) && presenceRole(u))) byUser.set(u.user_id, u);
|
||||
}
|
||||
return out.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
return [...byUser.values()].sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
}
|
||||
|
||||
/** The role shown next to a name: the server's role label, else the label of the screen the person has open. */
|
||||
export function presenceRole(u: Pick<PresenceUser, "role" | "screen">): string {
|
||||
if (u.role) return u.role;
|
||||
return u.screen && isScreen(u.screen) ? SCREEN_LABELS[u.screen] : "";
|
||||
}
|
||||
|
||||
/** "Also in this packet now: Andy Welder (Weld Stall) · Wes Super (Supervisor)". Empty when alone. */
|
||||
export function presenceText(others: readonly PresenceUser[]): string {
|
||||
if (!others.length) return "";
|
||||
const names = others.map((u) => {
|
||||
const r = presenceRole(u);
|
||||
return r ? `${u.display_name} (${r})` : u.display_name;
|
||||
});
|
||||
return `Also in this packet now: ${names.join(" · ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||
import { makeCard } from "../test/packets";
|
||||
import {
|
||||
countActive,
|
||||
countAdvanced,
|
||||
FLT_DEFAULT,
|
||||
FLT_FLAGKIND,
|
||||
FLT_FLAGS,
|
||||
FLT_KEY,
|
||||
FLT_SORT,
|
||||
FLT_STATUS,
|
||||
kpiActive,
|
||||
kpiFilter,
|
||||
loadFilters,
|
||||
loadPick,
|
||||
PROJ_KEY,
|
||||
saveFilters,
|
||||
savePick,
|
||||
sorter,
|
||||
toQuery,
|
||||
uniqueCards,
|
||||
useFilters,
|
||||
type Filters,
|
||||
} from "./filters";
|
||||
|
||||
const PROJECT = { id: 7, number: "L999", name: "Spools" };
|
||||
const UNIT = { id: 12, number: "L999002", lot: "002", name: null, due: null, due_effective: null, status: "open" as const, version: 1 };
|
||||
const f = (over: Partial<Filters> = {}): Filters => ({ ...FLT_DEFAULT, ...over });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("the vocabulary", () => {
|
||||
it("keeps the old labels", () => {
|
||||
expect(FLT_STATUS.map(([v]) => v)).toEqual(["draft", "warehouse", "ready", "shop", "station", "review", "qc", "complete", "filed", "open"]);
|
||||
expect(FLT_FLAGS.map(([, l]) => l)).toEqual(["Change order open", "Shorts open", "Project overdue", "Due within 7 days", "Mine"]);
|
||||
expect(FLT_FLAGKIND.map(([v]) => v)).toEqual(["any", "rev", "unconf", "hand", "none", "vault", "read", "wait", "no"]);
|
||||
expect(FLT_SORT).toEqual([
|
||||
["updated", "Last updated"],
|
||||
["project", "Project / lot"],
|
||||
["due", "Due date"],
|
||||
["status", "Status"],
|
||||
["dept", "Department"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load and save", () => {
|
||||
it("saves everything but the search text, and loads it back with q empty", () => {
|
||||
saveFilters(f({ q: "heat 4471", dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
|
||||
const raw = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
|
||||
expect(raw).not.toHaveProperty("q");
|
||||
expect(raw).toMatchObject({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true });
|
||||
expect(loadFilters()).toEqual(f({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
|
||||
});
|
||||
|
||||
it("falls back to the default on bad or missing values", () => {
|
||||
window.localStorage.setItem(FLT_KEY, JSON.stringify({ q: "x", sort: "nope", flags: ["short", "bogus", 3], views: { qc: "grid", stall: "list" }, dept: 5 }));
|
||||
expect(loadFilters()).toEqual(f({ flags: ["short"], views: { stall: "list" } }));
|
||||
window.localStorage.setItem(FLT_KEY, "{not json");
|
||||
expect(loadFilters()).toEqual(FLT_DEFAULT);
|
||||
});
|
||||
|
||||
it("keeps the project pick in ps.proj", () => {
|
||||
expect(loadPick()).toEqual({ project: null, unit: null });
|
||||
savePick({ project: PROJECT, unit: UNIT });
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJ_KEY) ?? "null")).toMatchObject({ project_id: 7, unit_id: 12 });
|
||||
expect(loadPick()).toEqual({ project: PROJECT, unit: UNIT });
|
||||
savePick({ project: null, unit: null });
|
||||
expect(window.localStorage.getItem(PROJ_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("counts", () => {
|
||||
it("counts the advanced selects and everything active", () => {
|
||||
const x = f({ q: " weld ", dept: "03", status: "ready", flag: "rev", flags: ["co", "mine"] });
|
||||
expect(countAdvanced(x)).toBe(3);
|
||||
expect(countActive(x, true)).toBe(1 + 3 + 2 + 1);
|
||||
expect(countActive(FLT_DEFAULT, false)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves out the parts a screen hides", () => {
|
||||
const x = f({ dept: "03", stall: "4", who: "u-1", flag: "rev", status: "qc", flags: ["mine", "short"] });
|
||||
expect(countAdvanced(x, "stall")).toBe(1);
|
||||
expect(countActive(x, false, "stall")).toBe(2);
|
||||
expect(countAdvanced(x, "supervisor")).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toQuery", () => {
|
||||
it("sends only what is set, the pick last", () => {
|
||||
expect(toQuery(FLT_DEFAULT, { project: null, unit: null }, { screen: "clerical" })).toEqual({});
|
||||
const q = toQuery(
|
||||
f({ q: " lot 52 ", dept: "03", status: "station", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "co"], sort: "project" }),
|
||||
{ project: PROJECT, unit: UNIT },
|
||||
{ screen: "clerical" },
|
||||
);
|
||||
expect(q).toEqual({
|
||||
q: "lot 52",
|
||||
dept: "03",
|
||||
status: "station",
|
||||
station_id: 4,
|
||||
welder_id: "u-1",
|
||||
flag: "rev",
|
||||
flags: "co,mine",
|
||||
mine_screen: "clerical",
|
||||
sort: "project",
|
||||
unit_id: 12,
|
||||
});
|
||||
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
|
||||
});
|
||||
|
||||
it("the stall sends no department, station, worker, flag kind or project, and its Mine names the station", () => {
|
||||
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
|
||||
screen: "stall",
|
||||
stationId: 9,
|
||||
});
|
||||
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
|
||||
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
|
||||
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("KPI taps", () => {
|
||||
it("toggles a status and opens the advanced row", () => {
|
||||
const on = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "status", "ready") };
|
||||
expect(on).toMatchObject({ status: "ready", more: true });
|
||||
expect(kpiActive(on, "status", "ready")).toBe(true);
|
||||
const off = { ...on, ...kpiFilter(on, "status", "ready") };
|
||||
expect(off.status).toBe("all");
|
||||
expect(kpiActive(off, "status", "ready")).toBe(false);
|
||||
});
|
||||
|
||||
it("a quick flag toggles the chip; a flag kind toggles the Flag select", () => {
|
||||
const short = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "short") };
|
||||
expect(short.flags).toEqual(["short"]);
|
||||
expect(kpiActive(short, "flag", "short")).toBe(true);
|
||||
expect({ ...short, ...kpiFilter(short, "flag", "short") }.flags).toEqual([]);
|
||||
const any = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "any") };
|
||||
expect(any).toMatchObject({ flag: "any", more: true, flags: [] });
|
||||
expect(kpiActive(any, "flag", "any")).toBe(true);
|
||||
expect({ ...any, ...kpiFilter(any, "flag", "any") }.flag).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilters", () => {
|
||||
it("debounces q into the query, saves without q, and keeps the view per screen", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result } = renderHook(() => useFilters("supervisor"));
|
||||
expect(result.current.query).toEqual({});
|
||||
act(() => result.current.set({ q: "elbow" }));
|
||||
expect(result.current.query).toEqual({});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(260);
|
||||
});
|
||||
expect(result.current.query).toEqual({ q: "elbow" });
|
||||
act(() => result.current.setView("list"));
|
||||
expect(result.current.view).toBe("list");
|
||||
const saved = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
|
||||
expect(saved).toMatchObject({ views: { supervisor: "list" } });
|
||||
expect(JSON.stringify(saved)).not.toContain("elbow");
|
||||
act(() => result.current.set({ q: "" }));
|
||||
expect(result.current.query).toEqual({});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps KPI numbers on the pick only, and Clear keeps the view and the sort", () => {
|
||||
const { result } = renderHook(() => useFilters("clerical"));
|
||||
act(() => result.current.setPick({ project: PROJECT, unit: null }));
|
||||
act(() => result.current.tapKpi("status", "warehouse"));
|
||||
expect(result.current.query).toEqual({ status: "warehouse", project_id: 7 });
|
||||
expect(result.current.scope).toEqual({ project_id: 7 });
|
||||
expect(result.current.isKpiOn("status", "warehouse")).toBe(true);
|
||||
expect(result.current.active).toBe(2);
|
||||
act(() => result.current.set({ sort: "due", views: { clerical: "list" } }));
|
||||
act(() => result.current.clear());
|
||||
expect(result.current.f).toEqual(f({ sort: "due", views: { clerical: "list" } }));
|
||||
expect(result.current.pick).toEqual({ project: null, unit: null });
|
||||
expect(result.current.query).toEqual({ sort: "due" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("list helpers", () => {
|
||||
it("merges columns once each and sorts like the server", () => {
|
||||
const a = makeCard({ id: 1, updated_at: "2026-10-10T00:00:00Z", project: { id: 1, number: "L10", name: null }, unit: { id: 1, number: "L10002" } });
|
||||
const b = makeCard({ id: 2, updated_at: "2026-10-12T00:00:00Z", project: { id: 2, number: "L9", name: null }, unit: { id: 2, number: "L9001" }, status: "qc" });
|
||||
const merged = uniqueCards([[a, b], [b]]);
|
||||
expect(merged.map((p) => p.id)).toEqual([1, 2]);
|
||||
expect([...merged].sort(sorter("updated")).map((p) => p.id)).toEqual([2, 1]);
|
||||
expect([...merged].sort(sorter("project")).map((p) => p.id)).toEqual([2, 1]);
|
||||
expect([...merged].sort(sorter("status")).map((p) => p.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
// The find bar's filters on every home screen (the old filters.js), applied by the server.
|
||||
// The filters are saved per browser under ps.flt without the search text. The project pick lives in ps.proj.
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { PacketCard, PacketListQuery, ProjectRef, Unit } from "../api/types";
|
||||
import { STATUSES } from "../api/types";
|
||||
import type { Screen } from "../auth/roles";
|
||||
import type { UnitPick } from "../components/PickUnit";
|
||||
import { nextFrame } from "./motion";
|
||||
|
||||
export type SortKey = "updated" | "project" | "due" | "status" | "dept";
|
||||
export type ViewKind = "board" | "list";
|
||||
|
||||
/** The value of a select that filters nothing. */
|
||||
export const ALL = "all";
|
||||
|
||||
/** Status words the server expands (domain/search.py FLT_STATUS). */
|
||||
export const FLT_STATUS: ReadonlyArray<readonly [string, string]> = [
|
||||
["draft", "Ready to release"],
|
||||
["warehouse", "With warehouse (any stage)"],
|
||||
["ready", "Ready to assign"],
|
||||
["shop", "In the shop (ready to Q.C.)"],
|
||||
["station", "At a station (assigned / working)"],
|
||||
["review", "Supervisor review"],
|
||||
["qc", "At Q.C."],
|
||||
["complete", "Accepted / routed"],
|
||||
["filed", "Filed"],
|
||||
["open", "Everything not filed"],
|
||||
];
|
||||
|
||||
/** Quick flag chips. They go to the server as flags=co,short,... */
|
||||
export const FLT_FLAGS: ReadonlyArray<readonly [string, string]> = [
|
||||
["co", "Change order open"],
|
||||
["short", "Shorts open"],
|
||||
["overdue", "Project overdue"],
|
||||
["week", "Due within 7 days"],
|
||||
["mine", "Mine"],
|
||||
];
|
||||
|
||||
/** Why a draft is flagged. Goes to the server as flag=. */
|
||||
export const FLT_FLAGKIND: ReadonlyArray<readonly [string, string]> = [
|
||||
["any", "Flagged (any reason)"],
|
||||
["rev", "Rev mismatch with M-Files"],
|
||||
["unconf", "Rev not confirmed yet"],
|
||||
["hand", "Made or uploaded by hand"],
|
||||
["none", "Still in drafting"],
|
||||
["vault", "Drawing not found in M-Files"],
|
||||
["read", "Drawing couldn't be read"],
|
||||
["wait", "Drawing being looked up"],
|
||||
["no", "Not flagged"],
|
||||
];
|
||||
|
||||
export const FLT_SORT: ReadonlyArray<readonly [SortKey, string]> = [
|
||||
["updated", "Last updated"],
|
||||
["project", "Project / lot"],
|
||||
["due", "Due date"],
|
||||
["status", "Status"],
|
||||
["dept", "Department"],
|
||||
];
|
||||
|
||||
export interface Filters {
|
||||
/** The search text. Never saved. */
|
||||
q: string;
|
||||
/** A department code, or "all". */
|
||||
dept: string;
|
||||
/** A FLT_STATUS value, or "all". */
|
||||
status: string;
|
||||
/** A station id as text, or "all". */
|
||||
stall: string;
|
||||
/** A welder's user id, or "all". */
|
||||
who: string;
|
||||
/** A FLT_FLAGKIND value, or "all". */
|
||||
flag: string;
|
||||
/** Quick flags that are on. */
|
||||
flags: string[];
|
||||
/** Board or list, per screen: {clerical: "list"}. */
|
||||
views: Record<string, ViewKind>;
|
||||
sort: SortKey;
|
||||
/** The advanced row is open. */
|
||||
more: boolean;
|
||||
}
|
||||
|
||||
export const FLT_DEFAULT: Filters = {
|
||||
q: "",
|
||||
dept: ALL,
|
||||
status: ALL,
|
||||
stall: ALL,
|
||||
who: ALL,
|
||||
flag: ALL,
|
||||
flags: [],
|
||||
views: {},
|
||||
sort: "updated",
|
||||
more: false,
|
||||
};
|
||||
|
||||
export const FLT_KEY = "ps.flt";
|
||||
export const PROJ_KEY = "ps.proj";
|
||||
|
||||
function lsGet(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lsSet(key: string, value: string | null): void {
|
||||
try {
|
||||
if (value === null) window.localStorage.removeItem(key);
|
||||
else window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage blocked: the filters last for this page only.
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_KEYS = new Set<string>(FLT_SORT.map(([k]) => k));
|
||||
const FLAG_KEYS = new Set<string>(FLT_FLAGS.map(([k]) => k));
|
||||
const str = (v: unknown, d: string) => (typeof v === "string" && v ? v : d);
|
||||
|
||||
/** ps.flt merged over the default. q is always empty. Bad values fall back to the default. */
|
||||
export function loadFilters(): Filters {
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(lsGet(FLT_KEY) ?? "{}") as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
raw = {};
|
||||
}
|
||||
const views: Record<string, ViewKind> = {};
|
||||
if (raw.views && typeof raw.views === "object") {
|
||||
for (const [k, v] of Object.entries(raw.views as Record<string, unknown>)) if (v === "board" || v === "list") views[k] = v;
|
||||
}
|
||||
return {
|
||||
q: "",
|
||||
dept: str(raw.dept, ALL),
|
||||
status: str(raw.status, ALL),
|
||||
stall: str(raw.stall, ALL),
|
||||
who: str(raw.who, ALL),
|
||||
flag: str(raw.flag, ALL),
|
||||
flags: Array.isArray(raw.flags) ? raw.flags.filter((x): x is string => typeof x === "string" && FLAG_KEYS.has(x)) : [],
|
||||
views,
|
||||
sort: typeof raw.sort === "string" && SORT_KEYS.has(raw.sort) ? (raw.sort as SortKey) : "updated",
|
||||
more: raw.more === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The saved form: everything but q. */
|
||||
export function savedForm(f: Filters): Omit<Filters, "q"> {
|
||||
const { q: _q, ...rest } = f;
|
||||
void _q;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Saves to ps.flt without q. */
|
||||
export function saveFilters(f: Filters): void {
|
||||
lsSet(FLT_KEY, JSON.stringify(savedForm(f)));
|
||||
}
|
||||
|
||||
/* ---------- the project pick (ps.proj) ---------- */
|
||||
|
||||
interface SavedPick {
|
||||
project_id: number;
|
||||
unit_id: number | null;
|
||||
project: ProjectRef;
|
||||
unit: Unit | null;
|
||||
}
|
||||
|
||||
export const NO_PICK: UnitPick = { project: null, unit: null };
|
||||
|
||||
export function loadPick(): UnitPick {
|
||||
try {
|
||||
const raw = JSON.parse(lsGet(PROJ_KEY) ?? "null") as SavedPick | null;
|
||||
if (!raw || typeof raw !== "object" || typeof raw.project_id !== "number" || !raw.project) return NO_PICK;
|
||||
const unit = raw.unit && typeof raw.unit.id === "number" && raw.unit.id === raw.unit_id ? raw.unit : null;
|
||||
return { project: { ...raw.project, id: raw.project_id }, unit };
|
||||
} catch {
|
||||
return NO_PICK;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePick(p: UnitPick): void {
|
||||
if (!p.project) {
|
||||
lsSet(PROJ_KEY, null);
|
||||
return;
|
||||
}
|
||||
const saved: SavedPick = { project_id: p.project.id, unit_id: p.unit?.id ?? null, project: p.project, unit: p.unit };
|
||||
lsSet(PROJ_KEY, JSON.stringify(saved));
|
||||
}
|
||||
|
||||
/* ---------- what each screen shows ---------- */
|
||||
|
||||
/** Which find bar parts a screen shows. Hidden parts never filter. */
|
||||
export interface ScreenParts {
|
||||
project: boolean;
|
||||
dept: boolean;
|
||||
stall: boolean;
|
||||
who: boolean;
|
||||
flag: boolean;
|
||||
mine: boolean;
|
||||
view: boolean;
|
||||
}
|
||||
|
||||
export function partsFor(screen: Screen): ScreenParts {
|
||||
const stall = screen === "stall";
|
||||
return {
|
||||
project: !stall,
|
||||
dept: !stall,
|
||||
stall: !stall,
|
||||
who: !stall,
|
||||
flag: screen === "clerical" || screen === "pm",
|
||||
mine: !stall && screen !== "pm",
|
||||
view: screen !== "pm",
|
||||
};
|
||||
}
|
||||
|
||||
/** The filters with the parts the screen hides set back to their default. */
|
||||
export function effective(f: Filters, screen?: Screen): Filters {
|
||||
if (!screen) return f;
|
||||
const s = partsFor(screen);
|
||||
return {
|
||||
...f,
|
||||
dept: s.dept ? f.dept : ALL,
|
||||
stall: s.stall ? f.stall : ALL,
|
||||
who: s.who ? f.who : ALL,
|
||||
flag: s.flag ? f.flag : ALL,
|
||||
flags: s.mine ? f.flags : f.flags.filter((x) => x !== "mine"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The old fltAdvCount: the advanced selects that are set. */
|
||||
export function countAdvanced(f: Filters, screen?: Screen): number {
|
||||
const e = effective(f, screen);
|
||||
return [e.dept, e.status, e.stall, e.who, e.flag].filter((v) => v !== ALL).length;
|
||||
}
|
||||
|
||||
/** The old fltCountActive: everything that narrows the boards, the project pick included. */
|
||||
export function countActive(f: Filters, projectPicked: boolean, screen?: Screen): number {
|
||||
const e = effective(f, screen);
|
||||
return (e.q.trim() ? 1 : 0) + countAdvanced(e) + e.flags.length + (projectPicked ? 1 : 0);
|
||||
}
|
||||
|
||||
/** The project and unit part of a query. KPI numbers follow only this. */
|
||||
export function pickQuery(pick: UnitPick): PacketListQuery {
|
||||
if (pick.unit) return { unit_id: pick.unit.id };
|
||||
if (pick.project) return { project_id: pick.project.id };
|
||||
return {};
|
||||
}
|
||||
|
||||
/** The server query for the filters and the pick. Defaults are left out. */
|
||||
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stationId?: number | null }): PacketListQuery {
|
||||
const e = effective(f, opts.screen);
|
||||
const out: PacketListQuery = {};
|
||||
const q = e.q.trim();
|
||||
if (q) out.q = q;
|
||||
if (e.dept !== ALL) out.dept = e.dept;
|
||||
if (e.status !== ALL) out.status = e.status;
|
||||
if (e.stall !== ALL && Number.isFinite(Number(e.stall))) out.station_id = Number(e.stall);
|
||||
if (e.who !== ALL) out.welder_id = e.who;
|
||||
if (e.flag !== ALL) out.flag = e.flag;
|
||||
if (e.flags.length) {
|
||||
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
|
||||
if (e.flags.includes("mine")) {
|
||||
out.mine_screen = opts.screen;
|
||||
if (opts.stationId) out.mine_station_id = opts.stationId;
|
||||
}
|
||||
}
|
||||
if (e.sort !== "updated") out.sort = e.sort;
|
||||
return { ...out, ...pickQuery(pick) };
|
||||
}
|
||||
|
||||
/* ---------- KPI taps ---------- */
|
||||
|
||||
export type KpiKey = "status" | "flag";
|
||||
|
||||
/**
|
||||
* A KPI tap toggles its filter. flag with a quick flag value (co, short, ...) toggles the chip; flag with a flag
|
||||
* kind (any, rev, ...) toggles the Flag select. Turning a select on opens the advanced row.
|
||||
*/
|
||||
export function kpiFilter(f: Filters, key: KpiKey, value: string): Partial<Filters> {
|
||||
if (key === "flag" && FLAG_KEYS.has(value)) {
|
||||
return { flags: f.flags.includes(value) ? f.flags.filter((x) => x !== value) : [...f.flags, value] };
|
||||
}
|
||||
const field = key === "flag" ? "flag" : "status";
|
||||
const on = f[field] !== value;
|
||||
return on ? { [field]: value, more: true } : { [field]: ALL };
|
||||
}
|
||||
|
||||
export function kpiActive(f: Filters, key: KpiKey, value: string): boolean {
|
||||
if (key === "flag" && FLAG_KEYS.has(value)) return f.flags.includes(value);
|
||||
return (key === "flag" ? f.flag : f.status) === value;
|
||||
}
|
||||
|
||||
/** Scrolls the find bar into view, after the render that follows a KPI tap. */
|
||||
export function scrollToFindBar(): void {
|
||||
nextFrame(() => {
|
||||
const el = document.querySelector<HTMLElement>(".fltbar");
|
||||
if (el && typeof el.scrollIntoView === "function") {
|
||||
el.scrollIntoView({ behavior: document.body.classList.contains("motion") ? "smooth" : "auto", block: "start" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- the hook ---------- */
|
||||
|
||||
export interface FiltersState {
|
||||
f: Filters;
|
||||
set: (patch: Partial<Filters>) => void;
|
||||
clear: () => void;
|
||||
pick: UnitPick;
|
||||
setPick: (p: UnitPick) => void;
|
||||
view: ViewKind;
|
||||
setView: (v: ViewKind) => void;
|
||||
/** The whole query, with q debounced 250 ms. */
|
||||
query: PacketListQuery;
|
||||
queryKey: string;
|
||||
/** The project and unit only, for the KPI numbers. */
|
||||
scope: PacketListQuery;
|
||||
scopeKey: string;
|
||||
/** A KPI tap: toggles the filter and scrolls the find bar into view when it turns on. */
|
||||
tapKpi: (key: KpiKey, value: string) => void;
|
||||
isKpiOn: (key: KpiKey, value: string) => boolean;
|
||||
/** The filters as they apply on this screen (hidden parts left out). */
|
||||
active: number;
|
||||
}
|
||||
|
||||
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
|
||||
export function useFilters(screen: Screen, opts: { stationId?: number | null } = {}): FiltersState {
|
||||
const [f, setF] = useState<Filters>(loadFilters);
|
||||
const [pick, setPickState] = useState<UnitPick>(loadPick);
|
||||
const [dq, setDq] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const next = f.q.trim();
|
||||
if (next === dq) return;
|
||||
// Clearing the box applies at once; typing waits for a pause.
|
||||
if (!next) {
|
||||
setDq("");
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(() => setDq(next), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [f.q, dq]);
|
||||
|
||||
const saved = useMemo(() => JSON.stringify(savedForm(f)), [f]);
|
||||
useEffect(() => {
|
||||
lsSet(FLT_KEY, saved);
|
||||
}, [saved]);
|
||||
|
||||
const set = useCallback((patch: Partial<Filters>) => setF((prev) => ({ ...prev, ...patch })), []);
|
||||
const setPick = useCallback((p: UnitPick) => {
|
||||
setPickState(p);
|
||||
savePick(p);
|
||||
}, []);
|
||||
const clear = useCallback(() => {
|
||||
setF((prev) => ({ ...FLT_DEFAULT, views: prev.views, sort: prev.sort }));
|
||||
setDq("");
|
||||
setPick(NO_PICK);
|
||||
}, [setPick]);
|
||||
|
||||
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
|
||||
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
|
||||
|
||||
const stationId = opts.stationId ?? null;
|
||||
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen, stationId }), [f, dq, pick, screen, stationId]);
|
||||
const queryKey = JSON.stringify(query);
|
||||
const scope = useMemo(() => pickQuery(pick), [pick]);
|
||||
const scopeKey = JSON.stringify(scope);
|
||||
|
||||
const tapKpi = useCallback(
|
||||
(key: KpiKey, value: string) => {
|
||||
const on = !kpiActive(f, key, value);
|
||||
set(kpiFilter(f, key, value));
|
||||
if (on) scrollToFindBar();
|
||||
},
|
||||
[f, set],
|
||||
);
|
||||
const isKpiOn = useCallback((key: KpiKey, value: string) => kpiActive(effective(f, screen), key, value), [f, screen]);
|
||||
|
||||
return {
|
||||
f,
|
||||
set,
|
||||
clear,
|
||||
pick,
|
||||
setPick,
|
||||
view,
|
||||
setView,
|
||||
query,
|
||||
queryKey,
|
||||
scope,
|
||||
scopeKey,
|
||||
tapKpi,
|
||||
isKpiOn,
|
||||
active: countActive(f, !!pick.project, screen),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- local sort (the board columns merged into one list) ---------- */
|
||||
|
||||
const natural = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true });
|
||||
const byUpdatedDesc = (a: PacketCard, b: PacketCard) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
|
||||
|
||||
/** The same order the server's sort= gives, for lists merged from several board columns. */
|
||||
export function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
|
||||
switch (k) {
|
||||
case "project":
|
||||
return (a, b) =>
|
||||
natural(a.project.number, b.project.number) || natural(a.unit.number, b.unit.number) || (a.op ?? "").localeCompare(b.op ?? "");
|
||||
case "due":
|
||||
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999") || byUpdatedDesc(a, b);
|
||||
case "status":
|
||||
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status) || byUpdatedDesc(a, b);
|
||||
case "dept":
|
||||
return (a, b) => natural(a.dept.code, b.dept.code) || byUpdatedDesc(a, b);
|
||||
default:
|
||||
return byUpdatedDesc;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards from several columns, once each, in column order. */
|
||||
export function uniqueCards<T extends { id: number }>(lists: ReadonlyArray<readonly T[]>): T[] {
|
||||
const seen = new Set<number>();
|
||||
const out: T[] = [];
|
||||
for (const list of lists) {
|
||||
for (const c of list) {
|
||||
if (seen.has(c.id)) continue;
|
||||
seen.add(c.id);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||
import { applyMotionClass, countStep, MOTION_KEY, motionOn, setMotion, useMotion } from "./motion";
|
||||
|
||||
function media(reduce: boolean) {
|
||||
vi.stubGlobal("matchMedia", (q: string) => ({ matches: reduce && q.includes("reduce"), addEventListener: () => undefined, removeEventListener: () => undefined }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
document.body.classList.remove("motion");
|
||||
});
|
||||
|
||||
describe("motion", () => {
|
||||
it("is on by default and off when ps.motion says off", () => {
|
||||
media(false);
|
||||
expect(motionOn()).toBe(true);
|
||||
window.localStorage.setItem(MOTION_KEY, "off");
|
||||
expect(motionOn()).toBe(false);
|
||||
window.localStorage.setItem(MOTION_KEY, "anything");
|
||||
expect(motionOn()).toBe(true);
|
||||
});
|
||||
|
||||
it("reduced motion always wins", () => {
|
||||
media(true);
|
||||
window.localStorage.setItem(MOTION_KEY, "on");
|
||||
expect(motionOn()).toBe(false);
|
||||
applyMotionClass();
|
||||
expect(document.body).not.toHaveClass("motion");
|
||||
});
|
||||
|
||||
it("setMotion writes the key, sets the body class and updates useMotion", () => {
|
||||
media(false);
|
||||
const { result } = renderHook(() => useMotion());
|
||||
expect(result.current).toBe(true);
|
||||
act(() => setMotion(false));
|
||||
expect(window.localStorage.getItem(MOTION_KEY)).toBe("off");
|
||||
expect(document.body).not.toHaveClass("motion");
|
||||
expect(result.current).toBe(false);
|
||||
act(() => setMotion(true));
|
||||
expect(window.localStorage.getItem(MOTION_KEY)).toBe("on");
|
||||
expect(document.body).toHaveClass("motion");
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("counts with an ease-out cubic", () => {
|
||||
expect(countStep(10, 20, 0)).toBe(10);
|
||||
expect(countStep(10, 20, 0.5)).toBe(19);
|
||||
expect(countStep(10, 20, 1)).toBe(20);
|
||||
expect(countStep(20, 10, 2)).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Motion (the old motion.js): short animation that shows what changed. One switch per browser in the top bar.
|
||||
// prefers-reduced-motion: reduce always wins. body.motion is set while motion is on; the CSS keys off it.
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const MOTION_KEY = "ps.motion";
|
||||
const REDUCE = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function reduced(): boolean {
|
||||
try {
|
||||
return typeof window.matchMedia === "function" && window.matchMedia(REDUCE).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stored(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(MOTION_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** On unless the browser asks for reduced motion or the switch says off. */
|
||||
export function motionOn(): boolean {
|
||||
if (reduced()) return false;
|
||||
return stored() !== "off";
|
||||
}
|
||||
|
||||
/** body.motion follows motionOn(). */
|
||||
export function applyMotionClass(): void {
|
||||
if (typeof document === "undefined" || !document.body) return;
|
||||
document.body.classList.toggle("motion", motionOn());
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
function emit(): void {
|
||||
applyMotionClass();
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
/** Turns motion on or off for this browser. Reduced motion still wins. */
|
||||
export function setMotion(on: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(MOTION_KEY, on ? "on" : "off");
|
||||
} catch {
|
||||
// Storage blocked: the switch lasts for this page only.
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
let mq: MediaQueryList | null = null;
|
||||
try {
|
||||
mq = typeof window.matchMedia === "function" ? window.matchMedia(REDUCE) : null;
|
||||
} catch {
|
||||
mq = null;
|
||||
}
|
||||
const onMq = () => emit();
|
||||
mq?.addEventListener?.("change", onMq);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === MOTION_KEY) emit();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
mq?.removeEventListener?.("change", onMq);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether motion is on, kept current with the switch, other tabs and the reduced-motion setting. */
|
||||
export function useMotion(): boolean {
|
||||
return useSyncExternalStore(subscribe, motionOn, () => false);
|
||||
}
|
||||
|
||||
/** Restarts a CSS animation class on an element (remove, reflow, add), and takes it off after `ms`. */
|
||||
export function replayClass(el: HTMLElement | null, cls: string, ms: number): () => void {
|
||||
if (!el) return () => undefined;
|
||||
el.classList.remove(cls);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
const t = window.setTimeout(() => el.classList.remove(cls), ms);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
el.classList.remove(cls);
|
||||
};
|
||||
}
|
||||
|
||||
/** The KPI count-up: 420 ms, then the tile keeps .m-bump for 500 ms. */
|
||||
export const COUNT_MS = 420;
|
||||
export const BUMP_MS = 500;
|
||||
|
||||
/** Ease-out cubic from a to z at fraction t, rounded to a whole number. */
|
||||
export function countStep(a: number, z: number, t: number): number {
|
||||
const f = Math.min(1, Math.max(0, t));
|
||||
return Math.round(a + (z - a) * (1 - Math.pow(1 - f, 3)));
|
||||
}
|
||||
|
||||
/** requestAnimationFrame, or a 16 ms timer where there is none. */
|
||||
export function nextFrame(cb: (t: number) => void): number {
|
||||
if (typeof window.requestAnimationFrame === "function") return window.requestAnimationFrame(cb);
|
||||
return window.setTimeout(() => cb(performance.now()), 16);
|
||||
}
|
||||
|
||||
export function cancelFrame(id: number): void {
|
||||
if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
|
||||
else window.clearTimeout(id);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { createMemoryRouter, Outlet, RouterProvider, useNavigate } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { rememberScroll, resetScroll, savedScroll, SCROLL_KEEP, useScrollRestore } from "./scrollRestore";
|
||||
|
||||
let y = 0;
|
||||
const scrollTo = vi.fn((_x: number, to: number) => {
|
||||
y = to;
|
||||
});
|
||||
|
||||
function Frame() {
|
||||
useScrollRestore();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => navigate("/packets/1")}>
|
||||
Open
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</button>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetScroll();
|
||||
y = 0;
|
||||
scrollTo.mockClear();
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, get: () => y });
|
||||
vi.stubGlobal("scrollTo", scrollTo);
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 16));
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("scroll restore", () => {
|
||||
it("keeps at most 40 positions, oldest out first", () => {
|
||||
for (let i = 0; i < SCROLL_KEEP + 5; i++) rememberScroll(`k${i}`, i * 10);
|
||||
expect(savedScroll("k0")).toBeNull();
|
||||
expect(savedScroll("k4")).toBeNull();
|
||||
expect(savedScroll("k5")).toBe(50);
|
||||
expect(savedScroll(`k${SCROLL_KEEP + 4}`)).toBe((SCROLL_KEEP + 4) * 10);
|
||||
});
|
||||
|
||||
it("a push starts at the top, and Back lands on the same spot", () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
element: <Frame />,
|
||||
children: [
|
||||
{ path: "/supervisor", element: <h1>Home</h1> },
|
||||
{ path: "/packets/:id", element: <h1>Packet</h1> },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/supervisor"] },
|
||||
);
|
||||
render(<RouterProvider router={router} />);
|
||||
// The person scrolls down the board.
|
||||
y = 900;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
act(() => screen.getByRole("button", { name: "Open" }).click());
|
||||
expect(screen.getByRole("heading", { name: "Packet" })).toBeInTheDocument();
|
||||
expect(y).toBe(0);
|
||||
act(() => screen.getByRole("button", { name: "Back" }).click());
|
||||
expect(screen.getByRole("heading", { name: "Home" })).toBeInTheDocument();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(y).toBe(900);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// Back lands on the same spot (the old navPush and navBack). Layout records window.scrollY per history entry
|
||||
// before the next page draws, and puts it back on a POP after the page has drawn.
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useLocation, useNavigationType } from "react-router";
|
||||
import { cancelFrame, nextFrame } from "./motion";
|
||||
|
||||
/** At most this many positions are kept. */
|
||||
export const SCROLL_KEEP = 40;
|
||||
|
||||
const positions = new Map<string, number>();
|
||||
|
||||
/** Remembers the scroll position of one history entry. The oldest go first past SCROLL_KEEP. */
|
||||
export function rememberScroll(key: string, y: number): void {
|
||||
positions.delete(key);
|
||||
positions.set(key, Math.max(0, Math.round(y)));
|
||||
while (positions.size > SCROLL_KEEP) {
|
||||
const oldest = positions.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
positions.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function savedScroll(key: string): number | null {
|
||||
return positions.get(key) ?? null;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetScroll(): void {
|
||||
positions.clear();
|
||||
}
|
||||
|
||||
function scrollTo(y: number): void {
|
||||
if (Math.abs(window.scrollY - y) < 1) return;
|
||||
try {
|
||||
window.scrollTo(0, y);
|
||||
} catch {
|
||||
// jsdom has no scrolling.
|
||||
}
|
||||
}
|
||||
|
||||
/** Extra tries after the 150 ms one, for a board whose data lands later. Each stops once the page is there. */
|
||||
export const RESTORE_RETRIES_MS = [400, 1000];
|
||||
|
||||
/**
|
||||
* Restores `y` after the page has drawn: on the next frame, and again at 150 ms once the screen has eased in
|
||||
* (the old way), then at 400 and 1000 ms while the page is still short of it. The user's own wheel, touch or
|
||||
* key stops it. Returns a cancel function.
|
||||
*/
|
||||
export function restoreScroll(y: number): () => void {
|
||||
const timers: number[] = [];
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
for (const t of timers) window.clearTimeout(t);
|
||||
cancelFrame(raf);
|
||||
for (const ev of USER_EVENTS) window.removeEventListener(ev, stop);
|
||||
};
|
||||
const go = (last: boolean) => {
|
||||
if (stopped) return;
|
||||
scrollTo(y);
|
||||
if (last) stop();
|
||||
};
|
||||
for (const ev of USER_EVENTS) window.addEventListener(ev, stop, { passive: true });
|
||||
const raf = nextFrame(() => go(false));
|
||||
timers.push(window.setTimeout(() => go(false), 150));
|
||||
RESTORE_RETRIES_MS.forEach((ms, i) => timers.push(window.setTimeout(() => go(i === RESTORE_RETRIES_MS.length - 1), ms)));
|
||||
return stop;
|
||||
}
|
||||
|
||||
const USER_EVENTS = ["wheel", "touchstart", "keydown"] as const;
|
||||
|
||||
/**
|
||||
* Keeps the scroll position per location.key. A POP (Back, Forward) restores the entry's position; a PUSH or
|
||||
* REPLACE to a new path starts at the top.
|
||||
*/
|
||||
export function useScrollRestore(): void {
|
||||
const loc = useLocation();
|
||||
const nav = useNavigationType();
|
||||
const prev = useRef<{ key: string; path: string } | null>(null);
|
||||
const yNow = useRef(0);
|
||||
const pending = useRef<(() => void) | null>(null);
|
||||
|
||||
// The last known position of the page being left. A scroll listener keeps it, because by the time the
|
||||
// new location renders the old page may already be gone and the window clamped.
|
||||
useLayoutEffect(() => {
|
||||
const on = () => {
|
||||
yNow.current = window.scrollY;
|
||||
};
|
||||
on();
|
||||
window.addEventListener("scroll", on, { passive: true });
|
||||
return () => window.removeEventListener("scroll", on);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const before = prev.current;
|
||||
if (!before || before.key === loc.key) {
|
||||
prev.current = { key: loc.key, path: loc.pathname };
|
||||
return;
|
||||
}
|
||||
rememberScroll(before.key, yNow.current);
|
||||
prev.current = { key: loc.key, path: loc.pathname };
|
||||
pending.current?.();
|
||||
pending.current = null;
|
||||
if (nav === "POP") {
|
||||
const y = savedScroll(loc.key);
|
||||
if (y !== null) pending.current = restoreScroll(y);
|
||||
} else if (before.path !== loc.pathname) {
|
||||
scrollTo(0);
|
||||
}
|
||||
}, [loc.key, loc.pathname, nav]);
|
||||
|
||||
// A restore still running when the app unmounts stops.
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
pending.current?.();
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Cards glide to their new place (FLIP), new cards fade in, and cards changed elsewhere glow (the old motionAfter).
|
||||
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||||
import { motionOn } from "./motion";
|
||||
|
||||
export const FLIP_EASE = "cubic-bezier(.2,.7,.2,1)";
|
||||
/** More moves or new cards than this: the board just appears (a filter, a big sync). */
|
||||
export const FLIP_MAX = 40;
|
||||
|
||||
const GLOW: Keyframe[] = [
|
||||
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
|
||||
{ boxShadow: "0 0 0 3px rgba(73,192,222,.75), 0 0 22px rgba(73,192,222,.45)", offset: 0.15 },
|
||||
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
|
||||
];
|
||||
|
||||
interface Place {
|
||||
box: string;
|
||||
x: number;
|
||||
y: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/** The column or section a card sits in. A panel opening above pushes every card down; that is not a move. */
|
||||
function boxOf(el: Element): Element | null {
|
||||
return el.closest(".col") ?? el.closest("section") ?? el.parentElement;
|
||||
}
|
||||
|
||||
function boxKey(c: Element | null): string {
|
||||
if (!c) return "";
|
||||
const h = c.querySelector(".col-h, .sec-h h2, h2, h3");
|
||||
return `${c.className}|${(h?.textContent ?? "").replace(/\d+/g, "").trim()}`;
|
||||
}
|
||||
|
||||
/** Where a card sits: inside its column, and inside the board (so a page scroll is not a move). */
|
||||
function placeOf(el: Element, rootRect: { left: number; top: number }): Place {
|
||||
const r = el.getBoundingClientRect();
|
||||
const c = boxOf(el);
|
||||
const cr = c ? c.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
return { box: boxKey(c), x: r.left - cr.left, y: r.top - cr.top, left: r.left - rootRect.left, top: r.top - rootRect.top };
|
||||
}
|
||||
|
||||
/** Moves to animate: [element, dx, dy] from the old place to the new. */
|
||||
export function flipMoves(before: ReadonlyMap<string, Place>, after: ReadonlyArray<[string, Element, Place]>): Array<[Element, number, number]> {
|
||||
const out: Array<[Element, number, number]> = [];
|
||||
for (const [k, el, n] of after) {
|
||||
const o = before.get(k);
|
||||
if (!o) continue;
|
||||
const same = o.box === n.box;
|
||||
const dx = same ? o.x - n.x : o.left - n.left;
|
||||
const dy = same ? o.y - n.y : o.top - n.top;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) out.push([el, dx, dy]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* FLIP for the cards on the page that holds `ref` (its `main`, else `ref` itself). `sigs` maps a card id to its signature (status, wh stage, station, open CO
|
||||
* count); a card whose signature changed since the last render glows. Cards carry data-card-id.
|
||||
* Before the dependent render the rects are taken from the last layout; after it, moved cards glide.
|
||||
*/
|
||||
export function useFlip(ref: RefObject<HTMLElement | null>, selector = "[data-card-id]", deps: readonly unknown[] = [], sigs?: ReadonlyMap<string, string>): void {
|
||||
const last = useRef<Map<string, Place>>(new Map());
|
||||
const lastSig = useRef<Map<string, string>>(new Map());
|
||||
const first = useRef(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Cards are tracked across the whole page (the old M_SEL under #main), so a card that leaves a board for the
|
||||
// station grid, or comes back, still glides. One board per screen drives it.
|
||||
const root = ref.current?.closest("main") ?? ref.current;
|
||||
if (!root) return;
|
||||
const els = Array.from(root.querySelectorAll(selector));
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const now: Array<[string, Element, Place]> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const el of els) {
|
||||
const k = (el as HTMLElement).dataset.cardId ?? "";
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
now.push([k, el, placeOf(el, rootRect)]);
|
||||
}
|
||||
const before = last.current;
|
||||
last.current = new Map(now.map(([k, , p]) => [k, p]));
|
||||
|
||||
const changed = new Set<string>();
|
||||
if (sigs) {
|
||||
for (const [k, s] of sigs) {
|
||||
const o = lastSig.current.get(k);
|
||||
if (o !== undefined && o !== s) changed.add(k);
|
||||
}
|
||||
lastSig.current = new Map(sigs);
|
||||
}
|
||||
|
||||
const wasFirst = first.current;
|
||||
first.current = false;
|
||||
if (wasFirst || !motionOn() || typeof (Element.prototype as Partial<Element>).animate !== "function") return;
|
||||
|
||||
const vh = window.innerHeight || 900;
|
||||
const visible = now.filter(([, , p]) => p.top + rootRect.top > -200 && p.top + rootRect.top < vh + 200);
|
||||
const moves = flipMoves(before, visible);
|
||||
const fresh = before.size ? visible.filter(([k]) => !before.has(k)).map(([, el]) => el) : [];
|
||||
if (moves.length <= FLIP_MAX) {
|
||||
for (const [el, dx, dy] of moves) {
|
||||
el.animate([{ transform: `translate(${dx}px,${dy}px)`, zIndex: 5 }, { transform: "none", zIndex: 5 }], {
|
||||
duration: Math.min(420, 220 + Math.hypot(dx, dy) / 6),
|
||||
easing: FLIP_EASE,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fresh.length <= FLIP_MAX) {
|
||||
for (const el of fresh) el.animate([{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 240, easing: FLIP_EASE });
|
||||
}
|
||||
for (const [k, el] of now) if (changed.has(k)) el.animate(GLOW, { duration: 1700, easing: "ease-out" });
|
||||
// The caller lists what the render depends on.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
/** A card's signature: what changing elsewhere makes it glow. */
|
||||
export function cardSig(p: { status: string; wh_stage?: string | null; station?: { id: number } | null; open_cos?: unknown }): string {
|
||||
const cos = Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
|
||||
return [p.status, p.wh_stage ?? "", p.station?.id ?? "", cos].join("|");
|
||||
}
|
||||
|
||||
/** Signatures for a list of cards. */
|
||||
export function sigMap(cards: Iterable<{ id: number } & Parameters<typeof cardSig>[0]>): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const c of cards) m.set(String(c.id), cardSig(c));
|
||||
return m;
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { createRoot } from "react-dom/client";
|
||||
import "./theme/fonts";
|
||||
import "./theme/theme.css";
|
||||
import App from "./App";
|
||||
import { applyMotionClass } from "./lib/motion";
|
||||
|
||||
// body.motion before the first paint, so nothing animates for someone who turned it off.
|
||||
applyMotionClass();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root element");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
||||
import { homePath } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { usePacketPresence } from "../auth/useEvents";
|
||||
import { presenceText, usePacketPresence } from "../auth/useEvents";
|
||||
import { useGuard } from "../components/useGuard";
|
||||
import { useChanged } from "../lib/changes";
|
||||
import { errorText } from "../lib/format";
|
||||
@@ -116,6 +116,10 @@ export function PacketPage() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
||||
// Phase 8: Back is one step back in this app's history (to the same scroll spot); a packet opened straight
|
||||
// from a link has no app entry before it ("default"), so Back goes to the screen home.
|
||||
const location = useLocation();
|
||||
const hasAppHistory = location.key !== "default";
|
||||
const versionRef = useRef<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -342,7 +346,15 @@ export function PacketPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back" to={backTo}>
|
||||
<Link
|
||||
className="back"
|
||||
to={backTo}
|
||||
onClick={(e) => {
|
||||
if (!hasAppHistory || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
navigate(-1);
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<div className="dhead">
|
||||
@@ -464,7 +476,7 @@ export function PacketPage() {
|
||||
{others.length ? (
|
||||
<div className="presence small" data-testid="presence" role="status">
|
||||
<span className="pdot" aria-hidden="true" />
|
||||
Also here: {others.map((u) => u.display_name).join(", ")}
|
||||
{presenceText(others)}
|
||||
</div>
|
||||
) : null}
|
||||
{msg ? (
|
||||
@@ -628,7 +640,7 @@ export function PacketPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
||||
<div key={shownTab} className="m-tab" role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
||||
{shownTab === "drawing" ? (
|
||||
<div className="stack">
|
||||
{draft && can("edit_document_set") && drawingPages.length ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { ClericalHome } from "./clerical/ClericalHome";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
import { PmHome } from "./pm/PmHome";
|
||||
import { QcHome } from "./qc/QcHome";
|
||||
import { StallHome } from "./stall/StallHome";
|
||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||
@@ -31,5 +32,6 @@ export function ScreenRoute() {
|
||||
if (screen === "supervisor") return <SupervisorHome />;
|
||||
if (screen === "stall") return <StallHome />;
|
||||
if (screen === "qc") return <QcHome />;
|
||||
if (screen === "pm") return <PmHome />;
|
||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||
import { makeCard, makeProject, makeUnit } from "../../test/packets";
|
||||
|
||||
const CARDS = [
|
||||
@@ -75,6 +76,10 @@ function column(title: string): HTMLElement {
|
||||
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("ClericalHome", () => {
|
||||
it("renders the boards and the numbers from the list answer", async () => {
|
||||
mockFetch(base());
|
||||
@@ -122,10 +127,12 @@ describe("ClericalHome", () => {
|
||||
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"));
|
||||
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7&facets=1"));
|
||||
|
||||
fireEvent.change(unit, { target: { value: "12" } });
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12&facets=1"));
|
||||
// The pick is kept for the next visit.
|
||||
expect(JSON.parse(window.localStorage.getItem("ps.proj") ?? "null")).toMatchObject({ project_id: 7, unit_id: 12 });
|
||||
});
|
||||
|
||||
it("the new project sheet posts and then opens Add units", async () => {
|
||||
@@ -194,35 +201,59 @@ describe("ClericalHome", () => {
|
||||
expect(callsTo(fetch, "POST", "/api/feed/ignored/5/pull-in")).toEqual([{ reason: "Customer wants it here" }]);
|
||||
});
|
||||
|
||||
it("the flag kind select filters the list and the Flagged column shows the flag text", async () => {
|
||||
it("the Flag select sends the flag kind and the Flagged column shows the flag text; the numbers stay", async () => {
|
||||
const flagged = makeCard({ id: 9, source_kind: "visual", flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", flag_kinds: ["rev_mismatch"] });
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"/api/packets?limit=500&flag=rev_mismatch": { body: { packets: [flagged] } },
|
||||
"/api/packets?limit=500&flag=rev": { body: { packets: [flagged] } },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
||||
const select = within(bar).getByLabelText("Flags");
|
||||
fireEvent.click(within(bar).getByRole("button", { name: /^Filters/ }));
|
||||
const select = within(bar).getByLabelText("Flag");
|
||||
expect(within(select).getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"All packets",
|
||||
"All flags",
|
||||
"Drawing needed",
|
||||
"Rev mismatch",
|
||||
"Not released",
|
||||
"Not found",
|
||||
"Lookup failed",
|
||||
"Work order gone",
|
||||
"Cancelled",
|
||||
"Hand upload",
|
||||
"Any",
|
||||
"Flagged (any reason)",
|
||||
"Rev mismatch with M-Files",
|
||||
"Rev not confirmed yet",
|
||||
"Made or uploaded by hand",
|
||||
"Still in drafting",
|
||||
"Drawing not found in M-Files",
|
||||
"Drawing couldn't be read",
|
||||
"Drawing being looked up",
|
||||
"Not flagged",
|
||||
]);
|
||||
fireEvent.change(select, { target: { value: "rev_mismatch" } });
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev_mismatch"));
|
||||
fireEvent.change(select, { target: { value: "rev" } });
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev"));
|
||||
const col = await waitFor(() => {
|
||||
const c = column("Flagged");
|
||||
expect(within(c).getByText(/Rev mismatch: VISUAL calls rev 2/)).toHaveClass("due", "over");
|
||||
return c;
|
||||
});
|
||||
expect(within(col).queryByText("L999001 · WELD-PART", { exact: false })).toBeInTheDocument();
|
||||
expect(within(col).getAllByRole("link")).toHaveLength(1);
|
||||
// The numbers follow the project pick only.
|
||||
expect(screen.getByLabelText("Flagged: 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("In the shop: 2")).toBeInTheDocument();
|
||||
expect(within(bar).getByRole("button", { name: "Clear filters (1)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a KPI tap filters the boards, the quick chips go to the server, Clear filters resets them", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/clerical");
|
||||
const tile = await screen.findByRole("button", { name: "With warehouse: 1" });
|
||||
fireEvent.click(tile);
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse"));
|
||||
expect(screen.getByRole("button", { name: "With warehouse: 1" })).toHaveAttribute("aria-pressed", "true");
|
||||
const bar = screen.getByRole("region", { name: "Find jobs" });
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Shorts open" }));
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse&flags=short"));
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Mine" }));
|
||||
await waitFor(() =>
|
||||
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse&flags=short%2Cmine&mine_screen=clerical"),
|
||||
);
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Clear filters (3)" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "With warehouse: 1" })).toHaveAttribute("aria-pressed", "false"));
|
||||
expect(within(bar).getByRole("button", { name: "Shorts open" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(bar).queryByRole("button", { name: /^Clear filters/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,179 +1,56 @@
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { useMemo, useState } 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 { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
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 { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { byTime, clericalBoards } from "./boards";
|
||||
import { clericalBoards } from "./boards";
|
||||
import { FromVisualPanel } from "./FromVisualPanel";
|
||||
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" aria-label="Jobs">
|
||||
<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 { me, departments } = 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 flt = useFilters("clerical");
|
||||
const { f, query, queryKey, scope, scopeKey, pick } = flt;
|
||||
const [drafting, setDrafting] = useState(false);
|
||||
const [flag, setFlag] = useState("");
|
||||
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 } : {}), ...(flag ? { flag } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) },
|
||||
s,
|
||||
),
|
||||
`${query}|${flag}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||
// The numbers and the attention row follow the project pick only. The boards follow every filter, and
|
||||
// take the same answer when nothing else is set.
|
||||
const filtered = queryKey !== scopeKey;
|
||||
const [scopeLoad, reloadScope] = useLoad((s) => packetsApi.find(scope, s), scopeKey);
|
||||
const [boardLoad, reloadBoard] = useLoad((s) => (filtered ? packetsApi.list(query, s) : Promise.resolve(null)), `${queryKey}|${String(filtered)}`);
|
||||
useChanged(
|
||||
() => {
|
||||
reloadScope();
|
||||
reloadBoard();
|
||||
},
|
||||
{ delayMs: 500 },
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
|
||||
const scoped = useMemo(() => (scopeLoad.state === "ok" ? scopeLoad.data.packets : []), [scopeLoad]);
|
||||
// Until the filtered answer lands the boards keep what they show.
|
||||
const all = useMemo(() => (filtered && boardLoad.state === "ok" && boardLoad.data ? boardLoad.data : scoped), [filtered, boardLoad, scoped]);
|
||||
const k = useMemo(() => clericalBoards(scoped), [scoped]);
|
||||
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 ids = new Set(uniqueCards(b.columns.map((c) => c.cards)).map((p) => p.id));
|
||||
// The answer comes in the server's sort order.
|
||||
return all.filter((p) => ids.has(p.id));
|
||||
}, [b, all]);
|
||||
const total = useMemo(() => uniqueCards(k.columns.map((c) => c.cards)).length, [k]);
|
||||
const load = filtered ? boardLoad : scopeLoad;
|
||||
const ready = load.state === "ok" && scopeLoad.state === "ok";
|
||||
const editable = canEditProjects(me?.roles ?? []);
|
||||
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -193,42 +70,63 @@ export function ClericalHome() {
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: b.cos, 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 },
|
||||
{ n: k.cos, label: "Changes to approve", alert: true },
|
||||
{ n: k.flagged, label: "Flagged", alert: true, ...kpi("flag", "any") },
|
||||
{ n: k.warehouse, label: "With warehouse", ...kpi("status", "warehouse") },
|
||||
{ n: k.shop, label: "In the shop", ...kpi("status", "shop") },
|
||||
{ n: k.toClose, label: "Ready to close", alert: true, ...kpi("status", "complete") },
|
||||
]}
|
||||
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||
/>
|
||||
|
||||
{editable ? <FromVisualPanel /> : null}
|
||||
|
||||
{b.cos || b.flagged || b.toClose ? (
|
||||
{k.cos || k.flagged || k.toClose ? (
|
||||
<div className="attn" role="status">
|
||||
{b.cos ? (
|
||||
{k.cos ? (
|
||||
<div className="callout warn flush">
|
||||
<b>{b.cos === 1 ? "1 change from VISUAL waits" : `${b.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
|
||||
<b>{k.cos === 1 ? "1 change from VISUAL waits" : `${k.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
|
||||
packet until you approve it.
|
||||
</div>
|
||||
) : null}
|
||||
{b.flagged ? (
|
||||
{k.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.
|
||||
<b>{k.flagged === 1 ? "1 draft is flagged." : `${k.flagged} drafts are flagged.`}</b> Check {k.flagged === 1 ? "it" : "them"} and send {k.flagged === 1 ? "it" : "them"} on.
|
||||
</div>
|
||||
) : null}
|
||||
{b.toClose ? (
|
||||
{k.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.
|
||||
<b>{k.toClose === 1 ? "1 packet is" : `${k.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} flag={flag} onFlag={setFlag} />
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={flt.set}
|
||||
onClear={flt.clear}
|
||||
pick={pick}
|
||||
onPick={flt.setPick}
|
||||
view={flt.view}
|
||||
onView={flt.setView}
|
||||
count={ready ? shown.length : null}
|
||||
total={ready ? total : null}
|
||||
facets={scopeLoad.state === "ok" ? (scopeLoad.data.facets ?? null) : null}
|
||||
depts={departments}
|
||||
screen="clerical"
|
||||
/>
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "loading" || scopeLoad.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}
|
||||
{scopeLoad.state === "error" && filtered ? <div className="callout warn">{scopeLoad.message}</div> : null}
|
||||
{ready ? (
|
||||
flt.view === "board" ? (
|
||||
<Board columns={b.columns} />
|
||||
) : (
|
||||
<JobList rows={shown} sort={f.sort} onSort={(sort) => flt.set({ sort })} serverSorted />
|
||||
)
|
||||
) : null}
|
||||
|
||||
<ProjectsSection />
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PmOverview, PmScope, PmTable, PmTables } from "../../api/pm";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { makeCard, makeUnit } from "../../test/packets";
|
||||
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||
|
||||
const PAULA = makeMe({
|
||||
user: { sam_account: "pm.paula", display_name: "Paula PM" },
|
||||
roles: ["pm"],
|
||||
screens: ["pm"],
|
||||
groups: ["PS-ProjectMgmt"],
|
||||
});
|
||||
|
||||
const scope = (over: Partial<PmScope> = {}): PmScope => ({
|
||||
label: "All projects",
|
||||
count: 6,
|
||||
period: { value: "90", label: "Last 90 days", start: "2026-07-20T00:00:00Z" },
|
||||
project: null,
|
||||
unit: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const row = (cells: string[], over: Record<string, unknown> = {}) => ({ cells, pid: null, project_id: null, unit_id: null, lvl: 0, health: null, flag: false, ...over });
|
||||
|
||||
const ATTENTION: PmTable = {
|
||||
key: "attention",
|
||||
title: "Units late or at risk",
|
||||
note: null,
|
||||
head: ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"],
|
||||
rows: [row(["L670001", "North tank", "2026-10-10", "8 d over", "3", "33%", "Late"], { project_id: 7, unit_id: 12, lvl: 1, health: "late" })] as PmTable["rows"],
|
||||
};
|
||||
|
||||
const OVERVIEW: PmOverview = {
|
||||
scope: scope(),
|
||||
kpis: [
|
||||
{ key: "open", n: 6, label: "Open packets", tab: "aging", alert: false },
|
||||
{ key: "late_units", n: 1, label: "Late units", tab: "projects", alert: true },
|
||||
{ key: "risk_units", n: 0, label: "Units at risk", tab: "projects", alert: true },
|
||||
{ key: "over", n: 2, label: "Packets over target hours", tab: "hours", alert: true },
|
||||
{ key: "lead", n: "4.2 d", label: "Average lead time, created → filed", tab: "aging", alert: false },
|
||||
],
|
||||
summary: ["6 open packets in scope (All projects).", "Late: L670001."],
|
||||
pipeline: [
|
||||
{ key: "draft", label: "Ready to release (Clerical)", count: 2, avg_s: 3600, max_s: 7200, oldest: { pid: 1, job: "L670001 · A" }, slowest: false },
|
||||
{ key: "wh_hold", label: "Warehouse: on hold", count: 1, avg_s: 86_400 * 3, max_s: 86_400 * 3, oldest: { pid: 2, job: "L670001 · B" }, slowest: true },
|
||||
{ key: "qc", label: "At Q.C.", count: 0, avg_s: 0, max_s: 0, oldest: null, slowest: false },
|
||||
],
|
||||
attention: ATTENTION,
|
||||
};
|
||||
|
||||
const PROJECTS_HEAD = ["Project / unit", "Name", "Customer", "Due", "Left", "Units", "Packets", "Draft", "Warehouse", "Shop", "Q.C.", "Done", "% done", "Health"];
|
||||
|
||||
function projects(expanded: boolean, picked = false): PmTables {
|
||||
const rows = [row(["L670", "Tank farm", "Acme", "2026-11-01", "14 d", "2", "6", "2", "1", "2", "0", "1", "17%", "Late"], { project_id: 7, health: "late" })];
|
||||
if (expanded || picked) {
|
||||
rows.push(row(["L670001", "North tank", "", "2026-10-10", "8 d over", "", "3", "1", "1", "1", "0", "0", "33%", "Late"], { project_id: 7, unit_id: 12, lvl: 1, health: "late" }));
|
||||
}
|
||||
rows.push(row(["L680", "Skid", "Beta", "", "—", "0", "0", "0", "0", "0", "0", "0", "0%", "No packets yet"], { project_id: 8, health: "empty" }));
|
||||
return {
|
||||
scope: scope(),
|
||||
tables: [{ key: "projects", title: "Projects and units", note: "Tap a project to show its units.", head: PROJECTS_HEAD, rows: rows as PmTable["rows"] }],
|
||||
};
|
||||
}
|
||||
|
||||
const HOURS: PmTables = {
|
||||
scope: scope(),
|
||||
tables: [
|
||||
{
|
||||
key: "hours_over",
|
||||
title: "Packets over target",
|
||||
note: "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record.",
|
||||
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||
rows: [row(["L670001 · A", "Stall 1", "Working", "9.0", "8.5", "6.0", "+3.0 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
|
||||
},
|
||||
{ key: "hours_dept", title: "By department", note: null, head: ["Department", "Packets"], rows: [] },
|
||||
],
|
||||
};
|
||||
|
||||
function routes(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
"/api/auth/me": { body: PAULA },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/projects": { body: [] },
|
||||
"/api/units": { body: [makeUnit({ id: 12, project_id: 7, number: "L670001", lot: "001" })] },
|
||||
"/api/units/12": { body: makeUnit({ id: 12, project_id: 7, number: "L670001", lot: "001", project: { id: 7, number: "L670", name: "Tank farm" } }) },
|
||||
"/api/packets": { body: { packets: [], facets: { stations: [{ id: 3, name: "Stall 1", dept_code: "03" }], workers: [] } } },
|
||||
"/api/pm/overview": { body: OVERVIEW },
|
||||
"/api/pm/projects": (req: { url: string }) => ({ body: projects(req.url.includes("expand=7"), req.url.includes("project_id=7")) }),
|
||||
"/api/pm/aging": { body: { scope: scope(), pipeline: OVERVIEW.pipeline, tables: [] } },
|
||||
"/api/pm/hours": { body: HOURS },
|
||||
"/api/pm/quality": { body: { scope: scope(), tables: [] } },
|
||||
"/api/pm/find": { body: { scope: scope({ label: "Unit L670001", count: 1 }), packets: [makeCard({ id: 41 })] } },
|
||||
...extra,
|
||||
} as Parameters<typeof mockFetch>[0];
|
||||
}
|
||||
|
||||
const urls = (fetch: ReturnType<typeof mockFetch>) => fetch.mock.calls.map(([u]) => String(u));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe("PmHome", () => {
|
||||
it("shows the head, the tabs, the KPI tiles, the summary, the pipeline and the units at risk", async () => {
|
||||
mockFetch(routes());
|
||||
renderApp("/pm");
|
||||
expect(await screen.findByRole("heading", { name: "Where every job stands" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Project management · Status and reporting")).toBeInTheDocument();
|
||||
const tabs = screen.getByRole("tablist");
|
||||
expect(within(tabs).getAllByRole("tab").map((t) => t.textContent)).toEqual([
|
||||
"Overview",
|
||||
"Projects & units",
|
||||
"Bottlenecks & aging",
|
||||
"Hours vs target",
|
||||
"Quality & issues",
|
||||
"Find jobs",
|
||||
]);
|
||||
expect(within(tabs).getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await screen.findByText("In plain words");
|
||||
expect(screen.getByText("6 open packets in scope (All projects).")).toBeInTheDocument();
|
||||
const scopeLine = screen.getByTestId("pm-scope");
|
||||
expect(scopeLine).toHaveTextContent("Scope: All projects · 6 packets · history figures: last 90 days · view only");
|
||||
|
||||
expect(screen.getByTestId("pm-kpi-late_units")).toHaveClass("kpi", "hot");
|
||||
expect(screen.getByTestId("pm-kpi-risk_units")).toHaveClass("zero");
|
||||
expect(screen.getByTestId("pm-kpi-open")).not.toHaveClass("hot");
|
||||
expect(screen.getByTestId("pm-kpi-lead")).toHaveTextContent("4.2 d");
|
||||
|
||||
const bars = screen.getByRole("img", { name: "Open packets by step" });
|
||||
const hold = bars.querySelector('[data-step="wh_hold"]');
|
||||
expect(hold).toHaveClass("pmbar", "hot");
|
||||
expect(hold).toHaveTextContent("slowest");
|
||||
expect(bars.querySelector('[data-step="qc"]')).toHaveClass("zero");
|
||||
expect(bars.querySelector('[data-step="draft"]')).toHaveTextContent("2 · avg 1 h");
|
||||
|
||||
const att = screen.getByRole("table", { name: "Units late or at risk" });
|
||||
expect(within(att).getByText("Late")).toHaveClass("pill", "st-review");
|
||||
});
|
||||
|
||||
it("opens a tab from a KPI tile and from Details, and keeps it in ps.pmtab", async () => {
|
||||
const fetch = mockFetch(routes());
|
||||
renderApp("/pm");
|
||||
fireEvent.click(await screen.findByTestId("pm-kpi-over"));
|
||||
expect(await screen.findByRole("heading", { name: "Packets over target" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Hours vs target" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(window.localStorage.getItem("ps.pmtab")).toBe("hours");
|
||||
expect(urls(fetch)).toContain("/api/pm/hours?period=90");
|
||||
|
||||
const over = screen.getByRole("table", { name: "Packets over target" });
|
||||
const r = within(over).getByText("+3.0 h").closest("tr");
|
||||
expect(r).toHaveClass("flag", "rowlink");
|
||||
expect(screen.getByText("1 row")).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("table", { name: "By department" })).getByText("Nothing in scope.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Details" }));
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/aging?period=90"));
|
||||
expect(screen.getByRole("tab", { name: "Bottlenecks & aging" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("opens a project to its units and drills a unit down to Find jobs", async () => {
|
||||
window.localStorage.setItem("ps.pmtab", "projects");
|
||||
const fetch = mockFetch(routes());
|
||||
renderApp("/pm");
|
||||
const table = await screen.findByRole("table", { name: "Projects and units" });
|
||||
expect(within(table).getByText(/L670/)).toHaveTextContent("▸ L670");
|
||||
expect(within(table).getByText("No packets yet")).toHaveClass("pill", "st-draft");
|
||||
expect(within(table).getByTitle("17% done")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(table).getByText(/L670/));
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/projects?period=90&expand=7"));
|
||||
const unit = await screen.findByText(/L670001/);
|
||||
expect(unit.closest("tr")).toHaveClass("sub");
|
||||
expect(screen.getByText(/▾ L670$/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(unit.closest("tr") as HTMLElement, { key: "Enter" });
|
||||
await waitFor(() => expect(screen.getByRole("tab", { name: "Find jobs" })).toHaveAttribute("aria-selected", "true"));
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?unit_id=12&period=90&sort=updated"));
|
||||
expect(await screen.findByRole("table", { name: "Jobs" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pm-scope")).toHaveTextContent("Scope: Unit L670001 · 1 packet");
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
expect(window.localStorage.getItem("ps.pmtab")).toBe("find");
|
||||
});
|
||||
|
||||
it("drills a unit late or at risk to Find jobs", async () => {
|
||||
const fetch = mockFetch(routes());
|
||||
renderApp("/pm");
|
||||
const att = await screen.findByRole("table", { name: "Units late or at risk" });
|
||||
fireEvent.click(within(att).getByText("L670001"));
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?unit_id=12&period=90&sort=updated"));
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/units/12"));
|
||||
});
|
||||
|
||||
it("builds the PDF and CSV links from the scope and the period", async () => {
|
||||
const fetch = mockFetch(routes());
|
||||
const opened = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const hrefs: string[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
||||
hrefs.push(this.getAttribute("href") ?? "");
|
||||
});
|
||||
renderApp("/pm");
|
||||
await screen.findByText("In plain words");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Period"), { target: { value: "30" } });
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/overview?period=30"));
|
||||
expect(window.localStorage.getItem("ps.pmper")).toBe("30");
|
||||
expect(screen.getByLabelText("Period")).toHaveAttribute("title", "Period for history-based figures");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "PDF report" }));
|
||||
expect(opened).toHaveBeenCalledWith("/api/pm/report.pdf?period=30", "_blank", "noopener");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Excel (CSV) — all" }));
|
||||
expect(hrefs).toEqual(["/api/pm/export.csv?table=all&period=30"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Hours vs target" }));
|
||||
const sec = await screen.findByTestId("pmt-hours_over");
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Excel (CSV)" }));
|
||||
expect(hrefs[1]).toBe("/api/pm/export.csv?table=hours_over&period=30");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Find jobs" }));
|
||||
await screen.findByRole("heading", { name: "Find jobs" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Excel (CSV)" }));
|
||||
expect(hrefs[2]).toBe("/api/pm/export.csv?table=jobs&period=30");
|
||||
});
|
||||
|
||||
it("sends the find bar's filters and the sort to the server", async () => {
|
||||
window.localStorage.setItem("ps.pmtab", "find");
|
||||
const fetch = mockFetch(routes());
|
||||
renderApp("/pm");
|
||||
await screen.findByRole("table", { name: "Jobs" });
|
||||
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("group", { name: "View" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Sort by"), { target: { value: "project" } });
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?period=90&sort=project"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "heat 77" } });
|
||||
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?q=heat+77&period=90&sort=project"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Shorts open" }));
|
||||
await waitFor(() => expect(urls(fetch).some((u) => u.startsWith("/api/pm/find?flags=short&q=heat+77"))).toBe(true));
|
||||
});
|
||||
|
||||
it("shows the empty line when no unit is late or at risk", async () => {
|
||||
mockFetch(routes({ "/api/pm/overview": { body: { ...OVERVIEW, attention: { ...ATTENTION, rows: [] } } } }));
|
||||
renderApp("/pm");
|
||||
expect(await screen.findByText("Every unit with packets is on track.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
// /pm (the old homePM): see everything, change nothing, report on it.
|
||||
// The find bar on top scopes every report. Tabs: Overview, Projects & units, Bottlenecks & aging,
|
||||
// Hours vs target, Quality & issues, Find jobs. Every table exports to Excel (CSV); "PDF report" opens the
|
||||
// PESCO production status report built by the server.
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { pmApi, type PmAging, type PmFind, type PmOverview, type PmRow, type PmScope, type PmTabKey, type PmTable, type PmTables } from "../../api/pm";
|
||||
import { projectsApi } from "../../api/projects";
|
||||
import type { ProjectRef, Unit } from "../../api/types";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
import { useFilters, type SortKey } from "../../lib/filters";
|
||||
import { PmBars, PmGrid, PmKpis, PmTableSection } from "./PmTables";
|
||||
import {
|
||||
DEFAULT_PERIOD,
|
||||
expandParam,
|
||||
isPeriod,
|
||||
isPmTab,
|
||||
packetsText,
|
||||
PERIOD_KEY,
|
||||
periodLabel,
|
||||
PM_PERIODS,
|
||||
PM_TABS,
|
||||
rowAct,
|
||||
scopeKey,
|
||||
scopeParams,
|
||||
TAB_KEY,
|
||||
toggleOpen,
|
||||
} from "./pmView";
|
||||
|
||||
type Data =
|
||||
| { tab: "overview"; d: PmOverview }
|
||||
| { tab: "projects"; d: PmTables }
|
||||
| { tab: "aging"; d: PmAging }
|
||||
| { tab: "hours"; d: PmTables }
|
||||
| { tab: "quality"; d: PmTables }
|
||||
| { tab: "find"; d: PmFind };
|
||||
|
||||
function lsRead(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lsWrite(key: string, v: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, v);
|
||||
} catch {
|
||||
// Storage may be blocked. The choice still holds for this page.
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a download of a same-origin URL without leaving the page. */
|
||||
function startDownload(url: string): void {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function toTop(): void {
|
||||
try {
|
||||
window.scrollTo(0, 0);
|
||||
} catch {
|
||||
// Not every host scrolls.
|
||||
}
|
||||
}
|
||||
|
||||
export function PmHome() {
|
||||
const { departments } = useAuth();
|
||||
const { f, set, clear, pick, setPick, query } = useFilters("pm");
|
||||
const [tab, setTabState] = useState<PmTabKey>(() => {
|
||||
const v = lsRead(TAB_KEY);
|
||||
return isPmTab(v) ? v : "overview";
|
||||
});
|
||||
const [period, setPeriodState] = useState<string>(() => {
|
||||
const v = lsRead(PERIOD_KEY);
|
||||
return isPeriod(v) ? v : DEFAULT_PERIOD;
|
||||
});
|
||||
const [open, setOpen] = useState<Set<number>>(() => new Set());
|
||||
// Bumped on every pick, so a late drill-down answer never overwrites a newer pick.
|
||||
const pickSeq = useRef(0);
|
||||
const pickNow = (p: UnitPick) => {
|
||||
pickSeq.current += 1;
|
||||
setPick(p);
|
||||
};
|
||||
const clearAll = () => {
|
||||
pickSeq.current += 1;
|
||||
clear();
|
||||
};
|
||||
|
||||
const setTab = (t: string) => {
|
||||
if (!isPmTab(t)) return;
|
||||
setTabState(t);
|
||||
lsWrite(TAB_KEY, t);
|
||||
};
|
||||
const setPeriod = (v: string) => {
|
||||
setPeriodState(v);
|
||||
lsWrite(PERIOD_KEY, v);
|
||||
};
|
||||
|
||||
const params = useMemo(() => scopeParams(query, period), [query, period]);
|
||||
const sKey = scopeKey(params);
|
||||
const expand = expandParam(open);
|
||||
const sort = f.sort;
|
||||
const key = `${tab}|${sKey}|${tab === "projects" ? expand : ""}|${tab === "find" ? sort : ""}`;
|
||||
|
||||
const [load, reload] = useLoad<Data>(async (s) => {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return { tab, d: await pmApi.overview(params, s) };
|
||||
case "projects":
|
||||
return { tab, d: await pmApi.projects(params, expand, s) };
|
||||
case "aging":
|
||||
return { tab, d: await pmApi.aging(params, s) };
|
||||
case "hours":
|
||||
return { tab, d: await pmApi.hours(params, s) };
|
||||
case "quality":
|
||||
return { tab, d: await pmApi.quality(params, s) };
|
||||
default:
|
||||
return { tab: "find", d: await pmApi.find(params, sort, s) };
|
||||
}
|
||||
}, key);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const facetKey = `${params.project_id ?? ""}|${params.unit_id ?? ""}`;
|
||||
const [facetLoad] = useLoad((s) => pmApi.facets(params, s), facetKey);
|
||||
const facets = facetLoad.state === "ok" ? facetLoad.data : null;
|
||||
|
||||
// A tab change keeps the last answer on screen only when it is for the same tab.
|
||||
const data = load.state === "ok" && load.data.tab === tab ? load.data : null;
|
||||
const scope: PmScope | null = data ? data.d.scope : null;
|
||||
const depts = departments.filter((d) => d.code !== "CL");
|
||||
|
||||
const csv = (table: string) => startDownload(pmApi.csvUrl(table, params));
|
||||
const pdf = () => window.open(pmApi.pdfUrl(params), "_blank", "noopener");
|
||||
|
||||
/** Scope the find bar to a project or unit and go to Find jobs (the old pmProj drill down). */
|
||||
const drill = (projectId: number, unitId: number | null, rows: readonly PmRow[]) => {
|
||||
const projRow = rows.find((r) => r.lvl === 0 && r.unit_id === null && r.project_id === projectId);
|
||||
const known = pick.project?.id === projectId ? pick.project : scope?.project?.id === projectId ? scope.project : null;
|
||||
const project: ProjectRef = known ?? { id: projectId, number: projRow?.cells[0] ?? "", name: projRow?.cells[1] || null };
|
||||
const unitRow = unitId === null ? null : rows.find((r) => r.unit_id === unitId);
|
||||
const unit: Unit | null =
|
||||
unitId === null
|
||||
? null
|
||||
: {
|
||||
id: unitId,
|
||||
project_id: projectId,
|
||||
number: unitRow?.cells[0] ?? "",
|
||||
lot: project.number && unitRow?.cells[0]?.startsWith(project.number) ? unitRow.cells[0].slice(project.number.length) : "",
|
||||
name: unitRow?.cells[1] || null,
|
||||
due: null,
|
||||
due_effective: null,
|
||||
status: "open",
|
||||
version: 0,
|
||||
};
|
||||
pickNow({ project, unit });
|
||||
const seq = pickSeq.current;
|
||||
const refine = (p: UnitPick) => {
|
||||
if (pickSeq.current === seq) setPick(p);
|
||||
};
|
||||
setTab("find");
|
||||
toTop();
|
||||
// Fill in the customer, PO and due date for the find bar's summary line.
|
||||
if (unitId !== null) {
|
||||
projectsApi
|
||||
.unit(unitId)
|
||||
.then((u) => {
|
||||
const p: ProjectRef = u.project ?? project;
|
||||
refine({ project: { ...p }, unit: { ...u, project_id: p.id } });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
projectsApi
|
||||
.get(projectId)
|
||||
.then((p) => refine({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null }))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const onRow = (t: PmTable) => (r: PmRow) => {
|
||||
const a = rowAct(t, r, pick.project?.id ?? null);
|
||||
if (!a || a.kind === "packet") return false;
|
||||
if (a.kind === "toggle") setOpen((o) => toggleOpen(o, a.projectId));
|
||||
else drill(a.projectId, a.unitId, t.rows);
|
||||
return true;
|
||||
};
|
||||
const canAct = (t: PmTable) => (r: PmRow) => rowAct(t, r, pick.project?.id ?? null) !== null;
|
||||
|
||||
const section = (t: PmTable) => (
|
||||
<PmTableSection
|
||||
key={t.key}
|
||||
t={t}
|
||||
onCsv={csv}
|
||||
onRow={onRow(t)}
|
||||
canAct={canAct(t)}
|
||||
{...(t.key === "projects" ? { opened: new Set([...open, ...(pick.project ? [pick.project.id] : [])]) } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="phead">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="eyebrow">Project management · Status and reporting</div>
|
||||
<h1>Where every job stands</h1>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<label className="f pmper">
|
||||
<span className="sr">Period</span>
|
||||
<select className="in" title="Period for history-based figures" value={period} onChange={(e) => setPeriod(e.target.value)}>
|
||||
{PM_PERIODS.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn pri" onClick={pdf}>
|
||||
PDF report
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => csv("all")}>
|
||||
Excel (CSV) — all
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="pmtabs tabs" role="tablist" aria-label="Reports">
|
||||
{PM_TABS.map(([k, l]) => (
|
||||
<button key={k} type="button" role="tab" aria-selected={tab === k} onClick={() => setTab(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={set}
|
||||
onClear={clearAll}
|
||||
pick={pick}
|
||||
onPick={pickNow}
|
||||
count={scope ? scope.count : null}
|
||||
facets={facets}
|
||||
depts={depts}
|
||||
screen="pm"
|
||||
/>
|
||||
|
||||
<div className="small muted pmscope" data-testid="pm-scope">
|
||||
Scope: <b>{scope?.label ?? "…"}</b> · {packetsText(scope?.count ?? 0)} · history figures: {(scope?.period.label || periodLabel(period)).toLowerCase()} · view only
|
||||
</div>
|
||||
|
||||
{load.state === "loading" || (load.state === "ok" && !data) ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
|
||||
{data?.tab === "overview" ? <Overview d={data.d} onTab={setTab} onRow={onRow(data.d.attention)} /> : null}
|
||||
{data?.tab === "projects" ? data.d.tables.map(section) : null}
|
||||
{data?.tab === "aging" ? (
|
||||
<>
|
||||
<section className="sec">
|
||||
<div className="panel">
|
||||
<PmBars steps={data.d.pipeline} />
|
||||
</div>
|
||||
</section>
|
||||
{data.d.tables.map(section)}
|
||||
</>
|
||||
) : null}
|
||||
{data?.tab === "hours" || data?.tab === "quality" ? data.d.tables.map(section) : null}
|
||||
{data?.tab === "find" ? (
|
||||
<>
|
||||
<section className="sec">
|
||||
<div className="sec-h">
|
||||
<h2>Find jobs</h2>
|
||||
<span className="grow" />
|
||||
<button type="button" className="btn sm ghost" onClick={() => csv("jobs")}>
|
||||
Excel (CSV)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<JobList rows={data.d.packets} sort={sort} onSort={(k: SortKey) => set({ sort: k })} serverSorted />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview({ d, onTab, onRow }: { d: PmOverview; onTab: (t: string) => void; onRow: (r: PmRow) => boolean }) {
|
||||
const att = d.attention;
|
||||
return (
|
||||
<>
|
||||
<PmKpis items={d.kpis} onTab={onTab} />
|
||||
<section className="sec">
|
||||
<div className="panel pmsum" data-testid="pm-summary">
|
||||
<h3>In plain words</h3>
|
||||
<ul>
|
||||
{d.summary.map((l, i) => (
|
||||
<li key={i}>{l}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section className="sec" aria-labelledby="pm-pipe-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="pm-pipe-h">Pipeline</h2>
|
||||
<span className="ct">open packets by step · hover a bar for time in step</span>
|
||||
<span className="grow" />
|
||||
<button type="button" className="btn sm ghost" onClick={() => onTab("aging")}>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<PmBars steps={d.pipeline} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="sec" aria-labelledby="pm-att-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="pm-att-h">{att.title || "Units late or at risk"}</h2>
|
||||
<span className="ct">{att.rows.length}</span>
|
||||
</div>
|
||||
{att.rows.length ? <PmGrid t={att} onRow={onRow} label="Units late or at risk" /> : <div className="empty slim">Every unit with packets is on track.</div>}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// The pieces of the Project Management screen: the KPI tiles, the pipeline bars and the report tables.
|
||||
// The old pm.js pmTable, pmBars and the overview markup.
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { PmKpi, PmRow, PmStep, PmTable } from "../../api/pm";
|
||||
import { barMax, barTitle, barWidth, colOf, fmtDays, healthLabel, healthPill, kpiClass, kpiParts, pctOf, rowsText, slowestKey, unitCount } from "./pmView";
|
||||
|
||||
export function HealthPill({ h }: { h: string }) {
|
||||
return <span className={healthPill(h)}>{healthLabel(h)}</span>;
|
||||
}
|
||||
|
||||
/** The overview tiles. Each one opens its tab. */
|
||||
export function PmKpis({ items, onTab }: { items: PmKpi[]; onTab: (tab: string) => void }) {
|
||||
return (
|
||||
<div className="kpis pmk" role="group" aria-label="Numbers">
|
||||
{items.map((k) => {
|
||||
const { main, unit } = kpiParts(k.n);
|
||||
return (
|
||||
<button key={k.key || k.label} type="button" className={kpiClass(k.n, k.alert)} data-testid={`pm-kpi-${k.key}`} aria-label={`${k.label}: ${String(k.n)}`} onClick={() => onTab(k.tab)}>
|
||||
<div className="n">
|
||||
{main}
|
||||
{unit ? <small>{unit}</small> : null}
|
||||
</div>
|
||||
<div className="l">{k.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** pmBars: open packets per step, the slowest step in pink. */
|
||||
export function PmBars({ steps }: { steps: PmStep[] }) {
|
||||
const max = barMax(steps);
|
||||
const slow = slowestKey(steps);
|
||||
return (
|
||||
<div className="pmbars" role="img" aria-label="Open packets by step" data-testid="pm-bars">
|
||||
{steps.map((s) => {
|
||||
const hot = s.key === slow;
|
||||
const cls = ["pmbar", hot ? "hot" : "", s.count ? "" : "zero"].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div key={s.key} className={cls} title={barTitle(s)} data-step={s.key}>
|
||||
<span className="lb">{s.label}</span>
|
||||
<span className="tr">
|
||||
<i style={{ width: `${barWidth(s.count, max)}%` }} />
|
||||
</span>
|
||||
<span className="v">
|
||||
<b>{s.count}</b>
|
||||
{s.count ? <span className="muted"> · avg {fmtDays(s.avg_s)}</span> : null}
|
||||
{hot ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="slowtag">slowest</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridProps {
|
||||
t: PmTable;
|
||||
/** The project table draws arrows on project rows and indents unit rows. */
|
||||
opened?: ReadonlySet<number>;
|
||||
/** A row without a packet: open, drill or toggle. Returns false when the row does nothing. */
|
||||
onRow?: (r: PmRow) => boolean;
|
||||
canAct?: (r: PmRow) => boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** The table itself: .tw > table.joblist.pmtable. */
|
||||
export function PmGrid({ t, opened, onRow, canAct, label }: GridProps) {
|
||||
const navigate = useNavigate();
|
||||
const isProj = t.key === "projects";
|
||||
const hCol = colOf(t, "Health");
|
||||
const pCol = colOf(t, "% done");
|
||||
const act = (r: PmRow) => {
|
||||
if (r.pid) navigate(`/packets/${r.pid}`);
|
||||
else onRow?.(r);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent, r: PmRow) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
act(r);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="tw">
|
||||
<table className="joblist pmtable" aria-label={label ?? t.title}>
|
||||
<thead>
|
||||
<tr>
|
||||
{t.head.map((h, i) => (
|
||||
<th key={`${i}-${h}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.rows.map((r, ri) => {
|
||||
const live = !!r.pid || (!!onRow && (canAct ? canAct(r) : true));
|
||||
const cls = [r.flag ? "flag" : "", r.lvl ? "sub" : "", live ? "rowlink" : ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<tr
|
||||
key={`${ri}-${r.pid ?? ""}-${r.project_id ?? ""}-${r.unit_id ?? ""}`}
|
||||
className={cls || undefined}
|
||||
{...(live ? { tabIndex: 0, onClick: () => act(r), onKeyDown: (e: KeyboardEvent) => onKey(e, r) } : {})}
|
||||
data-pid={r.pid ?? undefined}
|
||||
data-unit-id={r.unit_id ?? undefined}
|
||||
data-project-id={r.project_id ?? undefined}
|
||||
>
|
||||
{r.cells.map((c, i) => {
|
||||
if (i === hCol && r.health) {
|
||||
return (
|
||||
<td key={i}>
|
||||
<HealthPill h={r.health} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (i === pCol) {
|
||||
return (
|
||||
<td key={i} style={{ minWidth: 120 }}>
|
||||
<div className="pbar" title={`${c} done`}>
|
||||
<i className="c" style={{ width: `${pctOf(c)}%` }} />
|
||||
</div>
|
||||
<span className="small">{c}</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (isProj && i === 0) {
|
||||
const arrow = r.lvl ? " └ " : r.project_id !== null && opened ? (opened.has(r.project_id) ? "▾ " : unitCount(t, r) > 0 ? "▸ " : "") : "";
|
||||
return (
|
||||
<td key={i} className="mono">
|
||||
<b>
|
||||
{arrow}
|
||||
{c}
|
||||
</b>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (i === 0 && hCol >= 0) {
|
||||
return (
|
||||
<td key={i} className="mono">
|
||||
<b>{c}</b>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return <td key={i}>{c}</td>;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{t.rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={Math.max(1, t.head.length)} className="muted">
|
||||
Nothing in scope.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** pmTable: one report table in its section, with its own Excel (CSV) button. */
|
||||
export function PmTableSection({ t, onCsv, ...grid }: Omit<GridProps, "t"> & { t: PmTable; onCsv: (key: string) => void }) {
|
||||
const id = `pmt-${t.key}`;
|
||||
return (
|
||||
<section className="sec pmt" aria-labelledby={id} data-testid={id}>
|
||||
<div className="sec-h">
|
||||
<h2 id={id}>{t.title}</h2>
|
||||
<span className="ct">{rowsText(t.rows.length)}</span>
|
||||
<span className="grow" />
|
||||
<button type="button" className="btn sm ghost" onClick={() => onCsv(t.key)}>
|
||||
Excel (CSV)
|
||||
</button>
|
||||
</div>
|
||||
{t.note ? <div className="small muted pmnote">{t.note}</div> : null}
|
||||
<PmGrid t={t} {...grid} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PmRow, PmStep } from "../../api/pm";
|
||||
import { csvUrl, pdfUrl } from "../../api/pm";
|
||||
import {
|
||||
barMax,
|
||||
barTitle,
|
||||
barWidth,
|
||||
expandParam,
|
||||
fmtDays,
|
||||
healthLabel,
|
||||
healthPill,
|
||||
isPeriod,
|
||||
isPmTab,
|
||||
kpiClass,
|
||||
kpiParts,
|
||||
pctOf,
|
||||
periodLabel,
|
||||
pmFlags,
|
||||
rowAct,
|
||||
scopeKey,
|
||||
scopeParams,
|
||||
slowestKey,
|
||||
toggleOpen,
|
||||
unitCount,
|
||||
} from "./pmView";
|
||||
|
||||
const row = (over: Partial<PmRow> = {}): PmRow => ({ cells: [], pid: null, project_id: null, unit_id: null, lvl: 0, health: null, flag: false, ...over });
|
||||
const step = (over: Partial<PmStep> = {}): PmStep => ({ key: "ready", label: "Ready to assign", count: 0, avg_s: 0, max_s: 0, oldest: null, slowest: false, ...over });
|
||||
const PROJ = { key: "projects", head: ["Project / unit", "Name", "Customer", "Due", "Left", "Units", "Packets"] };
|
||||
|
||||
describe("scopeParams", () => {
|
||||
it("puts the scope in a fixed order and leaves out empty values", () => {
|
||||
const p = scopeParams({ q: "heat 123", unit_id: 12, project_id: undefined, status: "shop", flags: "co,short", flag: "", dept: "03" }, "30");
|
||||
expect(Object.keys(p)).toEqual(["unit_id", "dept", "status", "flags", "q", "period"]);
|
||||
expect(p).toEqual({ unit_id: "12", dept: "03", status: "shop", flags: "co,short", q: "heat 123", period: "30" });
|
||||
});
|
||||
|
||||
it("drops Mine, which PM ignores, and falls back to 90 days", () => {
|
||||
expect(scopeParams({ flags: "mine" }, "bogus")).toEqual({ period: "90" });
|
||||
expect(pmFlags(["co", "mine", "week"])).toBe("co,week");
|
||||
expect(pmFlags(null)).toBe("");
|
||||
});
|
||||
|
||||
it("gives a stable key and the export URLs", () => {
|
||||
const p = scopeParams({ project_id: 7, station_id: 3, welder_id: "u-1" }, "all");
|
||||
expect(scopeKey(p)).toBe("project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||
expect(csvUrl("all", p)).toBe("/api/pm/export.csv?table=all&project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||
expect(csvUrl("jobs", { period: "90" })).toBe("/api/pm/export.csv?table=jobs&period=90");
|
||||
expect(pdfUrl(p)).toBe("/api/pm/report.pdf?project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("labels", () => {
|
||||
it("knows the periods and the tabs", () => {
|
||||
expect(periodLabel("365")).toBe("Last 12 months");
|
||||
expect(periodLabel("x")).toBe("Last 90 days");
|
||||
expect(isPeriod("all")).toBe(true);
|
||||
expect(isPeriod("7")).toBe(false);
|
||||
expect(isPmTab("find")).toBe(true);
|
||||
expect(isPmTab("board")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps health to the old pill classes", () => {
|
||||
expect(healthPill("ok")).toBe("pill st-in_progress");
|
||||
expect(healthPill("risk")).toBe("pill st-warehouse");
|
||||
expect(healthPill("late")).toBe("pill st-review");
|
||||
expect(healthPill("done")).toBe("pill st-filed");
|
||||
expect(healthPill("empty")).toBe("pill st-draft");
|
||||
expect(healthLabel("empty")).toBe("No packets yet");
|
||||
expect(healthLabel("late")).toBe("Late");
|
||||
});
|
||||
|
||||
it("formats durations as the old fmtDays", () => {
|
||||
expect(fmtDays(0)).toBe("0 h");
|
||||
expect(fmtDays(5 * 3600 + 1200)).toBe("5 h");
|
||||
expect(fmtDays(86_400 * 2.25)).toBe("2.3 d");
|
||||
expect(fmtDays(86_400 * 14.6)).toBe("15 d");
|
||||
expect(fmtDays(null)).toBe("0 h");
|
||||
});
|
||||
|
||||
it("gives KPI classes and splits the day suffix", () => {
|
||||
expect(kpiClass(3, true)).toBe("kpi hot");
|
||||
expect(kpiClass(0, true)).toBe("kpi zero");
|
||||
expect(kpiClass(4, false)).toBe("kpi");
|
||||
expect(kpiParts("4.2 d")).toEqual({ main: "4.2", unit: " d" });
|
||||
expect(kpiParts("—")).toEqual({ main: "—", unit: "" });
|
||||
expect(kpiParts(12)).toEqual({ main: "12", unit: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bars", () => {
|
||||
it("scales to the largest count", () => {
|
||||
const steps = [step({ count: 2 }), step({ key: "qc", count: 8 })];
|
||||
expect(barMax(steps)).toBe(8);
|
||||
expect(barMax([])).toBe(1);
|
||||
expect(barWidth(2, 8)).toBe(25);
|
||||
expect(barWidth(0, 0)).toBe(0);
|
||||
expect(barWidth(9, 8)).toBe(100);
|
||||
});
|
||||
|
||||
it("finds the slowest step: the server's flag first, else the highest average with packets", () => {
|
||||
expect(slowestKey([step({ key: "a", count: 1, avg_s: 10 }), step({ key: "b", count: 1, avg_s: 5, slowest: true })])).toBe("b");
|
||||
expect(slowestKey([step({ key: "a", count: 1, avg_s: 10 }), step({ key: "b", count: 0, avg_s: 99 })])).toBe("a");
|
||||
expect(slowestKey([step({ count: 0 })])).toBeNull();
|
||||
});
|
||||
|
||||
it("titles a bar", () => {
|
||||
expect(barTitle(step({ count: 1, avg_s: 7200, max_s: 86_400 * 3 }))).toBe("Ready to assign: 1 packet, average 2 h in this step, longest 3.0 d");
|
||||
expect(barTitle(step({ count: 0 }))).toBe("Ready to assign: 0 packets");
|
||||
});
|
||||
|
||||
it("reads the % done text", () => {
|
||||
expect(pctOf("62%")).toBe(62);
|
||||
expect(pctOf("")).toBe(0);
|
||||
expect(pctOf("140%")).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows", () => {
|
||||
it("opens a packet row", () => {
|
||||
expect(rowAct(PROJ, row({ pid: 5, project_id: 7 }), null)).toEqual({ kind: "packet", pid: 5 });
|
||||
});
|
||||
|
||||
it("toggles a project with units, drills a project without, and drills the picked project", () => {
|
||||
const withUnits = row({ project_id: 7, cells: ["L670", "Tank", "", "", "", "2", "4"] });
|
||||
const noUnits = row({ project_id: 8, cells: ["L680", "Skid", "", "", "", "0", "0"] });
|
||||
expect(unitCount(PROJ, withUnits)).toBe(2);
|
||||
expect(rowAct(PROJ, withUnits, null)).toEqual({ kind: "toggle", projectId: 7 });
|
||||
expect(rowAct(PROJ, withUnits, 7)).toEqual({ kind: "drill", projectId: 7, unitId: null });
|
||||
expect(rowAct(PROJ, noUnits, null)).toEqual({ kind: "drill", projectId: 8, unitId: null });
|
||||
});
|
||||
|
||||
it("drills a unit row, and leaves the loose packets row alone", () => {
|
||||
expect(rowAct(PROJ, row({ project_id: 7, unit_id: 12, lvl: 1 }), null)).toEqual({ kind: "drill", projectId: 7, unitId: 12 });
|
||||
expect(rowAct({ key: "attention", head: ["Unit"] }, row({ project_id: 7, unit_id: 12 }), null)).toEqual({ kind: "drill", projectId: 7, unitId: 12 });
|
||||
expect(rowAct(PROJ, row({ cells: ["Packets with no project set up"] }), null)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the expand set", () => {
|
||||
let open = toggleOpen(new Set(), 9);
|
||||
open = toggleOpen(open, 7);
|
||||
expect(expandParam(open)).toBe("7,9");
|
||||
expect(expandParam(toggleOpen(open, 9))).toBe("7");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Pure helpers for the Project Management screen. The old pm.js names are noted where they came from.
|
||||
import type { HealthKey, PmPeriod, PmQuery, PmRow, PmStep, PmTabKey, PmTable } from "../../api/pm";
|
||||
|
||||
/** PM_TABS. */
|
||||
export const PM_TABS: ReadonlyArray<[PmTabKey, string]> = [
|
||||
["overview", "Overview"],
|
||||
["projects", "Projects & units"],
|
||||
["aging", "Bottlenecks & aging"],
|
||||
["hours", "Hours vs target"],
|
||||
["quality", "Quality & issues"],
|
||||
["find", "Find jobs"],
|
||||
];
|
||||
|
||||
/** PM_PERIODS. */
|
||||
export const PM_PERIODS: ReadonlyArray<[PmPeriod, string]> = [
|
||||
["30", "Last 30 days"],
|
||||
["90", "Last 90 days"],
|
||||
["365", "Last 12 months"],
|
||||
["all", "All time"],
|
||||
];
|
||||
|
||||
export const TAB_KEY = "ps.pmtab";
|
||||
export const PERIOD_KEY = "ps.pmper";
|
||||
export const DEFAULT_PERIOD: PmPeriod = "90";
|
||||
|
||||
export function isPmTab(v: unknown): v is PmTabKey {
|
||||
return typeof v === "string" && PM_TABS.some(([k]) => k === v);
|
||||
}
|
||||
|
||||
export function isPeriod(v: unknown): v is PmPeriod {
|
||||
return typeof v === "string" && PM_PERIODS.some(([k]) => k === v);
|
||||
}
|
||||
|
||||
/** "Last 90 days". */
|
||||
export function periodLabel(v: string): string {
|
||||
return PM_PERIODS.find(([k]) => k === v)?.[1] ?? "Last 90 days";
|
||||
}
|
||||
|
||||
/** HEALTH: label and pill class. */
|
||||
export const HEALTH: Record<HealthKey, [string, string]> = {
|
||||
ok: ["On track", "st-in_progress"],
|
||||
risk: ["At risk", "st-warehouse"],
|
||||
late: ["Late", "st-review"],
|
||||
done: ["Complete", "st-filed"],
|
||||
empty: ["No packets yet", "st-draft"],
|
||||
};
|
||||
|
||||
export function healthLabel(h: string): string {
|
||||
return (HEALTH as Record<string, [string, string]>)[h]?.[0] ?? h;
|
||||
}
|
||||
|
||||
/** hPill: "pill st-...". */
|
||||
export function healthPill(h: string): string {
|
||||
return `pill ${(HEALTH as Record<string, [string, string]>)[h]?.[1] ?? "st-draft"}`;
|
||||
}
|
||||
|
||||
/** What the scope is built from: the find bar's query (PacketListQuery or the like). */
|
||||
export interface ScopeSource {
|
||||
project_id?: number | string | null | undefined;
|
||||
unit_id?: number | string | null | undefined;
|
||||
dept?: string | null | undefined;
|
||||
status?: string | null | undefined;
|
||||
station_id?: number | string | null | undefined;
|
||||
welder_id?: string | null | undefined;
|
||||
flags?: string | readonly string[] | null | undefined;
|
||||
flag?: string | null | undefined;
|
||||
q?: string | null | undefined;
|
||||
}
|
||||
|
||||
/** The comma list of quick flags without `mine` (PM ignores it). */
|
||||
export function pmFlags(flags: ScopeSource["flags"]): string {
|
||||
const list = Array.isArray(flags) ? flags : typeof flags === "string" ? flags.split(",") : [];
|
||||
return list
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && s !== "mine")
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/** The scope params every PM call takes, in a fixed order, empty values left out. */
|
||||
export function scopeParams(src: ScopeSource, period: string): PmQuery {
|
||||
const out: PmQuery = {};
|
||||
const put = (k: string, v: unknown) => {
|
||||
if (v === undefined || v === null) return;
|
||||
const s = String(v).trim();
|
||||
if (s) out[k] = s;
|
||||
};
|
||||
// A picked unit already names its project; the server takes either.
|
||||
put("project_id", src.project_id);
|
||||
put("unit_id", src.unit_id);
|
||||
put("dept", src.dept);
|
||||
put("status", src.status);
|
||||
put("station_id", src.station_id);
|
||||
put("welder_id", src.welder_id);
|
||||
put("flags", pmFlags(src.flags));
|
||||
put("flag", src.flag);
|
||||
put("q", src.q);
|
||||
put("period", isPeriod(period) ? period : DEFAULT_PERIOD);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A stable key for reloading when the scope changes. */
|
||||
export function scopeKey(q: PmQuery): string {
|
||||
return Object.entries(q)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
/** fmtDays(seconds): under a day "<h> h", else "<d> d", one decimal under 10 days. */
|
||||
export function fmtDays(seconds: number | null | undefined): string {
|
||||
const s = Number(seconds) || 0;
|
||||
const days = s / 86_400;
|
||||
if (days < 1) return `${Math.max(0, Math.round(s / 3600))} h`;
|
||||
return days < 10 ? `${days.toFixed(1)} d` : `${Math.round(days)} d`;
|
||||
}
|
||||
|
||||
/** The largest count, at least 1, so an empty pipeline draws empty bars. */
|
||||
export function barMax(steps: readonly Pick<PmStep, "count">[]): number {
|
||||
return Math.max(1, ...steps.map((s) => Number(s.count) || 0));
|
||||
}
|
||||
|
||||
/** A bar's width in percent of the largest count. */
|
||||
export function barWidth(count: number, max: number): number {
|
||||
const m = Math.max(1, max);
|
||||
return Math.max(0, Math.min(100, ((Number(count) || 0) / m) * 100));
|
||||
}
|
||||
|
||||
/** The slowest step: the server's flag, else the highest average among steps with packets. */
|
||||
export function slowestKey(steps: readonly PmStep[]): string | null {
|
||||
const flagged = steps.find((s) => s.slowest);
|
||||
if (flagged) return flagged.key;
|
||||
const best = steps.filter((s) => s.count > 0).sort((a, b) => b.avg_s - a.avg_s)[0];
|
||||
return best ? best.key : null;
|
||||
}
|
||||
|
||||
/** The hover text on a bar. */
|
||||
export function barTitle(s: PmStep): string {
|
||||
const base = `${s.label}: ${s.count} packet${s.count === 1 ? "" : "s"}`;
|
||||
return s.count ? `${base}, average ${fmtDays(s.avg_s)} in this step, longest ${fmtDays(s.max_s)}` : base;
|
||||
}
|
||||
|
||||
/** "% done" text to a bar width: "62%" gives 62. */
|
||||
export function pctOf(cell: string | undefined): number {
|
||||
const n = parseInt(cell ?? "", 10);
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
|
||||
}
|
||||
|
||||
/** "n rows". */
|
||||
export function rowsText(n: number): string {
|
||||
return `${n} row${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** "n packets". */
|
||||
export function packetsText(n: number): string {
|
||||
return `${n} packet${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** The column index of a head label, or -1. */
|
||||
export function colOf(t: Pick<PmTable, "head">, name: string): number {
|
||||
return t.head.indexOf(name);
|
||||
}
|
||||
|
||||
/** The unit count on a project row of the projects table (the `Units` column). */
|
||||
export function unitCount(t: Pick<PmTable, "head">, r: PmRow): number {
|
||||
const i = colOf(t, "Units");
|
||||
return i < 0 ? 0 : parseInt(r.cells[i] ?? "", 10) || 0;
|
||||
}
|
||||
|
||||
/** Opens or closes one project in the expand set. Returns a new set. */
|
||||
export function toggleOpen(open: ReadonlySet<number>, id: number): Set<number> {
|
||||
const next = new Set(open);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The `expand` param: the opened project ids, sorted, comma separated. */
|
||||
export function expandParam(open: ReadonlySet<number>): string {
|
||||
return [...open].sort((a, b) => a - b).join(",");
|
||||
}
|
||||
|
||||
export type RowAct = { kind: "packet"; pid: number } | { kind: "toggle"; projectId: number } | { kind: "drill"; projectId: number; unitId: number | null } | null;
|
||||
|
||||
/**
|
||||
* What a tap on a row does (the old pmProj). A packet row opens the packet. A project row with units
|
||||
* toggles them, unless it is the project the find bar already picked (its units show already); then, and
|
||||
* for a project without units or a unit row, the find bar is scoped to it and the tab goes to Find jobs.
|
||||
*/
|
||||
export function rowAct(t: Pick<PmTable, "key" | "head">, r: PmRow, pickedProjectId: number | null): RowAct {
|
||||
if (r.pid) return { kind: "packet", pid: r.pid };
|
||||
if (r.project_id === null) return null;
|
||||
if (r.unit_id !== null || r.lvl === 1) return { kind: "drill", projectId: r.project_id, unitId: r.unit_id };
|
||||
if (t.key === "projects" && unitCount(t, r) > 0 && pickedProjectId !== r.project_id) return { kind: "toggle", projectId: r.project_id };
|
||||
return { kind: "drill", projectId: r.project_id, unitId: null };
|
||||
}
|
||||
|
||||
/** The KPI number as text. A trailing " d" is split off for the small suffix, as the old tile did. */
|
||||
export function kpiParts(n: number | string): { main: string; unit: string } {
|
||||
const s = String(n);
|
||||
const m = /^(.*\d)\s*d$/.exec(s);
|
||||
return m ? { main: m[1] ?? s, unit: " d" } : { main: s, unit: "" };
|
||||
}
|
||||
|
||||
/** A KPI tile's classes: hot when an alert above zero, zero when zero. */
|
||||
export function kpiClass(n: number | string, alert: boolean): string {
|
||||
const zero = String(n) === "0";
|
||||
return ["kpi", alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { mockFetch } from "../../test/fetchMock";
|
||||
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||
import { makeHome, QUINN } from "./fixtures";
|
||||
|
||||
function base(home = makeHome(), me = QUINN) {
|
||||
@@ -13,6 +14,10 @@ function base(home = makeHome(), me = QUINN) {
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
});
|
||||
|
||||
describe("QcHome", () => {
|
||||
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
|
||||
mockFetch(base());
|
||||
@@ -43,14 +48,26 @@ describe("QcHome", () => {
|
||||
expect(card).toHaveTextContent("PO 5541");
|
||||
});
|
||||
|
||||
it("filters the boards on the client", async () => {
|
||||
mockFetch(base());
|
||||
it("sends the find box to the server and keeps the numbers", async () => {
|
||||
const home = makeHome();
|
||||
const elbow = makeHome({ columns: { ready: [], accepted: home.columns.accepted, rejected: [] } });
|
||||
const fetch = mockFetch({ ...base(), "/api/qc/home": (req) => ({ body: req.url.includes("q=elbow") ? elbow : home }) });
|
||||
renderApp("/qc");
|
||||
await screen.findByRole("link", { name: /^L999002/ });
|
||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||
expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument());
|
||||
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/qc/home?q=elbow");
|
||||
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing here matches the filters.")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Nothing here matches the filters.").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Showing/)).toHaveTextContent("Showing 1 packet");
|
||||
});
|
||||
|
||||
it("the Waiting tile filters to status qc", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/qc");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Waiting for inspection: 1" }));
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/qc/home?status=qc"));
|
||||
expect(screen.getByRole("button", { name: "Waiting for inspection: 1" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("shows all folders on All, and the empty texts", async () => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
|
||||
import type { ColumnDef } from "../../components/Board";
|
||||
import { Board, type ColumnDef } from "../../components/Board";
|
||||
import { Card } from "../../components/Card";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProjectPill } from "../clerical/common";
|
||||
import { matchCard, plural } from "./folders";
|
||||
import { plural } from "./folders";
|
||||
|
||||
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
|
||||
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
|
||||
@@ -101,25 +103,28 @@ function cardExtra(col: QcColumn, c: QcCard) {
|
||||
|
||||
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
|
||||
export function QcHome() {
|
||||
const [q, setQ] = useState("");
|
||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||
const projectId = pick.project?.id;
|
||||
const unitId = pick.unit?.id;
|
||||
const [load, reload] = useLoad(
|
||||
(s) => getQcHome(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}, s),
|
||||
`${projectId ?? ""}|${unitId ?? ""}`,
|
||||
);
|
||||
const { departments } = useAuth();
|
||||
const flt = useFilters("qc");
|
||||
const { f, query, queryKey } = flt;
|
||||
// The server applies every filter to the columns; the numbers follow the project and unit only.
|
||||
const [load, reload] = useLoad((s) => getQcHome(query, s), queryKey);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const data = load.state === "ok" ? load.data : null;
|
||||
const k = data?.kpis;
|
||||
|
||||
const columns = COLUMNS.map((c) => {
|
||||
const all = data?.columns[c.key] ?? [];
|
||||
const shown = all.filter((x) => matchCard(x, q));
|
||||
return { ...c, cards: shown, empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty };
|
||||
});
|
||||
const narrowed = flt.active > 0;
|
||||
const columns: ColumnDef[] = useMemo(
|
||||
() =>
|
||||
COLUMNS.map((c) => {
|
||||
const list = data?.columns[c.key] ?? [];
|
||||
return { key: c.key, title: c.title, cards: list, empty: narrowed && !list.length ? "Nothing here matches the filters." : c.empty };
|
||||
}),
|
||||
[data, narrowed],
|
||||
);
|
||||
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
||||
const rows = useMemo(() => uniqueCards(columns.map((c) => c.cards)), [columns]);
|
||||
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,40 +137,39 @@ export function QcHome() {
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true },
|
||||
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true, ...kpi("status", "qc") },
|
||||
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
|
||||
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
|
||||
]}
|
||||
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||
/>
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={flt.set}
|
||||
onClear={flt.clear}
|
||||
pick={flt.pick}
|
||||
onPick={flt.setPick}
|
||||
view={flt.view}
|
||||
onView={flt.setView}
|
||||
count={count}
|
||||
facets={data?.facets ?? null}
|
||||
depts={departments}
|
||||
screen="qc"
|
||||
/>
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
|
||||
{data ? (
|
||||
<div className="board">
|
||||
{columns.map((c) => (
|
||||
<QcColumnView key={c.key} title={c.title} col={c.key} cards={c.cards} empty={c.empty} />
|
||||
))}
|
||||
</div>
|
||||
flt.view === "board" ? (
|
||||
<Board columns={columns} render={(p, col) => <Card p={p} extra={cardExtra(col as QcColumn, p as QcCard)} />} />
|
||||
) : (
|
||||
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{data ? <FoldersSection active={data.folders} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared Column, with the Q.C. lines under each card. */
|
||||
function QcColumnView({ title, col, cards, empty }: { title: string; col: QcColumn; cards: QcCard[]; empty: ColumnDef["empty"] }) {
|
||||
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} extra={cardExtra(col, p)} />) : <div className="empty">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getStall,
|
||||
getStations,
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
type StallAnswer,
|
||||
type StationOption,
|
||||
} from "../../api/shop";
|
||||
import { CardSlot } from "../../components/Board";
|
||||
import { Card } from "../../components/Card";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { uniqueCards, useFilters } from "../../lib/filters";
|
||||
import { sigMap, useFlip } from "../../lib/useFlip";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import {
|
||||
asCard,
|
||||
@@ -80,7 +86,9 @@ function StallColumn({
|
||||
</h3>
|
||||
{cards.length ? (
|
||||
cards.map((p) => (
|
||||
<StallCard key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
|
||||
<CardSlot key={p.id} id={p.id}>
|
||||
<StallCard p={p} fetchedAt={fetchedAt} now={now} />
|
||||
</CardSlot>
|
||||
))
|
||||
) : (
|
||||
<div className="empty">{empty}</div>
|
||||
@@ -110,18 +118,26 @@ export function StallHome() {
|
||||
lsSet(STALL_KEY, v);
|
||||
};
|
||||
|
||||
const { departments } = useAuth();
|
||||
const flt = useFilters("stall", { stationId: station?.id ?? null });
|
||||
const { f, query, queryKey } = flt;
|
||||
const [stall, reload] = useLoad<LoadedStall | null>(
|
||||
async (s) =>
|
||||
station
|
||||
? { ...(await getStall(station.id, s)), fetchedAt: Date.now() }
|
||||
? { ...(await getStall(station.id, s, query)), fetchedAt: Date.now() }
|
||||
: null,
|
||||
String(station?.id ?? ""),
|
||||
`${station?.id ?? ""}|${queryKey}`,
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const data = stall.state === "ok" ? stall.data : null;
|
||||
const running = data ? data.mine.some((p) => p.clock?.running) : false;
|
||||
const now = useNow(running);
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const cols = useMemo(() => (data ? [data.mine, data.submitted] : []), [data]);
|
||||
const sigs = useMemo(() => sigMap(cols.flat()), [cols]);
|
||||
useFlip(boardRef, "[data-card-id]", [cols], sigs);
|
||||
const rows = useMemo(() => uniqueCards(cols).map(asCard), [cols]);
|
||||
|
||||
if (stations.state === "loading")
|
||||
return <div className="empty slim">Loading</div>;
|
||||
@@ -166,8 +182,23 @@ export function StallHome() {
|
||||
<div className="callout warn">{stall.message}</div>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<div className="board">
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={flt.set}
|
||||
onClear={flt.clear}
|
||||
pick={flt.pick}
|
||||
onPick={flt.setPick}
|
||||
view={flt.view}
|
||||
onView={flt.setView}
|
||||
count={data ? data.mine.length + data.submitted.length : null}
|
||||
facets={null}
|
||||
depts={departments}
|
||||
screen="stall"
|
||||
/>
|
||||
|
||||
{data && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
|
||||
{data && flt.view === "board" ? (
|
||||
<div className="board" ref={boardRef}>
|
||||
<StallColumn
|
||||
title="My packets"
|
||||
cards={data.mine}
|
||||
|
||||
@@ -12,7 +12,7 @@ function StationJob({ p, fetchedAt, now }: { p: ShopCard; fetchedAt: number; now
|
||||
const hrs = ms / 3_600_000;
|
||||
const stamp = p.welder_stamp ?? p.welder?.stamp ?? null;
|
||||
return (
|
||||
<div className="stjob">
|
||||
<div className="stjob" data-card-id={p.id}>
|
||||
<button type="button" className="stopen" onClick={() => navigate(`/packets/${p.id}`)}>
|
||||
{jobName(p)}
|
||||
</button>
|
||||
|
||||
@@ -186,14 +186,46 @@ describe("SupervisorHome", () => {
|
||||
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=all")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the chips with one department and filters the boards with the find box", async () => {
|
||||
mockFetch(base());
|
||||
it("hides the chips with one department and sends the find box to the server", async () => {
|
||||
const elbow = board({ columns: { ready: [shopCard(3, { status: "ready", title: "Elbow" })], review: [], qc: [], warehouse: [], complete: [] } });
|
||||
const fetch = mockFetch({ ...base(), "/api/shop/board": (req) => ({ body: req.url.includes("q=elbow") ? elbow : board() }) });
|
||||
renderApp("/supervisor");
|
||||
await screen.findByRole("link", { name: /^L662003/ });
|
||||
expect(screen.queryByRole("group", { name: "Department" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||
expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument());
|
||||
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&q=elbow");
|
||||
expect(screen.getByRole("link", { name: /^L662003/ })).toBeInTheDocument();
|
||||
// The search text is never saved.
|
||||
expect(window.localStorage.getItem("ps.flt") ?? "").not.toContain("elbow");
|
||||
});
|
||||
|
||||
it("a KPI tap filters the boards by status, the numbers stay, and a second tap clears it", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/supervisor");
|
||||
const tile = await screen.findByRole("button", { name: "Ready to assign: 2" });
|
||||
expect(tile).toHaveAttribute("aria-pressed", "false");
|
||||
fireEvent.click(tile);
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&status=ready"));
|
||||
expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveClass("on");
|
||||
// The advanced row opened with the status set.
|
||||
expect(screen.getByLabelText("Status")).toHaveValue("ready");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ready to assign: 2" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveAttribute("aria-pressed", "false"));
|
||||
expect(screen.getByLabelText("Status")).toHaveValue("all");
|
||||
});
|
||||
|
||||
it("the list view lists the board and station packets once each, sorted on the server", async () => {
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/supervisor");
|
||||
await screen.findByRole("link", { name: /^L662003/ });
|
||||
fireEvent.click(screen.getByRole("button", { name: "List" }));
|
||||
const table = await screen.findByRole("table", { name: "Jobs" });
|
||||
expect(within(table).getAllByRole("row")).toHaveLength(1 + 3);
|
||||
fireEvent.change(screen.getByLabelText("Sort by"), { target: { value: "project" } });
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&sort=project"));
|
||||
expect(JSON.parse(window.localStorage.getItem("ps.flt") ?? "{}")).toMatchObject({ sort: "project", views: { supervisor: "list" } });
|
||||
});
|
||||
|
||||
it("shows the server's refusal", async () => {
|
||||
|
||||
@@ -5,14 +5,15 @@ import { useAuth } from "../../auth/useAuth";
|
||||
import { Board, type ColumnDef } from "../../components/Board";
|
||||
import { CoBanner, type CoBannerItem } from "../../components/CoBanner";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||
import { fmtTime } from "../../lib/format";
|
||||
import { useShopDepartments } from "../../lib/useDepartments";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { CoTable } from "./CoTable";
|
||||
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, matches, useNow } from "./shopView";
|
||||
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, useNow } from "./shopView";
|
||||
import { StationGrid } from "./StationGrid";
|
||||
|
||||
/** localStorage key for the department scope (the old ps.supdept). */
|
||||
@@ -30,7 +31,7 @@ type Loaded = ShopBoard & { fetchedAt: number };
|
||||
|
||||
/** /supervisor (the old homeSupervisor): the change order headline, numbers, stations and the five boards. */
|
||||
export function SupervisorHome() {
|
||||
const { me } = useAuth();
|
||||
const { me, departments } = useAuth();
|
||||
const roles = useMemo(() => me?.roles ?? [], [me]);
|
||||
const shop = useShopDepartments();
|
||||
const isAdmin = roles.includes("admin");
|
||||
@@ -43,17 +44,16 @@ export function SupervisorHome() {
|
||||
lsSet(SUP_DEPT_KEY, v);
|
||||
};
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||
const projectId = pick.project?.id;
|
||||
const unitId = pick.unit?.id;
|
||||
const flt = useFilters("supervisor");
|
||||
const { f, query, queryKey } = flt;
|
||||
|
||||
// The department chips scope the board; the find bar's Department, when set, narrows it further.
|
||||
const [load, reload] = useLoad<Loaded>(
|
||||
async (s) => {
|
||||
const b = await getBoard({ dept, ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s);
|
||||
const b = await getBoard({ dept, ...query }, s);
|
||||
return { ...b, fetchedAt: Date.now() };
|
||||
},
|
||||
`${dept}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||
`${dept}|${queryKey}`,
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
@@ -93,18 +93,20 @@ export function SupervisorHome() {
|
||||
const k = data?.kpis;
|
||||
const elapsedMs = (k?.elapsed_ms ?? 0) + (data ? running * Math.max(0, now - data.fetchedAt) : 0);
|
||||
|
||||
const filtered = (list: ShopCard[]) => list.filter((c) => matches(c, q));
|
||||
const columns: ColumnDef[] = COLUMNS.map((c) => {
|
||||
const all = data?.columns[c.key] ?? [];
|
||||
const shown = filtered(all);
|
||||
return {
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
cards: shown.map(asCard),
|
||||
empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty,
|
||||
};
|
||||
});
|
||||
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
||||
const narrowed = flt.active > 0;
|
||||
const columns: ColumnDef[] = useMemo(
|
||||
() =>
|
||||
COLUMNS.map((c) => {
|
||||
const list: ShopCard[] = data?.columns[c.key] ?? [];
|
||||
return { key: c.key, title: c.title, cards: list.map(asCard), empty: narrowed && !list.length ? "Nothing here matches the filters." : c.empty };
|
||||
}),
|
||||
[data, narrowed],
|
||||
);
|
||||
// The list holds what this screen shows: the board columns and the packets at the stations.
|
||||
const rows = useMemo(() => uniqueCards([...columns.map((c) => c.cards), atStations.map(asCard)]), [columns, atStations]);
|
||||
// The count follows the old build: every packet this screen shows, the station grid included.
|
||||
const count = data ? rows.length : null;
|
||||
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -133,11 +135,12 @@ export function SupervisorHome() {
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true },
|
||||
{ n: k?.at_stations ?? 0, label: "At stations" },
|
||||
{ n: k?.review ?? 0, label: "Waiting on review", alert: true },
|
||||
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true, ...kpi("status", "ready") },
|
||||
{ n: k?.at_stations ?? 0, label: "At stations", ...kpi("status", "station") },
|
||||
{ n: k?.review ?? 0, label: "Waiting on review", alert: true, ...kpi("status", "review") },
|
||||
{ n: `${fmtHrs(elapsedMs / 3_600_000)} / ${fmtHrs(Number(k?.target_hrs) || 0)} h`, label: "Station hours vs target" },
|
||||
]}
|
||||
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||
/>
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
@@ -146,9 +149,27 @@ export function SupervisorHome() {
|
||||
{data ? <CoTable rows={onHold} names={names} canVerify={canVerify} /> : null}
|
||||
{data ? <StationGrid depts={data.depts} names={names} fetchedAt={data.fetchedAt} now={now} /> : null}
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={flt.set}
|
||||
onClear={flt.clear}
|
||||
pick={flt.pick}
|
||||
onPick={flt.setPick}
|
||||
view={flt.view}
|
||||
onView={flt.setView}
|
||||
count={count}
|
||||
facets={data?.facets ?? null}
|
||||
depts={departments.filter((d) => mine.includes(d.code))}
|
||||
screen="supervisor"
|
||||
/>
|
||||
|
||||
{data ? <Board columns={columns} /> : null}
|
||||
{data ? (
|
||||
flt.view === "board" ? (
|
||||
<Board columns={columns} />
|
||||
) : (
|
||||
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { packetsApi } from "../../api/packets";
|
||||
import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types";
|
||||
import { warehouseApi } from "../../api/warehouse";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { FindBar } from "../../components/FindBar";
|
||||
import { JobList } from "../../components/JobList";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { jobName } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
@@ -20,20 +21,12 @@ import { RELEASE_REFUSED, WhBoard } from "./WhBoard";
|
||||
|
||||
/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */
|
||||
export function WarehouseHome() {
|
||||
const { me } = useAuth();
|
||||
const { me, departments } = useAuth();
|
||||
const { run, sheet: guardSheet } = useGuard();
|
||||
const [q, setQ] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||
const flt = useFilters("warehouse");
|
||||
const { f, query, queryKey } = flt;
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [receiving, setReceiving] = useState<ShortRow | null>(null);
|
||||
const projectId = pick.project?.id;
|
||||
const unitId = pick.unit?.id;
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
@@ -41,10 +34,8 @@ export function WarehouseHome() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
const [load, reload] = useLoad(
|
||||
(s) => warehouseApi.board({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
|
||||
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||
);
|
||||
// The server applies every filter to the columns; the numbers follow the project and unit only.
|
||||
const [load, reload] = useLoad((s) => warehouseApi.board(query, s), queryKey);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const onError = useCallback(
|
||||
@@ -81,6 +72,8 @@ export function WarehouseHome() {
|
||||
const k = data?.kpis;
|
||||
const canMove = isWarehouseUser(me?.roles ?? []);
|
||||
const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null;
|
||||
const rows = useMemo(() => (data ? uniqueCards(Object.values(data.columns)) : []), [data]);
|
||||
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -93,13 +86,14 @@ export function WarehouseHome() {
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: k?.waiting ?? 0, label: "Waiting to pull" },
|
||||
{ n: k?.waiting ?? 0, label: "Waiting to pull", ...kpi("status", "warehouse") },
|
||||
{ n: k?.pulling ?? 0, label: "Being pulled" },
|
||||
{ n: k?.hold ?? 0, label: "On hold", alert: true },
|
||||
{ n: k?.pulled ?? 0, label: "Ready for production", alert: true },
|
||||
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true },
|
||||
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true, ...kpi("flag", "short") },
|
||||
{ n: k?.released_24h ?? 0, label: "Released last 24 h" },
|
||||
]}
|
||||
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||
/>
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
@@ -107,9 +101,27 @@ export function WarehouseHome() {
|
||||
|
||||
{data ? <ShortsTable rows={data.shorts} busy={wh.busy} onReceive={setReceiving} /> : null}
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
||||
<FindBar
|
||||
f={f}
|
||||
onChange={flt.set}
|
||||
onClear={flt.clear}
|
||||
pick={flt.pick}
|
||||
onPick={flt.setPick}
|
||||
view={flt.view}
|
||||
onView={flt.setView}
|
||||
count={count}
|
||||
facets={data?.facets ?? null}
|
||||
depts={departments}
|
||||
screen="warehouse"
|
||||
/>
|
||||
|
||||
{data ? <WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} /> : null}
|
||||
{data ? (
|
||||
flt.view === "board" ? (
|
||||
<WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} />
|
||||
) : (
|
||||
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{receiving ? (
|
||||
<ShortSheet
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useMemo, useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types";
|
||||
import { CardSlot } from "../../components/Board";
|
||||
import { Card } from "../../components/Card";
|
||||
import { fmtDay } from "../../lib/format";
|
||||
import { sigMap, useFlip } from "../../lib/useFlip";
|
||||
import { jobName } from "../../lib/packets";
|
||||
import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages";
|
||||
import { groupByDept, WH_COLUMNS } from "./boards";
|
||||
@@ -104,6 +106,7 @@ export function WhCard({ p, canMove, busy, onMove, onDrop }: CardProps) {
|
||||
<div
|
||||
className={canMove ? "card whcard whdrag" : "card whcard"}
|
||||
data-whpid={p.id}
|
||||
data-card-id={p.id}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`${jobName(p)}, ${WH_STAGES[s].name}`}
|
||||
@@ -189,8 +192,11 @@ export function WhBoard({
|
||||
onMove: (p: PacketCard, to: WhStage) => void;
|
||||
onDrop: (p: PacketCard, to: BoardColumn) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const sigs = useMemo(() => sigMap(Object.values(columns).flat()), [columns]);
|
||||
useFlip(ref, "[data-card-id]", [columns], sigs);
|
||||
return (
|
||||
<div className="board whboard">
|
||||
<div className="board whboard" ref={ref}>
|
||||
{WH_COLUMNS.map((c) => {
|
||||
const list = columns[c.key];
|
||||
const id = `whcol-${c.key}`;
|
||||
@@ -205,7 +211,11 @@ export function WhBoard({
|
||||
{!list.length ? (
|
||||
<div className="empty">{c.empty}</div>
|
||||
) : c.key === "released" ? (
|
||||
list.map((p) => <Card key={p.id} p={p} />)
|
||||
list.map((p) => (
|
||||
<CardSlot key={p.id} id={p.id}>
|
||||
<Card p={p} />
|
||||
</CardSlot>
|
||||
))
|
||||
) : (
|
||||
groupByDept(list).map((grp) => (
|
||||
<div key={grp.dept.code} className="whgrpbox">
|
||||
|
||||
@@ -639,3 +639,119 @@ a.itpcard { flex-direction: row; text-align: left; }
|
||||
.ck-item.prow .t .small { display: block; margin-top: 2px; font-weight: 400; }
|
||||
.prints { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.callout .acts .grow { min-width: 240px; }
|
||||
|
||||
/* Phase 8: Project Management. From .pmtabs, .pmscope, .pmk, .pmsum, .pmbars, .pmbar, .slowtag, .pmtable. */
|
||||
.pmtabs { position: static; margin: 14px 0 10px; }
|
||||
.pmtabs button { font-size: 15px; }
|
||||
.pmscope { margin: -6px 0 14px; }
|
||||
.pmk { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
.pmk .kpi .n { font-size: 38px; }
|
||||
.pmsum h3 { margin-bottom: 6px; }
|
||||
.pmsum ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; color: var(--near); }
|
||||
.pmbars { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pmbar { display: grid; grid-template-columns: minmax(150px, 240px) minmax(0, 1fr) minmax(130px, 200px); gap: 12px; align-items: center; font-size: 13.5px; }
|
||||
.pmbar .lb { color: var(--body); }
|
||||
.pmbar .tr { height: 14px; background: var(--line); border-radius: 3px; overflow: hidden; }
|
||||
.pmbar .tr i { display: block; height: 100%; background: var(--cyan); border-radius: 0 4px 4px 0; min-width: 0; }
|
||||
.pmbar.hot .tr i { background: var(--pink); }
|
||||
.pmbar.zero { opacity: .5; }
|
||||
.pmbar .v { color: var(--near); font-variant-numeric: tabular-nums; }
|
||||
.slowtag { color: var(--pink); font-weight: 700; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.pmtable td { font-size: 13.5px; }
|
||||
.pmtable tr.flag td { color: #ffb3d6; }
|
||||
.pmtable tr.sub td { background: rgba(255, 255, 255, .02); font-size: 13px; }
|
||||
.pmtable tr.rowlink:hover td { background: rgba(73, 192, 222, .08); }
|
||||
.motion .m-enter .pmbar .tr i { transform-origin: left center; animation: mgrow .6s .15s cubic-bezier(.2, .7, .2, 1) both; }
|
||||
/* New: the period select's width, the note under a table heading, sideways scroll for wide report tables. */
|
||||
.pmper { min-width: 170px; }
|
||||
.pmnote { margin: -4px 0 8px; }
|
||||
.pmtable { min-width: 720px; }
|
||||
@media (max-width: 640px) {
|
||||
.pmbar { grid-template-columns: 1fr; gap: 4px; }
|
||||
.pmbar .v { font-size: 12.5px; }
|
||||
}
|
||||
|
||||
/* Phase 8: find bar, motion, layout. From the old .fltbar, .fltrow, .fltadv, .fltbtns, .fltn, .fltrow2 .chip,
|
||||
.projsum, .joblist rows, .mbtn, .mdot, .m-enter, .m-tab, .m-bump and the motion keyframes. Then the sheet
|
||||
placement and the iPad and phone widths, which are new in the rebuild. */
|
||||
.fltrow .f { flex: 1 1 160px; min-width: 130px; }
|
||||
.fltrow .f select, .fltrow .f input { width: 100%; }
|
||||
.fltrow .fltq { flex: 3 1 320px; }
|
||||
.fltrow .fltp { flex: 1.4 1 260px; }
|
||||
.fltbtns { flex-wrap: wrap; }
|
||||
.fltbtns .btn { min-height: 44px; }
|
||||
.fltbtns .btn.on { border-color: var(--cyan); color: var(--cyan); }
|
||||
.fltn { display: inline-grid; place-items: center; min-width: 20px; height: 20px; border-radius: 10px; background: var(--cyan); color: #06222b; font-size: 12px; padding: 0 5px; }
|
||||
.fltadv { padding-top: 2px; }
|
||||
.fltrow2 .chip { min-height: 36px; padding: 5px 12px; font-size: 13px; }
|
||||
.fltbar .projsum { padding: 8px 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; }
|
||||
.joblist-sec .sec-h { flex-wrap: wrap; align-items: flex-end; }
|
||||
.jlsort { display: flex; flex-direction: column; gap: 4px; min-width: 200px; }
|
||||
.jlsort > span { font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
|
||||
.joblist tr.rowlink:focus td { background: rgba(73, 192, 222, .08); }
|
||||
.cardw { border-radius: 10px; }
|
||||
.cardw > .card { height: 100%; }
|
||||
.presence { flex-wrap: wrap; }
|
||||
|
||||
/* The Motion switch in the top bar. */
|
||||
.mbtn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mbtn .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
|
||||
/* Motion, only under body.motion. Reduced motion never gets the class, and the rule at the top stops it anyway. */
|
||||
@keyframes menter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes mtab { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes mgrow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@keyframes mglow { 0% { box-shadow: 0 0 0 0 rgba(73, 192, 222, 0); } 15% { box-shadow: 0 0 0 3px rgba(73, 192, 222, .75), 0 0 22px rgba(73, 192, 222, .45); } 100% { box-shadow: 0 0 0 0 rgba(73, 192, 222, 0); } }
|
||||
@keyframes mlive { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.7); } }
|
||||
@keyframes mbump { 30% { color: var(--cyan); } }
|
||||
.motion .m-enter > * { animation: menter .32s cubic-bezier(.2, .7, .2, 1) both; }
|
||||
.motion .m-enter > *:nth-child(2) { animation-delay: .04s; }
|
||||
.motion .m-enter > *:nth-child(3) { animation-delay: .08s; }
|
||||
.motion .m-enter > *:nth-child(4) { animation-delay: .12s; }
|
||||
.motion .m-enter > *:nth-child(5) { animation-delay: .16s; }
|
||||
.motion .m-enter > *:nth-child(n+6) { animation-delay: .2s; }
|
||||
.motion .m-enter .bar i, .motion .m-enter .pbar i { transform-origin: left center; animation: mgrow .6s .15s cubic-bezier(.2, .7, .2, 1) both; }
|
||||
.motion .m-tab { animation: mtab .2s cubic-bezier(.2, .7, .2, 1) both; }
|
||||
.motion .m-changed { animation: mglow 1.7s ease-out; }
|
||||
.motion .m-bump .n { animation: mbump .5s ease-out; }
|
||||
.motion .card, .motion .kpi, .motion .projhit, .motion .chip, .motion .btn { transition: border-color .15s, background-color .15s, transform .15s, box-shadow .15s; }
|
||||
.motion .card:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0, 0, 0, .28); }
|
||||
.motion .tabs button { transition: color .15s, border-color .2s; }
|
||||
.motion .mbtn .mdot { background: var(--cyan); animation: mlive 2.2s ease-in-out infinite; }
|
||||
|
||||
/* Sheets on phones: at least 8 px to every edge, sitting low where the thumb is, scrolling inside when tall.
|
||||
Above 700 px the sheet stays centred with 16 px to every edge (the .scrim and .sheet rules above). */
|
||||
@media (max-width: 700px) {
|
||||
.scrim { align-items: flex-end; padding: 8px 8px max(8px, env(safe-area-inset-bottom, 0px)); }
|
||||
.sheet { margin: auto 0 0; width: 100%; max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px); padding: 16px; }
|
||||
.sheet.narrow { width: 100%; }
|
||||
}
|
||||
|
||||
/* iPad landscape and narrow desktop windows. */
|
||||
@media (max-width: 1024px) {
|
||||
.board { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||
.whboard { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.tw { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.joblist { min-width: 860px; }
|
||||
.pmtable { min-width: 720px; }
|
||||
.fltrow .fltq { flex-basis: 100%; }
|
||||
}
|
||||
/* iPad portrait: the board columns stack, each with its own heading and scroll frame. */
|
||||
@media (max-width: 820px) {
|
||||
.board, .whboard { grid-template-columns: minmax(0, 1fr); }
|
||||
.col { max-height: min(var(--col-max, 520px), calc(100vh - var(--toph, 64px) - 40px)); }
|
||||
}
|
||||
/* Phones. */
|
||||
@media (max-width: 700px) {
|
||||
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.col { max-height: none; }
|
||||
.fltrow .f { flex: 1 1 calc(50% - 8px); min-width: 0; }
|
||||
.fltrow .fltq, .fltrow .fltp { flex-basis: 100%; }
|
||||
.fltbtns { flex: 1 1 100%; }
|
||||
.fltbtns .seg { margin-left: auto; }
|
||||
.fltrow2 .grow { display: none; }
|
||||
.jlsort { min-width: 0; flex: 1 1 100%; }
|
||||
.kpi .n { font-size: 34px; }
|
||||
button.kpi::after { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user