feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates. - Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs. - Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
This commit is contained in:
@@ -341,9 +341,9 @@ export function AppSidebar() {
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement" and "hrm" are exempted from that check (frontend-only): no role is
|
||||
// currently seeded with NAV:procurement/NAV:hrm or their children server-side, which
|
||||
// would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// "procurement", "hrm" and "production" are exempted from that check (frontend-only): no
|
||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
|
||||
@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
export interface LineHeaderData extends Record<string, unknown> {
|
||||
templateId: number
|
||||
docNo: string
|
||||
/** The template's `code` (e.g. `PT-CHAIR`). Templates carry a code; only runs get a doc no. */
|
||||
code: string
|
||||
name: string
|
||||
status: "Active" | "Inactive"
|
||||
activeRunCount: number
|
||||
@@ -16,7 +17,7 @@ function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) {
|
||||
return (
|
||||
<div className="flex w-56 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.code}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { Flag, PackageCheck, Timer } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RunStatus } from "@/types/production"
|
||||
import { ProductionRunStatus } from "@/types/production"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
|
||||
|
||||
export interface RunHeaderData extends Record<string, unknown> {
|
||||
docNo: string
|
||||
templateName: string
|
||||
status: RunStatus
|
||||
status: ProductionRunStatus
|
||||
}
|
||||
|
||||
/** Left-most box on a run's production line — the run itself, not a stage. */
|
||||
@@ -25,45 +25,96 @@ function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) {
|
||||
|
||||
export interface RunStageData extends Record<string, unknown> {
|
||||
name: string
|
||||
roleLabel: string | null
|
||||
state: StageStatus
|
||||
isTerminal: boolean
|
||||
isEntry: boolean
|
||||
estimatedMinutes: number
|
||||
/** Whole minutes once finished; null while still running (FR-MFG-19). */
|
||||
actualMinutes: number | null
|
||||
/** Set once started — drives the "running" hint while actualMinutes is still null. */
|
||||
actualStartAt: string | null
|
||||
/**
|
||||
* Aggregate intake across this stage's Upstream inputs, or null when it has none (an entry
|
||||
* stage draws entirely from stock). `delivered >= planned` is exactly the readiness rule the
|
||||
* server applies, so the badge doubles as an explanation of why a stage is still Waiting.
|
||||
*/
|
||||
intake: { delivered: number; planned: number } | null
|
||||
/** Σ availableToTransfer across outputs — WIP produced but not yet pushed downstream. */
|
||||
availableToTransfer: number
|
||||
/** Marks the stage the operator is expected to act on next. */
|
||||
isActive: boolean
|
||||
/** Present only on the current (leftmost incomplete) stage of an InProgress run. */
|
||||
onAdvance?: () => void
|
||||
}
|
||||
|
||||
/** One stage on a run's production line, colored by its live status — the box the "give
|
||||
* progress" action lives on: the active stage grows an Advance button to push it forward. */
|
||||
function RunStageNode({ data }: NodeProps & { data: RunStageData }) {
|
||||
function fmt(n: number): string {
|
||||
// Quantities are decimal(18,4) server-side; trailing zeros just add noise on a canvas card.
|
||||
return Number(n.toFixed(4)).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
|
||||
* from the template at creation) and colored by its live status. Clicking it opens the stage
|
||||
* drawer — handled by the page via `onNodeClick`, not here.
|
||||
*/
|
||||
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
|
||||
const color = STAGE_STATUS_COLOR[data.state]
|
||||
const running = data.actualStartAt !== null && data.actualMinutes === null
|
||||
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-44 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
|
||||
data.isActive ? "ring-offset-2 ring-offset-background" : "ring-foreground/10"
|
||||
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
|
||||
selected ? "ring-primary" : "ring-foreground/10"
|
||||
)}
|
||||
style={data.isActive ? { boxShadow: `0 0 0 2px ${color}` } : undefined}
|
||||
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${color}` } : undefined}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
{data.isTerminal && (
|
||||
<span title="Final stage" className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
<Flag className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium" style={{ color }}>{STAGE_STATUS_LABEL[data.state]}</p>
|
||||
|
||||
{data.onAdvance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onAdvance?.()
|
||||
}}
|
||||
className="nodrag mt-2 flex w-full items-center justify-center gap-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium" style={{ color }}>
|
||||
{STAGE_STATUS_LABEL[data.state]}
|
||||
</span>
|
||||
{data.roleLabel && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">{data.roleLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Timer className="size-3" />
|
||||
{data.actualMinutes !== null
|
||||
? `${data.actualMinutes} / ${data.estimatedMinutes} min`
|
||||
: running
|
||||
? `running · est. ${data.estimatedMinutes} min`
|
||||
: `est. ${data.estimatedMinutes} min`}
|
||||
</div>
|
||||
|
||||
{data.intake && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 rounded-md px-1.5 py-0.5 text-xs font-medium",
|
||||
intakeShort ? "bg-warning/10 text-warning" : "bg-success/10 text-success"
|
||||
)}
|
||||
>
|
||||
Advance
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
Intake {fmt(data.intake.delivered)} / {fmt(data.intake.planned)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.availableToTransfer > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1 rounded-md bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
<PackageCheck className="size-3" />
|
||||
{fmt(data.availableToTransfer)} to transfer
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
STAGE_STATUS_LABEL,
|
||||
STAGE_STATUS_ORDER,
|
||||
} from "@/lib/production-status-colors"
|
||||
import { RunStatus, StageSummary } from "@/types/production"
|
||||
import { ProductionRunStatus, StageSummary } from "@/types/production"
|
||||
|
||||
/**
|
||||
* One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render
|
||||
@@ -19,7 +19,7 @@ export function StageProgressStrip({
|
||||
summary,
|
||||
className,
|
||||
}: {
|
||||
status: RunStatus
|
||||
status: ProductionRunStatus
|
||||
summary: StageSummary
|
||||
className?: string
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user