feat: implement frontend-only mock for production lines and runs

- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow.
- Create LineHeaderNode and LineStageNode components for rendering production line nodes.
- Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers.
- Implement StageProgressStrip for visualizing stage progress in runs.
- Create mock data for production runs and templates to simulate backend functionality.
- Define types for production templates and runs to structure mock data.
- Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
This commit is contained in:
2026-07-28 22:55:56 +05:30
parent b12adebaa0
commit 4561ef7ba8
19 changed files with 2457 additions and 1 deletions
@@ -0,0 +1,248 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTheme } from "next-themes"
import { ArrowLeft, ChevronRight, 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 {
RunHeaderNodeComponent,
RunStageNodeComponent,
type RunHeaderData,
type RunStageData,
} from "@/components/production/RunStageNode"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
function runStatusBadgeClass(status: RunStatus) {
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 nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent }
const STAGE_START_X = 260
const STAGE_GAP_X = 220
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 [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 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])
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
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(() => {
if (!run) return { nodes: [] as Node[], edges: [] as Edge[] }
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[] = []
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",
})
})
return { nodes, edges }
}, [run, stages, activeIndex, status])
if (!run) {
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>
<Button variant="outline" onClick={() => router.push("/dashboard/production/runs")}>
<ArrowLeft className="size-4" />
Back to runs
</Button>
</div>
)
}
return (
<div className="flex flex-col gap-6">
<Button
variant="ghost"
className="w-fit px-2 text-muted-foreground hover:text-foreground"
onClick={() => router.push("/dashboard/production/runs")}
>
<ArrowLeft className="size-4" />
Back to runs
</Button>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 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="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>
{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">
<RotateCcw className="size-3.5" />
Rework #{run.reworkCount}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{run.templateName} · {run.warehouseName}</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 text-muted-foreground">
Created {new Date(run.createdAt).toLocaleDateString()}
{completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}</>}
</span>
</div>
</div>
<StageProgressStrip status={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>}
</span>
</div>
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-all"
style={{ width: `${progressPercent}%`, backgroundColor: STAGE_STATUS_COLOR.Approved }}
/>
</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>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
<StageStatusLegend />
</div>
<div className="h-[45vh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted ? (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
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>
</div>
)
}
@@ -0,0 +1,317 @@
"use client"
import { useMemo, 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
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 { 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)))
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
type StatusFilter = RunStatus | "All"
type NameFilter = string | "All"
function runStatusBadgeClass(status: RunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
}
export default function ProductionRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<ProductionRun[]>(INITIAL_RUNS)
const [searchInput, setSearchInput] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [template, setTemplate] = useState<NameFilter>("All")
const [warehouse, setWarehouse] = useState<NameFilter>("All")
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 [formError, setFormError] = useState("")
const [submitting, setSubmitting] = useState(false)
const startTemplate = STARTABLE_TEMPLATES.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("")
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
}
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 },
}
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"
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle>
<DialogDescription>Fine-tune per-stage quantities afterward on the run itself.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!formError && !startTemplate}>
<FieldLabel>Template</FieldLabel>
<Select<number> value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Pick a template" />
</SelectTrigger>
<SelectContent>
{STARTABLE_TEMPLATES.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</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>
<Input
id="target-qty"
type="number"
min={0}
value={targetQty}
onChange={(e) => setTargetQty(e.target.value)}
placeholder="e.g. 200"
/>
</Field>
<Field data-invalid={!!formError && !startWarehouse}>
<FieldLabel>Warehouse</FieldLabel>
<Select<string> value={startWarehouse} onValueChange={setStartWarehouse}>
<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>
))}
</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" />
</Field>
{scaleFactor !== null && (
<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.
</div>
)}
<FieldError errors={[formError ? { message: formError } : undefined]} />
</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}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleStartRun} disabled={submitting}>
{submitting ? "Starting…" : "Start run"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…"
className="h-14 w-full pl-11 text-base"
aria-label="Search runs"
/>
</div>
<Select<NameFilter> value={template} onValueChange={(v) => setTemplate(v ?? "All")}>
<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>
))}
</SelectContent>
</Select>
<Select<NameFilter> value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}>
<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>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
<StageStatusLegend />
</div>
{filtered.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">
{hasFilters ? "No runs match your search/filter." : "No runs yet."}
</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"
>
<RotateCcw className="size-3.5" />
Rework #{r.reworkCount}
</Badge>
)}
</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>
<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>
))}
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,137 @@
import { memo, useRef } from "react"
import { NodeResizer, type NodeProps } from "@xyflow/react"
import { RotateCw, X } from "lucide-react"
import { AnnotationData } from "./types"
type AnnotationNodeData = AnnotationData & {
onLabelChange?: (label: string) => void
onRotationChange?: (rotation: number) => void
onDelete?: () => void
}
// `className` supplies its own position utility (e.g. "absolute -top-2.5 -right-2.5" or
// "static") — not baked in here, so callers that already sit inside a positioned flex
// row (LineNode's rotate/delete pair) aren't fighting a hardcoded `absolute`.
function DeleteHandle({ onDelete, className }: { onDelete?: () => void; className?: string }) {
return (
<button
type="button"
aria-label="Delete"
title="Delete"
className={`nodrag flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90 ${className ?? ""}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onDelete?.()
}}
>
<X className="size-3" />
</button>
)
}
/**
* Free-floating group/label box. Purely visual — no Handles, so it can never be an edge
* endpoint, and it's excluded from every graph check (see StageNode for the real stage card).
* Rendered behind stage nodes: the page prepends new boxes to the nodes array, and React
* Flow paints later array entries on top.
*/
function BoxNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) {
return (
<div className="relative size-full rounded-xl border-2 border-dashed border-muted-foreground/30 bg-muted/40">
<NodeResizer isVisible={selected} minWidth={160} minHeight={100} lineClassName="!border-primary" handleClassName="!size-2.5 !border-primary !bg-card" />
{selected && data.onDelete && <DeleteHandle onDelete={data.onDelete} className="absolute -top-2.5 -right-2.5" />}
<input
defaultValue={data.label}
disabled={!data.onLabelChange}
onChange={(e) => data.onLabelChange?.(e.target.value)}
placeholder="Group label…"
className="nodrag m-2 w-[calc(100%-1rem)] rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:bg-card"
/>
</div>
)
}
/**
* Thin resizable divider bar, optionally labeled (e.g. "Phase 1"), and rotatable by dragging
* the small handle that appears above it once selected. The resize outline/handles and the
* rotate handle itself rotate together with the bar — they all live in one rotated wrapper —
* so the selection box always matches the bar's visual angle. Note: NodeResizer computes its
* drag deltas in unrotated screen space, so resizing while significantly rotated will feel a
* little off; acceptable here since this is a lightweight annotation, not precision CAD.
* `wrapperRef` (the outer, unrotated element) is what the rotate math measures from, so the
* center point stays stable regardless of the current angle.
*/
function LineNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) {
const wrapperRef = useRef<HTMLDivElement>(null)
const rotation = data.rotation ?? 0
return (
<div ref={wrapperRef} className="relative size-full">
<div className="relative size-full" style={{ transform: `rotate(${rotation}deg)` }}>
<NodeResizer
isVisible={selected}
minWidth={80}
minHeight={4}
maxHeight={4}
lineClassName="!border-primary"
handleClassName="!size-2.5 !border-primary !bg-card"
/>
{selected && (data.onRotationChange || data.onDelete) && (
<div className="absolute -top-7 left-1/2 flex -translate-x-1/2 items-center gap-1.5">
{data.onRotationChange && (
<button
type="button"
aria-label="Rotate line"
title="Drag to rotate"
className="nodrag flex size-5 cursor-grab items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm active:cursor-grabbing"
onPointerDown={(e) => {
e.stopPropagation()
const handle = e.currentTarget
handle.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
const rect = wrapperRef.current?.getBoundingClientRect()
if (!rect) return
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
// atan2 is 0° pointing right; +90 so "handle straight up" reads as 0° rotation.
const angle = Math.atan2(ev.clientY - cy, ev.clientX - cx) * (180 / Math.PI) + 90
data.onRotationChange?.(Math.round(angle))
}
const onUp = () => {
handle.releasePointerCapture(e.pointerId)
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}}
>
<RotateCw className="size-3" />
</button>
)}
{data.onDelete && <DeleteHandle onDelete={data.onDelete} className="static" />}
</div>
)}
<div className="flex size-full flex-col items-center justify-center">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/40" />
</div>
</div>
<input
defaultValue={data.label}
disabled={!data.onLabelChange}
onChange={(e) => data.onLabelChange?.(e.target.value)}
placeholder="Label (optional)"
className="nodrag absolute -bottom-6 left-1/2 w-24 -translate-x-1/2 rounded-md bg-transparent px-1 text-center text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50 focus:bg-card"
/>
</div>
)
}
export default memo(BoxNode)
export const LineNodeComponent = memo(LineNode)
@@ -0,0 +1,384 @@
"use client"
import { Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { FieldDef, FieldType, FormulaInput, FormulaOutput, InputSource, MockItem, StageNodeData } from "./types"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
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"]
export interface UpstreamOutputOption {
stageId: string
stageName: string
outputId: string
outputName: string
}
export function StageEditorPanel({
nodeId,
data,
isTerminal,
upstreamOptions,
items,
readOnly,
onChange,
onDelete,
onClose,
}: {
nodeId: string
data: StageNodeData
isTerminal: boolean
upstreamOptions: UpstreamOutputOption[]
items: MockItem[]
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 addInput() {
onChange({ inputs: [...data.inputs, { inputId: newId(), source: "Stock" as InputSource, qty: 1 }] })
}
function removeInput(inputId: string) {
onChange({ inputs: data.inputs.filter((i) => i.inputId !== inputId) })
}
function updateOutput(outputId: string, patch: Partial<FormulaOutput>) {
onChange({ outputs: data.outputs.map((o) => (o.outputId === outputId ? { ...o, ...patch } : o)) })
}
function addOutput() {
onChange({ outputs: [...data.outputs, { outputId: newId(), name: "", uom: "PCS", qty: 1 }] })
}
function removeOutput(outputId: string) {
onChange({ outputs: data.outputs.filter((o) => o.outputId !== outputId) })
}
function updateField(fieldId: string, patch: Partial<FieldDef>) {
onChange({
fieldDefs: data.fieldDefs.map((f) => {
if (f.fieldId !== fieldId) return f
const next = { ...f, ...patch }
if (patch.label !== undefined) next.key = slugify(patch.label) || f.key
return next
}),
})
}
function addField() {
onChange({
fieldDefs: [...data.fieldDefs, { fieldId: newId(), key: "", label: "", type: "Text", options: [], required: false }],
})
}
function removeField(fieldId: string) {
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.fieldId !== fieldId) })
}
return (
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
<X className="size-5" />
</button>
</div>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field>
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</Field>
<Field>
<FieldLabel>Estimated minutes</FieldLabel>
<Input
type="number"
min={0}
value={data.estimatedMinutes}
disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/>
</Field>
{/* Inputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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 className="mb-2 flex items-center justify-between gap-2">
<Select<InputSource>
value={input.source}
onValueChange={(v) => v && updateInput(input.inputId, { source: v })}
>
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
</SelectContent>
</Select>
{!readOnly && (
<button type="button" onClick={() => removeInput(input.inputId)} 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">
<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 })
}}
>
<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>
))}
</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>
)}
</div>
))}
</div>
</div>
{/* Outputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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 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}>
<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>
))}
</SelectContent>
</Select>
) : (
<Input
value={output.name}
disabled={readOnly}
onChange={(e) => updateOutput(output.outputId, { name: e.target.value })}
placeholder="Output name"
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">
<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>
</div>
))}
</div>
</div>
{/* Custom fields */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Custom fields</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addField}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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 className="mb-2 flex items-center gap-2">
<Input
value={field.label}
disabled={readOnly}
onChange={(e) => updateField(field.fieldId, { 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">
<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>
value={field.type}
onValueChange={(v) => v && updateField(field.fieldId, { type: v })}
>
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.fieldId, { required: checked })}
/>
<span className="text-xs text-muted-foreground">Required</span>
</div>
</div>
{field.type === "Select" && (
<Input
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.fieldId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
)}
</div>
))}
</div>
</div>
{!readOnly && (
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
<Trash2 className="size-4" />
Delete stage
</Button>
)}
</div>
</div>
)
}
export { newId }
@@ -0,0 +1,66 @@
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { ArrowRight, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { StageNodeData } from "./types"
/**
* Stage card (docs/21-FRONTEND-PHASE2.md §2): name, role label chip, estimated minutes,
* input count → output count. Selecting it opens the stage editor panel (handled by the
* parent page via onNodeClick, not here) — the same delete affordance also lives there
* ("Delete stage" button); this inline × is a faster path once a stage is already selected.
*/
function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
const disconnected = data.disconnected
return (
<div
className={cn(
"relative w-56 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
selected ? "ring-primary" : "ring-foreground/10",
disconnected && "opacity-40"
)}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
{selected && data.onDelete && (
<button
type="button"
aria-label="Delete stage"
title="Delete stage"
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
onClick={(e) => {
e.stopPropagation()
data.onDelete?.()
}}
>
<X className="size-3" />
</button>
)}
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name || "Untitled stage"}</p>
{data.roleLabel && (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{data.roleLabel}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{data.estimatedMinutes} min</p>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{data.inputs.length} in</span>
<ArrowRight className="size-3" />
<span>{data.outputs.length} out</span>
</div>
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export default memo(StageNode)
@@ -0,0 +1,429 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
Controls,
MiniMap,
ReactFlow,
type Connection,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type NodeMouseHandler,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
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 { Button, buttonVariants } from "@/components/ui/button"
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" },
]
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}`,
type: "stage",
position: { x: i * 280 + 40, y: 120 },
data: { name, roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData,
}))
const edges: Edge[] = stageNames.slice(1).map((_, i) => ({
id: `e${i + 1}`,
source: `n${i + 1}`,
target: `n${i + 2}`,
}))
return { nodes, edges }
}
/** Kahn's algorithm — returns the ids left over (unprocessable) once no more in-degree-0 nodes exist, i.e. the 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)
const queue = nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id)
let visited = 0
while (queue.length > 0) {
const id = queue.shift()!
visited++
for (const e of edges.filter((e) => e.source === id)) {
const next = (inDegree.get(e.target) ?? 0) - 1
inDegree.set(e.target, next)
if (next === 0) queue.push(e.target)
}
}
return visited !== nodes.length
}
export default function TemplateBuilderPage() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
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
// 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 [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
// `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.
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
const onConnect = useCallback(
(connection: Connection) => {
if (locked) return
if (connection.source === connection.target) {
toast.error("Can't connect a stage to itself")
return
}
const duplicate = edges.some((e) => e.source === connection.source && e.target === connection.target)
if (duplicate) {
toast.error("These stages are already connected")
return
}
setEdges((eds) => addEdge(connection, eds))
},
[edges, locked]
)
const onNodeClick: NodeMouseHandler = useCallback((_, node) => setSelectedNodeId(node.id), [])
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.
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 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
setNodes((nds) => [
...nds,
{
id,
type: "stage",
position: { x: existingStages.length > 0 ? maxX + 280 : 40, y },
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.
function deleteNode(nodeId: string) {
if (locked) return
setNodes((nds) => nds.filter((n) => n.id !== nodeId))
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId))
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() {
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,
data: { label: "" } satisfies AnnotationData,
},
...nds,
])
}
// 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)").
const stageNodes = useMemo(() => nodes.filter((n) => n.type === "stage"), [nodes])
const analysis = useMemo(() => {
const terminalIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.source === n.id)).map((n) => n.id))
const entryIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.target === n.id)).map((n) => n.id))
const disconnectedIds = new Set(
stageNodes.length > 1
? stageNodes.filter((n) => !edges.some((e) => e.source === n.id || e.target === n.id)).map((n) => n.id)
: []
)
const hasCycle = detectCycle(stageNodes, edges)
return { terminalIds, entryIds, disconnectedIds, hasCycle }
}, [stageNodes, edges])
// Clear stale Upstream references after an edge is deleted, with a warning toast — per
// "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))
if (stale.length > 0) {
updateNodeData(node.id, {
inputs: data.inputs.map((i) =>
stale.includes(i) ? { ...i, upstreamStageId: undefined, upstreamOutputId: undefined } : i
),
})
toast.warning("Input reference cleared", `"${data.name}" referenced a stage that's no longer connected.`)
}
}
// Only re-run when the edge set changes — re-running on every node data edit would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edges])
const issues = useMemo(() => {
const list: string[] = []
if (analysis.hasCycle) list.push("Cycle detected — stages must form a one-directional flow.")
if (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 (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(", ")}.`)
}
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.`)
}
}
return list
}, [analysis, stageNodes])
const selectedNode = stageNodes.find((n) => n.id === selectedNodeId)
const upstreamOptions: UpstreamOutputOption[] = useMemo(() => {
if (!selectedNode) return []
const parentIds = edges.filter((e) => e.target === selectedNode.id).map((e) => e.source)
return parentIds.flatMap((parentId) => {
const parent = nodes.find((n) => n.id === parentId)
if (!parent) return []
const parentData = parent.data as StageNodeData
return parentData.outputs.map((o) => ({
stageId: parent.id,
stageName: parentData.name,
outputId: o.outputId,
outputName: o.name || "(unnamed output)",
}))
})
}, [selectedNode, edges, nodes])
const displayNodes = useMemo(
() =>
nodes.map((n) =>
n.type === "stage"
? {
...n,
data: {
...n.data,
disconnected: analysis.disconnectedIds.has(n.id),
onDelete: locked ? undefined : () => deleteNode(n.id),
},
}
: {
...n,
data: {
...n.data,
onLabelChange: locked ? undefined : (label: string) => updateNodeData(n.id, { label }),
onRotationChange: locked ? undefined : (rotation: number) => updateNodeData(n.id, { rotation }),
onDelete: locked ? undefined : () => deleteNode(n.id),
},
}
),
[nodes, analysis.disconnectedIds] // eslint-disable-line react-hooks/exhaustive-deps
)
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"}.`)
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/production/templates" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<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>
</div>
<div className="flex items-center gap-2">
{!locked && (
<Button type="button" variant="outline" onClick={addStage}>
<Plus className="size-5" />
Add Stage
</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}>
<Save className="size-5" />
Save
</Button>
)}
</div>
</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.
</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">
{issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{issue}</span>
</div>
))}
</div>
)}
<div className="flex min-h-0 flex-1 gap-4">
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted && (
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={locked ? undefined : onNodesChange}
onEdgesChange={locked ? undefined : onEdgesChange}
onNodesDelete={locked ? undefined : onNodesDelete}
onConnect={locked ? undefined : onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
>
<Background />
<Controls showInteractive={!locked} />
<MiniMap pannable zoomable />
</ReactFlow>
)}
</div>
{selectedNode && (
<StageEditorPanel
nodeId={selectedNode.id}
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={MOCK_ITEMS}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,73 @@
// 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.
export type FieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
export interface FieldDef {
fieldId: string
key: string
label: string
type: FieldType
/** Only meaningful when type === "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. */
disconnected?: 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.
*/
export interface AnnotationData extends Record<string, unknown> {
label: string
/** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */
rotation?: number
}
@@ -0,0 +1,254 @@
"use client"
import { 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 {
LineHeaderNodeComponent,
LineStageNodeComponent,
type LineHeaderData,
type LineStageData,
} from "@/components/production/ProductionLineNodes"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
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 nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
const ROW_HEIGHT = 150
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[] } {
const nodes: Node[] = []
const edges: Edge[] = []
templates.forEach((t, row) => {
const y = row * ROW_HEIGHT
nodes.push({
id: `h${t.templateId}`,
type: "lineHeader",
position: { x: 0, y },
data: {
templateId: t.templateId,
docNo: t.docNo,
name: t.name,
status: t.status,
activeRunCount: t.activeRunCount,
} satisfies LineHeaderData,
draggable: false,
})
const stages = MOCK_TEMPLATE_INFO[t.templateId]?.stages ?? []
stages.forEach((stageName, i) => {
const stageId = `s${t.templateId}-${i}`
nodes.push({
id: stageId,
type: "lineStage",
position: { x: STAGE_START_X + i * STAGE_GAP_X, y: y + 22 },
data: { templateId: t.templateId, name: stageName } satisfies LineStageData,
draggable: false,
})
edges.push({
id: `e-${stageId}`,
source: i === 0 ? `h${t.templateId}` : `s${t.templateId}-${i - 1}`,
target: stageId,
})
})
})
return { nodes, edges }
}
export default function ProductionTemplatesPage() {
const router = useRouter()
const { resolvedTheme } = useTheme()
const [templates, setTemplates] = useState<ProductionTemplate[]>(INITIAL_TEMPLATES)
const [searchInput, setSearchInput] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [error, setError] = useState("")
const [submitting, setSubmitting] = useState(false)
// 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.
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])
const hasFilters = searchInput.trim().length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(filtered), [filtered])
const onNodeClick: NodeMouseHandler = (_, node) => {
const templateId = (node.data as LineHeaderData | LineStageData).templateId
router.push(`/dashboard/production/templates/${templateId}`)
}
function openCreateDialog() {
setName("")
setError("")
setOpen(true)
}
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)
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`)
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<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>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!error}>
<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}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
<FieldError errors={[error ? { message: error } : 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}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create & open builder"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search templates…"
className="h-14 w-full pl-11 text-base"
aria-label="Search templates"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{filtered.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">
{hasFilters ? "No templates match your search/filter." : "No templates yet."}
</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>
)}
</div>
)
}
@@ -11,16 +11,19 @@ import {
CalendarClock,
ChevronRight,
ClipboardList,
Factory,
FileBarChart,
FileText,
HelpCircle,
IdCard,
LayoutGrid,
LayoutTemplate,
ListTree,
Menu,
Package,
PackageCheck,
PackageX,
PlayCircle,
Ruler,
Settings,
ShieldCheck,
@@ -89,6 +92,18 @@ const navItems: {
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
{
title: "Production",
code: "production",
href: "/dashboard/production",
landingHref: "/dashboard/production/runs",
icon: Factory,
chevron: true,
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
],
},
{
title: "HRM",
code: "hrm",
@@ -333,7 +348,7 @@ export function AppSidebar() {
// 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
// anything server-side.
const bypassCodes = new Set(["procurement", "hrm"])
const bypassCodes = new Set(["procurement", "hrm", "production"])
const visibleItems = loading
? []
: navItems
@@ -35,6 +35,9 @@ const SEGMENT_LABELS: Record<string, string> = {
rfqs: "RFQs",
"purchase-orders": "Purchase Orders",
"purchase-returns": "Purchase Returns",
production: "Production",
templates: "Templates",
runs: "Runs",
}
function labelFor(segment: string): string {
@@ -0,0 +1,57 @@
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { cn } from "@/lib/utils"
export interface LineHeaderData extends Record<string, unknown> {
templateId: number
docNo: string
name: string
status: "Active" | "Inactive"
activeRunCount: number
}
/** Row label docked at the left of each production line — the template itself. */
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={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
data.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{data.status}
</span>
</div>
<p className="truncate text-sm font-bold text-foreground">{data.name}</p>
{data.activeRunCount > 0 && (
<span className="w-fit rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
{data.activeRunCount} in progress
</span>
)}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export interface LineStageData extends Record<string, unknown> {
templateId: number
name: string
}
/** One stage on a production line — read-only, purely a visual chip on the overview canvas. */
function LineStageNode({ data }: NodeProps & { data: LineStageData }) {
return (
<div className="w-36 rounded-xl bg-primary/10 px-3 py-2.5 text-center shadow-sm ring-1 ring-primary/20">
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<p className="truncate text-sm font-semibold text-primary">{data.name}</p>
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export const LineHeaderNodeComponent = memo(LineHeaderNode)
export const LineStageNodeComponent = memo(LineStageNode)
@@ -0,0 +1,75 @@
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { RunStatus } 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
}
/** Left-most box on a run's production line — the run itself, not a stage. */
function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) {
return (
<div className="flex w-48 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
<p className="truncate text-sm font-bold text-foreground">{data.templateName}</p>
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export interface RunStageData extends Record<string, unknown> {
name: string
state: StageStatus
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 }) {
const color = STAGE_STATUS_COLOR[data.state]
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"
)}
style={data.isActive ? { boxShadow: `0 0 0 2px ${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>
<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"
>
Advance
<ChevronRight className="size-3.5" />
</button>
)}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export const RunHeaderNodeComponent = memo(RunHeaderNode)
export const RunStageNodeComponent = memo(RunStageNode)
@@ -0,0 +1,85 @@
import { CheckCircle2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
RUN_CANCELLED_COLOR,
RUN_COMPLETED_COLOR,
STAGE_STATUS_COLOR,
STAGE_STATUS_LABEL,
STAGE_STATUS_ORDER,
} from "@/lib/production-status-colors"
import { RunStatus, StageSummary } from "@/types/production"
/**
* One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render
* a full teal strip + check; cancelled runs get a red accent instead of per-stage segments.
*/
export function StageProgressStrip({
status,
summary,
className,
}: {
status: RunStatus
summary: StageSummary
className?: string
}) {
if (status === "Completed") {
return (
<div className={cn("flex h-2.5 items-center gap-1.5 rounded-full", className)}>
<div className="h-2.5 flex-1 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
<CheckCircle2 className="size-4 shrink-0" style={{ color: RUN_COMPLETED_COLOR }} />
</div>
)
}
const counts: Record<string, number> = {
Waiting: summary.waiting,
Ready: summary.ready,
InProgress: summary.inProgress,
Done: summary.done,
Approved: summary.approved,
}
const total = STAGE_STATUS_ORDER.reduce((sum, key) => sum + counts[key], 0)
return (
<div
className={cn("flex h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
style={status === "Cancelled" ? { boxShadow: `0 0 0 2px ${RUN_CANCELLED_COLOR}` } : undefined}
>
{total === 0
? null
: STAGE_STATUS_ORDER.map((key) => {
const count = counts[key]
if (count === 0) return null
return (
<div
key={key}
title={`${STAGE_STATUS_LABEL[key]}: ${count}`}
style={{ backgroundColor: STAGE_STATUS_COLOR[key], width: `${(count / total) * 100}%` }}
/>
)
})}
</div>
)
}
export function StageStatusLegend({ className }: { className?: string }) {
return (
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5", className)}>
{STAGE_STATUS_ORDER.map((key) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[key] }} />
{STAGE_STATUS_LABEL[key]}
</div>
))}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_CANCELLED_COLOR }} />
Cancelled
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
Completed
</div>
</div>
)
}
@@ -0,0 +1,105 @@
// 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] }))
}
@@ -0,0 +1,16 @@
// 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"] },
}
@@ -0,0 +1,27 @@
// Single source of truth for stage-status coloring (docs/21-FRONTEND-PHASE2.md §3):
// "These colors are the single source for status coloring everywhere (board, run graph,
// drawers, legend)." Every screen that renders a stage status imports from here.
export type StageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved"
export const STAGE_STATUS_ORDER: StageStatus[] = ["Waiting", "Ready", "InProgress", "Done", "Approved"]
export const STAGE_STATUS_COLOR: Record<StageStatus, string> = {
Waiting: "#9CA3AF",
Ready: "#3B82F6",
InProgress: "#F59E0B",
Done: "#22C55E",
Approved: "#14B8A6",
}
export const STAGE_STATUS_LABEL: Record<StageStatus, string> = {
Waiting: "Waiting",
Ready: "Ready",
InProgress: "In Progress",
Done: "Done",
Approved: "Approved",
}
/** Run-level (not stage-level) colors, per the same table. */
export const RUN_CANCELLED_COLOR = "#EF4444"
export const RUN_COMPLETED_COLOR = "#14B8A6"
+1
View File
@@ -13,6 +13,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",
+42
View File
@@ -0,0 +1,42 @@
// 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.
export type TemplateStatus = "Active" | "Inactive"
export interface ProductionTemplate {
templateId: number
docNo: string
name: string
status: TemplateStatus
stageCount: number
activeRunCount: number
updatedAt: string
}
export type RunStatus = "InProgress" | "Completed" | "Cancelled"
/** One count per canonical stage status (docs/21-FRONTEND-PHASE2.md §3). */
export interface StageSummary {
waiting: number
ready: number
inProgress: number
done: number
approved: number
}
export interface ProductionRun {
runId: number
docNo: string
templateName: string
targetQty: number
finishedItemName: string
uom: string
warehouseName: string
status: RunStatus
reworkCount: number
createdAt: string
completedAt: string | null
stageSummary: StageSummary
}
+122
View File
@@ -0,0 +1,122 @@
# 21 · FRONTEND-PHASE2 — Manufacturing: Production Lines (Flows & Rules)
> **Purpose:** Frontend source of truth for Phase 2 (Manufacturing): the template canvas builder, the run board, and run execution screens. API contract and all business rules live in `30-BACKEND-PHASE2.md` — this doc never redefines them. Validation posture follows `20-FRONTEND §3`: client validation is UX only; the server is authoritative. Register this doc in `00-CORE.md §7` and `01-DOC-GUIDE.md §2`.
---
## 1. Screens
| Screen | Route (suggested) | Actual route (frontend-only build) | Purpose |
|---|---|---|---|
| Template list | `/production/templates` | `/dashboard/production/templates` — a single shared React Flow canvas, one row per template (header + stages left→right), not a list/grid | Browse templates, status, active-run count; open builder. |
| Template builder (canvas) | `/production/templates/{id}` | `/dashboard/production/templates/{id}` | Drag-and-drop stage graph design. |
| Run board | `/production/runs` | `/dashboard/production/runs` | All runs with per-stage progress at a glance. |
| Start run dialog | modal from board/list | modal from run board | Pick template, target qty, warehouse; preview scaled quantities. |
| Run detail | `/production/runs/{id}` | `/dashboard/production/runs/{id}` | Read-only graph with live statuses + stage action drawer. |
> **Build status:** §§14 are implemented as a **frontend-only mock** (per-page `useState`, no persistence across pages/reloads) — no `Dtos/Production` or `30-BACKEND-PHASE2.md` exist yet, so nothing here talks to a real API. §5 (Run detail) is implemented in a **simplified form**: one generic per-stage advance action instead of the full status-specific stage drawer. §6 (validation/error posture) does not apply yet — there's no server to surface `ProblemDetails`/error codes from. See §8 for the itemized gap list.
---
## 2. Template builder (canvas)
**Library:** React Flow (drag/drop nodes, edge drawing, pan/zoom, minimap). Node positions map 1:1 to `posX`/`posY`; the backend stores layout uninterpreted, so all layout behavior is client-owned.
**Node (stage card)** shows: name, role label chip, estimated minutes, input count → output count. Selecting a node opens the **stage editor panel**:
- Name, role label (free text with suggestions e.g. QA, Assembly), estimated minutes.
- **Formula rows** — Inputs: source toggle `Stock | Upstream`; Stock → Item picker (active items only) + UOM + qty/batch; Upstream → dropdown of *direct parents' outputs only* (disable others). Outputs: name + UOM + qty/batch; on the terminal stage the single output requires an Item picker (finished good).
- **Custom field builder** — add/remove fields: key (auto-slug from label), label, type (`Text|Number|Checkbox|Date|Select` + options), required toggle. Serialized to the `fieldDefs` jsonb shape verbatim.
**Edges:** drawn parent → child. Client blocks duplicate edges and self-loops at draw time.
**Client-side graph checks (UX only — server re-validates on save):**
- Cycle detection (toposort) — highlight the offending edge.
- Exactly one terminal (no-outbound) node — banner "Connect stages so the line converges to a single final stage" when ≠1.
- ≥1 entry node; no disconnected nodes (grey them out).
- Terminal output has an Item; Upstream inputs reference a current direct parent (re-check after edge deletions and clear broken references with a warning toast).
**Save:** full-graph `POST`/`PUT` with `If-Match`. Surface `422 GRAPH_*` codes by focusing the offending node/edge. **Edit lock:** when `activeRunCount > 0`, render the canvas read-only with a banner "Template locked — N run(s) in progress" (server enforces via `409 TEMPLATE_IN_USE`; the banner is UX). Deactivate action instead of delete.
---
## 3. Run board
List/grid of runs, newest first, filters: status, template, warehouse, search by doc no.
Each row/card: `docNo` (`PRD-2026-00001`), template name, target qty + finished item, created/completed timestamps, rework badge when `reworkCount > 0`, and a **stage progress strip** rendered from `stageSummary` — one segment per stage-status count using the canonical colors:
| Status | Color |
|---|---|
| Waiting | grey `#9CA3AF` |
| Ready | blue `#3B82F6` |
| InProgress | amber `#F59E0B` |
| Done | green `#22C55E` |
| Approved | teal `#14B8A6` |
| Run Cancelled | red accent on the card |
| Run Completed | full teal strip + check |
These colors are the single source for status coloring everywhere (board, run graph, drawers, legend). Show a legend on the board.
---
## 4. Start run dialog
1. Template picker (Active only), target quantity (of the finished item, unit shown), warehouse, optional output bin.
2. **Scaled preview:** client computes `scaleFactor = targetQty / terminalOutputQtyPerBatch` and shows every stage's scaled inputs/outputs *as a preview only* — the authoritative scaled figures come back on the `201` response.
3. On create → navigate to run detail. Quantity fine-tuning happens there via the per-stage quantities editor (not in this dialog).
- **As built:** stays on the run board with a success toast instead of navigating — the new run's stages start `waiting: stageCount-1, ready: 1` and the user opens it from the board like any other run.
---
## 5. Run detail
> **As built (mock):** a single-row React Flow line (header box + one box per real stage name, left→right) instead of the full copied-template graph, with a header progress bar/percentage and one **Give Progress** action (canvas button on the active stage, and a mirrored button in the header) that steps that stage through the canonical status sequence `Waiting → Ready → InProgress → Done → Approved`. No drawer, no per-status action set, no quantities/scrap/custom-field forms, no delivered/available badges, no polling (single local page, no backend to refetch from). All state is local `useState` — reloading the page resets to the seeded mock run. See §8.
**Layout:** the template graph re-rendered read-only (same React Flow canvas, positions from the run's copied stages), each node colored by live status, with `deliveredQty/plannedQty` badges on inbound edges and an available-to-transfer badge on approved stages holding a remainder. Poll or refetch after every action.
**Stage drawer** (click a node) — content by status:
- Any status: name, role chip, estimated vs **actual** time (`actualStartAt`/`actualEndAt`, live elapsed while InProgress), event history timeline.
- **Waiting:** per-upstream-input delivery progress bars; nothing actionable except *Reject intake* when `deliveredQty > 0` (see below).
- **Ready:** *Edit quantities* (planned in/out — disabled after start, surface `409 STAGE_NOT_EDITABLE`), Stock-input availability hints (`on-hand` enquiry, advisory only — never block client-side, per `20-FRONTEND §3`), and **Start**. On start errors surface `STOCK_NEGATIVE_BLOCKED` / `ONHOLD_NOT_ISSUABLE` / `EXPIRED_BATCH_BLOCKED` with the item named.
- **InProgress:** **Complete** form — per output: produced qty, scrapped qty (reason-code picker appears and becomes required when scrap > 0), plus the **custom field form rendered from `fieldDefs`** (required fields block submit client-side; server backs with `400 REQUIRED_FIELD_MISSING`).
- **Done:** **Approve** — non-terminal: default "transfer all" with an optional per-output partial amount (validated ≤ available); terminal: confirmation summarizing the receipt (qty, computed unit cost from cost pool preview). Terminal also offers **Reject** with a strong confirm modal: *"This resets the entire run to its starting stages (rework #N). Consumed materials remain in the run."*
- **Approved (non-terminal):** *Transfer remainder* action while available > 0 (`422 TRANSFER_EXCEEDS_AVAILABLE` surfaced inline).
- **Reject intake** (on a Ready/Waiting stage with deliveries): confirm modal *"Returns work to the previous completed stage for rework"* → parents visibly flip back to InProgress on refresh.
**Run-level actions:** *Return leftover* (per started Stock input: qty ≤ consumed returned, reason code required; hidden once run Completed — `RUN_COST_CLOSED`), *Cancel run* (reason code + note, confirm modal explaining stock return; hidden when Completed).
---
## 6. Validation posture & error surfacing
- Client checks: required/format/range, graph checks (§2), qty ≤ available style guards — all UX; never assume stock rules client-side.
- Every `ProblemDetails` renders its `title`; map domain `code`s to friendly inline messages (table in `30-BACKEND-PHASE2 §D.4`). Unknown codes fall back to the ProblemDetails title + trace id.
- `412 CONCURRENCY_CONFLICT` → "This item changed elsewhere — reloading" + refetch. Stage-action `409`s (wrong status) → refetch the run silently and re-render; another user likely acted first.
- Stage-transition posts send an `Idempotency-Key` (uuid per click) so double-clicks are replay-safe.
---
## 7. Foundation additions (PROGRESS seed)
- [x] React Flow dependency + canvas components — but **not** a single shared editable/read-only variant: the template-overview canvas (`templates/page.tsx`), the builder canvas (`templates/[id]/page.tsx`), and the run-detail canvas (`runs/[id]/page.tsx`) are three separate node-type sets (`ProductionLineNodes.tsx`, `StageNode.tsx`/`AnnotationNodes.tsx`, `RunStageNode.tsx`).
- [ ] Types mirroring `Dtos/Production` (template graph, run graph, stage actions) — not started; `types/production.ts` is a standalone frontend-only placeholder shape, nothing to mirror against yet.
- [x] Status-color tokens (§3 table) exported from one module — `lib/production-status-colors.ts` (`STAGE_STATUS_COLOR`/`_LABEL`/`_ORDER`, `RUN_CANCELLED_COLOR`, `RUN_COMPLETED_COLOR`).
- [~] Custom-field renderer (defs jsonb → form) + builder (form → defs jsonb) — builder half only (`StageEditorPanel.tsx`, defs jsonb ← form). The runtime renderer (form → filled values, used during the spec'd Complete action) doesn't exist since there's no stage drawer/Complete step (§5).
- [~] Screens: template list · builder · run board · start dialog · run detail + drawer — list/builder/board/dialog implemented (as mock); run detail implemented **without** the drawer or per-status action set (§5, §8).
- [ ] Error-code → message map for §D.4 additions — not started, no backend/`ProblemDetails` to map yet.
---
## 8. Gaps vs. this spec (frontend-only mock — no `Dtos/Production` / `30-BACKEND-PHASE2.md` yet)
Everything below is intentional scope for the current build, not a bug — recorded so whoever wires up the real backend knows exactly what's still owed against this doc:
- **No persistence.** All state is per-page `useState` seeded from hardcoded mock arrays (`lib/production-mock-templates.ts`, `lib/production-mock-runs.ts`). Templates, runs, and stage-status edits don't survive a reload and don't sync across the three canvases/pages.
- **Run detail is a simplified single action, not the stage drawer (§5).** One generic "Give Progress" step (Waiting→Ready→InProgress→Done→Approved) replaces Start/Complete (qty+scrap+custom fields)/Approve/Reject/Transfer remainder/Reject intake. No event history timeline, no actual-vs-estimated time tracking, no delivered/available badges.
- **No run-level actions.** Return leftover and Cancel run (§5) aren't implemented.
- **Stage identity on the run board/detail is reconstructed, not authoritative.** `ProductionRun.stageSummary` only carries counts per status; `buildStagePlan()` (`lib/production-mock-runs.ts`) maps those counts onto the template's real stage names most-complete-first as a display approximation — a real backend would return named per-stage records directly.
- **No validation/error posture (§6).** No `ProblemDetails`, no domain error-code mapping, no `412`/`409` handling, no `Idempotency-Key` — there's no server to produce any of it yet.
- **Save uses no `If-Match`/concurrency token** on the builder (§2) — a local `locked` boolean (from mock `activeRunCount`) stands in for the server's `409 TEMPLATE_IN_USE` edit lock.
- **Template overview deviates from "list" (§1).** Implemented as one shared canvas (all templates as production lines, one row each) instead of a browsable list/grid, per explicit product direction during the build.
*End of 21-FRONTEND-PHASE2.md. Contract: `30-BACKEND-PHASE2.md`. Record work: `Frontend/PROGRESS.md`.*