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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+8
View File
@@ -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"),
+178
View File
@@ -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 };
+7 -5
View File
@@ -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 } : {}),
};
}
+13 -9
View File
@@ -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 } : {}),
};
}
+45 -6
View File
@@ -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) ---------- */
+3 -1
View File
@@ -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 } : {}),
};
}