feat: add production templates API and documentation for manufacturing phase 2

- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
This commit is contained in:
2026-07-31 10:22:40 +05:30
parent 415ac94ab2
commit 7d6e597389
80 changed files with 11037 additions and 886 deletions
+54
View File
@@ -115,6 +115,60 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md`
- **Leave reject uses a native `window.prompt`** instead of a dialog — functionally correct, but a lower-fidelity UX than the rest of the app's dialog-based patterns.
- **A pre-existing, unrelated syntax error in `app/dashboard/receiving/grn/new/page.tsx`** (unclosed JSX, last touched 2026-07-23 before this HRM pass started) blocks a clean whole-project `tsc --noEmit` run. Not introduced by this work and not fixed by it — confirmed via `git status`/`git log` that this file was untouched this session; scoped `eslint`/`tsc` checks against every HRM file individually (and the fact this is the *only* file `tsc` reports) confirm the HRM additions themselves are clean.
---
# Manufacturing — Production Lines (Phase 2)
Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND-PHASE2.md` (§D.1D.3). Validation posture: `docs/20-FRONTEND.md §3` — client checks are UX only.
> **Every production screen now runs on the real API. Both mock modules are deleted.** `npx tsc --noEmit` reports 0 errors in this module (the only 4 project errors are pre-existing HRM ones — see the note at the end of §13), and `npx next build` reports **"Compiled successfully"** before failing type-check on those same HRM files. **Nothing has been driven in a browser** — see the honesty note at the end.
## 11. Contract layer (F1) — DONE
- [x] `types/production.ts` **fully rewritten** against docs/30 Part D — every request/response DTO, all six enums, and the stage-action result shapes. Replaces the frontend-only placeholder shapes entirely
- [x] Three contract corrections carried through: templates are keyed by **`code`** (not `docNo` — only runs get a document number); quantities use **`itemId`/`uomId`/`qtyPerBatch`** numeric FKs (not free-text uom/qty); stages carry **`posX`/`posY`**, so canvas layout round-trips through the server
- [x] `lib/api/production-templates.ts` — list/get/create/update/updateStatus with ETag + `If-Match`
- [x] `lib/api/production-runs.ts` — the full §D.3 surface (start, complete, approve, transfer, reject-intake, reject, return-leftover, cancel, quantities), every action taking an `idempotencyKey`; plus `isStaleStageError()` for the docs/21 §6 "409 on a stage-status code → refetch silently" rule
- [x] `lib/error-map.ts` — all 17 docs/30 §D.4 codes. **Also fixed a real mechanism gap:** `errorMessage()` let any mapped domain code override the server's `detail`, which would have thrown away the specifics the user needs — the graph validator names the offending stages, and the transfer/leftover guards quote the actual figures. Added `DETAIL_PREFERRED_CODES` so those eight codes let `detail` win and keep their map entry as a fallback
## 12. Screens (F2F5) — DONE
- [x] **Template overview** (`app/dashboard/production/templates/page.tsx`) — real `productionTemplatesApi.list` with a 300 ms debounced search, status filter, pagination and real `activeRunCount`. One-row-per-template canvas labelled from live data
- [x] **Template builder** (`templates/[id]/page.tsx`) — **fully rewired.** GETs the graph, holds the ETag, and the former `handleSave()` toast stub is now a real create/update. Node ids **are** the server's stage keys (`tmp-<uuid>` for stages drawn this session), so a PUT diffs stages in place and keeps historical runs linked; `node.position` persists as `posX`/`posY`; real `itemsApi`/`uomsApi` pickers replaced `MOCK_ITEMS`; `/templates/new` renders an unsaved draft seeded from the overview dialog's query params and swaps its URL on first save. Also gained a Deactivate/Activate control — `productionTemplatesApi.updateStatus` previously had no UI path at all
- [x] **Run board** (`runs/page.tsx`) — real list with debounced doc-no search, template/warehouse/status filters and pagination. Start dialog posts `productionRunsApi.create` and **navigates to the run**
- [x] **Run detail** (`runs/[id]/page.tsx`) — canvas built from the run's own `posX`/`posY` and run edges, with per-stage intake (`delivered/planned`) and available-to-transfer badges, live cost pool, and a `stageSummary` computed from real stage statuses
- [x] **Stage drawer** (`runs/[id]/StageDrawer.tsx`) — the whole of docs/21 §5: per-status bodies (Waiting → explanation · Ready → editable planned quantities + Start · InProgress → produced/scrapped per output with a required Production reason + custom fields + Complete · Done non-terminal → approve with optional partial transfer · Done terminal → receipt preview + Approve & receive + Reject for rework · Approved → transfer remainder), reject-intake from Ready **or** Waiting-with-deliveries, and the per-stage event timeline
- [x] **Runtime custom-field renderer** (`runs/[id]/CustomFieldForm.tsx`) — `fieldDefs` → typed inputs for all five types, plus `missingRequiredFields()` which **mirrors the server's rule exactly**, including the part that surprises people: an unchecked Checkbox counts as *provided* (`false`), so a required checkbox does not force a tick
- [x] **Run-level actions** (`runs/[id]/RunActions.tsx`) — Return leftover (per consumed Stock input, showing consumed/returned/weighted cost, in base UOM and capped at the unreturned remainder) and Cancel run (previewing what goes back to stock). Both hidden once the run leaves InProgress, because `RUN_COST_CLOSED`/`RUN_NOT_CANCELLABLE` mean offering them could only produce an error
- [x] `lib/production-status-colors.ts` kept untouched — it already matches docs/21 §3 exactly and is the single source for status colour everywhere
- [x] **Deleted `lib/production-mock-runs.ts` and `lib/production-mock-templates.ts`**, including `buildStagePlan()`
**Deviations / decisions (recorded):**
- **The drawer is one file, not the seven the plan sketched.** Each per-status panel is ~30 lines and they all share the same lookup helpers, `submit()` wrapper and error handling; splitting them would mean threading that shared context through seven prop lists for no isolation benefit. `CustomFieldForm` and `RunActions` *are* separate, because both stand alone and neither needs the drawer's form state.
- **The board shows per-status counts, not named stages.** The list projection carries `stageSummary` only, so naming stages there would mean guessing which stage holds which count — exactly what the deleted `buildStagePlan()` did. Named per-stage state lives on the run detail, where the server actually returns it.
- **Non-terminal outputs have their `itemId` stripped on save, not rejected.** A stage that *was* terminal and then gained a child keeps its picked item in local state with the field no longer rendered; an issue-list message about an invisible field would be unactionable, so the builder drops it silently (FR-MFG-05 forbids it on a WIP output anyway).
- **The terminal receipt preview is computed client-side.** There is no preview endpoint and every input (cost pool, produced, scrapped) is already on the page, so the drawer mirrors the server's arithmetic to show the layer *before* creating it. Preview only — the server recomputes.
- **A status toggle re-reads the ETag.** `PATCH /status` bumps the row's `xmin`, invalidating the token the builder holds. It re-GETs and takes *only* the etag and status, deliberately not reloading the canvas, because a full reload there would silently discard unsaved edits.
- **"New Template" opens an unsaved draft rather than creating immediately.** A template cannot exist without a valid graph — the server requires ≥1 stage and a terminal output naming a real item (FR-MFG-02/05) — so there is nothing sensible to POST from a name alone.
- **`templateGraphToSaveRequest()` was deleted from `lib/api/production-templates.ts`.** It converted a fetched graph into a save payload, but the builder's canvas — not the last GET — is the source of truth for what gets saved, so it had no caller and would have drifted.
**Backend additions made for these screens** (all amended into docs/30 as built):
- **`TemplateSummaryDto.stageNames`, in flow order.** The overview draws each template as a line left-to-right and needs the names for every row; without the field the client would fetch every template's full graph just to label boxes. Ordering by stage id turned out to be insertion order, which put the *terminal* stage first and drew lines backwards — so the server toposorts (Kahn, tie-broken by id for stability, falling back to id order if the graph is ever cyclic so a listing can't fail on bad data).
- **`TemplateGraphDto.activeRunCount`.** The builder reads its edit-locked state straight off the graph; without it, it would need a second request to the list endpoint purely to know whether to disable itself.
- **`production_templates.Annotations` (jsonb) + `SaveTemplateRequest.annotations`.** The canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout notes. Round-tripped verbatim, capped at 200 entries, `kind` validated to `box`/`line`, and invisible to the graph validator. Migration `AddTemplateCanvasAnnotations`. Note the flip side, pinned by a smoke assertion: replacement is wholesale, so a client that forgets to echo `annotations` back on a PUT clears them.
## 13. Validation posture (F6) — DONE
- [x] Domain-code → message map complete (§11), with `detail` preferred where the server is more specific
- [x] `412 CONCURRENCY_CONFLICT` amber conflict banner + Reload on the builder (the `app/dashboard/vendors/[id]/page.tsx` pattern)
- [x] `409 TEMPLATE_IN_USE` edit-lock banner driven by the server. Two distinct messages: locked on load (`activeRunCount > 0`) versus locked *while editing* — the FR-MFG-06 TOCTOU, where a run starts between the GET and the PUT. The second locks the canvas rather than reloading, so nothing the user just drew disappears without an explanation
- [x] `422 GRAPH_*` focuses the offending stage — a best-effort substring match of the server's `detail` against stage names, which is why the validator quotes them. Advisory by design: the full message is always in the banner too, so an ambiguous name costs a highlight, never the explanation
- [x] `Idempotency-Key` per action (`useRef(crypto.randomUUID())`, re-minted after each success and whenever the drawer switches stage)
- [x] Silent refetch on stage-status 409s — `submit()` in the drawer routes every action through `isStaleStageError()`
**Not done, flagged rather than silently skipped:**
- **No browser verification of any production screen, and no live end-to-end run.** The contract layer is written against a backend whose every endpoint is smoke-verified, the tree type-checks and Turbopack compiles it, but **nothing has been clicked.** Blocked on AuthHex: its configured MySQL host (`187.127.102.190:3306`) is unreachable from this machine, so no token can be issued — which also means the backend smoke suite could not be re-run after this pass's backend additions.
- **`components/Layouts/AppSidebar.tsx:351` still lists `"production"` in `bypassCodes`.** Correct for now — no role is seeded with a `NAV:production` permission, so removing the bypass would hide the section from everyone. Seeding that permission is the real fix (same outstanding item as `procurement`/`hrm`).
- **4 pre-existing `tsc` errors, unrelated to this work — and they block `next build` for the whole app:** `hrm/employees/[id]/page.tsx` (`UpdateEmployeeRequest` missing `hireDate`) and three `hrm/settings/*` pages (an `Api<T>` generic expecting `{value}` where `ApiResult<T>` is returned). None of these files import anything added or changed by this pass, so they were left alone rather than fixed as a side effect of manufacturing work.
- **10 `react-hooks/set-state-in-effect` lint errors across the five production files.** Same rule fires 42 times repo-wide (`app/dashboard/receiving/grn/page.tsx` included); these are the load effects, the hydration-mismatch guards and the builder's stale-upstream repair. No other rule fires in this module.
## Done
<!-- move [x] items here with date + note if the active list grows long -->
@@ -0,0 +1,130 @@
"use client"
import { FieldDef } from "@/types/production"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
/**
* Runtime renderer for a stage's `fieldDefs` (FR-MFG-07) — the counterpart to the builder's
* field *designer*. The template author picks the types; this turns them into real inputs at
* complete-time and hands back the `fieldValues` object the server stores as jsonb.
*
* Values are keyed by `def.key`, exactly as the server expects, and are never coerced to a
* different shape than the type implies: Number stays a number, Checkbox a boolean, everything
* else a string. That matters because the stored jsonb is later read back verbatim.
*/
/**
* Labels of required fields with no value yet.
*
* Mirrors the server's `ValidateRequiredFields` exactly, including the part that surprises
* people: an unchecked Checkbox **is** a provided value (`false`), so a required checkbox does
* not force a tick. Only absent, null and whitespace-only strings count as missing — diverging
* here would either block a save the server would accept or let one through it rejects.
*/
export function missingRequiredFields(defs: FieldDef[], values: Record<string, unknown>): string[] {
return defs
.filter((def) => {
if (!def.required) return false
const value = values[def.key]
if (value === undefined || value === null) return true
return typeof value === "string" && value.trim() === ""
})
.map((def) => def.label)
}
export function CustomFieldForm({
defs,
values,
onChange,
disabled = false,
}: {
defs: FieldDef[]
values: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
disabled?: boolean
}) {
if (defs.length === 0) return null
function set(key: string, value: unknown) {
onChange({ ...values, [key]: value })
}
return (
<div className="flex flex-col gap-3">
{defs.map((def) => {
const value = values[def.key]
const label = (
<FieldLabel htmlFor={`cf-${def.key}`}>
{def.label}
{def.required && <span className="ml-0.5 text-destructive">*</span>}
</FieldLabel>
)
if (def.type === "Checkbox") {
return (
<div key={def.key} className="flex items-center gap-2">
<Checkbox
id={`cf-${def.key}`}
checked={value === true}
disabled={disabled}
onCheckedChange={(checked) => set(def.key, checked === true)}
/>
<label htmlFor={`cf-${def.key}`} className="text-sm text-foreground">
{def.label}
{def.required && <span className="ml-0.5 text-destructive">*</span>}
</label>
</div>
)
}
if (def.type === "Select") {
return (
<Field key={def.key}>
{label}
<Select<string>
value={typeof value === "string" ? value : null}
onValueChange={(v) => set(def.key, v ?? null)}
>
<SelectTrigger className="h-10! w-full text-sm" disabled={disabled} id={`cf-${def.key}`}>
<SelectValue placeholder="Choose…" />
</SelectTrigger>
<SelectContent>
{(def.options ?? []).map((opt) => (
<SelectItem key={opt} value={opt} className="text-sm">
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}
return (
<Field key={def.key}>
{label}
<Input
id={`cf-${def.key}`}
type={def.type === "Number" ? "number" : def.type === "Date" ? "date" : "text"}
step={def.type === "Number" ? "any" : undefined}
value={value === undefined || value === null ? "" : String(value)}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value
if (def.type !== "Number") return set(def.key, raw)
// An empty number box means "not answered", not zero — sending 0 would satisfy a
// required check the operator never actually answered.
set(def.key, raw === "" ? null : Number(raw))
}}
className="h-10 text-sm"
/>
</Field>
)
})}
</div>
)
}
@@ -0,0 +1,341 @@
"use client"
import { useMemo, useState } from "react"
import { AlertTriangle, Ban, Undo2 } from "lucide-react"
import { productionRunsApi } from "@/lib/api/production-runs"
import { errorMessage } from "@/lib/error-map"
import { ProductionRunGraph } from "@/types/production"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
/**
* Run-level actions (docs/21-FRONTEND-PHASE2.md §5): returning leftover raw material to stock,
* and cancelling the run. Both are hidden once the run leaves InProgress — a completed run's
* costs are closed (`409 RUN_COST_CLOSED`) and a completed run cannot be cancelled
* (`409 RUN_NOT_CANCELLABLE`), so offering either would only produce an error.
*/
function fmt(n: number): string {
return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
interface Returnable {
runInputId: number
stageName: string
itemId: number
/** Base UOM, matching what the return endpoint expects. */
remaining: number
consumedQty: number
consumedValue: number
returnedQty: number
returnedValue: number
}
export function RunActions({
run,
items,
reasonCodes,
onActed,
}: {
run: ProductionRunGraph
items: ItemListItem[]
reasonCodes: ReasonCode[]
onActed: () => void
}) {
const [dialog, setDialog] = useState<null | "leftover" | "cancel">(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedInputId, setSelectedInputId] = useState<number | null>(null)
const [returnQty, setReturnQty] = useState("")
const [returnReasonId, setReturnReasonId] = useState<number | null>(null)
const [cancelReasonId, setCancelReasonId] = useState<number | null>(null)
const [cancelNote, setCancelNote] = useState("")
const itemName = useMemo(() => {
const byId = new Map(items.map((i) => [i.itemId, i.name]))
return (id: number) => byId.get(id) ?? `Item #${id}`
}, [items])
/**
* Stock inputs with material still unreturned. Upstream inputs are excluded: they carry work
* in progress that never entered stock, so there is nothing to return.
*/
const returnable = useMemo<Returnable[]>(
() =>
run.stages.flatMap((stage) =>
stage.inputs
.filter((i) => i.source === "Stock" && i.itemId !== null && i.consumedQty - i.returnedQty > 0)
.map((i) => ({
runInputId: i.runInputId,
stageName: stage.name,
itemId: i.itemId!,
remaining: i.consumedQty - i.returnedQty,
consumedQty: i.consumedQty,
consumedValue: i.consumedValue,
returnedQty: i.returnedQty,
returnedValue: i.returnedValue,
}))
),
[run.stages]
)
const selected = returnable.find((r) => r.runInputId === selectedInputId) ?? null
const productionReasons = reasonCodes.filter((r) => r.context === "Production")
// Weighted average cost of what this input actually consumed — the rate the returned stock
// goes back in at, and the same figure the server derives from consumedValue/consumedQty.
const weightedCost = selected && selected.consumedQty > 0 ? selected.consumedValue / selected.consumedQty : null
function openLeftover() {
setSelectedInputId(returnable[0]?.runInputId ?? null)
setReturnQty("")
setReturnReasonId(null)
setError(null)
setDialog("leftover")
}
function openCancel() {
setCancelReasonId(null)
setCancelNote("")
setError(null)
setDialog("cancel")
}
async function submit(action: () => Promise<unknown>) {
setBusy(true)
setError(null)
try {
await action()
setDialog(null)
onActed()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (run.status !== "InProgress") return null
const returnQtyNum = Number(returnQty)
const returnValid =
selected !== null &&
returnReasonId !== null &&
Number.isFinite(returnQtyNum) &&
returnQtyNum > 0 &&
returnQtyNum <= selected.remaining
return (
<>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" />
Return leftover
</Button>
<Button variant="outline" className="text-destructive" onClick={openCancel}>
<Ban className="size-4" />
Cancel run
</Button>
</div>
{/* ------------------------------------------------------------- leftover */}
<Dialog open={dialog === "leftover"} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Return leftover material</DialogTitle>
<DialogDescription>
Puts unused raw material back into stock at the cost it was consumed at, and takes it out of this
run&apos;s cost pool.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Consumed material</FieldLabel>
<Select<number> value={selectedInputId} onValueChange={(v) => { setSelectedInputId(v); setReturnQty("") }}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a material" />
</SelectTrigger>
<SelectContent>
{returnable.map((r) => (
<SelectItem key={r.runInputId} value={r.runInputId} className="text-base">
{itemName(r.itemId)} · {r.stageName} · {fmt(r.remaining)} left
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{selected && (
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Consumed</span>
<span className="font-medium text-foreground">
{fmt(selected.consumedQty)} · {money(selected.consumedValue)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Already returned</span>
<span className="font-medium text-foreground">
{fmt(selected.returnedQty)} · {money(selected.returnedValue)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Weighted cost</span>
<span className="font-medium text-foreground">{weightedCost === null ? "—" : money(weightedCost)}</span>
</div>
</div>
)}
<Field>
<FieldLabel htmlFor="return-qty">
Quantity to return
{selected && <span className="font-normal text-muted-foreground"> (base UOM, max {fmt(selected.remaining)})</span>}
</FieldLabel>
<Input
id="return-qty"
type="number"
min={0}
step="any"
max={selected?.remaining}
value={returnQty}
onChange={(e) => setReturnQty(e.target.value)}
placeholder={selected ? String(selected.remaining) : ""}
/>
</Field>
<Field>
<FieldLabel>Reason</FieldLabel>
<Select<number> value={returnReasonId} onValueChange={setReturnReasonId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a reason" />
</SelectTrigger>
<SelectContent>
{productionReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
{r.code} {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
Cancel
</Button>
<Button
disabled={busy || !returnValid}
onClick={() =>
submit(() =>
productionRunsApi.returnLeftover(run.runId, selected!.runInputId, {
qty: returnQtyNum,
reasonCodeId: returnReasonId!,
})
)
}
>
{busy ? "Returning…" : "Return to stock"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* --------------------------------------------------------------- cancel */}
<Dialog open={dialog === "cancel"} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel this run?</DialogTitle>
<DialogDescription>
Everything consumed and not yet returned goes back into stock at its consumed cost. Scrapped output is
written off it never entered stock. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{returnable.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
<p className="font-medium text-foreground">Will be returned to stock</p>
{returnable.map((r) => (
<div key={r.runInputId} className="flex justify-between">
<span className="min-w-0 truncate text-muted-foreground">{itemName(r.itemId)}</span>
<span className="shrink-0 font-medium text-foreground">{fmt(r.remaining)}</span>
</div>
))}
</div>
)}
<Field>
<FieldLabel>Reason</FieldLabel>
<Select<number> value={cancelReasonId} onValueChange={setCancelReasonId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a reason" />
</SelectTrigger>
<SelectContent>
{productionReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
{r.code} {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="cancel-note">Note (optional)</FieldLabel>
<Input id="cancel-note" value={cancelNote} onChange={(e) => setCancelNote(e.target.value)} />
</Field>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
Keep run
</Button>
<Button
variant="destructive"
disabled={busy || cancelReasonId === null}
onClick={() =>
submit(() =>
productionRunsApi.cancel(run.runId, {
reasonCodeId: cancelReasonId!,
note: cancelNote.trim() || null,
})
)
}
>
{busy ? "Cancelling…" : "Cancel run"}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,739 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { AlertTriangle, Ban, CheckCircle2, PackageCheck, Play, Send, Undo2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { isStaleStageError, productionRunsApi } from "@/lib/api/production-runs"
import { errorMessage } from "@/lib/error-map"
import { CustomFieldForm, missingRequiredFields } from "./CustomFieldForm"
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
import {
CompleteOutputLine,
ProductionRunGraph,
RunStage,
RunStageInput,
RunStageOutput,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Separator } from "@/components/ui/separator"
/**
* The stage drawer (docs/21-FRONTEND-PHASE2.md §5) — the only place a run is actually driven.
*
* One component rather than the seven the plan sketched: each per-status panel is ~30 lines and
* they all share the same lookup helpers, submit wrapper, and error handling, so splitting them
* would mean threading that shared context through seven prop lists for no isolation benefit.
*
* The body is switched on `stage.status`, which is deliberately the *server's* status and never
* a local guess — every action re-fetches the whole run through `onActed`, so what is rendered
* is always what the server last said. Two consequences worth knowing:
*
* * A 409 carrying a stage-status code means someone else moved first. `submit` refreshes
* silently instead of showing an error (docs/21 §6); that is also what makes the server's
* accept-and-ignore `Idempotency-Key` posture feel right — a double-click just refreshes.
* * Local form state is keyed off `stage.runStageId` and reset whenever the stage changes, so
* a refresh mid-edit can never post figures from a stage the user is no longer looking at.
*/
function fmt(n: number): string {
return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
/** Minutes between two instants, floored — the same unit the server reports `actualMinutes` in. */
function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold"
style={{ backgroundColor: `${color}1a`, color }}
>
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
{STAGE_STATUS_LABEL[status]}
</span>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="text-right font-medium text-foreground">{children}</span>
</div>
)
}
export function StageDrawer({
run,
stage,
items,
uoms,
reasonCodes,
onClose,
onActed,
}: {
run: ProductionRunGraph
stage: RunStage | null
items: ItemListItem[]
uoms: Uom[]
reasonCodes: ReasonCode[]
onClose: () => void
onActed: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
const [scrapped, setScrapped] = useState<Record<number, string>>({})
const [scrapReason, setScrapReason] = useState<Record<number, number | null>>({})
const [fieldValues, setFieldValues] = useState<Record<string, unknown>>({})
// Transfer state — the amount to push per output, defaulted to everything available.
const [transferQty, setTransferQty] = useState<Record<number, string>>({})
const [confirm, setConfirm] = useState<null | "rejectIntake" | "rejectTerminal">(null)
// A per-action Idempotency-Key, minted fresh for each stage the drawer opens on. Same shape as
// app/dashboard/receiving/grn/[id]/page.tsx.
const idempotencyKey = useRef(crypto.randomUUID())
const stageId = stage?.runStageId ?? null
// Reseed every form whenever the drawer switches stage *or* the server sends new figures for
// the one it is on. Without the second half, a refresh after an action would leave the inputs
// showing pre-action numbers.
useEffect(() => {
if (!stage) return
setPlannedInputs(Object.fromEntries(stage.inputs.map((i) => [i.runInputId, String(i.plannedQty)])))
setPlannedOutputs(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.plannedQty)])))
setProduced(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.producedQty || o.plannedQty)])))
setScrapped(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.scrappedQty || 0)])))
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stageId, stage?.status, stage?.outputs, stage?.inputs])
// Live elapsed while the stage is running (FR-MFG-19). One tick a minute is enough — the
// figure is rendered in whole minutes, so a faster interval would just re-render for nothing.
const [now, setNow] = useState(() => Date.now())
const running = stage?.actualStartAt != null && stage?.actualEndAt == null
useEffect(() => {
if (!running) return
const timer = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(timer)
}, [running])
const itemName = useMemo(() => {
const byId = new Map(items.map((i) => [i.itemId, i.name]))
return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`)
}, [items])
const uomName = useMemo(() => {
const byId = new Map(uoms.map((u) => [u.uomId, u.name]))
return (id: number) => byId.get(id) ?? `#${id}`
}, [uoms])
const stageName = useMemo(() => {
const byId = new Map(run.stages.map((s) => [s.runStageId, s.name]))
return (id: number) => byId.get(id) ?? `Stage #${id}`
}, [run.stages])
if (!stage) return null
const isEditable = stage.status === "Ready"
const upstreamInputs = stage.inputs.filter((i) => i.source === "Upstream")
const deliveredSoFar = upstreamInputs.reduce((sum, i) => sum + i.deliveredQty, 0)
const canRejectIntake = (stage.status === "Ready" || stage.status === "Waiting") && deliveredSoFar > 0
const availableTotal = stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0)
const stageEvents = run.events.filter((e) => e.runStageId === stage.runStageId)
/**
* One wrapper for every action: busy flag, error surfacing, and the docs/21 §6 rule that a
* stage-status 409 refreshes silently rather than shouting at the user.
*/
async function submit(action: () => Promise<unknown>) {
setBusy(true)
setError(null)
try {
await action()
idempotencyKey.current = crypto.randomUUID()
onActed()
} catch (err) {
if (isStaleStageError(err)) {
onActed()
return
}
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
return submit(() => productionRunsApi.updateStageQuantities(run.runId, stage!.runStageId, { inputs, outputs }))
}
function completeStage() {
const outputs: CompleteOutputLine[] = stage!.outputs.map((o) => ({
runOutputId: o.runOutputId,
producedQty: Number(produced[o.runOutputId] ?? 0) || 0,
scrappedQty: Number(scrapped[o.runOutputId] ?? 0) || 0,
scrapReasonCodeId: Number(scrapped[o.runOutputId] ?? 0) > 0 ? scrapReason[o.runOutputId] : null,
}))
return submit(() =>
productionRunsApi.complete(
run.runId,
stage!.runStageId,
{ outputs, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : null },
idempotencyKey.current
)
)
}
/**
* Approve. An empty `transfers` array tells the server to push every output in full, which is
* the common case; a line is only sent when the user has dialled it down below what's
* available. On the terminal stage the server ignores transfers entirely and posts the receipt.
*/
function approveStage() {
const transfers: TransferLine[] = stage!.outputs
.filter((o) => {
const wanted = Number(transferQty[o.runOutputId] ?? o.availableToTransfer)
return Number.isFinite(wanted) && wanted !== o.availableToTransfer
})
.map((o) => ({ runOutputId: o.runOutputId, qty: Number(transferQty[o.runOutputId]) }))
return submit(() =>
productionRunsApi.approve(
run.runId,
stage!.runStageId,
transfers.length > 0 ? { transfers } : {},
idempotencyKey.current
)
)
}
function transferRemainder(output: RunStageOutput) {
const qty = Number(transferQty[output.runOutputId] ?? 0)
return submit(() =>
productionRunsApi.transfer(
run.runId,
stage!.runStageId,
{ runOutputId: output.runOutputId, qty },
idempotencyKey.current
)
)
}
// --- terminal receipt preview ---------------------------------------------
// Mirrors the server's arithmetic (docs/30 §D.3 terminal approve) so the operator sees the
// layer they are about to create *before* creating it. Deliberately recomputed here rather
// than requested: there is no preview endpoint, and every input is already on this page.
const terminalOutput = stage.isTerminal ? stage.outputs[0] : undefined
const goodQty = terminalOutput ? terminalOutput.producedQty - terminalOutput.scrappedQty : 0
const previewUnitCost = goodQty > 0 ? run.costPool.net / goodQty : null
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
.map((o) => o.name)
return (
<>
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent className="w-full overflow-y-auto sm:max-w-lg!">
<SheetHeader>
<div className="flex flex-wrap items-center gap-2">
<SheetTitle className="text-base">{stage.name}</SheetTitle>
<StatusPill status={stage.status} />
{stage.roleLabel && (
<Badge variant="outline" className="border-transparent bg-primary/10 text-primary">
{stage.roleLabel}
</Badge>
)}
{stage.isTerminal && (
<Badge variant="outline" className="border-transparent bg-muted text-muted-foreground">
Final stage
</Badge>
)}
</div>
<SheetDescription>
Estimated {stage.estimatedMinutes} min
{stage.actualMinutes !== null
? ` · actual ${stage.actualMinutes} min`
: running && stage.actualStartAt
? ` · running for ${minutesSince(stage.actualStartAt, now)} min`
: ""}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* ---------------------------------------------------------- inputs */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">Inputs</h3>
{stage.inputs.length === 0 && <p className="text-sm text-muted-foreground">This stage consumes nothing.</p>}
{stage.inputs.map((input) => (
<InputCard
key={input.runInputId}
input={input}
editable={isEditable}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
uomName={uomName}
/>
))}
</section>
<Separator />
{/* --------------------------------------------------------- outputs */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">Outputs</h3>
{stage.outputs.map((output) => (
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
</div>
{output.itemId !== null && (
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
)}
<div className="mt-2 flex flex-col gap-1">
{isEditable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={plannedOutputs[output.runOutputId] ?? String(output.plannedQty)}
onChange={(e) => setPlannedOutputs((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
className="h-8 w-28 text-sm"
/>
</label>
) : (
<Row label="Planned">{fmt(output.plannedQty)}</Row>
)}
{output.producedQty > 0 && <Row label="Produced">{fmt(output.producedQty)}</Row>}
{output.scrappedQty > 0 && (
<Row label="Scrapped">
<span className="text-destructive">{fmt(output.scrappedQty)}</span>
</Row>
)}
{output.transferredQty > 0 && <Row label="Transferred">{fmt(output.transferredQty)}</Row>}
{output.availableToTransfer > 0 && (
<Row label="Available to transfer">
<span className="text-info">{fmt(output.availableToTransfer)}</span>
</Row>
)}
</div>
</div>
))}
</section>
<Separator />
{/* ------------------------------------------------ status-specific */}
{stage.status === "Waiting" && (
<p className="text-sm text-muted-foreground">
Waiting on upstream deliveries. This stage becomes Ready once every upstream input has received its
planned quantity.
</p>
)}
{stage.status === "Ready" && (
<section className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Adjust planned quantities if needed, then start. Starting consumes the stock inputs above FIFO
quantities are locked from that point on.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={saveQuantities} disabled={busy}>
Save quantities
</Button>
<Button
onClick={() =>
submit(() => productionRunsApi.start(run.runId, stage.runStageId, idempotencyKey.current))
}
disabled={busy}
>
<Play className="size-4" />
Start stage
</Button>
</div>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
{stage.outputs.map((output) => {
const scrapQty = Number(scrapped[output.runOutputId] ?? 0)
return (
<div key={output.runOutputId} className="flex flex-col gap-2 rounded-lg border border-border p-3">
<p className="text-sm font-medium text-foreground">{output.name}</p>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Produced</span>
<Input
type="number"
min={0}
step="any"
value={produced[output.runOutputId] ?? ""}
onChange={(e) => setProduced((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
className="h-8 w-28 text-sm"
/>
</label>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Scrapped</span>
<Input
type="number"
min={0}
step="any"
value={scrapped[output.runOutputId] ?? ""}
onChange={(e) => setScrapped((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
className="h-8 w-28 text-sm"
/>
</label>
{scrapQty > 0 && (
<Select<number>
value={scrapReason[output.runOutputId] ?? null}
onValueChange={(v) => setScrapReason((prev) => ({ ...prev, [output.runOutputId]: v }))}
>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Scrap reason (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
})}
<CustomFields stage={stage} values={fieldValues} onChange={setFieldValues} />
{/*
Blocked client-side as well as server-side. The server's 400
REQUIRED_FIELD_MISSING rolls the whole transaction back, so letting it through
would cost a round trip and — worse — look to the operator like the completion
half-applied. `missingRequiredFields` deliberately mirrors the server's rule.
*/}
{missingScrapReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a scrap reason for: {missingScrapReasons.join(", ")}.
</p>
)}
{missingFields.length > 0 && (
<p className="text-sm text-destructive">Fill in: {missingFields.join(", ")}.</p>
)}
<Button
onClick={completeStage}
disabled={busy || missingFields.length > 0 || missingScrapReasons.length > 0}
>
<CheckCircle2 className="size-4" />
Complete stage
</Button>
</section>
)}
{stage.status === "Done" && !stage.isTerminal && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Approve &amp; transfer</h3>
<p className="text-sm text-muted-foreground">
Approving pushes work downstream. Leave the amounts as they are to transfer everything, or lower one to
hold some back you can transfer the remainder later.
</p>
{stage.outputs.map((output) => (
<label key={output.runOutputId} className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-muted-foreground">{output.name}</span>
<span className="flex shrink-0 items-center gap-2">
<Input
type="number"
min={0}
max={output.availableToTransfer}
step="any"
value={transferQty[output.runOutputId] ?? ""}
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
className="h-8 w-28 text-sm"
/>
<span className="text-xs text-muted-foreground">of {fmt(output.availableToTransfer)}</span>
</span>
</label>
))}
<Button onClick={approveStage} disabled={busy}>
<Send className="size-4" />
Approve &amp; transfer
</Button>
</section>
)}
{stage.status === "Done" && stage.isTerminal && terminalOutput && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
<Row label="Leftovers returned">{money(run.costPool.returned)}</Row>
<Separator className="my-1" />
<Row label="Cost pool">{money(run.costPool.net)}</Row>
<Row label="Unit cost">{previewUnitCost === null ? "—" : money(previewUnitCost)}</Row>
</div>
<p className="text-sm text-muted-foreground">
Approving creates a costed stock layer of {fmt(goodQty)} {itemName(terminalOutput.itemId)} and completes
the run. Its costs close at that point return any leftover materials first.
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={approveStage} disabled={busy || goodQty <= 0}>
<PackageCheck className="size-4" />
Approve &amp; receive
</Button>
<Button variant="destructive" onClick={() => setConfirm("rejectTerminal")} disabled={busy}>
<Undo2 className="size-4" />
Reject for rework
</Button>
</div>
</section>
)}
{stage.status === "Approved" && availableTotal > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Transfer remainder</h3>
{stage.outputs
.filter((o) => o.availableToTransfer > 0)
.map((output) => (
<div key={output.runOutputId} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{output.name}</span>
<Input
type="number"
min={0}
max={output.availableToTransfer}
step="any"
value={transferQty[output.runOutputId] ?? ""}
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
className="h-8 w-24 text-sm"
/>
<Button size="sm" onClick={() => transferRemainder(output)} disabled={busy}>
Transfer
</Button>
</div>
))}
</section>
)}
{stage.status === "Approved" && availableTotal === 0 && (
<p className="text-sm text-muted-foreground">Approved everything this stage produced has moved on.</p>
)}
{canRejectIntake && (
<>
<Separator />
<section className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">Reject what was delivered</h3>
<p className="text-sm text-muted-foreground">
Sends {fmt(deliveredSoFar)} back to the stage(s) that delivered it and reopens them for correction.
No stock moves this is work in progress, not inventory.
</p>
<Button variant="outline" className="w-fit text-destructive" onClick={() => setConfirm("rejectIntake")} disabled={busy}>
<Ban className="size-4" />
Reject intake
</Button>
</section>
</>
)}
{/* --------------------------------------------------------- history */}
{stageEvents.length > 0 && (
<>
<Separator />
<section className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">History</h3>
<ol className="flex flex-col gap-2">
{stageEvents.map((event) => (
<li key={event.eventId} className="flex gap-2 text-sm">
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span className="font-medium text-foreground">{event.eventType}</span>
<span className="text-xs text-muted-foreground">
{new Date(event.createdAt).toLocaleString()}
</span>
{event.note && <span className="text-xs text-muted-foreground">{event.note}</span>}
</div>
</li>
))}
</ol>
</section>
</>
)}
</div>
</SheetContent>
</Sheet>
<AlertDialog open={confirm === "rejectIntake"} onOpenChange={(open) => !open && setConfirm(null)}>
<AlertDialogContent
variant="destructive"
title="Reject the delivered work?"
description={`${stageName(stage.runStageId)} will go back to Waiting and every stage that delivered to it reopens for correction. Their recorded output is cleared, so it has to be re-entered.`}
confirmLabel="Reject intake"
onConfirm={() =>
submit(() => productionRunsApi.rejectIntake(run.runId, stage.runStageId, {}, idempotencyKey.current))
}
/>
</AlertDialog>
<AlertDialog open={confirm === "rejectTerminal"} onOpenChange={(open) => !open && setConfirm(null)}>
<AlertDialogContent
variant="destructive"
title="Reject and rework the whole run?"
description="Every stage resets and the run starts over as a rework pass. Materials already consumed stay in the cost pool — no stock is returned — so raise planned quantities before restarting if more will be needed."
confirmLabel="Reject for rework"
onConfirm={() =>
submit(() => productionRunsApi.rejectTerminal(run.runId, stage.runStageId, {}, idempotencyKey.current))
}
/>
</AlertDialog>
</>
)
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
function InputCard({
input,
editable,
value,
onValueChange,
itemName,
uomName,
}: {
input: RunStageInput
editable: boolean
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
uomName: (id: number) => string
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
</div>
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
{isUpstream && (
<Row label="Delivered">
<span className={cn(short ? "text-warning" : "text-success")}>{fmt(input.deliveredQty)}</span>
</Row>
)}
{/*
Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the
input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36.
Labelled explicitly so the two are never read as the same unit.
*/}
{input.consumedQty > 0 && (
<>
<Row label="Consumed (base)">
{fmt(input.consumedQty)} · {money(input.consumedValue)}
</Row>
{input.returnedQty > 0 && (
<Row label="Returned (base)">
{fmt(input.returnedQty)} · {money(input.returnedValue)}
</Row>
)}
</>
)}
</div>
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
values,
onChange,
}: {
stage: RunStage
values: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
}) {
if (stage.fieldDefs.length === 0) return null
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">Checks</h3>
<CustomFieldForm defs={stage.fieldDefs} values={values} onChange={onChange} />
</div>
)
}
@@ -1,16 +1,23 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node } from "@xyflow/react"
import { ReactFlow, Background, Controls, MiniMap, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTheme } from "next-themes"
import { ArrowLeft, ChevronRight, RotateCcw } from "lucide-react"
import { ArrowLeft, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"
import { RunStatus, StageSummary } from "@/types/production"
import { INITIAL_RUNS, buildStagePlan, type RunStagePlanItem } from "@/lib/production-mock-runs"
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors"
import { productionRunsApi } from "@/lib/api/production-runs"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { ProductionRunGraph, ProductionRunStatus, RunStage, StageSummary } from "@/types/production"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import {
RunHeaderNodeComponent,
RunStageNodeComponent,
@@ -22,13 +29,10 @@ import { StageProgressStrip, StageStatusLegend } from "@/components/production/s
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { StageDrawer } from "./StageDrawer"
import { RunActions } from "./RunActions"
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
function runStatusBadgeClass(status: RunStatus) {
function runStatusBadgeClass(status: ProductionRunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
@@ -36,116 +40,168 @@ function runStatusBadgeClass(status: RunStatus) {
const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent }
const STAGE_START_X = 260
const STAGE_GAP_X = 220
/**
* Where the run header box sits relative to the stages.
*
* Stage positions come from the run's own `posX`/`posY` — copied from the template at creation
* (FR-MFG-06), so the canvas matches what was drawn in the builder. The header is placed to the
* left of the leftmost stage rather than at a fixed origin, because those coordinates are
* arbitrary and could otherwise put the header on top of a stage.
*/
const HEADER_GAP_X = 240
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
const nodes: Node[] = [
{
id: "header",
type: "runHeader",
position: { x: minX - HEADER_GAP_X, y: minY },
data: { docNo: run.docNo, templateName: run.templateName, status: run.status } satisfies RunHeaderData,
draggable: false,
},
]
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({
id: String(stage.runStageId),
type: "runStage",
position: { x: stage.posX, y: stage.posY },
data: {
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
draggable: false,
})
}
const edges: Edge[] = run.edges.map((e) => {
const parent = run.stages.find((s) => s.runStageId === e.parentRunStageId)
return {
id: `e${e.runEdgeId}`,
source: String(e.parentRunStageId),
target: String(e.childRunStageId),
animated: parent?.status === "InProgress",
}
})
// Entry stages hang off the run header so the line reads left to right from the run itself.
for (const stage of run.stages.filter((s) => s.isEntry)) {
edges.push({ id: `eh-${stage.runStageId}`, source: "header", target: String(stage.runStageId) })
}
return { nodes, edges }
}
export default function ProductionRunDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const { resolvedTheme } = useTheme()
const runId = Number(params.id)
const run = useMemo(() => INITIAL_RUNS.find((r) => r.runId === runId) ?? null, [runId])
// Same hydration-mismatch guard as the other canvas pages (templates/page.tsx,
// templates/[id]/page.tsx): colorMode depends on resolvedTheme, unknown on first paint.
const [run, setRun] = useState<ProductionRunGraph | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<number | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
// Same hydration-mismatch guard as the other canvas pages: colorMode depends on
// resolvedTheme, which is unknown on the server and on the client's first paint.
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const [status, setStatus] = useState<RunStatus>(run?.status ?? "InProgress")
const [completedAt, setCompletedAt] = useState<string | null>(run?.completedAt ?? null)
const [stages, setStages] = useState<RunStagePlanItem[]>(() =>
run ? buildStagePlan(run.templateName, run.stageSummary) : []
)
const load = useCallback(() => {
setLoadError(null)
productionRunsApi
.get(runId)
.then(({ data }) => setRun(data))
.catch((err) => setLoadError(errorMessage(err)))
}, [runId])
const activeIndex = stages.findIndex((s) => s.state !== "Approved")
function advanceStage(index: number) {
setStages((prev) => {
const curIdx = STAGE_STATUS_ORDER.indexOf(prev[index].state)
if (curIdx >= STAGE_STATUS_ORDER.length - 1) return prev
const next = [...prev]
next[index] = { ...next[index], state: STAGE_STATUS_ORDER[curIdx + 1] }
return next
})
}
// No real backend to push this to — advancing every stage to Approved locally completes
// the run on this page only (the Runs board keeps its own separate seed state).
useEffect(() => {
if (stages.length > 0 && stages.every((s) => s.state === "Approved") && status === "InProgress") {
setStatus("Completed")
setCompletedAt(todayIso())
toast.success("Run completed", run ? `${run.docNo} — all stages approved` : undefined)
}
}, [stages, status, run])
if (Number.isFinite(runId)) load()
}, [runId, load])
const progressPercent = stages.length > 0 ? Math.round((stages.filter((s) => s.state === "Approved").length / stages.length) * 100) : 0
const activeStage = activeIndex >= 0 ? stages[activeIndex] : null
const canGiveProgress = status === "InProgress" && activeStage !== null
useEffect(() => {
Promise.all([
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
reasonCodesApi.list("Production", { pageSize: 100 }),
])
.then(([itemRes, uomRes, warehouseRes, reasonRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(warehouseRes.items)
setReasonCodes(reasonRes.items)
})
.catch(() => {
// Reference data only feeds names and pickers — a failure here degrades labels to ids
// rather than blocking the run, so it deliberately doesn't set loadError.
})
}, [])
function giveProgress() {
if (activeIndex < 0) return
const stage = stages[activeIndex]
const nextState = STAGE_STATUS_ORDER[STAGE_STATUS_ORDER.indexOf(stage.state) + 1]
advanceStage(activeIndex)
toast.success(`${stage.name}${STAGE_STATUS_LABEL[nextState]}`, run?.docNo)
}
const stageSummary: StageSummary = useMemo(
() => ({
waiting: stages.filter((s) => s.state === "Waiting").length,
ready: stages.filter((s) => s.state === "Ready").length,
inProgress: stages.filter((s) => s.state === "InProgress").length,
done: stages.filter((s) => s.state === "Done").length,
approved: stages.filter((s) => s.state === "Approved").length,
}),
[stages]
const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run]
)
const { nodes, edges } = useMemo(() => {
if (!run) return { nodes: [] as Node[], edges: [] as Edge[] }
const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
for (const stage of run?.stages ?? []) {
if (stage.status === "Waiting") counts.waiting++
else if (stage.status === "Ready") counts.ready++
else if (stage.status === "InProgress") counts.inProgress++
else if (stage.status === "Done") counts.done++
else counts.approved++
}
return counts
}, [run])
const nodes: Node[] = [
{
id: "header",
type: "runHeader",
position: { x: 0, y: 0 },
data: { docNo: run.docNo, templateName: run.templateName, status } satisfies RunHeaderData,
draggable: false,
},
]
const edges: Edge[] = []
const selectedStage: RunStage | null =
run?.stages.find((s) => s.runStageId === selectedStageId) ?? null
stages.forEach((s, i) => {
const id = `stage-${i}`
const isActive = i === activeIndex && status === "InProgress"
nodes.push({
id,
type: "runStage",
position: { x: STAGE_START_X + i * STAGE_GAP_X, y: -8 },
data: {
name: s.name,
state: s.state,
isActive,
onAdvance: isActive ? () => advanceStage(i) : undefined,
} satisfies RunStageData,
draggable: false,
})
edges.push({
id: `e-${id}`,
source: i === 0 ? "header" : `stage-${i - 1}`,
target: id,
animated: s.state === "InProgress",
})
})
const onNodeClick: NodeMouseHandler = useCallback((_, node) => {
if (node.type !== "runStage") return
setSelectedStageId(Number(node.id))
}, [])
return { nodes, edges }
}, [run, stages, activeIndex, status])
if (!run) {
if (loadError) {
return (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<p className="text-base text-muted-foreground">Run not found.</p>
<p className="text-base text-muted-foreground">{loadError}</p>
<Button variant="outline" onClick={() => router.push("/dashboard/production/runs")}>
<ArrowLeft className="size-4" />
Back to runs
@@ -154,6 +210,20 @@ export default function ProductionRunDetailPage() {
)
}
if (!run) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-[45vh] w-full rounded-2xl" />
</div>
)
}
const warehouse = warehouses.find((w) => w.warehouseId === run.warehouseId)
const approvedCount = stageSummary.approved
const progressPercent = run.stages.length > 0 ? Math.round((approvedCount / run.stages.length) * 100) : 0
return (
<div className="flex flex-col gap-6">
<Button
@@ -170,8 +240,8 @@ export default function ProductionRunDetailPage() {
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-lg font-bold text-foreground">{run.docNo}</span>
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(status))}>
{status === "InProgress" ? "In Progress" : status}
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(run.status))}>
{run.status === "InProgress" ? "In Progress" : run.status}
</Badge>
{run.reworkCount > 0 && (
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning">
@@ -180,27 +250,31 @@ export default function ProductionRunDetailPage() {
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{run.templateName} · {run.warehouseName}</p>
<p className="text-sm text-muted-foreground">
{run.templateName}
{warehouse && ` · ${warehouse.name}`}
{` · ×${Number(run.scaleFactor.toFixed(6)).toLocaleString()} scale`}
</p>
</div>
<div className="flex shrink-0 flex-col text-left sm:text-right">
<span className="text-sm font-medium text-foreground">
{run.targetQty.toLocaleString()} {run.uom} · {run.finishedItemName}
</span>
<span className="text-sm font-medium text-foreground">Target {run.targetQty.toLocaleString()}</span>
<span className="text-sm text-muted-foreground">
Created {new Date(run.createdAt).toLocaleDateString()}
{completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}</>}
{run.completedAt && <> · Completed {new Date(run.completedAt).toLocaleDateString()}</>}
</span>
</div>
</div>
<StageProgressStrip status={status} summary={stageSummary} className="mt-4" />
<StageProgressStrip status={run.status} summary={stageSummary} className="mt-4" />
<div className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground">
{progressPercent}% complete
{activeStage && <span className="text-muted-foreground"> · Current: {activeStage.name}</span>}
{progressPercent}% approved
<span className="text-muted-foreground">
{" "}· cost pool {run.costPool.net.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</span>
</div>
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-muted">
@@ -210,10 +284,7 @@ export default function ProductionRunDetailPage() {
/>
</div>
</div>
<Button onClick={giveProgress} disabled={!canGiveProgress} className="w-full shrink-0 sm:w-auto">
{activeStage ? `Give Progress — ${activeStage.name}` : "All stages approved"}
<ChevronRight className="size-4" />
</Button>
<RunActions run={run} items={items} reasonCodes={reasonCodes} onActed={load} />
</div>
</div>
@@ -221,15 +292,18 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend />
</div>
<div className="h-[45vh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
elementsSelectable
panOnDrag
zoomOnScroll
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
@@ -238,11 +312,22 @@ export default function ProductionRunDetailPage() {
>
<Background />
<Controls showInteractive={false} />
<MiniMap pannable zoomable />
</ReactFlow>
) : (
<Skeleton className="size-full rounded-none" />
)}
</div>
<StageDrawer
run={run}
stage={selectedStage}
items={items}
uoms={uoms}
reasonCodes={reasonCodes}
onClose={() => setSelectedStageId(null)}
onActed={load}
/>
</div>
)
}
@@ -1,13 +1,18 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronRight, PlayCircle, RotateCcw, Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { ProductionRun, RunStatus } from "@/types/production"
import { INITIAL_RUNS, STARTABLE_TEMPLATES, buildStagePlan } from "@/lib/production-mock-runs"
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL } from "@/lib/production-status-colors"
import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { ProductionRunStatus, ProductionRunSummary, ProductionTemplateSummary } from "@/types/production"
import { Bin, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -15,101 +20,152 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
const TEMPLATE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.templateName)))
const WAREHOUSE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.warehouseName)))
const PAGE_SIZE = 25
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
type StatusFilter = ProductionRunStatus | "All"
type StatusFilter = RunStatus | "All"
type NameFilter = string | "All"
function runStatusBadgeClass(status: RunStatus) {
function runStatusBadgeClass(status: ProductionRunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
}
const SUMMARY_KEYS = {
Waiting: "waiting",
Ready: "ready",
InProgress: "inProgress",
Done: "done",
Approved: "approved",
} as const
export default function ProductionRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<ProductionRun[]>(INITIAL_RUNS)
// null = still loading (the codebase convention for "no data yet" vs "empty result").
const [runs, setRuns] = useState<ProductionRunSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [template, setTemplate] = useState<NameFilter>("All")
const [warehouse, setWarehouse] = useState<NameFilter>("All")
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [open, setOpen] = useState(false)
const [startTemplateId, setStartTemplateId] = useState<number | null>(null)
const [targetQty, setTargetQty] = useState("")
const [startWarehouse, setStartWarehouse] = useState<string | null>(null)
const [outputBin, setOutputBin] = useState("")
const [startWarehouseId, setStartWarehouseId] = useState<number | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [outputBinId, setOutputBinId] = useState<number | null>(null)
const [formError, setFormError] = useState("")
const [submitting, setSubmitting] = useState(false)
const startTemplate = STARTABLE_TEMPLATES.find((t) => t.templateId === startTemplateId) ?? null
// 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the
// query so a narrower search can't leave you stranded past the last page.
useEffect(() => {
const timer = setTimeout(() => {
setQuery(searchInput.trim())
setPage(1)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const load = useCallback(() => {
setLoadError(null)
productionRunsApi
.list({
page,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
templateId: templateId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRuns(res.items)
setPagination(res.pagination)
})
.catch((err) => {
setRuns([])
setLoadError(errorMessage(err))
})
}, [page, query, status, templateId, warehouseId])
useEffect(load, [load])
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
// Inactive template that still has historical runs; the start dialog narrows to Active
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
useEffect(() => {
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([templateRes, warehouseRes]) => {
setTemplates(templateRes.items)
setWarehouses(warehouseRes.items)
})
.catch((err) => toast.error("Could not load filters", errorMessage(err)))
}, [])
// Bins belong to a warehouse, so the list is only meaningful once one is picked.
useEffect(() => {
if (startWarehouseId === null) {
setBins([])
return
}
warehousesApi
.listBins(startWarehouseId)
.then(setBins)
.catch(() => setBins([]))
}, [startWarehouseId])
const startableTemplates = templates.filter((t) => t.status === "Active")
const startTemplate = startableTemplates.find((t) => t.templateId === startTemplateId) ?? null
const targetQtyNum = Number(targetQty)
const scaleFactor = startTemplate && targetQtyNum > 0 ? targetQtyNum / startTemplate.nominalBatchQty : null
function openStartDialog() {
setStartTemplateId(null)
setTargetQty("")
setStartWarehouse(null)
setOutputBin("")
setStartWarehouseId(null)
setOutputBinId(null)
setFormError("")
setOpen(true)
}
function handleStartRun() {
if (!startTemplate) {
setFormError("Pick a template.")
return
}
if (!(targetQtyNum > 0)) {
setFormError("Target quantity must be greater than 0.")
return
}
if (!startWarehouse) {
setFormError("Pick a warehouse.")
return
}
async function handleStartRun() {
if (!startTemplate) return setFormError("Pick a template.")
if (!(targetQtyNum > 0)) return setFormError("Target quantity must be greater than 0.")
if (startWarehouseId === null) return setFormError("Pick a warehouse.")
setFormError("")
setSubmitting(true)
const nextId = runs.reduce((max, r) => Math.max(max, r.runId), 0) + 1
const created: ProductionRun = {
runId: nextId,
docNo: `PRD-2026-${String(nextId).padStart(5, "0")}`,
templateName: startTemplate.name,
targetQty: targetQtyNum,
finishedItemName: startTemplate.finishedItemName,
uom: startTemplate.uom,
warehouseName: startWarehouse,
status: "InProgress",
reworkCount: 0,
createdAt: todayIso(),
completedAt: null,
// Freshly started: nothing done yet, first stage ready, the rest waiting.
stageSummary: { waiting: Math.max(startTemplate.stageCount - 1, 0), ready: 1, inProgress: 0, done: 0, approved: 0 },
try {
const { data } = await productionRunsApi.create({
templateId: startTemplate.templateId,
targetQty: targetQtyNum,
warehouseId: startWarehouseId,
outputBinId,
})
toast.success("Run started", `${data.docNo}${data.templateName}`)
setOpen(false)
// Straight to the run: the per-stage quantities the operator may want to adjust before
// starting stage one only exist there (docs/30 §4).
router.push(`/dashboard/production/runs/${data.runId}`)
} catch (err) {
setFormError(errorMessage(err))
} finally {
setSubmitting(false)
}
setRuns((prev) => [...prev, created])
toast.success("Run started", `${created.docNo}${created.templateName}${outputBin ? ` → bin ${outputBin}` : ""}`)
setSubmitting(false)
setOpen(false)
}
const filtered = useMemo(() => {
const q = searchInput.trim().toLowerCase()
return runs
.filter((r) => (status === "All" ? true : r.status === status))
.filter((r) => (template === "All" ? true : r.templateName === template))
.filter((r) => (warehouse === "All" ? true : r.warehouseName === warehouse))
.filter((r) => (q ? r.docNo.toLowerCase().includes(q) : true))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}, [runs, searchInput, status, template, warehouse])
const hasFilters = searchInput.trim().length > 0 || status !== "All" || template !== "All" || warehouse !== "All"
const hasFilters = query.length > 0 || status !== "All" || templateId !== null || warehouseId !== null
return (
<div className="flex flex-col gap-6">
@@ -128,56 +184,70 @@ export default function ProductionRunsPage() {
<FieldGroup>
<Field data-invalid={!!formError && !startTemplate}>
<FieldLabel>Template</FieldLabel>
<Select<number> value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}>
<Select<number> value={startTemplateId} onValueChange={setStartTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a template" />
<SelectValue placeholder={startableTemplates.length === 0 ? "No active templates" : "Pick a template"} />
</SelectTrigger>
<SelectContent>
{STARTABLE_TEMPLATES.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
{startableTemplates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">
{t.name} · {t.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={!!formError && !(targetQtyNum > 0)}>
<FieldLabel htmlFor="target-qty">
Target quantity{startTemplate && <span className="font-normal text-muted-foreground"> ({startTemplate.uom}, {startTemplate.finishedItemName})</span>}
</FieldLabel>
<FieldLabel htmlFor="target-qty">Target quantity</FieldLabel>
<Input
id="target-qty"
type="number"
min={0}
step="any"
value={targetQty}
onChange={(e) => setTargetQty(e.target.value)}
placeholder="e.g. 200"
/>
</Field>
<Field data-invalid={!!formError && !startWarehouse}>
<Field data-invalid={!!formError && startWarehouseId === null}>
<FieldLabel>Warehouse</FieldLabel>
<Select<string> value={startWarehouse} onValueChange={setStartWarehouse}>
<Select<number> value={startWarehouseId} onValueChange={(v) => { setStartWarehouseId(v); setOutputBinId(null) }}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a warehouse" />
</SelectTrigger>
<SelectContent>
{WAREHOUSE_NAMES.map((n) => (
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.name} · {w.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="output-bin">Output bin (optional)</FieldLabel>
<Input id="output-bin" value={outputBin} onChange={(e) => setOutputBin(e.target.value)} placeholder="e.g. BIN-04" />
<FieldLabel>Output bin (optional)</FieldLabel>
<Select<number> value={outputBinId} onValueChange={setOutputBinId}>
<SelectTrigger className="h-11! w-full text-base" disabled={bins.length === 0}>
<SelectValue placeholder={startWarehouseId === null ? "Pick a warehouse first" : bins.length === 0 ? "No bins in this warehouse" : "No specific bin"} />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
{b.binType ? ` · ${b.binType}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{scaleFactor !== null && (
{startTemplate && targetQtyNum > 0 && (
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
Scale factor <span className="font-semibold text-foreground">{scaleFactor.toFixed(2)}×</span> target {targetQtyNum.toLocaleString()} {startTemplate!.uom} vs
{" "}a nominal batch of {startTemplate!.nominalBatchQty.toLocaleString()} {startTemplate!.uom}. Every stage's inputs/outputs scale by this factor; the authoritative
figures come back once the run is created.
Every stage&apos;s planned inputs and outputs are scaled from the template&apos;s per-batch figures against
this target. The authoritative numbers come back with the run and stay editable until each stage starts.
</div>
)}
@@ -206,29 +276,33 @@ export default function ProductionRunsPage() {
aria-label="Search runs"
/>
</div>
<Select<NameFilter> value={template} onValueChange={(v) => setTemplate(v ?? "All")}>
<Select<number | null>
value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All templates</SelectItem>
{TEMPLATE_NAMES.map((n) => (
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<NameFilter> value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{WAREHOUSE_NAMES.map((n) => (
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
@@ -241,11 +315,38 @@ export default function ProductionRunsPage() {
</Select>
</div>
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setSearchInput("")
setStatus("All")
setTemplateId(null)
setWarehouseId(null)
setPage(1)
}}
>
Clear filters
</Button>
)}
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
<StageStatusLegend />
</div>
{filtered.length === 0 ? (
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{runs === null ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-32 w-full rounded-2xl" />
<Skeleton className="h-32 w-full rounded-2xl" />
<Skeleton className="h-32 w-full rounded-2xl" />
</div>
) : runs.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PlayCircle className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
@@ -253,64 +354,94 @@ export default function ProductionRunsPage() {
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{filtered.map((r) => (
<button
key={r.runId}
type="button"
onClick={() => router.push(`/dashboard/production/runs/${r.runId}`)}
className="w-full rounded-2xl bg-card p-4 text-left shadow-sm ring-1 ring-foreground/10 transition-colors hover:ring-primary/40 sm:p-5"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">{r.docNo}</span>
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(r.status))}>
{r.status === "InProgress" ? "In Progress" : r.status}
</Badge>
{r.reworkCount > 0 && (
<Badge
variant="outline"
className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning"
<>
<div className="flex flex-col gap-3">
{runs.map((r) => {
const warehouse = warehouses.find((w) => w.warehouseId === r.warehouseId)
return (
<button
key={r.runId}
type="button"
onClick={() => router.push(`/dashboard/production/runs/${r.runId}`)}
className="w-full rounded-2xl bg-card p-4 text-left shadow-sm ring-1 ring-foreground/10 transition-colors hover:ring-primary/40 sm:p-5"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">{r.docNo}</span>
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(r.status))}>
{r.status === "InProgress" ? "In Progress" : r.status}
</Badge>
{r.reworkCount > 0 && (
<Badge
variant="outline"
className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning"
>
<RotateCcw className="size-3.5" />
Rework #{r.reworkCount}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{r.templateName}
{warehouse && ` · ${warehouse.name}`}
</p>
</div>
<div className="flex shrink-0 items-start gap-2 sm:items-center">
<div className="flex flex-col text-left sm:text-right">
<span className="text-sm font-medium text-foreground">
{r.targetQty.toLocaleString()}
{r.finishedItemName && ` · ${r.finishedItemName}`}
</span>
<span className="text-sm text-muted-foreground">
Created {new Date(r.createdAt).toLocaleDateString()}
{r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}</>}
</span>
</div>
<ChevronRight className="hidden size-5 shrink-0 text-muted-foreground sm:block" />
</div>
</div>
<StageProgressStrip status={r.status} summary={r.stageSummary} className="mt-4" />
{/*
Counts per status, not named stages. The list projection carries `stageSummary`
only — which stage is in which state is on the run detail — so naming them here
would mean guessing an allocation, which is what the mock used to do.
*/}
<div className="mt-3 flex flex-wrap gap-2">
{STAGE_STATUS_ORDER.filter((key) => r.stageSummary[SUMMARY_KEYS[key]] > 0).map((key) => (
<span
key={key}
className="inline-flex items-center gap-1.5 rounded-full bg-muted/50 px-2.5 py-1 text-xs font-medium text-foreground"
>
<RotateCcw className="size-3.5" />
Rework #{r.reworkCount}
</Badge>
)}
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[key] }} />
{r.stageSummary[SUMMARY_KEYS[key]]} {STAGE_STATUS_LABEL[key]}
</span>
))}
</div>
<p className="text-sm text-muted-foreground">{r.templateName} · {r.warehouseName}</p>
</div>
<div className="flex shrink-0 items-start gap-2 sm:items-center">
<div className="flex flex-col text-left sm:text-right">
<span className="text-sm font-medium text-foreground">
{r.targetQty.toLocaleString()} {r.uom} · {r.finishedItemName}
</span>
<span className="text-sm text-muted-foreground">
Created {new Date(r.createdAt).toLocaleDateString()}
{r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}</>}
</span>
</div>
<ChevronRight className="hidden size-5 shrink-0 text-muted-foreground sm:block" />
</div>
</div>
</button>
)
})}
</div>
<StageProgressStrip status={r.status} summary={r.stageSummary} className="mt-4" />
<div className="mt-3 flex flex-wrap gap-2">
{buildStagePlan(r.templateName, r.stageSummary).map((s, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 rounded-full bg-muted/50 px-2.5 py-1 text-xs font-medium text-foreground"
>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[s.state] }} />
{s.name}
<span className="text-muted-foreground">· {STAGE_STATUS_LABEL[s.state]}</span>
</span>
))}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-base text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
Previous
</Button>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={pagination.page >= pagination.totalPages}>
Next
</Button>
</div>
</button>
))}
</div>
</div>
)}
</>
)}
</div>
)
@@ -3,7 +3,16 @@
import { Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { FieldDef, FieldType, FormulaInput, FormulaOutput, InputSource, MockItem, StageNodeData } from "./types"
import { CustomFieldType, StageInputSource } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import {
BuilderFieldDef,
BuilderInput,
BuilderOutput,
StageNodeData,
newKey,
newLocalId,
} from "./types"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
@@ -15,65 +24,138 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function newId() {
return Math.random().toString(36).slice(2, 10)
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: FieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
export interface UpstreamOutputOption {
stageId: string
stageKey: string
stageName: string
outputId: string
outputKey: string
outputName: string
}
/** One row per input/output quantity — UOM select plus qty, used three times below. */
function QtyRow({
qty,
uomId,
uoms,
readOnly,
onQtyChange,
onUomChange,
}: {
qty: number
uomId: number | null
uoms: Uom[]
readOnly: boolean
onQtyChange: (qty: number) => void
onUomChange: (uomId: number) => void
}) {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
step="any"
value={qty}
disabled={readOnly}
onChange={(e) => onQtyChange(Number(e.target.value) || 0)}
className="h-8 text-sm"
placeholder="Qty per batch"
aria-label="Quantity per batch"
/>
<Select<number> value={uomId} onValueChange={(v) => v && onUomChange(v)}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="UOM">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
export function StageEditorPanel({
nodeId,
data,
isTerminal,
upstreamOptions,
items,
uoms,
readOnly,
onChange,
onDelete,
onClose,
}: {
nodeId: string
data: StageNodeData
isTerminal: boolean
upstreamOptions: UpstreamOutputOption[]
items: MockItem[]
items: ItemListItem[]
uoms: Uom[]
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
onClose: () => void
}) {
function updateInput(inputId: string, patch: Partial<FormulaInput>) {
onChange({ inputs: data.inputs.map((i) => (i.inputId === inputId ? { ...i, ...patch } : i)) })
function updateInput(localId: string, patch: Partial<BuilderInput>) {
onChange({ inputs: data.inputs.map((i) => (i.localId === localId ? { ...i, ...patch } : i)) })
}
function addInput() {
onChange({ inputs: [...data.inputs, { inputId: newId(), source: "Stock" as InputSource, qty: 1 }] })
onChange({
inputs: [
...data.inputs,
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 },
],
})
}
function removeInput(inputId: string) {
onChange({ inputs: data.inputs.filter((i) => i.inputId !== inputId) })
function removeInput(localId: string) {
onChange({ inputs: data.inputs.filter((i) => i.localId !== localId) })
}
function updateOutput(outputId: string, patch: Partial<FormulaOutput>) {
onChange({ outputs: data.outputs.map((o) => (o.outputId === outputId ? { ...o, ...patch } : o)) })
/**
* Switching source clears the other side's field. Leaving a stale `itemId` on an Upstream
* input (or a stale `fromOutputKey` on a Stock one) is a 422 GRAPH_INPUT_SOURCE_INVALID —
* the validator rejects an input that carries both.
*/
function changeInputSource(localId: string, source: StageInputSource) {
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
}
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
function pickInputItem(input: BuilderInput, itemId: number) {
const item = items.find((i) => i.itemId === itemId)
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
}
function updateOutput(key: string, patch: Partial<BuilderOutput>) {
onChange({ outputs: data.outputs.map((o) => (o.key === key ? { ...o, ...patch } : o)) })
}
function addOutput() {
onChange({ outputs: [...data.outputs, { outputId: newId(), name: "", uom: "PCS", qty: 1 }] })
onChange({
outputs: [...data.outputs, { key: newKey(), itemId: null, name: "", uomId: null, qtyPerBatch: 1 }],
})
}
function removeOutput(outputId: string) {
onChange({ outputs: data.outputs.filter((o) => o.outputId !== outputId) })
function removeOutput(key: string) {
onChange({ outputs: data.outputs.filter((o) => o.key !== key) })
}
function updateField(fieldId: string, patch: Partial<FieldDef>) {
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
function pickOutputItem(output: BuilderOutput, itemId: number) {
const item = items.find((i) => i.itemId === itemId)
updateOutput(output.key, {
itemId,
name: item?.name ?? output.name,
uomId: output.uomId ?? item?.baseUomId ?? null,
})
}
function updateField(localId: string, patch: Partial<BuilderFieldDef>) {
onChange({
fieldDefs: data.fieldDefs.map((f) => {
if (f.fieldId !== fieldId) return f
if (f.localId !== localId) return f
const next = { ...f, ...patch }
if (patch.label !== undefined) next.key = slugify(patch.label) || f.key
return next
@@ -82,11 +164,14 @@ export function StageEditorPanel({
}
function addField() {
onChange({
fieldDefs: [...data.fieldDefs, { fieldId: newId(), key: "", label: "", type: "Text", options: [], required: false }],
fieldDefs: [
...data.fieldDefs,
{ localId: newLocalId(), key: "", label: "", type: "Text", options: [], required: false },
],
})
}
function removeField(fieldId: string) {
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.fieldId !== fieldId) })
function removeField(localId: string) {
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.localId !== localId) })
}
return (
@@ -145,13 +230,13 @@ export function StageEditorPanel({
<div className="flex flex-col gap-2">
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
{data.inputs.map((input) => (
<div key={input.inputId} className="rounded-lg border border-border p-2.5">
<div key={input.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Select<InputSource>
<Select<StageInputSource>
value={input.source}
onValueChange={(v) => v && updateInput(input.inputId, { source: v })}
onValueChange={(v) => v && changeInputSource(input.localId, v)}
>
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -160,70 +245,56 @@ export function StageEditorPanel({
</SelectContent>
</Select>
{!readOnly && (
<button type="button" onClick={() => removeInput(input.inputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" />
</button>
)}
</div>
{input.source === "Stock" ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{input.source === "Stock" ? (
<Select<number>
value={input.itemId ?? null}
onValueChange={(v) => {
const item = items.find((i) => i.itemId === v)
updateInput(input.inputId, { itemId: v ?? undefined, itemName: item?.name, uom: item?.uom })
}}
value={input.itemId}
onValueChange={(v) => v && pickInputItem(input, v)}
>
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly}>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick an item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={input.qty ?? 0}
disabled={readOnly}
onChange={(e) => updateInput(input.inputId, { qty: Number(e.target.value) || 0 })}
className="h-8 text-sm"
placeholder="Qty"
/>
<span className="w-14 shrink-0 text-sm text-muted-foreground">{input.uom ?? "—"}</span>
</div>
<Input
value={input.batch ?? ""}
disabled={readOnly}
onChange={(e) => updateInput(input.inputId, { batch: e.target.value })}
className="h-8 text-sm"
placeholder="Batch (optional)"
/>
</div>
) : (
<Select<string>
value={input.upstreamOutputId ?? null}
onValueChange={(v) => {
const opt = upstreamOptions.find((o) => o.outputId === v)
updateInput(input.inputId, { upstreamOutputId: v ?? undefined, upstreamStageId: opt?.stageId })
}}
>
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
</SelectTrigger>
<SelectContent>
{upstreamOptions.map((o) => (
<SelectItem key={o.outputId} value={o.outputId} className="text-sm">
{o.stageName} {o.outputName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
) : (
<Select<string>
value={input.fromOutputKey}
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
</SelectTrigger>
<SelectContent>
{upstreamOptions.map((o) => (
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
{o.stageName} {o.outputName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
/>
</div>
</div>
))}
</div>
@@ -245,22 +316,18 @@ export function StageEditorPanel({
<div className="flex flex-col gap-2">
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
{data.outputs.map((output) => (
<div key={output.outputId} className="rounded-lg border border-border p-2.5">
<div key={output.key} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
{isTerminal ? (
<Select<number>
value={output.itemId ?? null}
onValueChange={(v) => {
const item = items.find((i) => i.itemId === v)
updateOutput(output.outputId, { itemId: v ?? undefined, name: item?.name ?? "", uom: item?.uom ?? output.uom })
}}
>
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick the finished-good item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -268,35 +335,25 @@ export function StageEditorPanel({
<Input
value={output.name}
disabled={readOnly}
onChange={(e) => updateOutput(output.outputId, { name: e.target.value })}
placeholder="Output name"
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
placeholder="Output name (work in progress)"
className="h-8 flex-1 text-sm"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.outputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={output.qty}
disabled={readOnly}
onChange={(e) => updateOutput(output.outputId, { qty: Number(e.target.value) || 0 })}
className="h-8 text-sm"
placeholder="Qty"
/>
<Input
value={output.uom}
disabled={readOnly}
onChange={(e) => updateOutput(output.outputId, { uom: e.target.value })}
className="h-8 w-20 text-sm"
placeholder="UOM"
/>
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
/>
</div>
))}
</div>
@@ -316,28 +373,28 @@ export function StageEditorPanel({
<div className="flex flex-col gap-2">
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
{data.fieldDefs.map((field) => (
<div key={field.fieldId} className="rounded-lg border border-border p-2.5">
<div key={field.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
<Input
value={field.label}
disabled={readOnly}
onChange={(e) => updateField(field.fieldId, { label: e.target.value })}
onChange={(e) => updateField(field.localId, { label: e.target.value })}
placeholder="Label"
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
<button type="button" onClick={() => removeField(field.fieldId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" />
</button>
)}
</div>
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
<div className="flex items-center gap-2">
<Select<FieldType>
<Select<CustomFieldType>
value={field.type}
onValueChange={(v) => v && updateField(field.fieldId, { type: v })}
onValueChange={(v) => v && updateField(field.localId, { type: v })}
>
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -351,7 +408,7 @@ export function StageEditorPanel({
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.fieldId, { required: checked })}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/>
<span className="text-xs text-muted-foreground">Required</span>
</div>
@@ -360,7 +417,7 @@ export function StageEditorPanel({
<Input
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.fieldId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
@@ -380,5 +437,3 @@ export function StageEditorPanel({
</div>
)
}
export { newId }
@@ -18,7 +18,9 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
<div
className={cn(
"relative w-56 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
selected ? "ring-primary" : "ring-foreground/10",
// `focused` wins over `selected`: it marks the stage the server's 422 named, and the
// click that selects a node must not hide the reason the save failed.
data.focused ? "ring-destructive" : selected ? "ring-primary" : "ring-foreground/10",
disconnected && "opacity-40"
)}
>
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
addEdge,
@@ -23,45 +23,91 @@ import { useTheme } from "next-themes"
import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react"
import { cn } from "@/lib/utils"
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import StageNode from "./StageNode"
import AnnotationBoxNode, { LineNodeComponent } from "./AnnotationNodes"
import { StageEditorPanel, type UpstreamOutputOption, newId } from "./StageEditorPanel"
import { AnnotationData, MockItem, StageNodeData } from "./types"
const MOCK_ITEMS: MockItem[] = [
{ itemId: 101, name: "Steel Sheet 2mm", uom: "KG" },
{ itemId: 102, name: "Screws M4", uom: "PCS" },
{ itemId: 103, name: "Steel Bracket A", uom: "PCS" },
{ itemId: 104, name: "PCB Board X", uom: "PCS" },
{ itemId: 105, name: "Solder Wire", uom: "M" },
{ itemId: 106, name: "Electronic Component Kit", uom: "SET" },
{ itemId: 107, name: "Wood Plank", uom: "PCS" },
{ itemId: 108, name: "Pallet Standard", uom: "PCS" },
{ itemId: 109, name: "Cable Wire", uom: "M" },
{ itemId: 110, name: "Harness Kit B", uom: "SET" },
]
import { StageEditorPanel, type UpstreamOutputOption } from "./StageEditorPanel"
import { AnnotationData, StageNodeData, newKey, newLocalId } from "./types"
const nodeTypes = { stage: StageNode, box: AnnotationBoxNode, line: LineNodeComponent }
function buildInitialGraph(stageNames: string[]): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = stageNames.map((name, i) => ({
id: `n${i + 1}`,
const DEFAULT_BOX = { width: 320, height: 220 }
const DEFAULT_LINE = { width: 220, height: 4 }
/**
* Server graph -> React Flow.
*
* A stage's node id **is** its server key, which is why edges need no translation in either
* direction: `edge.source`/`edge.target` are already the `parentKey`/`childKey` the save
* payload wants. Annotations come first in the array because React Flow paints later entries
* on top, and a grouping box belongs behind the stage cards it groups.
*/
function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Edge[] } {
const annotations: Node[] = graph.annotations.map((a) => ({
id: `ann-${newLocalId()}`,
type: a.kind,
position: { x: a.posX, y: a.posY },
width: a.width,
height: a.height,
data: { label: a.label ?? "", rotation: a.rotation ?? undefined } satisfies AnnotationData,
}))
const stages: Node[] = graph.stages.map((s) => ({
id: s.key,
type: "stage",
position: { x: i * 280 + 40, y: 120 },
data: { name, roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData,
position: { x: s.posX, y: s.posY },
data: {
name: s.name,
roleLabel: s.roleLabel ?? "",
estimatedMinutes: s.estimatedMinutes,
inputs: s.inputs.map((i) => ({
localId: newLocalId(),
source: i.source,
itemId: i.itemId,
fromOutputKey: i.fromOutputKey,
uomId: i.uomId,
qtyPerBatch: i.qtyPerBatch,
})),
outputs: s.outputs.map((o) => ({
key: o.key,
itemId: o.itemId,
name: o.name,
uomId: o.uomId,
qtyPerBatch: o.qtyPerBatch,
})),
fieldDefs: s.fieldDefs.map((f) => ({
localId: newLocalId(),
key: f.key,
label: f.label,
type: f.type,
options: f.options ?? [],
required: f.required,
})),
} satisfies StageNodeData,
}))
const edges: Edge[] = stageNames.slice(1).map((_, i) => ({
id: `e${i + 1}`,
source: `n${i + 1}`,
target: `n${i + 2}`,
const edges: Edge[] = graph.edges.map((e) => ({
id: `e${e.edgeId}`,
source: e.parentKey,
target: e.childKey,
}))
return { nodes, edges }
return { nodes: [...annotations, ...stages], edges }
}
/** Kahn's algorithm — returns the ids left over (unprocessable) once no more in-degree-0 nodes exist, i.e. the cycle. */
/** Kahn's algorithm — true when the toposort can't reach every node, i.e. there's a cycle. */
function detectCycle(nodes: Node[], edges: Edge[]): boolean {
const inDegree = new Map(nodes.map((n) => [n.id, 0]))
for (const e of edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1)
@@ -79,36 +125,118 @@ function detectCycle(nodes: Node[], edges: Edge[]): boolean {
return visited !== nodes.length
}
/**
* Best-effort mapping from a `422 GRAPH_*` detail back to the stages it names.
*
* The validator's messages quote real stage names ("...contains a cycle involving: Cut frame,
* Assemble"), so a substring match finds them without the server having to return keys. It is
* deliberately advisory: the full message is always shown in the banner too, so a stage renamed
* to something ambiguous costs a highlight, never the explanation.
*/
function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<string> {
if (!detail) return new Set()
const named = stageNodes.filter((n) => {
const name = (n.data as StageNodeData).name.trim()
return name.length > 0 && detail.includes(name)
})
return new Set(named.map((n) => n.id))
}
export default function TemplateBuilderPage() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
const { resolvedTheme } = useTheme()
// A template just created on the list page (see app/dashboard/production/templates/page.tsx
// handleCreate) — no backend exists to look it up by id, so its name/blank graph arrive via
// the URL instead. Every other id falls back to the 5 seeded mock templates.
const isFresh = searchParams.get("fresh") === "1"
const freshName = searchParams.get("name")
const template = isFresh && freshName
? { name: freshName, activeRunCount: 0, stages: [] as string[] }
: (MOCK_TEMPLATE_INFO[params.id] ?? { name: `Template #${params.id}`, activeRunCount: 0, stages: ["Stage 1"] })
const locked = template.activeRunCount > 0
// "new" is a draft that exists only in this page until the first successful Save. A template
// cannot be created from a name alone — the server requires at least one stage and a terminal
// output naming a real finished item (FR-MFG-02/05) — so there is nothing to POST up front.
const isNew = params.id === "new"
const templateId = Number(params.id)
// Deliberately only keyed on the id, not `template.stages` — this is the seed for
// uncontrolled node/edge state below, meant to run once per template, not on every
// in-place edit (which also changes what buildInitialGraph would return via stageNodes).
const initial = useMemo(() => buildInitialGraph(template.stages), [params.id]) // eslint-disable-line react-hooks/exhaustive-deps
const [nodes, setNodes] = useState<Node[]>(initial.nodes)
const [edges, setEdges] = useState<Edge[]>(initial.edges)
const [graph, setGraph] = useState<ProductionTemplateGraph | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Derived rather than its own state: a draft is ready immediately, and a saved template is
// ready as soon as the GET resolves either way.
const loaded = isNew || graph !== null || loadError !== null
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [conflict, setConflict] = useState(false)
const [lockedByServer, setLockedByServer] = useState(false)
const [focusedKeys, setFocusedKeys] = useState<Set<string>>(new Set())
const [togglingStatus, setTogglingStatus] = useState(false)
// `lockedByServer` covers the edit-lock TOCTOU: a run can start between our GET and our PUT,
// in which case the 409 is the first we hear of it (FR-MFG-06).
const activeRunCount = graph?.activeRunCount ?? 0
const locked = activeRunCount > 0 || lockedByServer
// `resolvedTheme` is unknown on the server (and on the client's first paint, before
// next-themes reads localStorage), so `colorMode` below would differ between the SSR
// markup and the client's first render — same hydration-mismatch class theme-toggle.tsx
// already guards against. Render the canvas only once mounted.
// next-themes reads localStorage), so `colorMode` would differ between the SSR markup and the
// first client render — the same hydration-mismatch class theme-toggle.tsx guards against.
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const applyGraph = useCallback((data: ProductionTemplateGraph, tag: string | null) => {
const flow = graphToFlow(data)
setGraph(data)
setEtag(tag)
setCode(data.code)
setName(data.name)
setDescription(data.description ?? "")
setNodes(flow.nodes)
setEdges(flow.edges)
setSelectedNodeId(null)
setConflict(false)
setLockedByServer(false)
setFocusedKeys(new Set())
setSaveError(null)
}, [])
const load = useCallback(() => {
setLoadError(null)
productionTemplatesApi
.get(templateId)
.then(({ data, etag: tag }) => applyGraph(data, tag))
.catch((err) => setLoadError(errorMessage(err)))
}, [templateId, applyGraph])
useEffect(() => {
if (isNew) {
// Seeded from the overview's "New Template" dialog.
setCode(searchParams.get("code") ?? "")
setName(searchParams.get("name") ?? "")
return
}
if (Number.isFinite(templateId)) load()
// searchParams is read once for the draft seed; re-running on every query change would
// overwrite what the user has typed since.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
@@ -138,18 +266,16 @@ export default function TemplateBuilderPage() {
const onPaneClick = useCallback(() => setSelectedNodeId(null), [])
// Guarded here, not just by hiding the toolbar/panel controls: `elementsSelectable` stays
// true even when locked (so a locked template can still be inspected), and these two
// setters go straight to setNodes/setEdges — they don't route through onNodesChange, which
// is what actually gets set to `undefined` when locked. Without this check, a locked
// template's box/line labels, rotation, and now the inline delete buttons would all still
// be editable via those paths.
// true even when locked (so a locked template can still be inspected), and these two setters
// go straight to setNodes/setEdges — they don't route through onNodesChange, which is what
// actually gets set to `undefined` when locked.
function updateNodeData(nodeId: string, patch: Partial<StageNodeData> | Partial<AnnotationData>) {
if (locked) return
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...patch } } : n)))
}
function addStage() {
const id = `n${newId()}`
const id = newKey()
const existingStages = nodes.filter((n) => n.type === "stage")
const maxX = existingStages.reduce((max, n) => Math.max(max, n.position.x), 0)
const y = existingStages.length > 0 ? existingStages[existingStages.length - 1].position.y : 120
@@ -159,15 +285,22 @@ export default function TemplateBuilderPage() {
id,
type: "stage",
position: { x: existingStages.length > 0 ? maxX + 280 : 40, y },
data: { name: "New stage", roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData,
data: {
name: "New stage",
roleLabel: "",
estimatedMinutes: 15,
inputs: [],
outputs: [],
fieldDefs: [],
} satisfies StageNodeData,
},
])
setSelectedNodeId(id)
}
// Generic across every node type — stage cards, boxes, lines all use this (inline × buttons
// on the nodes themselves, plus the stage editor panel's "Delete stage" button). Box/line
// nodes never have edges, so the edge-filter is a no-op for them, not a special case.
// Generic across every node type — stage cards, boxes and lines all use this (the inline ×
// buttons on the nodes themselves, plus the stage editor's "Delete stage"). Box/line nodes
// never have edges, so the edge filter is a no-op for them, not a special case.
function deleteNode(nodeId: string) {
if (locked) return
setNodes((nds) => nds.filter((n) => n.id !== nodeId))
@@ -175,30 +308,14 @@ export default function TemplateBuilderPage() {
setSelectedNodeId((id) => (id === nodeId ? null : id))
}
// Boxes/lines are prepended (not appended) so React Flow — which paints later array
// entries on top — renders them behind the stage nodes.
function addBox() {
function addAnnotation(kind: "box" | "line") {
const size = kind === "box" ? DEFAULT_BOX : DEFAULT_LINE
setNodes((nds) => [
{
id: `a${newId()}`,
type: "box",
position: { x: 40, y: 40 },
width: 320,
height: 220,
data: { label: "" } satisfies AnnotationData,
},
...nds,
])
}
function addLine() {
setNodes((nds) => [
{
id: `a${newId()}`,
type: "line",
position: { x: 60, y: 300 },
width: 220,
height: 4,
id: `ann-${newLocalId()}`,
type: kind,
position: kind === "box" ? { x: 40, y: 40 } : { x: 60, y: 300 },
...size,
data: { label: "" } satisfies AnnotationData,
},
...nds,
@@ -207,15 +324,14 @@ export default function TemplateBuilderPage() {
// React Flow's built-in keyboard delete (Backspace/Delete on a selected node) goes through
// this callback, not through deleteNode() above — stage edges need cleaning up either way.
// Box/line nodes never have edges, so this is a no-op for them.
const onNodesDelete = useCallback((deleted: Node[]) => {
const deletedIds = new Set(deleted.map((n) => n.id))
setEdges((eds) => eds.filter((e) => !deletedIds.has(e.source) && !deletedIds.has(e.target)))
}, [])
// Boxes/lines are pure annotations — never part of the stage graph, so every graph check
// below operates on stage nodes only (docs/21-FRONTEND-PHASE2.md §2 "Client-side graph
// checks (UX only — server re-validates on save)").
// Boxes and lines are pure annotations — never part of the stage graph, so every graph check
// below operates on stage nodes only (docs/21 §2 "Client-side graph checks (UX only — server
// re-validates on save)").
const stageNodes = useMemo(() => nodes.filter((n) => n.type === "stage"), [nodes])
const analysis = useMemo(() => {
@@ -230,20 +346,33 @@ export default function TemplateBuilderPage() {
return { terminalIds, entryIds, disconnectedIds, hasCycle }
}, [stageNodes, edges])
// Clear stale Upstream references after an edge is deleted, with a warning toast — per
/** Output keys a stage may legally draw from — its *direct* parents' outputs (FR-MFG-04). */
const allowedUpstreamKeys = useCallback(
(stageId: string) => {
const parentIds = new Set(edges.filter((e) => e.target === stageId).map((e) => e.source))
return new Set(
nodes
.filter((n) => n.type === "stage" && parentIds.has(n.id))
.flatMap((n) => (n.data as StageNodeData).outputs.map((o) => o.key))
)
},
[edges, nodes]
)
// Clear stale Upstream references after an edge is deleted, with a warning toast — docs/21 §2
// "re-check after edge deletions and clear broken references with a warning toast".
useEffect(() => {
for (const node of stageNodes) {
const data = node.data as StageNodeData
const directParentIds = new Set(edges.filter((e) => e.target === node.id).map((e) => e.source))
const stale = data.inputs.filter((i) => i.source === "Upstream" && i.upstreamStageId && !directParentIds.has(i.upstreamStageId))
const allowed = allowedUpstreamKeys(node.id)
const stale = data.inputs.filter(
(i) => i.source === "Upstream" && i.fromOutputKey && !allowed.has(i.fromOutputKey)
)
if (stale.length > 0) {
updateNodeData(node.id, {
inputs: data.inputs.map((i) =>
stale.includes(i) ? { ...i, upstreamStageId: undefined, upstreamOutputId: undefined } : i
),
inputs: data.inputs.map((i) => (stale.includes(i) ? { ...i, fromOutputKey: null } : i)),
})
toast.warning("Input reference cleared", `"${data.name}" referenced a stage that's no longer connected.`)
toast.warning("Input reference cleared", `"${data.name}" drew from a stage that no longer feeds it.`)
}
}
// Only re-run when the edge set changes — re-running on every node data edit would loop.
@@ -252,28 +381,61 @@ export default function TemplateBuilderPage() {
const issues = useMemo(() => {
const list: string[] = []
if (!code.trim()) list.push("Code is required.")
if (!name.trim()) list.push("Name is required.")
if (stageNodes.length === 0) list.push("Add at least one stage.")
if (analysis.hasCycle) list.push("Cycle detected — stages must form a one-directional flow.")
if (analysis.terminalIds.size !== 1) {
if (stageNodes.length > 0 && analysis.terminalIds.size !== 1) {
list.push(
analysis.terminalIds.size === 0
? "No terminal stage — connect stages so the line converges to a single final stage."
: `${analysis.terminalIds.size} terminal stages found — connect stages so the line converges to a single final stage.`
)
}
if (analysis.entryIds.size === 0) list.push("No entry stage — at least one stage must have no inputs from other stages.")
if (stageNodes.length > 0 && analysis.entryIds.size === 0) {
list.push("No entry stage — at least one stage must have no inputs from other stages.")
}
if (analysis.disconnectedIds.size > 0) {
const names = stageNodes.filter((n) => analysis.disconnectedIds.has(n.id)).map((n) => (n.data as StageNodeData).name)
list.push(`Disconnected stage${names.length > 1 ? "s" : ""}: ${names.join(", ")}.`)
}
// Field-completeness. These mirror the server's own requirements, and they are what make
// the non-null assertions in buildRequest() below sound — a row is never sent half-filled.
for (const node of stageNodes) {
if (!analysis.terminalIds.has(node.id)) continue
const data = node.data as StageNodeData
if (data.outputs.length === 0 || data.outputs.some((o) => !o.itemId)) {
list.push(`Terminal stage "${data.name}" needs an output with a finished-good item picked.`)
const label = data.name.trim() || "Untitled stage"
const isTerminal = analysis.terminalIds.has(node.id)
if (!data.name.trim()) list.push("Every stage needs a name.")
data.inputs.forEach((input, i) => {
const where = `Input ${i + 1} of "${label}"`
if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`)
if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`)
if (input.uomId === null) list.push(`${where} needs a UOM.`)
if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
})
data.outputs.forEach((output, i) => {
const where = `Output ${i + 1} of "${label}"`
if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`)
if (output.uomId === null) list.push(`${where} needs a UOM.`)
if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
})
if (isTerminal) {
if (data.outputs.length !== 1) {
list.push(`Terminal stage "${label}" must have exactly one output (the finished good).`)
} else if (data.outputs[0].itemId === null) {
list.push(`Terminal stage "${label}" needs its output linked to a finished-good item.`)
}
}
}
return list
}, [analysis, stageNodes])
}, [analysis, stageNodes, code, name])
const selectedNode = stageNodes.find((n) => n.id === selectedNodeId)
const upstreamOptions: UpstreamOutputOption[] = useMemo(() => {
@@ -284,9 +446,9 @@ export default function TemplateBuilderPage() {
if (!parent) return []
const parentData = parent.data as StageNodeData
return parentData.outputs.map((o) => ({
stageId: parent.id,
stageKey: parent.id,
stageName: parentData.name,
outputId: o.outputId,
outputKey: o.key,
outputName: o.name || "(unnamed output)",
}))
})
@@ -301,6 +463,7 @@ export default function TemplateBuilderPage() {
data: {
...n.data,
disconnected: analysis.disconnectedIds.has(n.id),
focused: focusedKeys.has(n.id),
onDelete: locked ? undefined : () => deleteNode(n.id),
},
}
@@ -314,16 +477,166 @@ export default function TemplateBuilderPage() {
},
}
),
[nodes, analysis.disconnectedIds] // eslint-disable-line react-hooks/exhaustive-deps
[nodes, analysis.disconnectedIds, focusedKeys] // eslint-disable-line react-hooks/exhaustive-deps
)
function handleSave() {
function buildRequest(): SaveTemplateRequest {
const stages = stageNodes.map((n) => {
const data = n.data as StageNodeData
const isTerminal = analysis.terminalIds.has(n.id)
return {
key: n.id,
name: data.name.trim(),
roleLabel: data.roleLabel.trim() || null,
estimatedMinutes: data.estimatedMinutes,
posX: Math.round(n.position.x),
posY: Math.round(n.position.y),
fieldDefs: data.fieldDefs.map((f) => ({
key: f.key,
label: f.label,
type: f.type,
options: f.type === "Select" ? f.options : null,
required: f.required,
})),
inputs: data.inputs.map((i) => ({
source: i.source,
itemId: i.source === "Stock" ? i.itemId : null,
fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null,
uomId: i.uomId!,
qtyPerBatch: i.qtyPerBatch,
})),
// Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was*
// terminal and then got a child keeps its picked itemId in local state with the item
// field no longer rendered, so dropping it here is the only way the user can recover —
// an issue-list message about an invisible field would be unactionable.
outputs: data.outputs.map((o) => ({
key: o.key,
itemId: isTerminal ? o.itemId : null,
name: o.name.trim(),
uomId: o.uomId!,
qtyPerBatch: o.qtyPerBatch,
})),
}
})
const annotations: CanvasAnnotation[] = nodes
.filter((n) => n.type === "box" || n.type === "line")
.map((n) => {
const data = n.data as AnnotationData
const fallback = n.type === "box" ? DEFAULT_BOX : DEFAULT_LINE
return {
kind: n.type as "box" | "line",
posX: Math.round(n.position.x),
posY: Math.round(n.position.y),
// `width`/`height` are set on creation and updated by NodeResizer; `measured` is what
// React Flow fills in after layout for nodes sized purely by CSS.
width: Math.round(n.width ?? n.measured?.width ?? fallback.width),
height: Math.round(n.height ?? n.measured?.height ?? fallback.height),
label: data.label.trim() || null,
rotation: data.rotation ?? null,
}
})
return {
code: code.trim(),
name: name.trim(),
description: description.trim() || null,
stages,
edges: edges.map((e) => ({ parentKey: e.source, childKey: e.target })),
annotations,
}
}
async function handleSave() {
if (issues.length > 0) {
toast.error("Can't save yet", `${issues.length} issue${issues.length > 1 ? "s" : ""} to fix first.`)
return
}
// No backend contract exists yet (docs/21-FRONTEND-PHASE2.md) — this is a UI-only stub.
toast.success("Template saved", `${template.name}${stageNodes.length} stage${stageNodes.length === 1 ? "" : "s"}.`)
setSaving(true)
setSaveError(null)
setFocusedKeys(new Set())
try {
if (isNew) {
const result = await productionTemplatesApi.create(buildRequest())
toast.success("Template created", `${result.data.code}${result.data.name}`)
// Swap the draft URL for the real one. The load effect re-runs on the new id and
// rehydrates from the server, so keys minted here are replaced by real ones.
router.replace(`/dashboard/production/templates/${result.data.templateId}`)
return
}
if (!etag) return
const result = await productionTemplatesApi.update(templateId, buildRequest(), etag)
applyGraph(result.data, result.etag)
toast.success("Template saved", `${result.data.code}${result.data.stages.length} stage(s)`)
} catch (err) {
const errorCode = (err as { code?: string })?.code
const detail = (err as { detail?: string })?.detail
if (errorCode === "CONCURRENCY_CONFLICT" || errorCode === "PRECONDITION_REQUIRED") {
setConflict(true)
setSaveError(errorMessage(err))
return
}
if (errorCode === "TEMPLATE_IN_USE") {
// A run started between our GET and this PUT. Lock the canvas rather than reloading,
// so nothing the user just drew is thrown away without them seeing why.
setLockedByServer(true)
setSaveError(errorMessage(err))
return
}
if (errorCode?.startsWith("GRAPH_") || errorCode === "TERMINAL_OUTPUT_ITEM_REQUIRED") {
setFocusedKeys(stagesNamedIn(detail, stageNodes))
}
setSaveError(errorMessage(err))
toast.error("Could not save template", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleToggleStatus() {
if (!graph) return
const next: TemplateStatus = graph.status === "Active" ? "Inactive" : "Active"
setTogglingStatus(true)
try {
await productionTemplatesApi.updateStatus(templateId, next)
// PATCH /status bumps the row's xmin, which invalidates the ETag we hold. Re-read it (and
// only it) so an unsaved canvas edit can still be saved afterwards — a full reload here
// would silently discard the user's work.
const refreshed = await productionTemplatesApi.get(templateId)
setEtag(refreshed.etag)
setGraph((g) =>
g ? { ...g, status: refreshed.data.status, activeRunCount: refreshed.data.activeRunCount } : g
)
toast.success(next === "Active" ? "Template activated" : "Template deactivated")
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingStatus(false)
}
}
if (!loaded) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-[60vh] w-full rounded-2xl" />
</div>
)
}
if (loadError) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/production/templates" className={cn(buttonVariants({ variant: "outline" }))}>
Back to templates
</Link>
</div>
)
}
return (
@@ -334,47 +647,118 @@ export default function TemplateBuilderPage() {
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{template.name}</h1>
<p className="text-base text-muted-foreground">{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection{edges.length === 1 ? "" : "s"}</p>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
{graph ? (
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
graph.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{graph.status}
</Badge>
) : (
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
Unsaved draft
</Badge>
)}
</div>
<p className="text-base text-muted-foreground">
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{!locked && (
<Button type="button" variant="outline" onClick={addStage}>
<Plus className="size-5" />
Add Stage
<>
<Button type="button" variant="outline" onClick={addStage}>
<Plus className="size-5" />
Add Stage
</Button>
<Button type="button" variant="outline" onClick={() => addAnnotation("box")}>
<Square className="size-5" />
Add Box
</Button>
<Button type="button" variant="outline" onClick={() => addAnnotation("line")}>
<Minus className="size-5" />
Add Line
</Button>
</>
)}
{graph && (
<Button
type="button"
variant={graph.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : graph.status === "Active" ? "Deactivate" : "Activate"}
</Button>
)}
{!locked && (
<Button type="button" variant="outline" onClick={addBox}>
<Square className="size-5" />
Add Box
</Button>
)}
{!locked && (
<Button type="button" variant="outline" onClick={addLine}>
<Minus className="size-5" />
Add Line
</Button>
)}
{!locked && (
<Button type="button" onClick={handleSave}>
<Button type="button" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
Save
{saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]">
<Field>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
</Field>
<Field>
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
</Field>
<Field>
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input
id="tpl-description"
value={description}
disabled={locked}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
className="h-10"
/>
</Field>
</div>
{locked && (
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
<Lock className="size-4 shrink-0" />
Template locked {template.activeRunCount} run{template.activeRunCount === 1 ? "" : "s"} in progress.
{activeRunCount > 0
? `Template locked — ${activeRunCount} run${activeRunCount === 1 ? "" : "s"} in progress. It can be viewed but not edited until they finish.`
: "Template locked — a run started while you were editing, so this template can no longer be changed."}
</div>
)}
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="flex flex-col items-start gap-2">
<span>{saveError ?? "This template was changed by someone else."} Reload before retrying.</span>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{saveError}</span>
</div>
)}
{issues.length > 0 && (
<div className="flex flex-col gap-1.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<div className="flex max-h-32 flex-col gap-1.5 overflow-y-auto rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
@@ -412,11 +796,11 @@ export default function TemplateBuilderPage() {
{selectedNode && (
<StageEditorPanel
nodeId={selectedNode.id}
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={MOCK_ITEMS}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
@@ -1,73 +1,87 @@
// Canvas builder types (docs/21-FRONTEND-PHASE2.md §2). Frontend-only shapes — no
// Dtos/Production backend contract exists yet; these mirror the doc's described jsonb
// shapes closely enough to swap in real API types later without touching the canvas/panel.
// Canvas builder state (docs/21-FRONTEND-PHASE2.md §2), shaped for editing rather than for
// the wire. `page.tsx` converts between these and the real contract in `types/production.ts`.
//
// Two things the API shapes can't express and the canvas needs:
//
// * **Stable React list keys.** Stage inputs and custom fields have no client-facing key in
// the contract (only outputs do, because Upstream inputs reference them by key). Rendering
// them by array index would make React reuse the wrong <input> when a row is removed, so
// every editable row carries a throwaway `localId` that is stripped on save.
// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the
// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set,
// which is what makes the non-null assertions in its payload builder sound.
//
// A stage's identity IS its React Flow node id, which is its server key — the stringified
// stage id, or `tmp-<uuid>` for a stage drawn in this session. That is why edges need no
// translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`.
export type FieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
import { CustomFieldType, StageInputSource } from "@/types/production"
export interface FieldDef {
fieldId: string
/** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */
export function newKey(): string {
return `tmp-${crypto.randomUUID()}`
}
/** Render-only identity for rows the contract keys by position. Never sent. */
export function newLocalId(): string {
return crypto.randomUUID()
}
export interface BuilderInput {
localId: string
source: StageInputSource
/** Stock inputs only. */
itemId: number | null
/** Upstream inputs only — an output key belonging to a *direct* parent stage. */
fromOutputKey: string | null
uomId: number | null
qtyPerBatch: number
}
export interface BuilderOutput {
/** Server output id as a string, or `tmp-<uuid>`. Upstream inputs reference this. */
key: string
/** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */
itemId: number | null
name: string
uomId: number | null
qtyPerBatch: number
}
export interface BuilderFieldDef {
localId: string
/** Slugified from `label`; the run's `fieldValues` are keyed by it (FR-MFG-07). */
key: string
label: string
type: FieldType
/** Only meaningful when type === "Select". */
type: CustomFieldType
/** Only sent when `type` is `Select`. */
options: string[]
required: boolean
}
export type InputSource = "Stock" | "Upstream"
export interface FormulaInput {
inputId: string
source: InputSource
// Stock source:
itemId?: number
itemName?: string
uom?: string
qty?: number
batch?: string
// Upstream source — references a direct parent stage's output:
upstreamStageId?: string
upstreamOutputId?: string
}
export interface FormulaOutput {
outputId: string
/** Free text for a non-terminal stage; on the terminal stage this mirrors the picked item's name. */
name: string
uom: string
qty: number
batch?: string
/** Required once this output sits on the terminal stage (finished good). */
itemId?: number
}
export interface StageNodeData extends Record<string, unknown> {
name: string
roleLabel: string
estimatedMinutes: number
inputs: FormulaInput[]
outputs: FormulaOutput[]
fieldDefs: FieldDef[]
/** Computed by the page on every graph change, not user-editable — no in/out edges at all. */
inputs: BuilderInput[]
outputs: BuilderOutput[]
fieldDefs: BuilderFieldDef[]
/** Recomputed by the page on every graph change, not user-editable — no in/out edges at all. */
disconnected?: boolean
/** Set when a server `422 GRAPH_*` named this stage, so the canvas can point at it. */
focused?: boolean
/** Injected by the page at render time — deletes this node (and any edges touching it). */
onDelete?: () => void
}
export interface MockItem {
itemId: number
name: string
uom: string
}
/**
* Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry
* no graph semantics (no ports, never appear in cycle/terminal/entry/disconnected checks
* or the save-blocking issue list), unlike "stage" nodes.
* Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry no
* graph semantics (no ports, never part of the cycle/terminal/entry/disconnected checks or the
* save-blocking issue list), unlike "stage" nodes. Persisted verbatim in the template's
* `annotations` jsonb so a layout survives a reload.
*/
export interface AnnotationData extends Record<string, unknown> {
label: string
/** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */
/** Degrees, applied as a CSS rotation around the node's own center. Lines only. */
rotation?: number
}
@@ -1,14 +1,16 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTheme } from "next-themes"
import { LayoutTemplate, Plus, Search } from "lucide-react"
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
import { ProductionTemplate, TemplateStatus } from "@/types/production"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common"
import {
LineHeaderNodeComponent,
LineStageNodeComponent,
@@ -22,22 +24,10 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
// Frontend-only mock data — no Dtos/Production backend exists yet (docs/21-FRONTEND-PHASE2.md).
const INITIAL_TEMPLATES: ProductionTemplate[] = [
{ templateId: 1, docNo: "TPL-1001", name: "Steel Bracket Assembly", status: "Active", stageCount: 3, activeRunCount: 2, updatedAt: "2026-07-20" },
{ templateId: 2, docNo: "TPL-1002", name: "PCB Soldering Line", status: "Active", stageCount: 5, activeRunCount: 0, updatedAt: "2026-07-18" },
{ templateId: 3, docNo: "TPL-1003", name: "Wooden Pallet Build", status: "Active", stageCount: 2, activeRunCount: 1, updatedAt: "2026-07-25" },
{ templateId: 4, docNo: "TPL-1004", name: "Plastic Injection Mold", status: "Inactive", stageCount: 4, activeRunCount: 0, updatedAt: "2026-07-10" },
{ templateId: 5, docNo: "TPL-1005", name: "Cable Harness Kit", status: "Active", stageCount: 3, activeRunCount: 0, updatedAt: "2026-07-22" },
]
type StatusFilter = TemplateStatus | "All"
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
const PAGE_SIZE = 25
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -46,7 +36,7 @@ const STAGE_START_X = 300
const STAGE_GAP_X = 200
/** One row per template — its production line, header on the left, stages left to right. */
function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edges: Edge[] } {
function buildLinesGraph(templates: ProductionTemplateSummary[]): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
@@ -58,7 +48,7 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
position: { x: 0, y },
data: {
templateId: t.templateId,
docNo: t.docNo,
code: t.code,
name: t.name,
status: t.status,
activeRunCount: t.activeRunCount,
@@ -66,8 +56,9 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
draggable: false,
})
const stages = MOCK_TEMPLATE_INFO[t.templateId]?.stages ?? []
stages.forEach((stageName, i) => {
// Names come straight from the list projection, so the overview needs one request
// regardless of how many templates it shows.
t.stageNames.forEach((stageName, i) => {
const stageId = `s${t.templateId}-${i}`
nodes.push({
id: stageId,
@@ -90,32 +81,61 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
export default function ProductionTemplatesPage() {
const router = useRouter()
const { resolvedTheme } = useTheme()
const [templates, setTemplates] = useState<ProductionTemplate[]>(INITIAL_TEMPLATES)
// null = still loading (the codebase convention for "no data yet" vs "empty result").
const [templates, setTemplates] = useState<ProductionTemplateSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [error, setError] = useState("")
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<{ code?: string; name?: string }>({})
// Same hydration-mismatch guard as the builder canvas (theme-toggle.tsx / templates/[id]/page.tsx):
// colorMode depends on resolvedTheme, which is unknown on the server and on first paint.
// Same hydration-mismatch guard as the builder canvas: colorMode depends on resolvedTheme,
// which is unknown on the server and on first paint.
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const filtered = useMemo(() => {
const q = searchInput.trim().toLowerCase()
return templates.filter((t) => {
if (status !== "All" && t.status !== status) return false
if (q && !t.name.toLowerCase().includes(q) && !t.docNo.toLowerCase().includes(q)) return false
return true
})
}, [templates, searchInput, status])
// 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the
// query so a narrower search can't leave you stranded past the last page — done here rather
// than in a second effect watching [query, status], which would be a cascading render.
useEffect(() => {
const timer = setTimeout(() => {
setQuery(searchInput.trim())
setPage(1)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const hasFilters = searchInput.trim().length > 0 || status !== "All"
const load = useCallback(() => {
setLoadError(null)
productionTemplatesApi
.list({
page,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
})
.then((res) => {
setTemplates(res.items)
setPagination(res.pagination)
})
.catch((err) => {
setTemplates([])
setLoadError(errorMessage(err))
})
}, [page, query, status])
const { nodes, edges } = useMemo(() => buildLinesGraph(filtered), [filtered])
useEffect(load, [load])
const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
const onNodeClick: NodeMouseHandler = (_, node) => {
const templateId = (node.data as LineHeaderData | LineStageData).templateId
@@ -123,35 +143,30 @@ export default function ProductionTemplatesPage() {
}
function openCreateDialog() {
setCode("")
setName("")
setError("")
setErrors({})
setOpen(true)
}
/**
* Opens the builder on an unsaved draft rather than creating anything now.
*
* A template cannot exist without a valid graph: the server requires at least one stage and
* a terminal output naming a real finished item (FR-MFG-02/05). There is nothing sensible to
* POST from a name alone, so the draft lives in the builder and the first Save creates it.
*/
function handleCreate() {
const trimmed = name.trim()
if (!trimmed) {
setError("Name is required.")
return
}
setSubmitting(true)
const nextId = templates.reduce((max, t) => Math.max(max, t.templateId), 0) + 1
const created: ProductionTemplate = {
templateId: nextId,
docNo: `TPL-${1000 + nextId}`,
name: trimmed,
status: "Active",
stageCount: 0,
activeRunCount: 0,
updatedAt: todayIso(),
}
setTemplates((prev) => [...prev, created])
toast.success("Template created", trimmed)
const nextErrors: { code?: string; name?: string } = {}
if (!code.trim()) nextErrors.code = "Code is required."
if (!name.trim()) nextErrors.name = "Name is required."
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setOpen(false)
setSubmitting(false)
// No backend exists yet, so the builder can't look this template up by id (its mock
// lookup only knows the 5 seeded ones) — pass the name through and start it blank.
router.push(`/dashboard/production/templates/${nextId}?name=${encodeURIComponent(trimmed)}&fresh=1`)
router.push(
`/dashboard/production/templates/new?code=${encodeURIComponent(code.trim())}&name=${encodeURIComponent(name.trim())}`,
)
}
return (
@@ -166,28 +181,41 @@ export default function ProductionTemplatesPage() {
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle>
<DialogDescription>Give the template a name you'll build its stage graph next.</DialogDescription>
<DialogDescription>
Name it, then build its stage graph. It&apos;s saved once the graph is valid.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!error}>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
<Input
id="tpl-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. PT-CHAIR"
aria-invalid={!!errors.code}
/>
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
<Input
id="tpl-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Aluminium Frame Assembly"
aria-invalid={!!error}
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
<FieldError errors={[error ? { message: error } : undefined]} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create & open builder"}
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate}>
Open builder
</Button>
</div>
</DialogContent>
@@ -200,7 +228,7 @@ export default function ProductionTemplatesPage() {
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search templates…"
placeholder="Search by code or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search templates"
/>
@@ -217,7 +245,15 @@ export default function ProductionTemplatesPage() {
</Select>
</div>
{filtered.length === 0 ? (
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
{loadError}
</div>
)}
{templates === null ? (
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" />
) : templates.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LayoutTemplate className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
@@ -225,29 +261,52 @@ export default function ProductionTemplatesPage() {
</p>
</div>
) : (
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
) : (
<Skeleton className="size-full rounded-none" />
<>
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
) : (
<Skeleton className="size-full rounded-none" />
)}
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-base text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
Previous
</Button>
<Button
variant="outline"
onClick={() => setPage((p) => p + 1)}
disabled={pagination.page >= pagination.totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
</>
)}
</div>
)
@@ -341,9 +341,9 @@ export function AppSidebar() {
// flashing the full menu to a restricted role. Once resolved, a nav item
// is visible if its own code is granted, or (for parents) if any child is.
//
// "procurement" and "hrm" are exempted from that check (frontend-only): no role is
// currently seeded with NAV:procurement/NAV:hrm or their children server-side, which
// would hide the whole section for everyone. Remove each bypass once roles are granted
// "procurement", "hrm" and "production" are exempted from that check (frontend-only): no
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"
export interface LineHeaderData extends Record<string, unknown> {
templateId: number
docNo: string
/** The template's `code` (e.g. `PT-CHAIR`). Templates carry a code; only runs get a doc no. */
code: string
name: string
status: "Active" | "Inactive"
activeRunCount: number
@@ -16,7 +17,7 @@ function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) {
return (
<div className="flex w-56 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
<span className="truncate text-xs font-medium text-muted-foreground">{data.code}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
@@ -1,15 +1,15 @@
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { ChevronRight } from "lucide-react"
import { Flag, PackageCheck, Timer } from "lucide-react"
import { cn } from "@/lib/utils"
import { RunStatus } from "@/types/production"
import { ProductionRunStatus } from "@/types/production"
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
export interface RunHeaderData extends Record<string, unknown> {
docNo: string
templateName: string
status: RunStatus
status: ProductionRunStatus
}
/** Left-most box on a run's production line — the run itself, not a stage. */
@@ -25,45 +25,96 @@ function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) {
export interface RunStageData extends Record<string, unknown> {
name: string
roleLabel: string | null
state: StageStatus
isTerminal: boolean
isEntry: boolean
estimatedMinutes: number
/** Whole minutes once finished; null while still running (FR-MFG-19). */
actualMinutes: number | null
/** Set once started — drives the "running" hint while actualMinutes is still null. */
actualStartAt: string | null
/**
* Aggregate intake across this stage's Upstream inputs, or null when it has none (an entry
* stage draws entirely from stock). `delivered >= planned` is exactly the readiness rule the
* server applies, so the badge doubles as an explanation of why a stage is still Waiting.
*/
intake: { delivered: number; planned: number } | null
/** Σ availableToTransfer across outputs — WIP produced but not yet pushed downstream. */
availableToTransfer: number
/** Marks the stage the operator is expected to act on next. */
isActive: boolean
/** Present only on the current (leftmost incomplete) stage of an InProgress run. */
onAdvance?: () => void
}
/** One stage on a run's production line, colored by its live status — the box the "give
* progress" action lives on: the active stage grows an Advance button to push it forward. */
function RunStageNode({ data }: NodeProps & { data: RunStageData }) {
function fmt(n: number): string {
// Quantities are decimal(18,4) server-side; trailing zeros just add noise on a canvas card.
return Number(n.toFixed(4)).toLocaleString()
}
/**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
* from the template at creation) and colored by its live status. Clicking it opens the stage
* drawer — handled by the page via `onNodeClick`, not here.
*/
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
const color = STAGE_STATUS_COLOR[data.state]
const running = data.actualStartAt !== null && data.actualMinutes === null
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
return (
<div
className={cn(
"w-44 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
data.isActive ? "ring-offset-2 ring-offset-background" : "ring-foreground/10"
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive ? { boxShadow: `0 0 0 2px ${color}` } : undefined}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${color}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<div className="flex items-center gap-1.5">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name}</p>
<div className="flex items-start gap-1.5">
<span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p>
{data.isTerminal && (
<span title="Final stage" className="mt-0.5 shrink-0 text-muted-foreground">
<Flag className="size-3.5" />
</span>
)}
</div>
<p className="mt-1 text-xs font-medium" style={{ color }}>{STAGE_STATUS_LABEL[data.state]}</p>
{data.onAdvance && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
data.onAdvance?.()
}}
className="nodrag mt-2 flex w-full items-center justify-center gap-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium" style={{ color }}>
{STAGE_STATUS_LABEL[data.state]}
</span>
{data.roleLabel && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">{data.roleLabel}</span>
)}
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<Timer className="size-3" />
{data.actualMinutes !== null
? `${data.actualMinutes} / ${data.estimatedMinutes} min`
: running
? `running · est. ${data.estimatedMinutes} min`
: `est. ${data.estimatedMinutes} min`}
</div>
{data.intake && (
<div
className={cn(
"mt-1.5 rounded-md px-1.5 py-0.5 text-xs font-medium",
intakeShort ? "bg-warning/10 text-warning" : "bg-success/10 text-success"
)}
>
Advance
<ChevronRight className="size-3.5" />
</button>
Intake {fmt(data.intake.delivered)} / {fmt(data.intake.planned)}
</div>
)}
{data.availableToTransfer > 0 && (
<div className="mt-1.5 flex items-center gap-1 rounded-md bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
<PackageCheck className="size-3" />
{fmt(data.availableToTransfer)} to transfer
</div>
)}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
@@ -8,7 +8,7 @@ import {
STAGE_STATUS_LABEL,
STAGE_STATUS_ORDER,
} from "@/lib/production-status-colors"
import { RunStatus, StageSummary } from "@/types/production"
import { ProductionRunStatus, StageSummary } from "@/types/production"
/**
* One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render
@@ -19,7 +19,7 @@ export function StageProgressStrip({
summary,
className,
}: {
status: RunStatus
status: ProductionRunStatus
summary: StageSummary
className?: string
}) {
@@ -0,0 +1,189 @@
// One typed client method per production-run endpoint (docs/30-BACKEND-PHASE2.md §D.2D.3,
// FR-MFG-08..19).
//
// Every stage action takes an `idempotencyKey`. The server accepts the header but does not
// store it (matching GRN confirm): replay safety comes from the status guards, so a
// double-fire returns a 409 rather than acting twice. Pass a per-action
// `useRef(crypto.randomUUID())` anyway — it is part of the contract and costs nothing.
//
// Treat a 409 carrying a stage-status code as "someone else moved first": refetch the run
// and re-render silently instead of showing an error (docs/21-FRONTEND-PHASE2.md §6). Use
// `isStaleStageError` for that check.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
ApproveStageRequest,
ApproveStageResult,
CancelRunRequest,
CancelRunResult,
CompleteStageRequest,
CreateRunRequest,
ProductionRunGraph,
ProductionRunStatus,
ProductionRunSummary,
RejectIntakeResult,
RejectRequest,
ReturnLeftoverRequest,
ReturnLeftoverResult,
RunStage,
StartStageResult,
TerminalRejectResult,
TransferRemainderRequest,
TransferResult,
UpdateStageQuantitiesRequest,
} from "@/types/production"
export interface ListRunsParams {
page?: number
pageSize?: number
q?: string
status?: ProductionRunStatus
templateId?: number
warehouseId?: number
sort?: string
}
export const productionRunsApi = {
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
},
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
},
create(request: CreateRunRequest): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>("/production-runs", { method: "POST", body: request })
},
/** `409 STAGE_NOT_EDITABLE` once the stage has started (FR-MFG-08). */
updateStageQuantities(
runId: number,
runStageId: number,
request: UpdateStageQuantitiesRequest,
): Promise<RunStage> {
return apiRequest<RunStage>(`/production-runs/${runId}/stages/${runStageId}/quantities`, {
method: "PUT",
body: request,
})
},
// --- stage actions ---------------------------------------------------------
start(runId: number, runStageId: number, idempotencyKey?: string): Promise<StartStageResult> {
return apiRequest<StartStageResult>(`/production-runs/${runId}/stages/${runStageId}/start`, {
method: "POST",
idempotencyKey,
})
},
complete(
runId: number,
runStageId: number,
request: CompleteStageRequest,
idempotencyKey?: string,
): Promise<RunStage> {
return apiRequest<RunStage>(`/production-runs/${runId}/stages/${runStageId}/complete`, {
method: "POST",
body: request,
idempotencyKey,
})
},
/** Non-terminal: transfers WIP to children. Terminal: posts the receipt and completes the run. */
approve(
runId: number,
runStageId: number,
request: ApproveStageRequest = {},
idempotencyKey?: string,
): Promise<ApproveStageResult> {
return apiRequest<ApproveStageResult>(`/production-runs/${runId}/stages/${runStageId}/approve`, {
method: "POST",
body: request,
idempotencyKey,
})
},
transfer(
runId: number,
runStageId: number,
request: TransferRemainderRequest,
idempotencyKey?: string,
): Promise<TransferResult> {
return apiRequest<TransferResult>(`/production-runs/${runId}/stages/${runStageId}/transfer`, {
method: "POST",
body: request,
idempotencyKey,
})
},
rejectIntake(
runId: number,
runStageId: number,
request: RejectRequest = {},
idempotencyKey?: string,
): Promise<RejectIntakeResult> {
return apiRequest<RejectIntakeResult>(`/production-runs/${runId}/stages/${runStageId}/reject-intake`, {
method: "POST",
body: request,
idempotencyKey,
})
},
/** Terminal stage only — resets the entire run for a rework pass (FR-MFG-16). */
rejectTerminal(
runId: number,
runStageId: number,
request: RejectRequest = {},
idempotencyKey?: string,
): Promise<TerminalRejectResult> {
return apiRequest<TerminalRejectResult>(`/production-runs/${runId}/stages/${runStageId}/reject`, {
method: "POST",
body: request,
idempotencyKey,
})
},
// --- run-level actions -----------------------------------------------------
returnLeftover(
runId: number,
runInputId: number,
request: ReturnLeftoverRequest,
idempotencyKey?: string,
): Promise<ReturnLeftoverResult> {
return apiRequest<ReturnLeftoverResult>(`/production-runs/${runId}/inputs/${runInputId}/return-leftover`, {
method: "POST",
body: request,
idempotencyKey,
})
},
cancel(runId: number, request: CancelRunRequest, idempotencyKey?: string): Promise<CancelRunResult> {
return apiRequest<CancelRunResult>(`/production-runs/${runId}/cancel`, {
method: "POST",
body: request,
idempotencyKey,
})
},
}
/**
* Stage-status conflicts, i.e. "the stage already moved on — probably another user".
*
* docs/21-FRONTEND-PHASE2.md §6 says to refetch the run silently and re-render for these
* rather than surfacing an error, which is also what makes the server's accept-and-ignore
* idempotency posture feel right: a double-click just refreshes.
*/
const STALE_STAGE_CODES = new Set([
"STAGE_NOT_READY",
"STAGE_NOT_IN_PROGRESS",
"STAGE_NOT_DONE",
"STAGE_NOT_EDITABLE",
"STAGE_REJECT_INVALID",
])
export function isStaleStageError(error: unknown): boolean {
const code = (error as { code?: string } | null)?.code
return code !== undefined && STALE_STAGE_CODES.has(code)
}
@@ -0,0 +1,61 @@
// One typed client method per production-template endpoint (docs/30-BACKEND-PHASE2.md §D.1,
// FR-MFG-01..07). ETag/If-Match on update, PATCH status for deactivate (templates are
// deactivated, never hard-deleted, FR-MFG-01).
//
// Note that `update` replaces the WHOLE graph, and that the server's stage/output keys are
// load-bearing: a key it recognises is diffed in place (so historical runs stay linked to the
// stage), while anything else — a `tmp-<uuid>` from the canvas — is inserted. The builder holds
// those keys as React Flow node ids and echoes them back; see the [id]/types.ts header.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
ProductionTemplateGraph,
ProductionTemplateSummary,
SaveTemplateRequest,
TemplateStatus,
} from "@/types/production"
export interface ListTemplatesParams {
page?: number
pageSize?: number
q?: string
status?: TemplateStatus
sort?: string
}
export const productionTemplatesApi = {
list(params: ListTemplatesParams = {}): Promise<PagedResponse<ProductionTemplateSummary>> {
return apiRequest<PagedResponse<ProductionTemplateSummary>>(`/production-templates${buildQuery(params)}`)
},
get(templateId: number): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>(`/production-templates/${templateId}`)
},
create(request: SaveTemplateRequest): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>("/production-templates", {
method: "POST",
body: request,
})
},
/** Full-graph replace. `409 TEMPLATE_IN_USE` while any run of this template is InProgress. */
update(
templateId: number,
request: SaveTemplateRequest,
ifMatch: string,
): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>(`/production-templates/${templateId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
updateStatus(templateId: number, status: TemplateStatus): Promise<void> {
return apiRequest<void>(`/production-templates/${templateId}/status`, {
method: "PATCH",
body: { status },
})
},
}
+50 -2
View File
@@ -16,6 +16,27 @@ interface ApiErrorLike {
*/
const GENERIC_CODES = new Set(["validation_error", "not_found", "conflict"])
/**
* Domain codes whose server `detail` is *more* specific than any fixed copy, so the detail
* wins and the entry in the map below is only a fallback.
*
* The graph validator names the offending stages ("...contains a cycle involving: Cut frame,
* Assemble"), and the transfer/leftover guards quote the actual figures ("only 20 is
* available (produced 50 - scrapped 0 - transferred 30)"). Replacing that with generic prose
* would throw away exactly what the user needs to fix the problem — and, for the graph codes,
* what the canvas uses to focus the offending node (docs/21-FRONTEND-PHASE2.md §2).
*/
const DETAIL_PREFERRED_CODES = new Set([
"GRAPH_CYCLE",
"GRAPH_TERMINAL_COUNT",
"GRAPH_DISCONNECTED",
"GRAPH_INPUT_SOURCE_INVALID",
"TERMINAL_OUTPUT_ITEM_REQUIRED",
"TRANSFER_EXCEEDS_AVAILABLE",
"LEFTOVER_EXCEEDS_CONSUMED",
"REQUIRED_FIELD_MISSING",
])
const CODE_MESSAGES: Record<string, string> = {
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
STOCK_NEGATIVE_BLOCKED: "Not enough available stock for this action.",
@@ -41,6 +62,25 @@ const CODE_MESSAGES: Record<string, string> = {
SALARY_STRUCTURE_OVERLAP: "The new effective date must be after the current salary structure's effective date.",
TAX_SLAB_GAP_INVALID: "This tax slab overlaps another slab for the same effective date.",
PAYROLL_PERIOD_LOCKED: "This payroll run is locked.",
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4). Several of these are
// listed in DETAIL_PREFERRED_CODES above, so their copy here is only a fallback.
TEMPLATE_IN_USE: "This template has runs in progress and can't be edited until they finish.",
TEMPLATE_INACTIVE: "This template is inactive, so new runs can't be started from it.",
GRAPH_CYCLE: "The stages form a loop. Remove the connection that feeds back on itself.",
GRAPH_TERMINAL_COUNT: "Connect stages so the line converges to a single final stage.",
GRAPH_DISCONNECTED: "Every stage must sit on a path from a starting stage to the final stage.",
GRAPH_INPUT_SOURCE_INVALID: "An upstream input must draw from a stage directly feeding into it.",
TERMINAL_OUTPUT_ITEM_REQUIRED: "The final stage needs exactly one output, and it must name the finished item.",
STAGE_NOT_READY: "This stage isn't ready to start yet.",
STAGE_NOT_IN_PROGRESS: "This stage isn't in progress, so it can't be completed.",
STAGE_NOT_DONE: "This stage has to be completed before it can be approved.",
STAGE_NOT_EDITABLE: "Quantities can't be changed once the stage has started.",
STAGE_REJECT_INVALID: "This stage has no received work to reject.",
REQUIRED_FIELD_MISSING: "Fill in every required field before completing this stage.",
TRANSFER_EXCEEDS_AVAILABLE: "That's more than this stage has available to transfer.",
LEFTOVER_EXCEEDS_CONSUMED: "You can't return more than was consumed and not already returned.",
RUN_COST_CLOSED: "This run is complete — its costs are closed, so leftovers can't be returned.",
RUN_NOT_CANCELLABLE: "This run can no longer be cancelled.",
validation_error: "Please check the highlighted fields.",
not_found: "The requested record was not found.",
conflict: "This action conflicts with the record's current state.",
@@ -49,8 +89,16 @@ const CODE_MESSAGES: Record<string, string> = {
export function errorMessage(error: unknown): string {
if (error && typeof error === "object") {
const e = error as ApiErrorLike
// A specific domain code beats the server's prose; a generic one loses to it.
if (e.code && !GENERIC_CODES.has(e.code) && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
// A specific domain code beats the server's prose; a generic one — or one whose detail
// carries the specifics — loses to it.
if (
e.code &&
!GENERIC_CODES.has(e.code) &&
!(DETAIL_PREFERRED_CODES.has(e.code) && e.detail) &&
CODE_MESSAGES[e.code]
) {
return CODE_MESSAGES[e.code]
}
if (e.detail) return e.detail
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
}
@@ -1,105 +0,0 @@
// Frontend-only mock run registry — no Dtos/Production backend exists yet
// (docs/21-FRONTEND-PHASE2.md). Shared by the Runs board and the run detail page so both
// read the same seed data (each page still keeps its own local edits — there's no backend
// to persist an advance/start-run action back to the other screen).
import { ProductionRun } from "@/types/production"
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
import { STAGE_STATUS_ORDER, type StageStatus } from "@/lib/production-status-colors"
export const INITIAL_RUNS: ProductionRun[] = [
{
runId: 1, docNo: "PRD-2026-00001", templateName: "Steel Bracket Assembly", targetQty: 500,
finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-26", completedAt: null,
stageSummary: { waiting: 1, ready: 0, inProgress: 1, done: 1, approved: 0 },
},
{
runId: 2, docNo: "PRD-2026-00002", templateName: "PCB Soldering Line", targetQty: 200,
finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Colombo Warehouse", status: "InProgress",
reworkCount: 1, createdAt: "2026-07-25", completedAt: null,
stageSummary: { waiting: 0, ready: 1, inProgress: 2, done: 1, approved: 1 },
},
{
runId: 3, docNo: "PRD-2026-00003", templateName: "Wooden Pallet Build", targetQty: 1000,
finishedItemName: "Pallet Standard", uom: "PCS", warehouseName: "Main Warehouse", status: "Completed",
reworkCount: 0, createdAt: "2026-07-20", completedAt: "2026-07-24",
stageSummary: { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 2 },
},
{
runId: 4, docNo: "PRD-2026-00004", templateName: "Cable Harness Kit", targetQty: 300,
finishedItemName: "Harness Kit B", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-27", completedAt: null,
stageSummary: { waiting: 2, ready: 1, inProgress: 0, done: 0, approved: 0 },
},
{
runId: 5, docNo: "PRD-2026-00005", templateName: "Steel Bracket Assembly", targetQty: 150,
finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Colombo Warehouse", status: "Cancelled",
reworkCount: 0, createdAt: "2026-07-15", completedAt: null,
stageSummary: { waiting: 0, ready: 0, inProgress: 1, done: 0, approved: 0 },
},
{
runId: 6, docNo: "PRD-2026-00006", templateName: "PCB Soldering Line", targetQty: 400,
finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-23", completedAt: null,
stageSummary: { waiting: 1, ready: 2, inProgress: 1, done: 1, approved: 0 },
},
]
// Active templates only (docs/21-FRONTEND-PHASE2.md §4 "Template picker (Active only)") —
// mirrors the 4 Active rows on the Template list page ("Plastic Injection Mold" is Inactive
// there, so it's excluded here too. `nominalBatchQty` backs the scaled-preview calculation;
// there's no real per-template formula graph shared across routes to scale properly (each
// builder page's stage data is local, unsaved state — see templates/[id]/page.tsx), so this
// is a simplified stand-in for the doc's full per-stage scaled preview.
export interface StartableTemplate {
templateId: number
name: string
finishedItemName: string
uom: string
nominalBatchQty: number
stageCount: number
}
export const STARTABLE_TEMPLATES: StartableTemplate[] = [
{ templateId: 1, name: "Steel Bracket Assembly", finishedItemName: "Steel Bracket A", uom: "PCS", nominalBatchQty: 100, stageCount: 3 },
{ templateId: 2, name: "PCB Soldering Line", finishedItemName: "PCB Board X", uom: "PCS", nominalBatchQty: 50, stageCount: 5 },
{ templateId: 3, name: "Wooden Pallet Build", finishedItemName: "Pallet Standard", uom: "PCS", nominalBatchQty: 200, stageCount: 2 },
{ templateId: 5, name: "Cable Harness Kit", finishedItemName: "Harness Kit B", uom: "SET", nominalBatchQty: 75, stageCount: 3 },
]
export interface RunStagePlanItem {
name: string
state: StageStatus
}
const SUMMARY_KEY_BY_STATUS: Record<StageStatus, keyof ProductionRun["stageSummary"]> = {
Waiting: "waiting",
Ready: "ready",
InProgress: "inProgress",
Done: "done",
Approved: "approved",
}
/**
* `stageSummary` only carries counts per status, not which named stage each count belongs
* to. Reconstruct a per-stage breakdown by looking up the template's real stage names (via
* MOCK_TEMPLATE_INFO) and allocating the counts across them most-complete-first — stages
* run left to right, so the furthest-along stages are assumed to be the earliest ones in
* the list. Pad with "Waiting" (and truncate) when the counts don't add up to the template's
* actual stage count — e.g. the Cancelled mock run stops partway through its stage list.
*/
export function buildStagePlan(templateName: string, summary: ProductionRun["stageSummary"]): RunStagePlanItem[] {
const info = Object.values(MOCK_TEMPLATE_INFO).find((t) => t.name === templateName)
const totalCount = Object.values(summary).reduce((sum, n) => sum + n, 0)
const stageNames = info?.stages ?? Array.from({ length: Math.max(totalCount, 1) }, (_, i) => `Stage ${i + 1}`)
const statuses: StageStatus[] = []
for (const s of [...STAGE_STATUS_ORDER].reverse()) {
const count = summary[SUMMARY_KEY_BY_STATUS[s]]
for (let i = 0; i < count; i++) statuses.push(s)
}
while (statuses.length < stageNames.length) statuses.push("Waiting")
statuses.length = stageNames.length
return stageNames.map((name, i) => ({ name, state: statuses[i] }))
}
@@ -1,16 +0,0 @@
// Frontend-only mock template registry — no Dtos/Production backend exists yet
// (docs/21-FRONTEND-PHASE2.md). Shared by the Template list page (production-line preview
// per card) and the canvas builder page (initial graph + edit-lock), so the two never drift.
export interface MockTemplateInfo {
name: string
activeRunCount: number
stages: string[]
}
export const MOCK_TEMPLATE_INFO: Record<string, MockTemplateInfo> = {
"1": { name: "Steel Bracket Assembly", activeRunCount: 2, stages: ["Cutting", "Welding", "QA Inspection"] },
"2": { name: "PCB Soldering Line", activeRunCount: 0, stages: ["Component Placement", "Soldering", "Inspection", "Cleaning", "Final Test"] },
"3": { name: "Wooden Pallet Build", activeRunCount: 1, stages: ["Assembly", "Quality Check"] },
"4": { name: "Plastic Injection Mold", activeRunCount: 0, stages: ["Mold Prep", "Injection", "Cooling", "Trimming"] },
"5": { name: "Cable Harness Kit", activeRunCount: 0, stages: ["Wire Cutting", "Crimping", "Bundling"] },
}
+257 -27
View File
@@ -12,6 +12,7 @@
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-icons": "^1.3.2",
"@tanstack/react-query": "^5.101.2",
"@xyflow/react": "^12.11.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -82,6 +83,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -532,7 +534,8 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.75.1",
@@ -725,6 +728,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -768,32 +772,10 @@
"integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
"license": "BSD-3-Clause"
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2292,6 +2274,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2329,6 +2360,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2337,8 +2369,9 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2394,6 +2427,7 @@
"integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
@@ -2991,6 +3025,48 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.11.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.79",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.79",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -3010,6 +3086,7 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3465,6 +3542,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3602,6 +3680,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -3621,6 +3700,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -3908,6 +3993,112 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -3974,6 +4165,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -4502,6 +4694,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4687,6 +4880,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4976,6 +5170,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5605,6 +5800,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7954,6 +8150,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7999,6 +8196,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8011,6 +8209,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9142,6 +9341,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9362,6 +9562,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9757,6 +9958,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -9782,6 +9984,34 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}
+464 -15
View File
@@ -1,23 +1,185 @@
// Phase 2 (Manufacturing) frontend-only types — mirrors docs/21-FRONTEND-PHASE2.md.
// No backend contract exists yet (no Dtos/Production, no 30-BACKEND-PHASE2.md), so these
// are UI-shape placeholders for the mock data driving the Template list / Run board screens
// until the real API lands.
// Manufacturing / Production Lines DTOs (docs/30-BACKEND-PHASE2.md §D.1D.3; FR-MFG-01..20).
// Mirrors the backend DTOs exactly — the API contract is the source of truth.
//
// Three things changed when the real backend landed, and they are worth knowing if you are
// reading old code or docs/21-FRONTEND-PHASE2.md:
// * a template is identified by `code`, not `docNo` (only runs carry a document number)
// * quantities reference `itemId`/`uomId` numeric FKs with `qtyPerBatch` — not free text
// * stages carry `posX`/`posY`, so canvas layout round-trips through the server
//
// The `key` vocabulary (§D.1): every stage and output has a client-facing string key
// alongside its database id. On a GET the key IS the stringified id; on a save you echo
// those keys back for rows you kept and mint `tmp-<uuid>` keys for rows you just drew.
// Edges and Upstream inputs then reference stages/outputs *by key only*, which is what lets
// one payload shape serve both create (nothing has an id) and update (most things do).
export type TemplateStatus = "Active" | "Inactive"
export type ProductionRunStatus = "InProgress" | "Completed" | "Cancelled"
export type RunStageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved"
export type StageInputSource = "Stock" | "Upstream"
export type CustomFieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
export interface ProductionTemplate {
export type RunStageEventType =
| "Start"
| "Complete"
| "Approve"
| "Transfer"
| "RejectIntake"
| "TerminalReject"
| "LeftoverReturn"
| "Cancel"
| "QuantityEdit"
// --- templates ---------------------------------------------------------------
/** One custom field definition, stored verbatim in the stage's `fieldDefs` jsonb (FR-MFG-07). */
export interface FieldDef {
key: string
label: string
type: CustomFieldType
/** Only read when `type` is `Select`. */
options: string[] | null
required: boolean
}
export interface StageInput {
inputId: number
source: StageInputSource
/** Set when `source` is `Stock`; null when `Upstream`. */
itemId: number | null
/** Set when `source` is `Upstream` — an output of a *direct* parent stage. */
fromOutputId: number | null
fromOutputKey: string | null
uomId: number
qtyPerBatch: number
}
export interface StageOutput {
outputId: number
key: string
/** Null on intermediate (WIP) outputs; required on the terminal stage's single output. */
itemId: number | null
name: string
uomId: number
qtyPerBatch: number
}
export interface TemplateStage {
stageId: number
key: string
name: string
roleLabel: string | null
estimatedMinutes: number
posX: number
posY: number
fieldDefs: FieldDef[]
inputs: StageInput[]
outputs: StageOutput[]
}
export interface TemplateEdge {
edgeId: number
parentStageId: number
childStageId: number
parentKey: string
childKey: string
}
/** Row on the template list. `activeRunCount > 0` means the builder is edit-locked (FR-MFG-06). */
export interface ProductionTemplateSummary {
templateId: number
docNo: string
code: string
name: string
status: TemplateStatus
stageCount: number
/** Ordered by stage id — enough to label the overview canvas without fetching each graph. */
stageNames: string[]
activeRunCount: number
updatedAt: string
createdBy: number
createdAt: string
}
export type RunStatus = "InProgress" | "Completed" | "Cancelled"
/**
* A canvas grouping box or divider line. Round-tripped verbatim through a jsonb column and
* completely invisible to the graph validator — annotations have no ports, no edges, and no
* bearing on whether a template is valid.
*/
export interface CanvasAnnotation {
kind: "box" | "line"
posX: number
posY: number
width: number
height: number
label: string | null
/** Degrees. Lines only. */
rotation: number | null
}
/** One count per canonical stage status (docs/21-FRONTEND-PHASE2.md §3). */
export interface ProductionTemplateGraph {
templateId: number
code: string
name: string
description: string | null
status: TemplateStatus
stages: TemplateStage[]
edges: TemplateEdge[]
annotations: CanvasAnnotation[]
/** `> 0` puts the builder in its read-only edit-locked state (FR-MFG-06). */
activeRunCount: number
createdBy: number
createdAt: string
}
// Save payloads. Narrow by design — status, ids, createdBy and timestamps are all
// server-controlled and rejected if sent (02-SECURITY §B.6).
export interface SaveStageInputInput {
source: StageInputSource
itemId?: number | null
fromOutputKey?: string | null
uomId: number
qtyPerBatch: number
}
export interface SaveStageOutputInput {
key: string
itemId?: number | null
name: string
uomId: number
qtyPerBatch: number
}
export interface SaveStageInput {
key: string
name: string
roleLabel?: string | null
estimatedMinutes: number
posX: number
posY: number
fieldDefs: FieldDef[]
inputs: SaveStageInputInput[]
outputs: SaveStageOutputInput[]
}
export interface SaveEdgeInput {
parentKey: string
childKey: string
}
export interface SaveTemplateRequest {
code: string
name: string
description?: string | null
stages: SaveStageInput[]
/** Empty is legal — a single-stage template is both entry and terminal. */
edges: SaveEdgeInput[]
/** Replaced wholesale on every save. Server caps the array at 200. */
annotations: CanvasAnnotation[]
}
// --- runs --------------------------------------------------------------------
/** Per-status stage counts driving the board's progress strip (FR-MFG-18). */
export interface StageSummary {
waiting: number
ready: number
@@ -26,17 +188,304 @@ export interface StageSummary {
approved: number
}
export interface ProductionRun {
/** Derived server-side, never stored: `consumed returned` across every run input. */
export interface CostPool {
consumed: number
returned: number
net: number
}
export interface RunStageInput {
runInputId: number
source: StageInputSource
itemId: number | null
fromRunOutputId: number | null
uomId: number
/** In the input's *declared* UOM. Editable until the stage starts. */
plannedQty: number
/**
* The four figures below are in the item's *base* UOM — the only unit the FIFO engine and
* the ledger speak. A stage input declared in "box of 12" therefore shows plannedQty 3 and
* consumedQty 36. Do not compare them to plannedQty without converting.
*/
consumedQty: number
consumedValue: number
returnedQty: number
returnedValue: number
/** Upstream inputs only: accumulated by parent transfers. */
deliveredQty: number
}
export interface RunStageOutput {
runOutputId: number
itemId: number | null
name: string
uomId: number
plannedQty: number
producedQty: number
scrappedQty: number
scrapReasonCodeId: number | null
transferredQty: number
/** Derived: produced scrapped transferred (FR-MFG-12). */
availableToTransfer: number
}
export interface RunStage {
runStageId: number
templateStageId: number | null
name: string
roleLabel: string | null
estimatedMinutes: number
posX: number
posY: number
status: RunStageStatus
/** Derived from the run edge set — no outbound edge. */
isTerminal: boolean
/** Derived — no inbound edge; Ready from run creation. */
isEntry: boolean
actualStartAt: string | null
actualEndAt: string | null
/** Whole minutes once finished; null while still running (FR-MFG-19). */
actualMinutes: number | null
fieldDefs: FieldDef[]
/** Captured at complete, keyed by `fieldDefs[].key`. */
fieldValues: Record<string, unknown> | null
inputs: RunStageInput[]
outputs: RunStageOutput[]
}
export interface RunEdge {
runEdgeId: number
parentRunStageId: number
childRunStageId: number
}
export interface RunEvent {
eventId: number
/** Null for run-level events (cancel). */
runStageId: number | null
eventType: RunStageEventType
note: string | null
payload: Record<string, unknown> | null
userId: number
createdAt: string
}
/** Row on the run board (docs/30 §D.2). */
export interface ProductionRunSummary {
runId: number
docNo: string
templateId: number
templateName: string
targetQty: number
finishedItemName: string
uom: string
warehouseName: string
status: RunStatus
status: ProductionRunStatus
reworkCount: number
stageSummary: StageSummary
warehouseId: number
finishedItemId: number | null
finishedItemName: string | null
createdBy: number
createdAt: string
completedAt: string | null
stageSummary: StageSummary
}
export interface ProductionRunGraph {
runId: number
docNo: string
templateId: number
templateName: string
warehouseId: number
outputBinId: number | null
targetQty: number
scaleFactor: number
status: ProductionRunStatus
reworkCount: number
cancelReasonCodeId: number | null
costPool: CostPool
stages: RunStage[]
edges: RunEdge[]
events: RunEvent[]
createdBy: number
createdAt: string
completedAt: string | null
}
export interface CreateRunRequest {
templateId: number
targetQty: number
warehouseId: number
outputBinId?: number | null
}
/** `id` is the runInputId or runOutputId being adjusted. */
export interface StageQuantityLine {
id: number
plannedQty: number
}
export interface UpdateStageQuantitiesRequest {
inputs: StageQuantityLine[]
outputs: StageQuantityLine[]
}
// --- stage action results ----------------------------------------------------
export interface ConsumedLayer {
layerId: number
qty: number
unitCost: number
}
export interface ConsumedInput {
runInputId: number
itemId: number
/** Base UOM. */
qty: number
value: number
consumedLayers: ConsumedLayer[]
}
export interface StartStageResult {
runStageId: number
status: RunStageStatus
actualStartAt: string | null
consumed: ConsumedInput[]
ledgerRefs: number[]
stage: RunStage
}
export interface CompleteOutputLine {
runOutputId: number
producedQty: number
scrappedQty: number
/** Required once `scrappedQty > 0`; must be a Production-context reason. */
scrapReasonCodeId?: number | null
}
export interface CompleteStageRequest {
outputs: CompleteOutputLine[]
fieldValues?: Record<string, unknown> | null
}
export interface Transfer {
runOutputId: number
runInputId: number
childRunStageId: number
qty: number
childDeliveredQty: number
childStatus: RunStageStatus
}
export interface Receipt {
layerId: number
itemId: number
warehouseId: number
binId: number | null
qtyReceived: number
unitCost: number
value: number
}
export interface ApproveStageResult {
runStageId: number
status: RunStageStatus
runStatus: ProductionRunStatus
transfers: Transfer[]
/** Terminal approve only. */
receipt: Receipt | null
costPool: CostPool | null
ledgerRefs: number[]
stage: RunStage
}
export interface TransferLine {
runOutputId: number
qty: number
/** Optional explicit target when one output feeds several child inputs. */
runInputId?: number | null
}
export interface ApproveStageRequest {
/** Omit or leave empty to transfer the full available quantity of every output. */
transfers?: TransferLine[]
}
export interface TransferRemainderRequest {
runOutputId: number
qty: number
runInputId?: number | null
}
export interface TransferResult {
runStageId: number
transfers: Transfer[]
stage: RunStage
}
export interface ReturnLeftoverRequest {
/** In the item's *base* UOM, matching `consumedQty`. */
qty: number
reasonCodeId: number
}
export interface ReturnLeftoverResult {
runInputId: number
returnedQty: number
returnedValue: number
createdLayer: { layerId: number; unitCost: number }
ledgerRefs: number[]
costPool: CostPool
}
export interface RejectRequest {
note?: string | null
}
export interface PulledBack {
runInputId: number
parentRunStageId: number
parentRunOutputId: number
qty: number
priorParentStatus: RunStageStatus
parentStatus: RunStageStatus
}
export interface RejectIntakeResult {
runStageId: number
status: RunStageStatus
pulledBack: PulledBack[]
run: ProductionRunGraph
}
export interface TerminalRejectResult {
runStageId: number
reworkCount: number
run: ProductionRunGraph
}
export interface CancelRunRequest {
reasonCodeId: number
note?: string | null
}
export interface CancelReturn {
itemId: number
qty: number
unitCost: number
layerId: number
}
/** Scrap never entered stock, so it is written off on the event rather than returned. */
export interface ScrapWriteOff {
runOutputId: number
name: string
qty: number
}
export interface CancelRunResult {
runId: number
status: ProductionRunStatus
returns: CancelReturn[]
scrappedWrittenOff: ScrapWriteOff[]
ledgerRefs: number[]
}
+2 -1
View File
@@ -278,7 +278,8 @@ export type ReorderRequisitionResponse = Requisition
// --- §6 Reference data ------------------------------------------------------------------
/** Server enum is Adjustment | Return | Count (docs/11 §6). */
export type ReasonCodeContext = "Adjustment" | "Return" | "Count"
/** `Production` was added with Phase 2 (docs/30 §A.2) — scrap, leftover, cancel and rework loss. */
export type ReasonCodeContext = "Adjustment" | "Return" | "Count" | "Production"
export interface ReasonCode {
reasonCodeId: number