= {}
+ for (const p of params) {
+ const g = p.displayGroup ?? ""
+ const cur = groupMinOrder[g]
+ if (cur === undefined || p.displayOrder < cur) {
+ groupMinOrder[g] = p.displayOrder
+ }
+ }
+ const sorted = Object.keys(groupMinOrder).sort((a, b) => {
+ if (a === "" && b !== "") return 1
+ if (b === "" && a !== "") return -1
+ return (groupMinOrder[a] ?? 0) - (groupMinOrder[b] ?? 0)
+ })
+ return sorted.map((g) => ({
+ name: g,
+ params: params
+ .filter((p) => (p.displayGroup ?? "") === g)
+ .sort((a, b) => a.displayOrder - b.displayOrder || a.paramCode.localeCompare(b.paramCode)),
+ }))
+ }, [params])
+
+ return (
+
+ {groups.map((group, i) => (
+
+ {group.name && (
+
0 ? "mt-1" : ""}`}>
+
+ {group.name}
+
+
+
+ )}
+
+ {group.params.map((p) => (
+
+
+ {p.paramCode}
+
+ {p.hasValue ? (
+
+ {p.dataType === "NUMBER"
+ ? p.valueNumeric
+ : p.dataType === "BOOLEAN"
+ ? p.valueFlag
+ ? "true"
+ : "false"
+ : p.valueText}
+ {p.uomCode ? ` ${p.uomCode}` : ""}
+
+ ) : (
+ ——
+ )}
+
+ ))}
+
+
+ ))}
+
+ )
+}
+
function taskBadge(status: string) {
switch (status) {
case "APPROVED":
@@ -106,32 +173,7 @@ function LevelSection({
-
- {level.params.map((p) => (
-
-
- {p.paramCode}
-
- {p.hasValue ? (
-
- {p.dataType === "NUMBER"
- ? p.valueNumeric
- : p.dataType === "BOOLEAN"
- ? p.valueFlag
- ? "true"
- : "false"
- : p.valueText}
- {p.uomCode ? ` ${p.uomCode}` : ""}
-
- ) : (
- ——
- )}
-
- ))}
-
+
{level.filledByUserId && (
@@ -185,12 +227,6 @@ export function ParamDetailDrawer({ open, onClose, requestId, product, canEdit,
Back
-
-
-
- Close
-
-
diff --git a/src/components/finance/cost-product-request/param-edit-log-drawer.tsx b/src/components/finance/cost-product-request/param-edit-log-drawer.tsx
index d5c9d66..8b5a071 100644
--- a/src/components/finance/cost-product-request/param-edit-log-drawer.tsx
+++ b/src/components/finance/cost-product-request/param-edit-log-drawer.tsx
@@ -1,8 +1,8 @@
"use client"
-import { ArrowLeft, X } from "lucide-react"
+import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
-import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"
+import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { useParamEditLog } from "@/hooks/finance/use-param-summary"
@@ -53,12 +53,6 @@ export function ParamEditLogDrawer({ open, onClose, requestId, routeLevel, produ
Back
-
-
-
- Close
-
-
diff --git a/src/components/finance/cost-product-request/param-summary-panel.tsx b/src/components/finance/cost-product-request/param-summary-panel.tsx
index 1cb694c..9341bde 100644
--- a/src/components/finance/cost-product-request/param-summary-panel.tsx
+++ b/src/components/finance/cost-product-request/param-summary-panel.tsx
@@ -97,8 +97,9 @@ export function ParamSummaryPanel({ requestId, routeLocked = false }: Props) {
>
{product.productCode}
+ {product.productName && {product.productName} }
- {product.levels.length} level{product.levels.length !== 1 ? "s" : ""} · {productFilled}/{productTotal} params
+ Level {product.levels.map(l => l.routeLevel).sort((a,b)=>a-b).join(", ")} · {productFilled}/{productTotal} params
{fillStatusBadge(productFilled, productTotal, hasRejected)}
diff --git a/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx b/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx
deleted file mode 100644
index 80b2f22..0000000
--- a/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import { useState } from "react"
-
-import { ProductMasterCombobox } from "@/components/finance/comboboxes/product-master-combobox"
-import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Label } from "@/components/ui/label"
-import { useRouteByProduct } from "@/hooks/finance/use-cost-route"
-
-interface Props {
- open: boolean
- onClose: () => void
- onPick: (headId: number) => void
-}
-
-export function PickExistingRouteDialog({ open, onClose, onPick }: Props) {
- const [productSysId, setProductSysId] = useState()
- const [productCode, setProductCode] = useState("")
- const [productName, setProductName] = useState("")
- const { data: head } = useRouteByProduct(productSysId)
-
- return (
- !o && onClose()}>
-
-
- Pick existing product with route
-
-
-
-
Product (must already have an active route)
-
{
- setProductSysId(id)
- setProductCode(code)
- setProductName(name)
- }}
- placeholder="Search product by code or name…"
- />
-
- {productSysId && (
-
- {head ? (
- <>
-
- Route #{head.headId} · {head.routingStatus}
-
-
- {productCode} — {productName}
-
- >
- ) : (
-
This product does not have a route yet.
- )}
-
- )}
-
-
-
- Cancel
-
- head && onPick(head.headId)}>
- Link
-
-
-
-
- )
-}
diff --git a/src/components/finance/cost-product-request/request-detail-panel.tsx b/src/components/finance/cost-product-request/request-detail-panel.tsx
index 559acdc..2c1adcf 100644
--- a/src/components/finance/cost-product-request/request-detail-panel.tsx
+++ b/src/components/finance/cost-product-request/request-detail-panel.tsx
@@ -32,18 +32,14 @@ import { RoutingPanel } from "./routing-panel"
import { StatusBadge } from "./status-badge"
import { ParamSummaryPanel } from "./param-summary-panel"
import {
+ ClassificationAndFeasibilityDialog,
CloseDialog,
ConfirmActionDialog,
- FeasibilityDialog,
ReasonDialog,
- UseExistingCostingDialog,
- VerifyClassificationDialog,
} from "./transition-dialogs"
import {
useApproveRequest,
- useCancelRequest,
useConfirmRequest,
- useDecideFeasibility,
useMarkParameterComplete,
useMarkParameterPending,
useReleaseRequest,
@@ -51,9 +47,6 @@ import {
useReopenRequest,
useRejectRequest,
useStartReview,
- useSubmitRequest,
- useUseExistingCosting,
- useVerifyClassification,
useCloseRequest,
} from "@/hooks/finance/use-cost-product-request"
import type { CostProductRequest } from "@/types/finance/cost-product-request"
@@ -72,7 +65,11 @@ interface Props {
hasFillTracking?: boolean
}
-type DialogKind = "reject" | "cancel" | "verify" | "feasibility" | "close" | "useExisting" | "confirmAction" | null
+// "submitDecide" — B3 merge (design.md §3 B3): the DRAFT "Submit" button now opens
+// ClassificationAndFeasibilityDialog in mode="submit" instead of firing a bare
+// useSubmitRequest() mutation. "reviewDecide" (mode="review", the UNDER_REVIEW flow)
+// is unchanged and stays a separate dialog kind since its retry semantics differ.
+type DialogKind = "reject" | "reviewDecide" | "submitDecide" | "close" | "confirmAction" | null
export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, hasFillTracking = false }: Props) {
useCPRRealtimeSync(request.requestId)
@@ -80,15 +77,10 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
const [dialog, setDialog] = useState(null)
const [confirmActionType, setConfirmActionType] = useState<"confirm" | "approve" | "release">("confirm")
- const submitM = useSubmitRequest()
const startM = useStartReview()
const reviseM = useReviseRequest()
const reopenM = useReopenRequest()
- const useExistingM = useUseExistingCosting()
- const verifyM = useVerifyClassification()
- const feasibilityM = useDecideFeasibility()
const rejectM = useRejectRequest()
- const cancelM = useCancelRequest()
const closeM = useCloseRequest()
const markPendingM = useMarkParameterPending()
const markCompleteM = useMarkParameterComplete()
@@ -176,7 +168,7 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
)}
{isDraft && canSubmit && (
- submitM.mutate({ requestId })} disabled={submitM.isPending}>
+ setDialog("submitDecide")}>
Submit
)}
@@ -193,25 +185,9 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
Promote route
)}
- {isUnderReview && canResolve && !request.verifiedClassification && (
- setDialog("verify")}>
- Verify classification
-
- )}
{isUnderReview && canResolve && (
- setDialog("feasibility")} disabled={feasibilityM.isPending}>
- Decide feasibility
-
- )}
- {isUnderReview && canResolve &&
- (request.verifiedClassification === "existing" ||
- (!request.verifiedClassification && request.productClassification === "existing")) && (
- setDialog("useExisting")}
- disabled={useExistingM.isPending}
- >
- Use existing costing
+ setDialog("reviewDecide")}>
+ Review & decide
)}
{(isSubmitted || isUnderReview) && canReject && (
@@ -273,9 +249,6 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
setDialog("close")}>
Close
- setDialog("cancel")}>
- Cancel
-
>
)}
@@ -317,38 +290,30 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
{request.targetPriceRange || "—"}
- {request.description && (
-
-
Description
-
{request.description}
-
- )}
-
- {request.spec && (
-
-
- Product specification
-
-
- {request.spec.rawMaterialType}
-
- {request.spec.weightPerBobbinKg} kg
- {request.spec.boxType}
- {request.spec.shadeCustomText || `master #${request.spec.shadeId ?? "—"}`}
-
-
-
- {request.spec.productDescription}
+
+
+ Product specification
+
+
+ {request.spec && (
+
+
+
+
{request.spec.shadeCode || `master #${request.spec.shadeId ?? "—"}`}
+
{request.spec.shadeName || "—"}
-
-
- )}
+ )}
+
+ {request.spec?.productDescription || request.description || "—"}
+
+
+
+
+
{(request.classificationOverrideReason || request.feasibilityDecision) && (
@@ -497,47 +462,22 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false,
rejectM.mutate({ requestId, body: { reason } }, { onSuccess: () => setDialog(null) })
}}
/>
- setDialog(o ? "cancel" : null)}
- title="Cancel request"
- description="Cancelling closes the request with sub-status = cancelled. Provide a reason."
- confirmLabel="Cancel request"
- pending={cancelM.isPending}
- onConfirm={(reason) => {
- cancelM.mutate({ requestId, body: { reason } }, { onSuccess: () => setDialog(null) })
- }}
- />
- setDialog(o ? "verify" : null)}
+ setDialog(o ? "reviewDecide" : null)}
+ requestId={requestId}
currentClassification={request.productClassification}
- pending={verifyM.isPending}
- onConfirm={(verified, overrideReason) => {
- verifyM.mutate(
- { requestId, verifiedClassification: verified, overrideReason },
- { onSuccess: () => setDialog(null) },
- )
- }}
+ initialVerifiedClassification={request.verifiedClassification}
+ referenceProductSysId={request.referenceProductSysId}
/>
- setDialog(o ? "feasibility" : null)}
- pending={feasibilityM.isPending}
- onConfirm={(decision, note) => {
- feasibilityM.mutate({ requestId, decision, note }, { onSuccess: () => setDialog(null) })
- }}
- />
- setDialog(o ? "useExisting" : null)}
- pending={useExistingM.isPending}
- onConfirm={(existingProductSysId) => {
- useExistingM.mutate(
- { requestId, body: { existingProductSysId } },
- { onSuccess: () => setDialog(null) },
- )
- }}
+ setDialog(o ? "submitDecide" : null)}
+ requestId={requestId}
+ currentClassification={request.productClassification}
+ initialVerifiedClassification={request.verifiedClassification}
+ referenceProductSysId={request.referenceProductSysId}
+ mode="submit"
/>
@@ -60,67 +62,46 @@ interface Props {
const DEFAULTS: FormValues = {
requestTypeId: 0,
title: "",
- description: "",
customerName: "",
customerCode: "",
- productClassification: "existing",
urgencyLevel: "medium",
neededByDate: "",
targetVolume: "",
targetPriceRange: "",
- specRawMaterial: "",
+ referenceProductSysId: undefined,
specProductDescription: "",
- specShadeCustomText: "",
- specPaperTubeTypeId: 0,
- specWeightPerBobbinKg: "",
- specBoxType: "",
+ specShadeCode: "",
+ specShadeName: "",
+ specTubeType: TubeType.TUBE_TYPE_UNSPECIFIED,
}
export function RequestFormDialog({ open, onOpenChange, request }: Props) {
const isEditing = !!request && request.status === "DRAFT"
const createMutation = useCreateCostProductRequest()
const updateMutation = useUpdateCostProductRequest()
- const { data: requestTypes } = useCostRequestTypes()
const form = useForm({
resolver: zodResolver(schema) as never,
defaultValues: DEFAULTS,
})
- const productClassification = form.watch("productClassification")
- const requestTypeId = form.watch("requestTypeId")
- const selectedType = useMemo(
- () => (requestTypes ?? []).find((t) => t.typeId === requestTypeId),
- [requestTypes, requestTypeId],
- )
-
- // DEVELOPMENT always implies new classification (FR-1).
- useEffect(() => {
- if (selectedType?.code === "DEVELOPMENT" && form.getValues("productClassification") !== "new") {
- form.setValue("productClassification", "new")
- }
- }, [selectedType, form])
-
useEffect(() => {
if (!open) return
if (request) {
form.reset({
requestTypeId: request.requestTypeId,
title: request.title,
- description: request.description || "",
customerName: request.customerName,
customerCode: request.customerCode || "",
- productClassification: request.productClassification,
urgencyLevel: request.urgencyLevel,
neededByDate: request.neededByDate || "",
targetVolume: request.targetVolume || "",
targetPriceRange: request.targetPriceRange || "",
- specRawMaterial: (request.spec?.rawMaterialType as RawMaterialType) || "",
+ referenceProductSysId: request.referenceProductSysId || undefined,
specProductDescription: request.spec?.productDescription || "",
- specShadeCustomText: request.spec?.shadeCustomText || "",
- specPaperTubeTypeId: request.spec?.paperTubeTypeId || 0,
- specWeightPerBobbinKg: request.spec?.weightPerBobbinKg || "",
- specBoxType: (request.spec?.boxType as "JUMBO" | "NORMAL" | "PALLET") || "",
+ specShadeCode: request.spec?.shadeCode || "",
+ specShadeName: request.spec?.shadeName || "",
+ specTubeType: request.spec?.tubeType || TubeType.TUBE_TYPE_UNSPECIFIED,
})
} else {
form.reset(DEFAULTS)
@@ -128,56 +109,53 @@ export function RequestFormDialog({ open, onOpenChange, request }: Props) {
}, [open, request, form])
async function onSubmit(values: FormValues) {
- // Cross-field rule: spec required iff classification = new.
- if (values.productClassification === "new") {
- if (!values.specRawMaterial) {
- form.setError("specRawMaterial", { message: "Required for new products" })
- return
- }
- if (!values.specProductDescription) {
- form.setError("specProductDescription", { message: "Required for new products" })
- return
- }
- if (!values.specPaperTubeTypeId) {
- form.setError("specPaperTubeTypeId", { message: "Required for new products" })
- return
- }
- if (!values.specWeightPerBobbinKg) {
- form.setError("specWeightPerBobbinKg", { message: "Required for new products" })
- return
- }
- if (!values.specBoxType) {
- form.setError("specBoxType", { message: "Required for new products" })
- return
- }
- if (!values.specShadeCustomText) {
- form.setError("specShadeCustomText", { message: "Required (use 'natural' if unpigmented)" })
- return
+ // Cross-field rule: spec is all-or-nothing — either every field is filled, or none are.
+ // Shade code/name are excluded from this group — both are independently optional.
+ const specFieldEntries: Array<[keyof FormValues, string]> = [
+ ["specProductDescription", "Fill in all spec fields, or leave all blank"],
+ ["specTubeType", "Fill in all spec fields, or leave all blank"],
+ ]
+ const isEmpty = (v: unknown) =>
+ v === undefined || v === null || v === "" || v === 0 || v === TubeType.TUBE_TYPE_UNSPECIFIED
+ const filledCount = specFieldEntries.filter(([key]) => !isEmpty(values[key])).length
+ if (filledCount > 0 && filledCount < specFieldEntries.length) {
+ for (const [key, message] of specFieldEntries) {
+ if (isEmpty(values[key])) {
+ form.setError(key, { message })
+ }
}
+ return
}
+ // Shade code/name never gate whether a spec is sent — they're independently
+ // optional extras included alongside the description+tube all-or-nothing group.
+ const hasSpec = filledCount === specFieldEntries.length
const payload = {
requestTypeId: values.requestTypeId,
title: values.title,
- description: values.description,
+ description: values.specProductDescription || "",
customerName: values.customerName,
customerCode: values.customerCode,
- productClassification: values.productClassification as ProductClassification,
+ productClassification: isEditing && request ? request.productClassification : "pending",
urgencyLevel: values.urgencyLevel as UrgencyLevel,
neededByDate: values.neededByDate,
targetVolume: values.targetVolume,
targetPriceRange: values.targetPriceRange,
- spec:
- values.productClassification === "new"
- ? {
- rawMaterialType: values.specRawMaterial as RawMaterialType,
- productDescription: values.specProductDescription!,
- shadeId: 0, // master shade not picked — using free-text fallback
- shadeCustomText: values.specShadeCustomText,
- paperTubeTypeId: values.specPaperTubeTypeId!,
- weightPerBobbinKg: values.specWeightPerBobbinKg!,
- boxType: values.specBoxType as "JUMBO" | "NORMAL" | "PALLET",
- }
- : undefined,
+ referenceProductSysId: values.referenceProductSysId,
+ spec: hasSpec
+ ? {
+ // rawMaterialType/weightPerBobbinKg/boxType removed from the form (D1) — send
+ // empty so historical rows' columns stay unpopulated on new writes.
+ rawMaterialType: "" as SpecInput["rawMaterialType"],
+ productDescription: values.specProductDescription || "",
+ shadeId: 0, // master shade not picked — using free-text fallback
+ shadeCode: values.specShadeCode,
+ shadeName: values.specShadeName,
+ paperTubeTypeId: 0,
+ tubeType: values.specTubeType ?? TubeType.TUBE_TYPE_UNSPECIFIED,
+ weightPerBobbinKg: "",
+ boxType: "" as SpecInput["boxType"],
+ }
+ : undefined,
}
try {
if (isEditing && request) {
@@ -199,8 +177,8 @@ export function RequestFormDialog({ open, onOpenChange, request }: Props) {
{isEditing ? `Edit ${request?.requestNo}` : "New product request"}
- Section 2 (Product specification) becomes required when classification = new. DEVELOPMENT
- requests are forced to new.
+ Product specification is optional — leave it blank, or fill in every field if you have the
+ details.
- (
-
- Product classification *
-
-
-
-
- Existing product
-
-
-
- New product
-
-
-
- {selectedType?.code === "DEVELOPMENT" && (
-
- DEVELOPMENT request type forces classification = new.
-
- )}
-
-
- )}
- />
+
+
+ {/* SECTION 2 — Product Specification & Pricing (always visible; description+tube
+ all-or-nothing, everything else independently optional) */}
+
+
+ Section 2 — Product specification & pricing
+
(
- Description
+ Product description
-
+
+ Optional — fill in if known.
)}
/>
-
-
- {/* SECTION 2 — Product Specification (conditional) */}
- {productClassification === "new" && (
-
-
- Section 2 — Product specification
-
- (
-
- Raw material type *
-
-
-
-
-
-
-
- POY Boughtout
- Chips SD
- Chips BRT
- Chips Recycle
-
-
-
-
- )}
- />
+
+
+ Shade code/name are optional and independent of each other. Use {`"natural"`} for unpigmented.
+
+ (
+
+ Tube
+ field.onChange(Number(v) as TubeType)}
+ >
+
+
+
+
+
+
+ {TUBE_TYPE_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ Optional — fill in if known.
+
+
+ )}
+ />
+ (
+
+ Reference product (optional)
+
+ field.onChange(productSysId)}
+ placeholder="Search product by code or name…"
+ />
+
+
+ If this request is similar to an existing product, pick it — its routing will be
+ suggested during review.
+
+
+
+ )}
+ />
void
onTrack?: (r: CostProductRequest) => void
visibility: Record
+ sortBy?: string
+ sortOrder?: "asc" | "desc"
+ onSort: (sortKey: string) => void
}
function humanize(value: string): string {
@@ -49,11 +54,12 @@ export function useRequestTableColumns(hasTrack: boolean) {
const th = cn(typography.tableHeader)
-export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }: Props) {
+export function RequestTable({ items, isLoading, onTrack, visibility, sortBy, sortOrder, onSort }: Props) {
const hasTrack = !!onTrack
const columns = useMemo(() => buildColumns(hasTrack), [hasTrack])
const show = (id: string) => visibility[id] !== false
- const visibleCount = columns.filter((c) => show(c.id)).length + 1 // +1 for action col
+ const visibleCount = columns.filter((c) => show(c.id)).length
+ const sortProps = { currentSortBy: sortBy, currentSortOrder: sortOrder, onSort }
return (
/*
@@ -69,15 +75,28 @@ export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }:
- {show("request_no") && Request # }
- {show("type") && Type }
- {show("title") && Title }
- {show("customer") && Customer }
- {show("class") && Class }
- {show("urgency") && Urgency }
- {show("status") && Status }
+ {show("request_no") && (
+
+ )}
+ {show("type") && (
+
+ )}
+ {show("title") && (
+
+ )}
+ {show("customer") && (
+
+ )}
+ {show("class") && (
+
+ )}
+ {show("urgency") && (
+
+ )}
+ {show("status") && (
+
+ )}
{show("fills") && onTrack && Fills }
-
@@ -92,7 +111,6 @@ export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }:
{show("urgency") && }
{show("status") && }
{show("fills") && onTrack && }
-
))}
@@ -110,13 +128,14 @@ export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }:
)}
{items.map((r) => (
- onOpen(r)}
- >
+
{show("request_no") && (
- {r.requestNo}
+
+
+ Open {r.requestNo}
+
+ {r.requestNo}
+
)}
{show("type") && (
@@ -138,7 +157,11 @@ export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }:
)}
{show("class") && (
- {humanize(r.productClassification)}
+ {r.productClassification === "pending" ? (
+ Pending
+ ) : (
+ {humanize(r.productClassification)}
+ )}
{r.verifiedClassification && r.verifiedClassification !== r.productClassification && (
→ {humanize(r.verifiedClassification)}
)}
@@ -153,17 +176,12 @@ export function RequestTable({ items, isLoading, onOpen, onTrack, visibility }:
)}
{show("fills") && onTrack && (
- e.stopPropagation()}>
+ e.stopPropagation()}>
onTrack(r)}>
)}
-
-
-
-
-
))}
diff --git a/src/components/finance/cost-product-request/routing-panel.tsx b/src/components/finance/cost-product-request/routing-panel.tsx
index f2eeec2..924b828 100644
--- a/src/components/finance/cost-product-request/routing-panel.tsx
+++ b/src/components/finance/cost-product-request/routing-panel.tsx
@@ -7,15 +7,15 @@ import { AlertTriangle, Lock, Loader2, Unlock } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { UserName } from "@/components/common/user-name"
-import { CreateRoutingWizard } from "@/components/finance/cost-product-request/create-routing-wizard"
import { DuplicateRouteDialog } from "@/components/finance/cost-route/duplicate-route-dialog"
-import { PickExistingRouteDialog } from "@/components/finance/cost-product-request/pick-existing-route-dialog"
+import { RoutingResolver } from "@/components/finance/cost-product-request/routing-resolver"
import { UnlockPasswordDialog } from "./unlock-password-dialog"
import { useCompleteRoute, useLockRoute, useRouteGraph, useUnlockRoute } from "@/hooks/finance/use-cost-route"
import { useLinkedRequests } from "@/hooks/finance/use-duplicate-route"
-import { useLinkExistingRoute, useUnlinkRoute } from "@/hooks/finance/use-link-route"
+import { useUnlinkRoute } from "@/hooks/finance/use-link-route"
import { useParamSummary } from "@/hooks/finance/use-param-summary"
interface Props {
@@ -39,13 +39,11 @@ export function RoutingPanel({
canManageLock = false,
showCompleteAction = false,
}: Props) {
- const [pickOpen, setPickOpen] = useState(false)
- const [wizardOpen, setWizardOpen] = useState(false)
+ const [resolverOpen, setResolverOpen] = useState(false)
const [dupOpen, setDupOpen] = useState(false)
const [dialogAction, setDialogAction] = useState<"lock" | "unlock" | null>(null)
const [passwordError, setPasswordError] = useState()
- const linkM = useLinkExistingRoute()
const unlinkM = useUnlinkRoute()
const completeRouteM = useCompleteRoute()
const lockM = useLockRoute()
@@ -97,33 +95,26 @@ export function RoutingPanel({
) : (
<>
- No routing defined yet. Choose how to build the cost basis:
+ No routing defined yet. Attach a product to build the cost basis.
-
- setPickOpen(true)}>
- 📋 Pick existing product
-
- setWizardOpen(true)}>
- 🆕 Create new routing
-
-
+ setResolverOpen(true)}>
+ Attach product routing
+
>
)}
- setPickOpen(false)}
- onPick={(headId) => {
- linkM.mutate({ requestId, routeHeadId: headId })
- setPickOpen(false)
- }}
- />
- setWizardOpen(false)}
- />
+
+
+
+ Attach product routing
+
+ setResolverOpen(false)}
+ />
+
+
>
)
}
@@ -187,11 +178,32 @@ export function RoutingPanel({
)}
{!readOnly && canUnlink && (
- unlinkM.mutate({ requestId })}>
- Unlink
-
+
+
+
+
+ unlinkM.mutate({ requestId })}
+ >
+ Unlink
+
+
+
+ {isLocked && (
+ Unlock the route before unlinking.
+ )}
+
+
)}
+ {!readOnly && canUnlink && isLocked && (
+
+ Unlock the route before unlinking.
+
+ )}
{/* Lock management — only visible to route managers */}
{canManageLock && (
diff --git a/src/components/finance/cost-product-request/routing-resolver.tsx b/src/components/finance/cost-product-request/routing-resolver.tsx
new file mode 100644
index 0000000..cbfac82
--- /dev/null
+++ b/src/components/finance/cost-product-request/routing-resolver.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+// RoutingResolver — the ONE entry point for resolving a Cost Product Request's
+// routing (design.md §3, Area B — B1/B2/D4 unification). Given a product,
+// auto-detects whether an active route already exists:
+// - route exists → link it to the request (useLinkExistingRoute)
+// - no route exists yet → create a new route from the product (useCreateRouteFromProduct)
+// - brand-new product → create the product master first (useCreateCostProductMaster),
+// then create a route from it (useCreateRouteFromProduct)
+// All 3 branches converge on the same onResolved(headId) callback.
+//
+// This replaces the old 2-entry-point split (PickExistingRouteDialog +
+// CreateRoutingWizard) with a single auto-detecting flow; the "create new
+// product master" branch is preserved inline, not dropped.
+import { useState } from "react"
+
+import { ProductMasterCombobox } from "@/components/finance/comboboxes/product-master-combobox"
+import { ProductTypeCombobox } from "@/components/finance/comboboxes/product-type-combobox"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { useCreateCostProductMaster } from "@/hooks/finance/use-cost-product-master"
+import { useCreateRouteFromProduct, useRouteByProduct } from "@/hooks/finance/use-cost-route"
+import { useLinkExistingRoute } from "@/hooks/finance/use-link-route"
+import { cn } from "@/lib/utils"
+
+export interface NewProductInput {
+ name: string
+ typeId: number
+ shade: string
+ grade: string
+ description: string
+}
+
+const emptyNewProduct: NewProductInput = { name: "", typeId: 0, shade: "", grade: "AX", description: "" }
+
+/**
+ * useResolveRouting resolves the routing for a given (possibly undefined)
+ * product. It exposes the "does this product already have a route" check
+ * (via useRouteByProduct) plus two resolve functions:
+ * - resolveExisting: link-or-create for an already-known product master.
+ * - resolveNewProduct: create the product master, then create a route from it.
+ */
+export function useResolveRouting(productSysId: number | undefined) {
+ const routeQuery = useRouteByProduct(productSysId)
+ const linkM = useLinkExistingRoute()
+ const createFromProductM = useCreateRouteFromProduct()
+ const createProductM = useCreateCostProductMaster()
+
+ const hasExistingRoute = !!routeQuery.data
+ const existingHeadId = routeQuery.data?.headId
+
+ async function resolveExisting(requestId: number): Promise {
+ if (!productSysId) throw new Error("No product selected")
+ if (hasExistingRoute && existingHeadId) {
+ await linkM.mutateAsync({ requestId, routeHeadId: existingHeadId })
+ return existingHeadId
+ }
+ return createFromProductM.mutateAsync({ productSysId, linkedRequestId: requestId })
+ }
+
+ async function resolveNewProduct(requestId: number, newProduct: NewProductInput): Promise {
+ const created = await createProductM.mutateAsync({
+ productTypeId: newProduct.typeId,
+ productName: newProduct.name,
+ shadeCode: newProduct.shade,
+ gradeCode: newProduct.grade || "AX",
+ description: newProduct.description,
+ })
+ if (!created.productSysId) throw new Error("Product master create returned no sys id")
+ return createFromProductM.mutateAsync({ productSysId: created.productSysId, linkedRequestId: requestId })
+ }
+
+ return {
+ routeQuery,
+ hasExistingRoute,
+ existingHeadId,
+ resolveExisting,
+ resolveNewProduct,
+ isPending: linkM.isPending || createFromProductM.isPending || createProductM.isPending,
+ }
+}
+
+interface RoutingResolverProps {
+ requestId: number
+ productSysId?: number
+ onResolved: (headId: number) => void
+ className?: string
+ /**
+ * When true, skip the "brand-new product" toggle entirely and always render
+ * the new-product-master form — used by ClassificationAndFeasibilityDialog's
+ * inline B1 picker when verified classification is already known to be "new"
+ * (design.md §3 B1: "skipping the existing-product picker entirely").
+ */
+ forceNewProduct?: boolean
+}
+
+export function RoutingResolver({
+ requestId,
+ productSysId,
+ onResolved,
+ className,
+ forceNewProduct = false,
+}: RoutingResolverProps) {
+ const [selectedProductSysId, setSelectedProductSysId] = useState(productSysId)
+ const [isNewProduct, setIsNewProduct] = useState(forceNewProduct)
+ const [newProduct, setNewProduct] = useState(emptyNewProduct)
+
+ const { routeQuery, hasExistingRoute, resolveExisting, resolveNewProduct, isPending } = useResolveRouting(
+ isNewProduct ? undefined : selectedProductSysId,
+ )
+
+ async function handleResolve() {
+ try {
+ const headId = isNewProduct
+ ? await resolveNewProduct(requestId, newProduct)
+ : await resolveExisting(requestId)
+ onResolved(headId)
+ } catch {
+ // Mutation hooks already surface a toast on error; avoid double-toasting.
+ }
+ }
+
+ const canResolveExisting = !isNewProduct && !!selectedProductSysId
+ const canResolveNew = isNewProduct && newProduct.name.trim().length > 0 && newProduct.typeId > 0
+ const canResolve = canResolveExisting || canResolveNew
+
+ return (
+
+ {!forceNewProduct && (
+
+ setIsNewProduct(checked === true)}
+ />
+
+ This is a brand-new product (not yet in the product master)
+
+
+ )}
+
+ {!isNewProduct && (
+
+
Product
+
setSelectedProductSysId(id)}
+ placeholder="Search product by code or name…"
+ />
+ {selectedProductSysId && (
+
+ {routeQuery.isLoading
+ ? "Checking for an existing route…"
+ : hasExistingRoute
+ ? `Existing route #${routeQuery.data?.headId} will be linked to this request.`
+ : "No route yet for this product — a new route will be created."}
+
+ )}
+
+ )}
+
+ {isNewProduct && (
+
+
+ Product name *
+ setNewProduct({ ...newProduct, name: e.target.value })}
+ placeholder="e.g. PTY 75/72 SD BRIGHT"
+ />
+
+
+
+
Product type *
+
setNewProduct({ ...newProduct, typeId: id })}
+ />
+
+
+ Grade code
+ setNewProduct({ ...newProduct, grade: e.target.value })}
+ />
+
+
+
+ Shade code (optional)
+ setNewProduct({ ...newProduct, shade: e.target.value })}
+ />
+
+
+ Description (optional)
+ setNewProduct({ ...newProduct, description: e.target.value })}
+ />
+
+
+ Product code is auto-generated. Required parameters (CAPP) are empty initially — open the new
+ product master after creation to fill values.
+
+
+ )}
+
+
+ {isPending ? "Resolving…" : "Resolve routing"}
+
+
+ )
+}
diff --git a/src/components/finance/cost-product-request/transition-dialogs.tsx b/src/components/finance/cost-product-request/transition-dialogs.tsx
index 4d92458..287ea72 100644
--- a/src/components/finance/cost-product-request/transition-dialogs.tsx
+++ b/src/components/finance/cost-product-request/transition-dialogs.tsx
@@ -3,6 +3,7 @@
// transition-dialogs — small per-transition modal helpers (reason / decision / substatus inputs).
import { useMemo, useState } from "react"
import { Check, Loader2 } from "lucide-react"
+import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
@@ -13,7 +14,11 @@ import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
+import { Separator } from "@/components/ui/separator"
+import { RoutingResolver } from "@/components/finance/cost-product-request/routing-resolver"
import { useCostProductMaster, useCostProductMasters } from "@/hooks/finance/use-cost-product-master"
+import { useDecideFeasibility, useSubmitAndDecide, useVerifyClassification } from "@/hooks/finance/use-cost-product-request"
+import { useLinkExistingRoute } from "@/hooks/finance/use-link-route"
import { cn } from "@/lib/utils"
import type { ClosedSubstatus, ProductClassification } from "@/types/finance/cost-product-request"
@@ -61,140 +66,362 @@ export function ReasonDialog({ open, onOpenChange, title, description, confirmLa
)
}
-// ----- VerifyClassificationDialog -------------------------------------------------------
-interface VerifyProps {
+// ----- ClassificationAndFeasibilityDialog -------------------------------------------------
+// Merges the former VerifyClassificationDialog + FeasibilityDialog into a single screen
+// (item #2, 2026-07-03 CPR UX batch). Extended per design.md §3 Area B (B1) to also resolve
+// + link the request's routing inline, when the decision is FEASIBLE, instead of deferring
+// routing to a separate later step via the Routing card.
+//
+// Routing resolution itself (picking a product / brand-new-product form, then clicking
+// RoutingResolver's own "Resolve routing" button) is a separate, frontend-only interaction
+// that happens BEFORE the dialog's main Submit button is even enabled (mirrors
+// `classificationLocked`'s existing gating pattern) — see `resolvedHeadId` below.
+//
+// Once resolvedHeadId is set and Submit is clicked, the RPC chain is:
+// VerifyClassification -> LinkRoute(requestId, resolvedHeadId) -> DecideFeasibility -> done
+// (design.md §3 B1's recommended ordering: routing resolves right after classification, before
+// feasibility, since a routing failure should block the whole dialog — consistent with the
+// existing "classification saved but feasibility failed -> retry feasibility" pattern extending
+// naturally to "classification+routing saved but feasibility failed -> retry feasibility only").
+//
+// Local state machine: "idle" (all sections editable) -> "classifying" (step 1, VerifyClassification,
+// in flight) -> "classified" (step 1 saved; either about to auto-run the routing link, or a later
+// step failed and the user is retrying just that step, with classification now read-only) ->
+// "routing" (step 2, LinkRoute, in flight; on failure this phase doubles as the "only the route
+// link needs a retry" state) -> "deciding" (step 3, DecideFeasibility, in flight) -> "done" (all
+// steps succeeded, dialog closes). `error` carries the latest inline message; it is distinct from
+// the toasts the underlying hooks already fire on their own success/failure.
+type ReviewPhase = "idle" | "classifying" | "classified" | "routing" | "deciding" | "done"
+
+interface ClassificationAndFeasibilityProps {
open: boolean
onOpenChange: (open: boolean) => void
+ requestId: number
+ /** productClassification — the system/marketing-predicted value. */
currentClassification: ProductClassification
- pending?: boolean
- onConfirm: (verified: ProductClassification, overrideReason: string) => void
+ /** request.verifiedClassification, if already set — used to pre-fill instead of the prediction. */
+ initialVerifiedClassification?: ProductClassification
+ /** request.referenceProductSysId (D4) — prefills RoutingResolver's product picker when set. */
+ referenceProductSysId?: number
+ /**
+ * "review" (default) — UNDER_REVIEW flow: classification/routing/feasibility are saved via
+ * 3 separate calls (VerifyClassification -> LinkRoute -> DecideFeasibility), each individually
+ * retryable on failure — used by the "Review & decide" button.
+ * "submit" — DRAFT flow (B3 merge): the whole sequence (Submit + StartReview + VerifyClassification
+ * + DecideFeasibility + LinkRoute) is composed server-side in a single SubmitAndDecide call, so
+ * only one consolidated notification pair fires — used by the DRAFT "Submit" button.
+ */
+ mode?: "review" | "submit"
}
-export function VerifyClassificationDialog({ open, onOpenChange, currentClassification, pending, onConfirm }: VerifyProps) {
- const [verified, setVerified] = useState(currentClassification)
- const [overrideReason, setOverrideReason] = useState("")
- const isOverride = verified !== currentClassification
+export function ClassificationAndFeasibilityDialog({
+ open,
+ onOpenChange,
+ requestId,
+ currentClassification,
+ initialVerifiedClassification,
+ referenceProductSysId,
+ mode = "review",
+}: ClassificationAndFeasibilityProps) {
+ const verifyM = useVerifyClassification()
+ const feasibilityM = useDecideFeasibility()
+ const linkRouteM = useLinkExistingRoute()
+ const submitAndDecideM = useSubmitAndDecide()
- return (
- {
- onOpenChange(o)
- if (!o) {
- setVerified(currentClassification)
- setOverrideReason("")
- }
- }}
- >
-
-
- Verify classification
-
- Marketing marked this as {currentClassification} . Confirm or override; an override
- requires a reason.
-
-
-
-
setVerified(v as ProductClassification)}
- className="flex gap-6"
- >
-
-
- Existing
-
-
-
- New
-
-
- {isOverride && (
-
- Override reason *
-
- )}
-
-
- onOpenChange(false)}>
- Cancel
-
- onConfirm(verified, isOverride ? overrideReason.trim() : "")}
- >
- {pending && }
- Confirm
-
-
-
-
- )
-}
+ const [phase, setPhase] = useState("idle")
+ const [error, setError] = useState(null)
-// ----- FeasibilityDialog -----------------------------------------------------------------
-interface FeasibilityProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- pending?: boolean
- onConfirm: (decision: "FEASIBLE" | "NOT_FEASIBLE", note: string) => void
-}
-
-export function FeasibilityDialog({ open, onOpenChange, pending, onConfirm }: FeasibilityProps) {
+ const [verified, setVerified] = useState(
+ initialVerifiedClassification ?? (currentClassification === "pending" ? "existing" : currentClassification),
+ )
+ const [overrideReason, setOverrideReason] = useState("")
const [decision, setDecision] = useState<"FEASIBLE" | "NOT_FEASIBLE">("FEASIBLE")
const [note, setNote] = useState("")
+ // Populated by RoutingResolver's onResolved callback once the user has picked/created a
+ // product and clicked its own "Resolve routing" button — a precondition step that happens
+ // before the dialog's main Submit button is even enabled (mirrors classificationLocked's gate).
+ const [resolvedHeadId, setResolvedHeadId] = useState(null)
+ // Tracks whether LinkRoute already succeeded, so a feasibility-only retry (after routing
+ // succeeded but feasibility failed) never re-submits the already-linked route.
+ const [routeLinked, setRouteLinked] = useState(false)
+
+ const isOverride = verified !== currentClassification && currentClassification !== "pending"
const isInfeasible = decision === "NOT_FEASIBLE"
+ // Once classification has been saved (either mid-flow or after a later-step failure we're
+ // retrying), lock the classification section so a retry never re-submits it.
+ const classificationLocked = phase === "classified" || phase === "routing" || phase === "deciding"
+ const pending = verifyM.isPending || linkRouteM.isPending || feasibilityM.isPending || submitAndDecideM.isPending
+
+ function resetAll() {
+ setPhase("idle")
+ setError(null)
+ setVerified(initialVerifiedClassification ?? (currentClassification === "pending" ? "existing" : currentClassification))
+ setOverrideReason("")
+ setDecision("FEASIBLE")
+ setNote("")
+ setResolvedHeadId(null)
+ setRouteLinked(false)
+ }
+
+ async function submitFeasibility() {
+ setPhase("deciding")
+ try {
+ await feasibilityM.mutateAsync({
+ requestId,
+ decision,
+ note: isInfeasible ? note.trim() : "",
+ })
+ setPhase("done")
+ setError(null)
+ toast.success("Classification & feasibility recorded")
+ onOpenChange(false)
+ } catch {
+ // Classification (and routing, if FEASIBLE) already saved — only feasibility needs a retry.
+ setPhase("classified")
+ setError("Classification saved. Feasibility decision failed — please retry.")
+ }
+ }
+
+ async function linkResolvedRouteThenDecide() {
+ if (!routeLinked) {
+ setPhase("routing")
+ if (!resolvedHeadId) {
+ // Guarded by canSubmit below — should not be reachable, but keep an explicit message.
+ setError("Resolve routing before continuing.")
+ return
+ }
+ try {
+ await linkRouteM.mutateAsync({ requestId, routeHeadId: resolvedHeadId })
+ setRouteLinked(true)
+ } catch {
+ // Classification already saved — only the routing link needs a retry.
+ setPhase("classified")
+ setError("Classification saved. Linking the route failed — please retry.")
+ return
+ }
+ }
+ await submitFeasibility()
+ }
+
+ // "submit" mode (B3 merge): the entire sequence is one server-side call
+ // (SubmitAndDecide) instead of the "review" mode's 3 separately-retryable
+ // mutations — so on failure the whole action is simply retried as a whole,
+ // there is no partial "classification saved, routing failed" state to track.
+ async function submitAndDecide() {
+ setPhase("deciding")
+ setError(null)
+ try {
+ await submitAndDecideM.mutateAsync({
+ requestId,
+ verifiedClassification: verified,
+ overrideReason: isOverride ? overrideReason.trim() : "",
+ decision,
+ note: isInfeasible ? note.trim() : "",
+ referenceProductHeadId: !isInfeasible && resolvedHeadId != null ? resolvedHeadId : undefined,
+ })
+ setPhase("done")
+ toast.success("Submitted for review")
+ onOpenChange(false)
+ } catch {
+ setPhase("idle")
+ setError("Submit failed — please check the details and try again.")
+ }
+ }
+
+ async function handleSubmit() {
+ if (mode === "submit") {
+ await submitAndDecide()
+ return
+ }
+ if (classificationLocked) {
+ // Retry path after a later-step failure: classification is already saved. Re-drive
+ // whichever step is next — routing (if FEASIBLE and not yet linked) or feasibility.
+ if (!isInfeasible && !routeLinked) {
+ await linkResolvedRouteThenDecide()
+ } else {
+ await submitFeasibility()
+ }
+ return
+ }
+ if (isInfeasible) {
+ await submitFeasibility()
+ return
+ }
+ setPhase("classifying")
+ setError(null)
+ try {
+ await verifyM.mutateAsync({
+ requestId,
+ verifiedClassification: verified,
+ overrideReason: isOverride ? overrideReason.trim() : "",
+ })
+ } catch {
+ setPhase("idle")
+ setError("Failed to save classification — please check the details and try again.")
+ return
+ }
+ setPhase("classified")
+ await linkResolvedRouteThenDecide()
+ }
+
+ const canSubmitClassification = classificationLocked || !isOverride || !!overrideReason.trim()
+ const canSubmitFeasibility = !isInfeasible || !!note.trim()
+ const canSubmitRouting = isInfeasible || resolvedHeadId != null
+ const canSubmit = (isInfeasible || canSubmitClassification) && canSubmitFeasibility && canSubmitRouting
+
+ const submitLabel = classificationLocked
+ ? !isInfeasible && !routeLinked
+ ? "Retry routing link"
+ : "Retry feasibility"
+ : isInfeasible
+ ? "Reject as infeasible"
+ : mode === "submit"
+ ? "Submit for review"
+ : "Save & continue"
return (
{
onOpenChange(o)
- if (!o) {
- setDecision("FEASIBLE")
- setNote("")
- }
+ if (!o) resetAll()
}}
>
- Decide feasibility
+ {mode === "submit" ? "Submit for review" : "Review & decide"}
- FEASIBLE moves the request to ROUTING_DEFINED. NOT_FEASIBLE sends it to REJECTED — note is required.
+ {mode === "submit"
+ ? "Confirm the product classification, resolve routing, and decide feasibility — submitted directly to review."
+ : "Confirm the product classification and decide feasibility for this request."}
-
-
setDecision(v as "FEASIBLE" | "NOT_FEASIBLE")}
- className="flex gap-6"
- >
-
-
- Feasible
-
-
-
- Not feasible
-
-
-
-
Note {isInfeasible ? "*" : "(optional)"}
-