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:
@@ -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'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 & 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 & 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 & 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's planned inputs and outputs are scaled from the template'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>
|
||||
)
|
||||
|
||||
+176
-121
@@ -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'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.2–D.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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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"] },
|
||||
}
|
||||
Generated
+257
-27
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.1–D.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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user