diff --git a/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx b/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx index 41314e7..236e090 100644 --- a/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx +++ b/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx @@ -110,9 +110,9 @@ export default function ProductMasterDetailClient({ productSysId }: Props) { - - - + + + {product.description && (
diff --git a/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx b/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx index 0eebcc2..d8c8cab 100644 --- a/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx +++ b/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx @@ -21,11 +21,11 @@ import { } from "@/components/ui/select" import { DeactivateProductMasterDialog, - ErpLinkageDialog, ProductMasterFormDialog, ProductMasterTable, } from "@/components/finance/cost-product-master" import { BulkImportDialog } from "@/components/finance/costing/bulk-import-dialog" +import { ParamsOnlyImportDialog } from "@/components/finance/costing/params-only-import-dialog" import { ImportDialog } from "@/components/finance/costing/import-dialog" import { ProductTypeCombobox } from "@/components/finance/comboboxes" import { DataTablePagination } from "@/components/shared" @@ -51,10 +51,10 @@ export default function ProductMasterPageClient() { const router = useRouter() const [formOpen, setFormOpen] = useState(false) - const [erpOpen, setErpOpen] = useState(false) const [deactivateOpen, setDeactivateOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) const [bulkImportOpen, setBulkImportOpen] = useState(false) + const [paramsImportOpen, setParamsImportOpen] = useState(false) const [editing, setEditing] = useState(null) const [bulkExportLoading, setBulkExportLoading] = useState(false) @@ -68,10 +68,6 @@ export default function ProductMasterPageClient() { setEditing(p) setFormOpen(true) } - function openErp(p: CostProductMaster) { - setEditing(p) - setErpOpen(true) - } function openDeactivate(p: CostProductMaster) { setEditing(p) setDeactivateOpen(true) @@ -129,6 +125,9 @@ export default function ProductMasterPageClient() { setBulkImportOpen(true)}> Import Produk + Routing (Bulk) + setParamsImportOpen(true)}> + Import Params Saja (Bulk) + @@ -205,7 +204,6 @@ export default function ProductMasterPageClient() { items={items} isLoading={isLoading} onEdit={openEdit} - onLinkErp={openErp} onDeactivate={openDeactivate} /> @@ -229,13 +227,13 @@ export default function ProductMasterPageClient() { } /> - +
) } diff --git a/src/app/api/v1/finance/cost-erp/grades/route.ts b/src/app/api/v1/finance/cost-erp/grades/route.ts deleted file mode 100644 index b7eb215..0000000 --- a/src/app/api/v1/finance/cost-erp/grades/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import { getCostErpClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - const metadata = createMetadataFromRequest(request) - const client = getCostErpClient() - const response = await client.listCostErpGrades( - { - search: searchParams.get("search") || "", - activeFilter: searchParams.get("activeFilter") || searchParams.get("active_filter") || "active", - pagination: { - page: Number(searchParams.get("page")) || 1, - pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 50, - }, - }, - metadata, - ) - return NextResponse.json({ base: response.base, data: response.data, pagination: response.pagination }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to list ERP grades", validationErrors: [] }, data: [] }, { status: 500 }) - } -} diff --git a/src/app/api/v1/finance/cost-erp/items/route.ts b/src/app/api/v1/finance/cost-erp/items/route.ts deleted file mode 100644 index 6c09a30..0000000 --- a/src/app/api/v1/finance/cost-erp/items/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -// CostErpLookup — list items (read-only). -import { NextRequest, NextResponse } from "next/server" -import { getCostErpClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - const metadata = createMetadataFromRequest(request) - const client = getCostErpClient() - const response = await client.listCostErpItems( - { - search: searchParams.get("search") || "", - itemType: searchParams.get("itemType") || searchParams.get("item_type") || "", - activeFilter: searchParams.get("activeFilter") || searchParams.get("active_filter") || "active", - pagination: { - page: Number(searchParams.get("page")) || 1, - pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 50, - }, - }, - metadata, - ) - return NextResponse.json({ base: response.base, data: response.data, pagination: response.pagination }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to list ERP items", validationErrors: [] }, data: [] }, { status: 500 }) - } -} diff --git a/src/app/api/v1/finance/cost-erp/shades/route.ts b/src/app/api/v1/finance/cost-erp/shades/route.ts deleted file mode 100644 index 695ebb6..0000000 --- a/src/app/api/v1/finance/cost-erp/shades/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import { getCostErpClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - const metadata = createMetadataFromRequest(request) - const client = getCostErpClient() - const response = await client.listCostErpShades( - { - search: searchParams.get("search") || "", - activeFilter: searchParams.get("activeFilter") || searchParams.get("active_filter") || "active", - pagination: { - page: Number(searchParams.get("page")) || 1, - pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 50, - }, - }, - metadata, - ) - return NextResponse.json({ base: response.base, data: response.data, pagination: response.pagination }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to list ERP shades", validationErrors: [] }, data: [] }, { status: 500 }) - } -} diff --git a/src/app/api/v1/finance/cost-product-masters/[productSysId]/erp-linkage/route.ts b/src/app/api/v1/finance/cost-product-masters/[productSysId]/erp-linkage/route.ts deleted file mode 100644 index 633f5c6..0000000 --- a/src/app/api/v1/finance/cost-product-masters/[productSysId]/erp-linkage/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -// CostProductMaster — set/unset ERP linkage. -import { NextRequest, NextResponse } from "next/server" -import { getCostProductMasterClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" - -export async function PUT(request: NextRequest, { params }: { params: Promise<{ productSysId: string }> }) { - try { - const { productSysId } = await params - const body = await request.json() - const metadata = createMetadataFromRequest(request) - const client = getCostProductMasterClient() - const response = await client.updateCostProductMasterErpLinkage( - { - productSysId: Number(productSysId), - erpItemCode: body.erpItemCode || body.erp_item_code || "", - erpGradeCode1: body.erpGradeCode1 || body.erp_grade_code_1 || "", - erpGradeCode2: body.erpGradeCode2 || body.erp_grade_code_2 || "", - }, - metadata, - ) - return NextResponse.json({ base: response.base, data: response.data }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to update ERP linkage", validationErrors: [] } }, { status: 500 }) - } -} diff --git a/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts b/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts new file mode 100644 index 0000000..6cee208 --- /dev/null +++ b/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts @@ -0,0 +1,73 @@ +// POST /api/v1/finance/costing/import/bulk_params_only +// Accepts multipart/form-data ("file" field) to avoid JSON number-array inflation: +// a 50 MB binary file as JSON becomes ~200 MB — this sends the actual binary bytes. +// Falls back to JSON body (small files only) for backward compatibility. +// Returns: { base, data: { jobId: number } } +import { NextRequest, NextResponse } from "next/server" +import { + getCostDataImportClient, + createMetadataFromRequest, + isGrpcError, + handleGrpcError, +} from "@/lib/grpc" + +export async function POST(request: NextRequest) { + try { + const metadata = createMetadataFromRequest(request) + const client = getCostDataImportClient() + + let fileContent: Uint8Array + let fileName: string + + const contentType = request.headers.get("content-type") ?? "" + + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData() + const file = formData.get("file") as File | null + if (!file) { + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "400", + message: "Missing 'file' field in form data", + validationErrors: [], + }, + }, + { status: 400 }, + ) + } + fileContent = new Uint8Array(await file.arrayBuffer()) + fileName = file.name + } else { + // Legacy JSON fallback + const body = await request.json() + fileContent = new Uint8Array(body.fileContent as number[]) + fileName = body.fileName ?? "" + } + + const res = await client.importBulkParamsOnly( + { fileContent, fileName }, + metadata, + ) + + return NextResponse.json({ + base: res.base, + data: { jobId: res.jobId, status: res.status }, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error importing bulk params:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to import params", + validationErrors: [], + }, + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/v1/finance/costing/template/bulk_params_only/route.ts b/src/app/api/v1/finance/costing/template/bulk_params_only/route.ts new file mode 100644 index 0000000..1fa0b2c --- /dev/null +++ b/src/app/api/v1/finance/costing/template/bulk_params_only/route.ts @@ -0,0 +1,57 @@ +// GET /api/v1/finance/costing/template/bulk_params_only +// Returns a 2-sheet Excel template: product_parameters + product_applicable_params. +// Use this when importing params separately from product master + routing. +// Supports split part-sheets (e.g. product_parameters_p1, _p2) on import. +import ExcelJS from "exceljs" +import { NextResponse } from "next/server" + +const SHEETS: { name: string; headers: string[]; sample: string[] }[] = [ + { + name: "product_parameters", + headers: [ + "legacy_oracle_sys_id", + "param_code", + "data_type", + "value_numeric", + "value_text", + "value_flag", + ], + sample: ["2512", "PARAM_CODE", "NUMERIC", "100.5", "", ""], + }, + { + name: "product_applicable_params", + headers: ["legacy_oracle_sys_id", "param_code", "is_required", "display_order"], + sample: ["2512", "PARAM_CODE", "true", "1"], + }, +] + +export async function GET() { + const wb = new ExcelJS.Workbook() + + for (const sheet of SHEETS) { + const ws = wb.addWorksheet(sheet.name) + ws.addRow(sheet.headers) + ws.addRow(sheet.sample) + + // Bold header row + const headerRow = ws.getRow(1) + headerRow.font = { bold: true } + headerRow.commit() + + // Auto-width columns + sheet.headers.forEach((h, i) => { + const col = ws.getColumn(i + 1) + col.width = Math.max(h.length + 4, 14) + }) + } + + const buffer = await wb.xlsx.writeBuffer() + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": 'attachment; filename="bulk_params_only_template.xlsx"', + }, + }) +} diff --git a/src/app/api/v1/finance/costing/template/bulk_product_routing/route.ts b/src/app/api/v1/finance/costing/template/bulk_product_routing/route.ts index 8fb8d21..50ea80f 100644 --- a/src/app/api/v1/finance/costing/template/bulk_product_routing/route.ts +++ b/src/app/api/v1/finance/costing/template/bulk_product_routing/route.ts @@ -1,136 +1,155 @@ -import ExcelJS from "exceljs"; -import { NextResponse } from "next/server"; +import ExcelJS from "exceljs" +import { NextResponse } from "next/server" + +// Column definitions match EXACTLY what the backend import handler reads. +// See: services/finance/internal/application/costbulkimport/sheet_*.go +// +// Sheet name rules: +// - Prefixed names like "1_product_master" are accepted (fuzzy match) +// - Split part-sheets like "product_parameters_p1" + "_p2" are merged automatically +// - param sheets (product_parameters, product_applicable_params) are OPTIONAL +// — a file with only product_master + routing sheets imports cleanly +// +// Cross-sheet FK key: legacy_oracle_sys_id +// = cpm_flex_02 (Oracle integer sys_id) when set, else cpm_product_code +// Every sheet uses the same value to reference the same product. +// +// route_head_legacy_product_id (routing sheets) +// = the legacy_oracle_sys_id of the FG product that OWNS this route +// +// node_product_legacy_id (route_sequences) +// = legacy_oracle_sys_id of the intermediate/FG product at that DAG node +// +// rm_product_legacy_id (route_rms, rm_type=PRODUCT only) +// = legacy_oracle_sys_id of an upstream product used as RM input +// DIFFERENT from route_head_legacy_product_id — this is the RM product, not the head const SHEETS: { name: string; headers: string[]; sample: string[] }[] = [ { name: "product_master", headers: [ - "legacy_oracle_sys_id", - "product_type_code", + "legacy_oracle_sys_id", // Oracle integer sys_id (or product_code as fallback) + "product_type_code", // must exist in Finance > Master > Product Type "product_name", "shade_code", "shade_name", "grade_code", "description", - "erp_item_code", - "flex_01", - "flex_03", - "is_active", + "erp_item_code", // optional ERP item code reference + "legacy_erp_compound_key", // stored as flex_01 — optional legacy identifier + "legacy_type_label", // stored as flex_03 — optional legacy type label + "is_active", // true / false ], sample: [ - "PROD-001", + "2512", "FINISH", "Sample Product Name", - "SH-001", - "Shade Red", - "A", - "Sample description", - "ERP-001", + "NL", + "NATURAL", + "AX", + "", + "", "", "", "true", ], }, { - name: "cpp", + name: "product_parameters", headers: [ "legacy_oracle_sys_id", - "param_code", - "value_numeric", - "value_text", - "value_flag", + "param_code", // must exist in Finance > Master > Parameter + "data_type", // NUMERIC | TEXT | FLAG + "value_numeric", // fill when data_type = NUMERIC + "value_text", // fill when data_type = TEXT + "value_flag", // fill when data_type = FLAG (true/false) ], - sample: ["PROD-001", "PARAM_CODE", "100.5", "", ""], + sample: ["2512", "PARAM_CODE", "NUMERIC", "100.5", "", ""], }, { - name: "capp", + name: "product_applicable_params", headers: [ "legacy_oracle_sys_id", - "param_code", - "is_required", - "display_order", + "param_code", // must exist in Finance > Master > Parameter + "is_required", // true / false + "display_order", // integer, 0 = inherit default ], - sample: ["PROD-001", "PARAM_CODE", "true", "1"], + sample: ["2512", "PARAM_CODE", "true", "1"], }, { name: "route_head", - headers: ["legacy_oracle_sys_id", "notes"], - sample: ["PROD-001", "Main routing"], + headers: [ + "legacy_oracle_sys_id", // product that owns this route + "routing_status", // DRAFT (always DRAFT for new imports) + "notes", + ], + sample: ["2512", "DRAFT", "Imported from Oracle"], }, { - name: "route_seq", + name: "route_sequences", headers: [ - "legacy_oracle_sys_id", - "route_level", - "route_seq", - "route_name", - "route_item_code", - "position_x", - "position_y", - "cyl_type_id", + "route_head_legacy_product_id", // = legacy_oracle_sys_id of the FG product (head) + "node_product_legacy_id", // = legacy_oracle_sys_id of the product at this node + "route_level", // positive integer, 1 = FG level + "route_seq", // sequence within a level, 1-based + "route_name", // optional: e.g. "POY 380/144/TBL/DBR/SIM/NS/1/O" + "route_item_code", // optional: Oracle item code for this stage + "route_shade_code", // optional: shade code for this stage + "route_shade_name", // optional: shade name for this stage ], - sample: ["PROD-001", "1", "1", "Process 1", "SEQ-001", "0", "0", ""], + sample: ["2512", "2512", "1", "1", "", "", "", ""], }, { - name: "route_rm", + name: "route_rms", headers: [ - "legacy_oracle_sys_id", - "route_level", - "route_seq", - "rm_type", - "rm_product_legacy_id", - "rm_item_code", - "rm_group_code", - "rm_name", - "rm_item_code_ref", - "ratio", - "sub_type", - "notes", - ], - sample: [ - "PROD-001", - "1", - "1", - "PRODUCT", - "RM-001", - "", - "", - "RM Name", - "", - "1.0", - "", - "", + "route_head_legacy_product_id", // = legacy_oracle_sys_id of the FG product (head) + "route_level", // must match a row in route_sequences + "route_seq", // must match a row in route_sequences + "rm_type", // PRODUCT | GROUP + "ratio", // numeric, e.g. 1.0 + "rm_product_legacy_id", // for rm_type=PRODUCT: legacy_oracle_sys_id of upstream product + "rm_item_code", // for rm_type=ITEM (legacy, leave empty) + "rm_group_code", // for rm_type=GROUP: RM group code from Finance > Master > RM Groups + "rm_name", // optional display name + "rm_shade_code", // optional + "rm_shade_name", // optional + "sub_type", // optional: e.g. WARP / WEFT + "notes", // optional ], + sample: ["2512", "1", "1", "GROUP", "1.0", "", "", "MY_GROUP_CODE", "", "", "", "", ""], }, -]; +] export async function GET() { - const wb = new ExcelJS.Workbook(); + const wb = new ExcelJS.Workbook() for (const sheet of SHEETS) { - const ws = wb.addWorksheet(sheet.name); + const ws = wb.addWorksheet(sheet.name) - // Set columns first — this writes the header row (row 1) automatically - ws.columns = sheet.headers.map((h, i) => ({ + ws.columns = sheet.headers.map((h) => ({ header: h, - key: String(i), + key: h, width: Math.max(h.length + 4, 16), - })); + })) - // Add only the sample data row (row 2) - ws.addRow(sheet.sample); + ws.addRow(sheet.sample) - // Bold header row - ws.getRow(1).font = { bold: true }; + // Bold + light grey header row + const headerRow = ws.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE8E8E8" }, + } + headerRow.commit() } - const buffer = await wb.xlsx.writeBuffer(); + const buffer = await wb.xlsx.writeBuffer() return new NextResponse(buffer, { headers: { - "Content-Type": - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": - 'attachment; filename="bulk_product_routing_template.xlsx"', + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": 'attachment; filename="bulk_product_routing_template.xlsx"', }, - }); + }) } diff --git a/src/components/finance/box-bobbin-cost/box-bobbin-cost-form-dialog.tsx b/src/components/finance/box-bobbin-cost/box-bobbin-cost-form-dialog.tsx index 4fb9be0..5a9ced3 100644 --- a/src/components/finance/box-bobbin-cost/box-bobbin-cost-form-dialog.tsx +++ b/src/components/finance/box-bobbin-cost/box-bobbin-cost-form-dialog.tsx @@ -50,6 +50,14 @@ const formSchema = z.object({ noOfBob: z.coerce.number().int().min(1, "Must be at least 1"), notes: z.string().max(1000).optional(), isActive: z.boolean(), + bbnReuse: z.coerce.number().min(0).optional().nullable(), + bbnReuseVal: z.coerce.number().min(0).optional().nullable(), + boxReuse: z.coerce.number().min(0).optional().nullable(), + boxReuseVal: z.coerce.number().min(0).optional().nullable(), + boxCost: z.coerce.number().min(0).optional().nullable(), + bobinCost: z.coerce.number().min(0).optional().nullable(), + boxCostVal: z.coerce.number().min(0).optional().nullable(), + bobinCostVal: z.coerce.number().min(0).optional().nullable(), }) type FormValues = z.infer @@ -80,6 +88,14 @@ export function BoxBobbinCostFormDialog({ noOfBob: 24, notes: "", isActive: true, + bbnReuse: null, + bbnReuseVal: null, + boxReuse: null, + boxReuseVal: null, + boxCost: null, + bobinCost: null, + boxCostVal: null, + bobinCostVal: null, }, }) @@ -88,14 +104,22 @@ export function BoxBobbinCostFormDialog({ form.reset( boxBobbinCost ? { - bbcCode: boxBobbinCost.bbcCode || "", - bbcName: boxBobbinCost.bbcName || "", - bbcType: boxBobbinCost.bbcType || "", - noOfBob: boxBobbinCost.noOfBob ?? 24, - notes: boxBobbinCost.notes || "", - isActive: boxBobbinCost.isActive ?? true, + bbcCode: boxBobbinCost.bbcCode || "", + bbcName: boxBobbinCost.bbcName || "", + bbcType: boxBobbinCost.bbcType || "", + noOfBob: boxBobbinCost.noOfBob ?? 24, + notes: boxBobbinCost.notes || "", + isActive: boxBobbinCost.isActive ?? true, + bbnReuse: boxBobbinCost.bbnReuse ?? null, + bbnReuseVal: boxBobbinCost.bbnReuseVal ?? null, + boxReuse: boxBobbinCost.boxReuse ?? null, + boxReuseVal: boxBobbinCost.boxReuseVal ?? null, + boxCost: boxBobbinCost.boxCost ?? null, + bobinCost: boxBobbinCost.bobinCost ?? null, + boxCostVal: boxBobbinCost.boxCostVal ?? null, + bobinCostVal: boxBobbinCost.bobinCostVal ?? null, } - : { bbcCode: "", bbcName: "", bbcType: "", noOfBob: 24, notes: "", isActive: true } + : { bbcCode: "", bbcName: "", bbcType: "", noOfBob: 24, notes: "", isActive: true, bbnReuse: null, bbnReuseVal: null, boxReuse: null, boxReuseVal: null, boxCost: null, bobinCost: null, boxCostVal: null, bobinCostVal: null } ) } }, [open, boxBobbinCost, form]) @@ -106,21 +130,37 @@ export function BoxBobbinCostFormDialog({ await updateMutation.mutateAsync({ id: boxBobbinCost.bbcId, data: { - bbcId: boxBobbinCost.bbcId, - bbcName: values.bbcName, - bbcType: values.bbcType, - noOfBob: values.noOfBob, - notes: values.notes || "", - isActive: values.isActive, + bbcId: boxBobbinCost.bbcId, + bbcName: values.bbcName, + bbcType: values.bbcType, + noOfBob: values.noOfBob, + notes: values.notes || "", + isActive: values.isActive, + bbnReuse: values.bbnReuse ?? undefined, + bbnReuseVal: values.bbnReuseVal ?? undefined, + boxReuse: values.boxReuse ?? undefined, + boxReuseVal: values.boxReuseVal ?? undefined, + boxCost: values.boxCost ?? undefined, + bobinCost: values.bobinCost ?? undefined, + boxCostVal: values.boxCostVal ?? undefined, + bobinCostVal: values.bobinCostVal ?? undefined, }, }) } else { await createMutation.mutateAsync({ - bbcCode: values.bbcCode, - bbcName: values.bbcName, - bbcType: values.bbcType, - noOfBob: values.noOfBob, - notes: values.notes || "", + bbcCode: values.bbcCode, + bbcName: values.bbcName, + bbcType: values.bbcType, + noOfBob: values.noOfBob, + notes: values.notes || "", + bbnReuse: values.bbnReuse ?? undefined, + bbnReuseVal: values.bbnReuseVal ?? undefined, + boxReuse: values.boxReuse ?? undefined, + boxReuseVal: values.boxReuseVal ?? undefined, + boxCost: values.boxCost ?? undefined, + bobinCost: values.bobinCost ?? undefined, + boxCostVal: values.boxCostVal ?? undefined, + bobinCostVal: values.bobinCostVal ?? undefined, }) } onOpenChange(false) @@ -232,6 +272,180 @@ export function BoxBobbinCostFormDialog({ /> +
+

Rates & Reuse

+
+ ( + + Bobbin Reuse Count (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Bobbin Reuse Count VAL (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Box Reuse Count (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Box Reuse Count VAL (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Box Rate MKT (USD) (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Bobbin Rate MKT (USD) (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Box Rate VAL (USD) (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> + ( + + Bobbin Rate VAL (USD) (optional) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + disabled={isPending} + /> + + + + )} + /> +
+
+ row.noOfBob ?? "-", }, + { + id: "boxCost", + header: "Box Rate MKT", + width: "w-[120px]", + hideOnMobile: true, + cell: (row) => row.boxCost != null ? `$${row.boxCost.toFixed(4)}` : , + }, + { + id: "bobinCost", + header: "Bobbin Rate MKT", + width: "w-[130px]", + hideOnMobile: true, + cell: (row) => row.bobinCost != null ? `$${row.bobinCost.toFixed(4)}` : , + }, { id: "notes", header: "Notes", diff --git a/src/components/finance/comboboxes/erp-item-combobox.tsx b/src/components/finance/comboboxes/erp-item-combobox.tsx deleted file mode 100644 index 66152db..0000000 --- a/src/components/finance/comboboxes/erp-item-combobox.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client" - -// ErpItemCombobox — picks a CostErpItem by item_code/item_name. Used when rm_type.reference_target = MASTER. -import { Check, ChevronsUpDown, Loader2 } from "lucide-react" -import { useMemo, useState } from "react" - -import { Button } from "@/components/ui/button" -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { useErpItems } from "@/hooks/finance/use-cost-erp" -import { cn } from "@/lib/utils" - -interface ErpItemComboboxProps { - value: number | undefined - onChange: (itemId: number, itemCode: string, itemName: string) => void - itemType?: string - placeholder?: string - disabled?: boolean - className?: string -} - -export function ErpItemCombobox({ value, onChange, itemType, placeholder = "Select ERP item…", disabled, className }: ErpItemComboboxProps) { - const [open, setOpen] = useState(false) - const [search, setSearch] = useState("") - const { data, isLoading } = useErpItems({ search, itemType, activeFilter: "active", pageSize: 50 }) - const selected = useMemo(() => data?.items.find((it) => it.itemId === value), [data, value]) - - return ( - - - - - - - - - {isLoading && ( -
- Loading… -
- )} - No ERP item matches. - - {(data?.items ?? []).map((it) => ( - { - onChange(it.itemId, it.itemCode, it.itemName) - setOpen(false) - }} - > - -
-
- {it.itemCode} - {it.itemName} -
- {it.itemType && {it.itemType}} -
-
- ))} -
-
-
-
-
- ) -} diff --git a/src/components/finance/comboboxes/index.ts b/src/components/finance/comboboxes/index.ts index ed35773..6d9e1ed 100644 --- a/src/components/finance/comboboxes/index.ts +++ b/src/components/finance/comboboxes/index.ts @@ -1,6 +1,5 @@ export { ProductTypeCombobox } from "./product-type-combobox" export { RmTypeCombobox } from "./rm-type-combobox" -export { ErpItemCombobox } from "./erp-item-combobox" export { ProductMasterCombobox } from "./product-master-combobox" export { RequestTypeCombobox } from "./request-type-combobox" export { PaperTubeTypeCombobox } from "./paper-tube-type-combobox" diff --git a/src/components/finance/cost-product-master/add-parameter-dialog.tsx b/src/components/finance/cost-product-master/add-parameter-dialog.tsx index ab57c10..86aba4f 100644 --- a/src/components/finance/cost-product-master/add-parameter-dialog.tsx +++ b/src/components/finance/cost-product-master/add-parameter-dialog.tsx @@ -32,6 +32,7 @@ export function AddParameterDialog({ productSysId, open, onOpenChange }: Props) const qc = useQueryClient() const [search, setSearch] = useState("") const [overrides, setOverrides] = useState>({}) // paramId → isRequired + const [displayOrders, setDisplayOrders] = useState>({}) // paramId → display_order const [addingId, setAddingId] = useState(null) // Filter out child params — they are auto-managed via MASTER_LOOKUP trigger. @@ -55,6 +56,7 @@ export function AddParameterDialog({ productSysId, open, onOpenChange }: Props) setAddingId(entry.paramId) try { const isReq = overrides[entry.paramId] ?? entry.isRequiredForCosting + const displayOrder = Number(displayOrders[entry.paramId] ?? 0) const isMasterLookup = entry.paramCategory === "MASTER_LOOKUP" const endpoint = isMasterLookup ? "/api/v1/finance/cost-product-parameters/applicable/add-with-children" @@ -62,7 +64,7 @@ export function AddParameterDialog({ productSysId, open, onOpenChange }: Props) const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ productSysId, paramId: entry.paramId, isRequired: isReq }), + body: JSON.stringify({ productSysId, paramId: entry.paramId, isRequired: isReq, displayOrder }), }) const body = await res.json() if (!res.ok || body?.base?.isSuccess === false) { @@ -113,7 +115,7 @@ export function AddParameterDialog({ productSysId, open, onOpenChange }: Props) key={entry.paramId} className="rounded border p-3 grid grid-cols-12 gap-3 items-center" > -
+
{entry.paramName} @@ -131,14 +133,27 @@ export function AddParameterDialog({ productSysId, open, onOpenChange }: Props) {entry.ownerDepartment && <> · Owner: {entry.ownerDepartment}}
-
+
setOverrides((prev) => ({ ...prev, [entry.paramId]: v })) } /> - + +
+
+ + setDisplayOrders((prev) => ({ ...prev, [entry.paramId]: e.target.value })) + } + />
-
- )} -
-
-
- - setErpGrade1(e.target.value)} placeholder="AX" /> -
-
- - setErpGrade2(e.target.value)} - placeholder="(optional)" - /> -
-
-
- - - - - - ) -} diff --git a/src/components/finance/cost-product-master/index.ts b/src/components/finance/cost-product-master/index.ts index e846b4d..9b99351 100644 --- a/src/components/finance/cost-product-master/index.ts +++ b/src/components/finance/cost-product-master/index.ts @@ -1,4 +1,3 @@ export { ProductMasterFormDialog } from "./product-master-form-dialog" export { ProductMasterTable } from "./product-master-table" -export { ErpLinkageDialog } from "./erp-linkage-dialog" export { DeactivateProductMasterDialog } from "./deactivate-dialog" diff --git a/src/components/finance/cost-product-master/product-master-form-dialog.tsx b/src/components/finance/cost-product-master/product-master-form-dialog.tsx index 802a535..ab45507 100644 --- a/src/components/finance/cost-product-master/product-master-form-dialog.tsx +++ b/src/components/finance/cost-product-master/product-master-form-dialog.tsx @@ -25,6 +25,9 @@ const schema = z.object({ shadeCode: z.string().max(50, "Max 50 chars"), gradeCode: z.string().max(20, "Max 20 chars"), description: z.string().max(2000, "Max 2000 chars"), + flex01: z.string().max(255, "Max 255 chars"), + flex02: z.string().max(100, "Max 100 chars"), + flex03: z.string().max(100, "Max 100 chars"), }) type FormValues = z.infer @@ -44,6 +47,7 @@ export function ProductMasterFormDialog({ open, onOpenChange, product }: Props) resolver: zodResolver(schema) as never, defaultValues: { productTypeId: 0, productName: "", shadeCode: "", gradeCode: "AX", description: "", + flex01: "", flex02: "", flex03: "", }, }) @@ -55,6 +59,9 @@ export function ProductMasterFormDialog({ open, onOpenChange, product }: Props) shadeCode: product?.shadeCode ?? "", gradeCode: product?.gradeCode || "AX", description: product?.description ?? "", + flex01: product?.flex01 ?? "", + flex02: product?.flex02 ?? "", + flex03: product?.flex03 ?? "", }) } }, [open, product, form]) @@ -68,6 +75,9 @@ export function ProductMasterFormDialog({ open, onOpenChange, product }: Props) shadeCode: values.shadeCode, gradeCode: values.gradeCode, description: values.description, + flex01: values.flex01, + flex02: values.flex02, + flex03: values.flex03, }) } else { await createMutation.mutateAsync({ @@ -76,6 +86,9 @@ export function ProductMasterFormDialog({ open, onOpenChange, product }: Props) shadeCode: values.shadeCode, gradeCode: values.gradeCode, description: values.description, + flex01: values.flex01, + flex02: values.flex02, + flex03: values.flex03, }) } onOpenChange(false) @@ -174,6 +187,55 @@ export function ProductMasterFormDialog({ open, onOpenChange, product }: Props) )} /> +
+ + Legacy / Import identifiers (optional) + +
+ ( + + Oracle Sys ID + + + + Legacy Oracle integer sys_id (legacy_oracle_sys_id). + + + )} + /> + ( + + ERP Compound Key + + + + Legacy ERP compound key (legacy_erp_compound_key). + + + )} + /> + ( + + Type Label + + + + Legacy product type label (legacy_type_label). + + + )} + /> +
+
+ + 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 && ( +
+