-
-
Sales (Line)
-
+ {loaded ? (
+
+ ) : (
+ !error &&
+ )}
+
+
+
+
+
+
+ Recent Stock Movements
+
+
+ View all
+
-
-
-
+ {loaded ? (
+ movements.length > 0 ? (
+
+
+
+ Item
+ Warehouse
+ Direction
+ Qty
+ Source
+ Date
+
+
+
+ {movements.map((entry) => (
+
+ #{entry.itemId}
+
+ {warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
+
+
+
+ {entry.direction}
+
+
+ {entry.qtyBase}
+
+ {entry.sourceDocType} #{entry.sourceDocId}
+
+
+ {new Date(entry.createdAt).toLocaleDateString()}
+
+
+ ))}
+
+
+ ) : (
+
No stock movements yet.
+ )
+ ) : (
+ !error && (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )
+ )}
)
diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx
new file mode 100644
index 0000000..3a72224
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx
@@ -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
(run?.status ?? "InProgress")
+ const [completedAt, setCompletedAt] = useState(run?.completedAt ?? null)
+ const [stages, setStages] = useState(() =>
+ 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 (
+
+
Run not found.
+
router.push("/dashboard/production/runs")}>
+
+ Back to runs
+
+
+ )
+ }
+
+ return (
+
+
router.push("/dashboard/production/runs")}
+ >
+
+ Back to runs
+
+
+
+
+
+
+ {run.docNo}
+
+ {status === "InProgress" ? "In Progress" : status}
+
+ {run.reworkCount > 0 && (
+
+
+ Rework #{run.reworkCount}
+
+ )}
+
+
{run.templateName} · {run.warehouseName}
+
+
+
+ {run.targetQty.toLocaleString()} {run.uom} · {run.finishedItemName}
+
+
+ Created {new Date(run.createdAt).toLocaleDateString()}
+ {completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}>}
+
+
+
+
+
+
+
+
+
+
+ {progressPercent}% complete
+ {activeStage && · Current: {activeStage.name} }
+
+
+
+
+
+ {activeStage ? `Give Progress — ${activeStage.name}` : "All stages approved"}
+
+
+
+
+
+
+
+
+
+
+ {mounted ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/Frontend/erp-system/app/dashboard/production/runs/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/page.tsx
new file mode 100644
index 0000000..3d85b90
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/runs/page.tsx
@@ -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(INITIAL_RUNS)
+ const [searchInput, setSearchInput] = useState("")
+ const [status, setStatus] = useState("All")
+ const [template, setTemplate] = useState("All")
+ const [warehouse, setWarehouse] = useState("All")
+
+ const [open, setOpen] = useState(false)
+ const [startTemplateId, setStartTemplateId] = useState(null)
+ const [targetQty, setTargetQty] = useState("")
+ const [startWarehouse, setStartWarehouse] = useState(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 (
+
+
+
+
Production Runs
+
All manufacturing runs with per-stage progress at a glance.
+
+
+ Start Run} />
+
+
+ Start a run
+ Fine-tune per-stage quantities afterward on the run itself.
+
+
+
+ Template
+ value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}>
+
+
+
+
+ {STARTABLE_TEMPLATES.map((t) => (
+ {t.name}
+ ))}
+
+
+
+
+ 0)}>
+
+ Target quantity{startTemplate && ({startTemplate.uom}, {startTemplate.finishedItemName}) }
+
+ setTargetQty(e.target.value)}
+ placeholder="e.g. 200"
+ />
+
+
+
+ Warehouse
+ value={startWarehouse} onValueChange={setStartWarehouse}>
+
+
+
+
+ {WAREHOUSE_NAMES.map((n) => (
+ {n}
+ ))}
+
+
+
+
+
+ Output bin (optional)
+ setOutputBin(e.target.value)} placeholder="e.g. BIN-04" />
+
+
+ {scaleFactor !== null && (
+
+ Scale factor {scaleFactor.toFixed(2)}× — 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.
+
+ )}
+
+
+
+
+ setOpen(false)} disabled={submitting}>
+ Cancel
+
+
+ {submitting ? "Starting…" : "Start run"}
+
+
+
+
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ placeholder="Search doc no…"
+ className="h-14 w-full pl-11 text-base"
+ aria-label="Search runs"
+ />
+
+
value={template} onValueChange={(v) => setTemplate(v ?? "All")}>
+
+
+
+
+ All templates
+ {TEMPLATE_NAMES.map((n) => (
+ {n}
+ ))}
+
+
+
value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}>
+
+
+
+
+ All warehouses
+ {WAREHOUSE_NAMES.map((n) => (
+ {n}
+ ))}
+
+
+
value={status} onValueChange={(v) => setStatus(v ?? "All")}>
+
+
+
+
+ All statuses
+ In Progress
+ Completed
+ Cancelled
+
+
+
+
+
+
+
+
+ {filtered.length === 0 ? (
+
+
+
+ {hasFilters ? "No runs match your search/filter." : "No runs yet."}
+
+
+ ) : (
+
+ {filtered.map((r) => (
+
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"
+ >
+
+
+
+ {r.docNo}
+
+ {r.status === "InProgress" ? "In Progress" : r.status}
+
+ {r.reworkCount > 0 && (
+
+
+ Rework #{r.reworkCount}
+
+ )}
+
+
{r.templateName} · {r.warehouseName}
+
+
+
+
+ {r.targetQty.toLocaleString()} {r.uom} · {r.finishedItemName}
+
+
+ Created {new Date(r.createdAt).toLocaleDateString()}
+ {r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}>}
+
+
+
+
+
+
+
+
+
+ {buildStagePlan(r.templateName, r.stageSummary).map((s, i) => (
+
+
+ {s.name}
+ · {STAGE_STATUS_LABEL[s.state]}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx
new file mode 100644
index 0000000..f212906
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx
@@ -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 (
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onDelete?.()
+ }}
+ >
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+ {selected && data.onDelete && }
+ 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"
+ />
+
+ )
+}
+
+/**
+ * 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(null)
+ const rotation = data.rotation ?? 0
+
+ return (
+
+
+
+
+ {selected && (data.onRotationChange || data.onDelete) && (
+
+ {data.onRotationChange && (
+ {
+ 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)
+ }}
+ >
+
+
+ )}
+ {data.onDelete && }
+
+ )}
+
+
+
+
+
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"
+ />
+
+ )
+}
+
+export default memo(BoxNode)
+export const LineNodeComponent = memo(LineNode)
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx
new file mode 100644
index 0000000..60dc214
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx
@@ -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) => void
+ onDelete: () => void
+ onClose: () => void
+}) {
+ function updateInput(inputId: string, patch: Partial) {
+ 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) {
+ 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) {
+ 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 (
+
+
+
Stage editor
+
+
+
+
+
+
+
+ Name
+ onChange({ name: e.target.value })} placeholder="e.g. Welding" />
+
+
+
+ Role label
+ onChange({ roleLabel: e.target.value })}
+ placeholder="e.g. QA"
+ list="role-suggestions"
+ />
+
+ {ROLE_SUGGESTIONS.map((r) => (
+
+ ))}
+
+
+
+
+ Estimated minutes
+ onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
+ />
+
+
+ {/* Inputs */}
+
+
+
Inputs
+ {!readOnly && (
+
+
+ Add
+
+ )}
+
+
+ {data.inputs.length === 0 &&
No inputs yet.
}
+ {data.inputs.map((input) => (
+
+
+
+ value={input.source}
+ onValueChange={(v) => v && updateInput(input.inputId, { source: v })}
+ >
+
+
+
+
+ Stock
+ Upstream
+
+
+ {!readOnly && (
+ removeInput(input.inputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
+
+
+ )}
+
+
+ {input.source === "Stock" ? (
+
+ ) : (
+
+ value={input.upstreamOutputId ?? null}
+ onValueChange={(v) => {
+ const opt = upstreamOptions.find((o) => o.outputId === v)
+ updateInput(input.inputId, { upstreamOutputId: v ?? undefined, upstreamStageId: opt?.stageId })
+ }}
+ >
+
+
+
+
+ {upstreamOptions.map((o) => (
+
+ {o.stageName} — {o.outputName}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+
+
+ {/* Outputs */}
+
+
+
+ Outputs{isTerminal && (terminal — finished good) }
+
+ {!readOnly && (
+
+
+ Add
+
+ )}
+
+
+ {data.outputs.length === 0 &&
No outputs yet.
}
+ {data.outputs.map((output) => (
+
+ ))}
+
+
+
+ {/* Custom fields */}
+
+
+
Custom fields
+ {!readOnly && (
+
+
+ Add
+
+ )}
+
+
+ {data.fieldDefs.length === 0 &&
No custom fields.
}
+ {data.fieldDefs.map((field) => (
+
+
+ updateField(field.fieldId, { label: e.target.value })}
+ placeholder="Label"
+ className="h-8 flex-1 text-sm"
+ />
+ {!readOnly && (
+ removeField(field.fieldId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
+
+
+ )}
+
+ {field.key &&
key: {field.key}
}
+
+
+ value={field.type}
+ onValueChange={(v) => v && updateField(field.fieldId, { type: v })}
+ >
+
+
+
+
+ {FIELD_TYPES.map((t) => (
+ {t}
+ ))}
+
+
+
+ updateField(field.fieldId, { required: checked })}
+ />
+ Required
+
+
+ {field.type === "Select" && (
+
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"
+ />
+ )}
+
+ ))}
+
+
+
+ {!readOnly && (
+
+
+ Delete stage
+
+ )}
+
+
+ )
+}
+
+export { newId }
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx
new file mode 100644
index 0000000..459defc
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx
@@ -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 (
+
+
+
+ {selected && data.onDelete && (
+
{
+ e.stopPropagation()
+ data.onDelete?.()
+ }}
+ >
+
+
+ )}
+
+
+
{data.name || "Untitled stage"}
+ {data.roleLabel && (
+
+ {data.roleLabel}
+
+ )}
+
+
+
{data.estimatedMinutes} min
+
+
+
{data.inputs.length} in
+
+
{data.outputs.length} out
+
+
+ {disconnected &&
Disconnected
}
+
+
+
+ )
+}
+
+export default memo(StageNode)
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx
new file mode 100644
index 0000000..80aee4f
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx
@@ -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(initial.nodes)
+ const [edges, setEdges] = useState(initial.edges)
+ const [selectedNodeId, setSelectedNodeId] = useState(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 | Partial) {
+ 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 (
+
+
+
+
+
+
+
+
{template.name}
+
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection{edges.length === 1 ? "" : "s"}
+
+
+
+ {!locked && (
+
+
+ Add Stage
+
+ )}
+ {!locked && (
+
+
+ Add Box
+
+ )}
+ {!locked && (
+
+
+ Add Line
+
+ )}
+ {!locked && (
+
+
+ Save
+
+ )}
+
+
+
+ {locked && (
+
+
+ Template locked — {template.activeRunCount} run{template.activeRunCount === 1 ? "" : "s"} in progress.
+
+ )}
+
+ {issues.length > 0 && (
+
+ {issues.map((issue, i) => (
+
+ ))}
+
+ )}
+
+
+
+ {mounted && (
+
+
+
+
+
+ )}
+
+
+ {selectedNode && (
+
updateNodeData(selectedNode.id, patch)}
+ onDelete={() => deleteNode(selectedNode.id)}
+ onClose={() => setSelectedNodeId(null)}
+ />
+ )}
+
+
+ )
+}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts
new file mode 100644
index 0000000..29b5f1e
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts
@@ -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 {
+ 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 {
+ label: string
+ /** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */
+ rotation?: number
+}
+
diff --git a/Frontend/erp-system/app/dashboard/production/templates/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/page.tsx
new file mode 100644
index 0000000..c4e6db0
--- /dev/null
+++ b/Frontend/erp-system/app/dashboard/production/templates/page.tsx
@@ -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(INITIAL_TEMPLATES)
+ const [searchInput, setSearchInput] = useState("")
+ const [status, setStatus] = useState("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 (
+
+
+
+
Production Templates
+
Every production line, stage by stage. Click a line to open its builder.
+
+
+ New Template} />
+
+
+ New template
+ Give the template a name — you'll build its stage graph next.
+
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="e.g. Aluminium Frame Assembly"
+ aria-invalid={!!error}
+ onKeyDown={(e) => e.key === "Enter" && handleCreate()}
+ />
+
+
+
+
+ setOpen(false)} disabled={submitting}>
+ Cancel
+
+
+ {submitting ? "Creating…" : "Create & open builder"}
+
+
+
+
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ placeholder="Search templates…"
+ className="h-14 w-full pl-11 text-base"
+ aria-label="Search templates"
+ />
+
+
value={status} onValueChange={(v) => setStatus(v ?? "All")}>
+
+
+
+
+ All statuses
+ Active
+ Inactive
+
+
+
+
+ {filtered.length === 0 ? (
+
+
+
+ {hasFilters ? "No templates match your search/filter." : "No templates yet."}
+
+
+ ) : (
+
+ {mounted ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx
index f977cef..1d4e739 100644
--- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx
+++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
-import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
+import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
@@ -156,14 +156,9 @@ export default function BrandsPage() {
return (
-
-
-
-
-
-
Brands
-
Manage product brands.
-
+
+
Brands
+
Manage product brands.
@@ -239,21 +234,21 @@ export default function BrandsPage() {
{!error && brands !== null && brands.length > 0 && (
<>
-
-
-
+
+
+
toggleSort("brandId")} />
-
+
toggleSort("name")} />
-
+
toggleSort("status")} />
-
+
toggleSort("createdAt")} />
- Actions
+ Actions
@@ -364,7 +359,7 @@ function SortableHeader({
return (
@@ -376,7 +371,7 @@ function SortableHeader({
)
) : (
-
+
)}
)
diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx
index 566f376..f13f273 100644
--- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx
+++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
-import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
+import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
@@ -155,14 +155,9 @@ export default function CategoriesPage() {
return (
-
-
-
-
-
-
Categories
-
Item category master (FR-MD-04).
-
+
+
Categories
+
Item category master (FR-MD-04).
@@ -238,21 +233,21 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && (
<>
-
-
-
+
+
+
toggleSort("categoryId")} />
-
+
toggleSort("name")} />
-
+
toggleSort("status")} />
-
+
toggleSort("createdAt")} />
- Actions
+ Actions
@@ -371,7 +366,7 @@ function SortableHeader({
return (
@@ -383,7 +378,7 @@ function SortableHeader({
)
) : (
-
+
)}
)
diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx
index cd7f25a..2a19bdf 100644
--- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx
+++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx
@@ -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
diff --git a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx
index 1d17c38..a5c4ad9 100644
--- a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx
+++ b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx
@@ -35,6 +35,9 @@ const SEGMENT_LABELS: Record = {
rfqs: "RFQs",
"purchase-orders": "Purchase Orders",
"purchase-returns": "Purchase Returns",
+ production: "Production",
+ templates: "Templates",
+ runs: "Runs",
}
function labelFor(segment: string): string {
diff --git a/Frontend/erp-system/components/production/ProductionLineNodes.tsx b/Frontend/erp-system/components/production/ProductionLineNodes.tsx
new file mode 100644
index 0000000..81c2168
--- /dev/null
+++ b/Frontend/erp-system/components/production/ProductionLineNodes.tsx
@@ -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 {
+ 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 (
+
+
+ {data.docNo}
+
+ {data.status}
+
+
+
{data.name}
+ {data.activeRunCount > 0 && (
+
+ {data.activeRunCount} in progress
+
+ )}
+
+
+ )
+}
+
+export interface LineStageData extends Record {
+ 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 (
+
+ )
+}
+
+export const LineHeaderNodeComponent = memo(LineHeaderNode)
+export const LineStageNodeComponent = memo(LineStageNode)
diff --git a/Frontend/erp-system/components/production/RunStageNode.tsx b/Frontend/erp-system/components/production/RunStageNode.tsx
new file mode 100644
index 0000000..7326b06
--- /dev/null
+++ b/Frontend/erp-system/components/production/RunStageNode.tsx
@@ -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 {
+ 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 (
+
+
{data.docNo}
+
{data.templateName}
+
+
+ )
+}
+
+export interface RunStageData extends Record {
+ 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 (
+
+
+
+
+
{STAGE_STATUS_LABEL[data.state]}
+
+ {data.onAdvance && (
+
{
+ 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
+
+
+ )}
+
+
+
+ )
+}
+
+export const RunHeaderNodeComponent = memo(RunHeaderNode)
+export const RunStageNodeComponent = memo(RunStageNode)
diff --git a/Frontend/erp-system/components/production/stage-progress-strip.tsx b/Frontend/erp-system/components/production/stage-progress-strip.tsx
new file mode 100644
index 0000000..9cfcf64
--- /dev/null
+++ b/Frontend/erp-system/components/production/stage-progress-strip.tsx
@@ -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 (
+
+ )
+ }
+
+ const counts: Record = {
+ 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 (
+
+ {total === 0
+ ? null
+ : STAGE_STATUS_ORDER.map((key) => {
+ const count = counts[key]
+ if (count === 0) return null
+ return (
+
+ )
+ })}
+
+ )
+}
+
+export function StageStatusLegend({ className }: { className?: string }) {
+ return (
+
+ {STAGE_STATUS_ORDER.map((key) => (
+
+
+ {STAGE_STATUS_LABEL[key]}
+
+ ))}
+
+
+ Cancelled
+
+
+
+ Completed
+
+
+ )
+}
diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx
index 852c14f..4b598ed 100644
--- a/Frontend/erp-system/components/ui/select.tsx
+++ b/Frontend/erp-system/components/ui/select.tsx
@@ -150,7 +150,7 @@ function SelectItem({
}
>
-
+
)
diff --git a/Frontend/erp-system/components/ui/stat-card.tsx b/Frontend/erp-system/components/ui/stat-card.tsx
index e4371a9..4aebb88 100644
--- a/Frontend/erp-system/components/ui/stat-card.tsx
+++ b/Frontend/erp-system/components/ui/stat-card.tsx
@@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) {
className="h-4.5 w-12 shrink-0 overflow-visible"
aria-hidden="true"
>
-
-
+
+
)
}
@@ -71,21 +71,21 @@ export function StatCard({
return (
{label}
{Icon && (
-
-
{formatValue(value)}
+
{formatValue(value)}
{trend && trend.length > 1 &&
}
@@ -94,7 +94,7 @@ export function StatCard({
{isPositive ? "+" : "-"}
diff --git a/Frontend/erp-system/components/ui/table.tsx b/Frontend/erp-system/components/ui/table.tsx
index c7eb05c..6e3126c 100644
--- a/Frontend/erp-system/components/ui/table.tsx
+++ b/Frontend/erp-system/components/ui/table.tsx
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
)
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
{
+ return apiRequest("/dashboard/stats")
+ },
+}
diff --git a/Frontend/erp-system/lib/production-mock-runs.ts b/Frontend/erp-system/lib/production-mock-runs.ts
new file mode 100644
index 0000000..a56701a
--- /dev/null
+++ b/Frontend/erp-system/lib/production-mock-runs.ts
@@ -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 = {
+ 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] }))
+}
diff --git a/Frontend/erp-system/lib/production-mock-templates.ts b/Frontend/erp-system/lib/production-mock-templates.ts
new file mode 100644
index 0000000..6f83558
--- /dev/null
+++ b/Frontend/erp-system/lib/production-mock-templates.ts
@@ -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 = {
+ "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"] },
+}
diff --git a/Frontend/erp-system/lib/production-status-colors.ts b/Frontend/erp-system/lib/production-status-colors.ts
new file mode 100644
index 0000000..bcaeb3e
--- /dev/null
+++ b/Frontend/erp-system/lib/production-status-colors.ts
@@ -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 = {
+ Waiting: "#9CA3AF",
+ Ready: "#3B82F6",
+ InProgress: "#F59E0B",
+ Done: "#22C55E",
+ Approved: "#14B8A6",
+}
+
+export const STAGE_STATUS_LABEL: Record = {
+ 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"
diff --git a/Frontend/erp-system/package.json b/Frontend/erp-system/package.json
index 63efa97..0c624e3 100644
--- a/Frontend/erp-system/package.json
+++ b/Frontend/erp-system/package.json
@@ -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",
diff --git a/Frontend/erp-system/types/dashboard.ts b/Frontend/erp-system/types/dashboard.ts
new file mode 100644
index 0000000..7e9857a
--- /dev/null
+++ b/Frontend/erp-system/types/dashboard.ts
@@ -0,0 +1,20 @@
+// Dashboard overview types. Mirrors Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs —
+// a single computed-on-read aggregate object, not a paged list.
+
+export interface WarehouseValuation {
+ warehouseId: number
+ total: number
+}
+
+export interface DashboardStats {
+ lowStockAlerts: number
+ onHandTotal: number
+ onHandWarehouses: number
+ stockValuationTotal: number
+ stockValuationByWarehouse: WarehouseValuation[]
+ pendingApprovalPurchaseOrders: number
+ pendingGrns: number
+ openRequisitions: number
+ pendingCounts: number
+ openRfqs: number
+}
diff --git a/Frontend/erp-system/types/production.ts b/Frontend/erp-system/types/production.ts
new file mode 100644
index 0000000..8b71056
--- /dev/null
+++ b/Frontend/erp-system/types/production.ts
@@ -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
+}
diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md
index fbcf01b..5c04b97 100644
--- a/docs/11-BACKEND-PHASE1.md
+++ b/docs/11-BACKEND-PHASE1.md
@@ -713,6 +713,28 @@ Items at/below ROP (FR-STK-10); computed on read, no stored entity.
```
`POST /stock/reorder-alerts/{itemId}/requisition?warehouseId=1` → creates a draft requisition for the suggested qty.
+### 5.8 Dashboard overview (added 2026-07-28)
+#### `GET /dashboard/stats`
+Cross-domain aggregate counts for the dashboard UI — a single computed-on-read object, not a stored entity or a `PagedResponse` list (same posture as reorder alerts, §5.7). `lowStockAlerts` reuses `IReorderService.GetAlertsAsync` rather than re-deriving the FIFO-available-vs-reorder-point comparison; `onHandTotal`/`onHandWarehouses`/`stockValuationTotal`/`stockValuationByWarehouse` are SQL-side `SUM`/`GROUP BY` over `StockLayer` (cheap — unlike reorder alerts, they need no per-item live lookup).
+```json
+{
+ "lowStockAlerts": 6,
+ "onHandTotal": 15420,
+ "onHandWarehouses": 3,
+ "stockValuationTotal": 4820500.00,
+ "stockValuationByWarehouse": [
+ { "warehouseId": 1, "total": 3120000.00 },
+ { "warehouseId": 2, "total": 1700500.00 }
+ ],
+ "pendingApprovalPurchaseOrders": 2,
+ "pendingGrns": 4,
+ "openRequisitions": 5,
+ "pendingCounts": 1,
+ "openRfqs": 3
+}
+```
+`pendingApprovalPurchaseOrders` = PO status `PendingApproval`; `pendingGrns` = GRN status `Draft`; `openRequisitions` = Requisition status `Submitted`; `pendingCounts` = StockCount status `Counted` (counted but not yet posted); `openRfqs` = RFQ status `Open`. **Not covered here:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it yet) and recent stock movements (just call `GET /stock/ledger` directly with a small `pageSize` — no aggregation needed).
+
---
## 6. Reference Data
diff --git a/docs/21-FRONTEND-PHASE2.md b/docs/21-FRONTEND-PHASE2.md
new file mode 100644
index 0000000..c4c98c9
--- /dev/null
+++ b/docs/21-FRONTEND-PHASE2.md
@@ -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:** §§1–4 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`.*