feat: implement frontend-only mock for production lines and runs
- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow. - Create LineHeaderNode and LineStageNode components for rendering production line nodes. - Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers. - Implement StageProgressStrip for visualizing stage progress in runs. - Create mock data for production runs and templates to simulate backend functionality. - Define types for production templates and runs to structure mock data. - Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
This commit is contained in:
@@ -11,16 +11,19 @@ import {
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
Factory,
|
||||
FileBarChart,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
ListTree,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
PlayCircle,
|
||||
Ruler,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -89,6 +92,18 @@ const navItems: {
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "Production",
|
||||
code: "production",
|
||||
href: "/dashboard/production",
|
||||
landingHref: "/dashboard/production/runs",
|
||||
icon: Factory,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "HRM",
|
||||
code: "hrm",
|
||||
@@ -333,7 +348,7 @@ export function AppSidebar() {
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "hrm"])
|
||||
const bypassCodes = new Set(["procurement", "hrm", "production"])
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
|
||||
@@ -35,6 +35,9 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
rfqs: "RFQs",
|
||||
"purchase-orders": "Purchase Orders",
|
||||
"purchase-returns": "Purchase Returns",
|
||||
production: "Production",
|
||||
templates: "Templates",
|
||||
runs: "Runs",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface LineHeaderData extends Record<string, unknown> {
|
||||
templateId: number
|
||||
docNo: string
|
||||
name: string
|
||||
status: "Active" | "Inactive"
|
||||
activeRunCount: number
|
||||
}
|
||||
|
||||
/** Row label docked at the left of each production line — the template itself. */
|
||||
function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) {
|
||||
return (
|
||||
<div className="flex w-56 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
data.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
{data.activeRunCount > 0 && (
|
||||
<span className="w-fit rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
{data.activeRunCount} in progress
|
||||
</span>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface LineStageData extends Record<string, unknown> {
|
||||
templateId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/** One stage on a production line — read-only, purely a visual chip on the overview canvas. */
|
||||
function LineStageNode({ data }: NodeProps & { data: LineStageData }) {
|
||||
return (
|
||||
<div className="w-36 rounded-xl bg-primary/10 px-3 py-2.5 text-center shadow-sm ring-1 ring-primary/20">
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
<p className="truncate text-sm font-semibold text-primary">{data.name}</p>
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LineHeaderNodeComponent = memo(LineHeaderNode)
|
||||
export const LineStageNodeComponent = memo(LineStageNode)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RunStatus } from "@/types/production"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
|
||||
|
||||
export interface RunHeaderData extends Record<string, unknown> {
|
||||
docNo: string
|
||||
templateName: string
|
||||
status: RunStatus
|
||||
}
|
||||
|
||||
/** Left-most box on a run's production line — the run itself, not a stage. */
|
||||
function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) {
|
||||
return (
|
||||
<div className="flex w-48 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<p className="truncate text-sm font-bold text-foreground">{data.templateName}</p>
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RunStageData extends Record<string, unknown> {
|
||||
name: string
|
||||
state: StageStatus
|
||||
isActive: boolean
|
||||
/** Present only on the current (leftmost incomplete) stage of an InProgress run. */
|
||||
onAdvance?: () => void
|
||||
}
|
||||
|
||||
/** One stage on a run's production line, colored by its live status — the box the "give
|
||||
* progress" action lives on: the active stage grows an Advance button to push it forward. */
|
||||
function RunStageNode({ data }: NodeProps & { data: RunStageData }) {
|
||||
const color = STAGE_STATUS_COLOR[data.state]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-44 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
|
||||
data.isActive ? "ring-offset-2 ring-offset-background" : "ring-foreground/10"
|
||||
)}
|
||||
style={data.isActive ? { boxShadow: `0 0 0 2px ${color}` } : undefined}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium" style={{ color }}>{STAGE_STATUS_LABEL[data.state]}</p>
|
||||
|
||||
{data.onAdvance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onAdvance?.()
|
||||
}}
|
||||
className="nodrag mt-2 flex w-full items-center justify-center gap-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Advance
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RunHeaderNodeComponent = memo(RunHeaderNode)
|
||||
export const RunStageNodeComponent = memo(RunStageNode)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RUN_CANCELLED_COLOR,
|
||||
RUN_COMPLETED_COLOR,
|
||||
STAGE_STATUS_COLOR,
|
||||
STAGE_STATUS_LABEL,
|
||||
STAGE_STATUS_ORDER,
|
||||
} from "@/lib/production-status-colors"
|
||||
import { RunStatus, StageSummary } from "@/types/production"
|
||||
|
||||
/**
|
||||
* One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render
|
||||
* a full teal strip + check; cancelled runs get a red accent instead of per-stage segments.
|
||||
*/
|
||||
export function StageProgressStrip({
|
||||
status,
|
||||
summary,
|
||||
className,
|
||||
}: {
|
||||
status: RunStatus
|
||||
summary: StageSummary
|
||||
className?: string
|
||||
}) {
|
||||
if (status === "Completed") {
|
||||
return (
|
||||
<div className={cn("flex h-2.5 items-center gap-1.5 rounded-full", className)}>
|
||||
<div className="h-2.5 flex-1 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
|
||||
<CheckCircle2 className="size-4 shrink-0" style={{ color: RUN_COMPLETED_COLOR }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {
|
||||
Waiting: summary.waiting,
|
||||
Ready: summary.ready,
|
||||
InProgress: summary.inProgress,
|
||||
Done: summary.done,
|
||||
Approved: summary.approved,
|
||||
}
|
||||
const total = STAGE_STATUS_ORDER.reduce((sum, key) => sum + counts[key], 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||
style={status === "Cancelled" ? { boxShadow: `0 0 0 2px ${RUN_CANCELLED_COLOR}` } : undefined}
|
||||
>
|
||||
{total === 0
|
||||
? null
|
||||
: STAGE_STATUS_ORDER.map((key) => {
|
||||
const count = counts[key]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
title={`${STAGE_STATUS_LABEL[key]}: ${count}`}
|
||||
style={{ backgroundColor: STAGE_STATUS_COLOR[key], width: `${(count / total) * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StageStatusLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5", className)}>
|
||||
{STAGE_STATUS_ORDER.map((key) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[key] }} />
|
||||
{STAGE_STATUS_LABEL[key]}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_CANCELLED_COLOR }} />
|
||||
Cancelled
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
|
||||
Completed
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user