No formula evaluations.
+ return No formula evaluations.
}
return (
-
- {rows.map((f, idx) => {
- const inputEntries = Object.entries(f.inputs ?? {})
- return (
-
-
-
-
-
- {f.formulaCode}
-
- {f.formulaName}
-
-
- → {f.outputParamCode}
-
-
-
-
-
- {f.expression}
-
- {inputEntries.length > 0 && (
-
- Inputs:
- {inputEntries.map(([k, v], i) => (
-
- {k}
- =
- {v}
- {i < inputEntries.length - 1 ? "" : ""}
-
- ))}
-
- )}
-
- Output:
-
- {formatNumeric(f.outputValue)}
-
-
-
-
- )
- })}
+
+ {rows.map((f, i) => (
+
+ ))}
+
+ )
+}
+
+function FormulaCard({ formula: f }: { formula: FormulaEval }) {
+ const inputEntries = Object.entries(f.inputs ?? {})
+ return (
+
+ {/* Card header row — code + output param */}
+
+
+
+ {f.formulaCode}
+
+ {f.formulaName && (
+
+ · {f.formulaName}
+
+ )}
+
+
+
+
{f.outputParamCode}
+
+
+
+ {/* Card body */}
+
+ {/* Expression */}
+
+ {f.expression}
+
+
+ {/* Inputs — compact inline pairs */}
+ {inputEntries.length > 0 && (
+
+ Inputs:
+ {inputEntries.map(([k, v]) => (
+
+ {k}
+ =
+ {v}
+
+ ))}
+
+ )}
+
+ {/* Output */}
+
+ Output
+
+ {formatNumeric(f.outputValue)}
+
+
+
+
+ )
+}
+
+// ── Shared ────────────────────────────────────────────────────────────────────
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
)
}
diff --git a/src/components/finance/cost-results/cost-history-tab.tsx b/src/components/finance/cost-results/cost-history-tab.tsx
index 6dde1ef..90c075d 100644
--- a/src/components/finance/cost-results/cost-history-tab.tsx
+++ b/src/components/finance/cost-results/cost-history-tab.tsx
@@ -2,7 +2,6 @@
import { useState } from "react"
-import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
@@ -12,6 +11,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
+import { StatusBadge } from "@/components/common/status-badge"
+import { UserName } from "@/components/common/user-name"
import { useCostHistory } from "@/hooks/finance/use-cost-calc"
import type { CalculationType } from "@/types/finance/cost-calc"
@@ -19,7 +20,7 @@ import { formatDate, formatNumeric } from "./format"
interface Props {
productSysId: number
- calcType?: CalculationType // optional filter; undefined = all types
+ calcType?: CalculationType
}
export function CostHistoryTab({ productSysId, calcType }: Props) {
@@ -31,57 +32,67 @@ export function CostHistoryTab({ productSysId, calcType }: Props) {
})
if (isLoading) {
- return
Loading…
+ return (
+
+ Loading…
+
+ )
}
if (!data || data.items.length === 0) {
- return
No history yet.
+ return
No history yet.
}
return (
-
-
-
- Period
- Type
- Version
- Cost per unit
- Variance
- Status
- Calculated
- By
-
-
-
- {data.items.map((row) => (
-
- {row.period}
- {row.calculationType}
- v{row.version}
-
- {formatNumeric(row.costPerUnit)}
-
-
-
-
-
-
- {row.status}
-
-
- {formatDate(row.calculatedAt)}
- {row.calculatedBy}
+
+
+
+
+ Period
+ Type
+ Version
+ Cost per unit
+ Variance
+ Status
+ Calculated
+ By
- ))}
-
-
+
+
+ {data.items.map((row) => (
+
+ {row.period}
+ {row.calculationType}
+ v{row.version}
+
+ {formatNumeric(row.costPerUnit)}
+
+
+
+
+
+
+
+ {formatDate(row.calculatedAt)}
+
+ {row.calculatedBy ? (
+
+ ) : (
+ "—"
+ )}
+
+
+ ))}
+
+
+
{data.totalPages > 1 && (
Page {page} of {data.totalPages}
-
+
+ return (
+
+ )
}
+
if (!result) {
return (
-
- No active cost result for {period} / {calcType}. Trigger a calc job to compute
- one.
+
+
+
+
+
router.push("/finance/calc-jobs")}>
+ Go to calc jobs
+
+ }
+ />
)
}
- const canVerify =
- result.status === "CALCULATED" && hasPermission("finance.cost.result.verify")
- const canApprove =
- result.status === "VERIFIED" && hasPermission("finance.cost.result.approve")
+ const canVerify = result.status === "CALCULATED" && hasPermission("finance.cost.result.verify")
+ const canApprove = result.status === "VERIFIED" && hasPermission("finance.cost.result.approve")
return (
-
-
-
-
- {result.productCode}
-
- {result.productName}
-
-
-
-
-
-
-
-
-
- {result.status}} />
-
-
-
-
-
- {canVerify && (
-
- )}
- {canApprove && (
-
- )}
-
-
-
-
-
-
- History (all versions)
-
-
-
-
-
+
+
+
+
+
+ {/* Left column */}
+
+
+ {/* Main cost card */}
+
+
+
+
+
{productCode}
+ {productName && (
+
{productName}
+ )}
+
+
+
+
+
+
+
+
+ {result.currencyCode ? `${result.currencyCode} ` : ""}
+ {formatNumeric(result.costPerUnit)}
+
+
+
+
+ {formatNumeric(result.totalRmCost)}
+
+
+
+
+ {formatNumeric(result.totalConversion)}
+
+
+
+
+ {formatNumeric(result.totalCost)}
+
+
+
+
+
+
+
+ {canVerify && (
+
+ )}
+ {canApprove && (
+
+ )}
+
+
+
+
+
+ {/* History card */}
+
+
+ Calculation history
+
+
+
+
+
+
+
+ {/* Right column — audit sidebar */}
+
+
+
+ Audit trail
+
+
+
+ v{result.version}
+
+
+ {result.period}
+
+
+ {result.calculationType}
+
+ {result.uomCode && (
+
+ {result.uomCode}
+
+ )}
+
+ {result.calculatedAt && (
+
+ {formatDate(result.calculatedAt)}
+
+ )}
+ {result.calculatedBy && (
+
+
+
+ )}
+ {result.verifiedAt && (
+ <>
+
+
+ {formatDate(result.verifiedAt)}
+
+ >
+ )}
+ {result.verifiedBy && (
+
+
+
+ )}
+
+
+
+
- {label}
-
- {value}
-
+
+
{label}
+ {children}
)
}
diff --git a/src/components/finance/cost-results/cost-results-page-client.tsx b/src/components/finance/cost-results/cost-results-page-client.tsx
index 97e3253..51cac27 100644
--- a/src/components/finance/cost-results/cost-results-page-client.tsx
+++ b/src/components/finance/cost-results/cost-results-page-client.tsx
@@ -11,6 +11,8 @@ import { KpiCard, KpiGrid } from "@/components/common"
import { StatusBadge } from "@/components/common/status-badge"
import { UserName } from "@/components/common/user-name"
import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
@@ -31,8 +33,6 @@ interface FiltersState {
pageSize: number
}
-// Default view: latest period, ACTUAL, active rows — NO forced filters. Users
-// narrow from here. The backend resolves the latest period when none is given.
const defaultFilters: FiltersState = {
period: "",
calcType: "ACTUAL",
@@ -83,135 +83,201 @@ export function CostResultsPageClient() {
}, [items, totalItems])
return (
-
+
-
-
-
+
+
+
-
- setFilters({ ...filters, search, page: 1 })}
- placeholder="Search product code or name…"
- />
- setFilters({ ...filters, period, page: 1 })} />
-
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 8 }).map((_, i) => )}
-
- ) : items.length === 0 ? (
-
Go to calc jobs}
- />
- ) : (
-
-
-
-
- Product
- Type
- Cost / unit
- RM
- Conversion
- Total
- Status
- By
-
-
-
- {items.map((r) => (
-
-
-
- {r.productCode || `#${r.productSysId}`}
-
- {r.productName && (
- {r.productName}
- )}
-
- {r.calculationType}
- {fmtMoney(r.costPerUnit)}
- {fmtMoney(r.totalRmCost)}
- {fmtMoney(r.totalConversion)}
- {fmtMoney(r.totalCost)}
-
-
- {r.calculatedBy ? : "—"}
-
-
+
+
+
+ Result list
+
+ {isLoading ? "Loading…" : `${totalItems.toLocaleString()} total results`}
+
+
+
+
+
+ {/* Filter bar */}
+
+ setFilters({ ...filters, search, page: 1 })}
+ placeholder="Search product code or name…"
+ />
+ setFilters({ ...filters, period, page: 1 })}
+ />
+
+
+
+
+ {/* Table or empty states */}
+ {isLoading ? (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
))}
-
-
-
- )}
-
- {totalItems > 0 && (
- setFilters({ ...filters, page })}
- onPageSizeChange={(pageSize) => setFilters({ ...filters, pageSize, page: 1 })}
- />
- )}
+
+ ) : items.length === 0 ? (
+
+ Go to calc jobs
+
+ }
+ />
+ ) : (
+
+
+
+
+ Product
+ Type
+ Cost / unit
+ RM
+ Conversion
+ Total
+ Status
+ By
+
+
+
+ {items.map((r) => (
+
+
+
+ {r.productCode || `#${r.productSysId}`}
+
+ {r.productName && (
+ {r.productName}
+ )}
+
+
+ {r.calculationType}
+
+
+ {fmtMoney(r.costPerUnit)}
+
+
+ {fmtMoney(r.totalRmCost)}
+
+
+ {fmtMoney(r.totalConversion)}
+
+
+ {fmtMoney(r.totalCost)}
+
+
+
+
+
+ {r.calculatedBy ? (
+
+ ) : (
+ "—"
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {totalItems > 0 && (
+ setFilters({ ...filters, page })}
+ onPageSizeChange={(pageSize) => setFilters({ ...filters, pageSize, page: 1 })}
+ />
+ )}
+
+
)
}
-// Input6Period is a tiny YYYYMM text input that only commits 6-digit values.
+// Period input — 6-digit YYYYMM that only commits on blur when valid.
function Input6Period({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
- {
const v = e.target.value.trim()
if (v === "" || /^[0-9]{6}$/.test(v)) onChange(v)
}}
- className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
/>
)
}
diff --git a/src/components/finance/cost-route/route-graph-editor.tsx b/src/components/finance/cost-route/route-graph-editor.tsx
index 731ec71..0567706 100644
--- a/src/components/finance/cost-route/route-graph-editor.tsx
+++ b/src/components/finance/cost-route/route-graph-editor.tsx
@@ -3,7 +3,7 @@
// RouteGraphEditor — level-based editor for cost_route_seq + cost_route_rm.
//
// Model is level-based (1 = FG, 2..N = upstream). Each level renders as a row
-// of stage cards; each card lists its RM inputs (PRODUCT / ITEM / GROUP).
+// of stage cards; each card lists its RM inputs (PRODUCT / GROUP).
//
// **UX rule (enforced):** users NEVER see or type a UUID / sys_id. Every
// reference is picked via a combobox keyed on human-readable code/name.
@@ -24,7 +24,6 @@ import {
import Link from "next/link"
import { CalculateButton } from "@/components/finance/calc-jobs/calculate-button"
-import { ErpItemCombobox } from "@/components/finance/comboboxes/erp-item-combobox"
import { ProductMasterCombobox } from "@/components/finance/comboboxes/product-master-combobox"
import { DuplicateRouteDialog } from "@/components/finance/cost-route/duplicate-route-dialog"
import { LinkedRequestsPopover } from "@/components/finance/cost-route/linked-requests-popover"
@@ -45,6 +44,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
@@ -156,6 +156,7 @@ export function RouteGraphEditor({ headId }: Props) {
productSysId: number,
productCode?: string,
productName?: string,
+ meta?: { routeName?: string; routeItemCode?: string; routeShadeCode?: string; routeShadeName?: string },
): { newSeqIdx: number } => {
let newSeqIdx = -1
setWorking((prev) => {
@@ -170,6 +171,10 @@ export function RouteGraphEditor({ headId }: Props) {
productName,
routeLevel: level,
routeSeq: existing + 1,
+ routeName: meta?.routeName || undefined,
+ routeItemCode: meta?.routeItemCode || undefined,
+ routeShadeCode: meta?.routeShadeCode || undefined,
+ routeShadeName: meta?.routeShadeName || undefined,
positionX: 0,
positionY: 0,
rms: [],
@@ -206,6 +211,15 @@ export function RouteGraphEditor({ headId }: Props) {
setDirty(true)
}
+ const setHeadNotes = (notes: string) => {
+ setWorking((prev) => {
+ const base = prev ?? (persisted ? (JSON.parse(JSON.stringify(persisted)) as RouteGraph) : null)
+ if (!base) return prev
+ return { ...base, head: { ...base.head, notes } }
+ })
+ setDirty(true)
+ }
+
const deleteRm = (seqIdx: number, rmIdx: number) => {
setWorking((prev) => {
const base = prev ?? (persisted ? (JSON.parse(JSON.stringify(persisted)) as RouteGraph) : null)
@@ -361,6 +375,19 @@ export function RouteGraphEditor({ headId }: Props) {
version v{head?.version}
{head?.promotedFromDraftId ? · promoted from a routing draft : null}
+ {!locked && (
+
+
+ )}
+ {locked && head?.notes && (
+
{head.notes}
+ )}
@@ -593,8 +620,8 @@ export function RouteGraphEditor({ headId }: Props) {
onClose={() => setStageDialogState({ open: false })}
existingLevels={seqsByLevel.map((g) => g.level)}
defaultLevel={stageDialogState.defaultLevel}
- onAdd={(level, productSysId, productCode, productName) => {
- const { newSeqIdx } = addStage(level, productSysId, productCode, productName)
+ onAdd={(level, productSysId, productCode, productName, meta) => {
+ const { newSeqIdx } = addStage(level, productSysId, productCode, productName, meta)
// F3 link-after-add: if we opened the dialog by dropping on the pane,
// wire a PRODUCT-RM between the source seq and the newly-created seq.
if (stageDialogState.open && stageDialogState.linkToSeqId !== undefined && newSeqIdx >= 0) {
@@ -749,12 +776,16 @@ function AddStageDialog({
onClose: () => void
existingLevels: number[]
defaultLevel?: number
- onAdd: (level: number, productSysId: number, productCode?: string, productName?: string) => void
+ onAdd: (level: number, productSysId: number, productCode?: string, productName?: string, meta?: { routeName?: string; routeItemCode?: string; routeShadeCode?: string; routeShadeName?: string }) => void
}) {
const nextLevel =
defaultLevel ?? (existingLevels.length === 0 ? 1 : Math.max(...existingLevels) + 1)
const [level, setLevel] = useState(nextLevel)
const [picked, setPicked] = useState<{ id: number; code: string; name: string } | null>(null)
+ const [routeName, setRouteName] = useState("")
+ const [routeItemCode, setRouteItemCode] = useState("")
+ const [routeShadeCode, setRouteShadeCode] = useState("")
+ const [routeShadeName, setRouteShadeName] = useState("")
return (
+
+ Additional metadata (optional)
+
+
-
@@ -825,17 +889,18 @@ function AddRmDialog({
upstreamProducts: UpstreamProduct[]
onAdd: (rm: CostRouteRm) => void
}) {
- const [rmType, setRmType] = useState
("ITEM")
+ const [rmType, setRmType] = useState("PRODUCT")
const [productPick, setProductPick] = useState(null)
- const [itemPick, setItemPick] = useState<{ code: string; name: string } | null>(null)
const [groupPick, setGroupPick] = useState<{ code: string; name: string } | null>(null)
const [ratio, setRatio] = useState("1")
const [subType, setSubType] = useState("")
+ const [shadeCode, setShadeCode] = useState("")
+ const [shadeName, setRmShadeName] = useState("")
+ const [notes, setNotes] = useState("")
const isValid =
Number(ratio) > 0 &&
((rmType === "PRODUCT" && !!productPick) ||
- (rmType === "ITEM" && !!itemPick) ||
(rmType === "GROUP" && !!groupPick))
return (
@@ -852,7 +917,6 @@ function AddRmDialog({
onValueChange={(v) => {
setRmType(v as RmRefType)
setProductPick(null)
- setItemPick(null)
setGroupPick(null)
}}
>
@@ -861,8 +925,7 @@ function AddRmDialog({
PRODUCT — from another stage in this routing
- ITEM — ERP raw material
- GROUP — RM group
+ GROUP — RM group (cons/stock/PO)
@@ -883,22 +946,6 @@ function AddRmDialog({
)}
- {rmType === "ITEM" && (
-