From b709db70d251ffda3560d38618bb28db5f697bac Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 23 Jun 2026 09:30:16 +0700 Subject: [PATCH 1/2] feat(finance): add Export button to product master detail page - BFF route: pass productSysIds to gRPC export request - Service: expose productSysIds param in exportBulkProductRouting() - Detail page: "Export product + routing" button queues async export scoped to that product's closure; toast shows job ID with deep link Co-Authored-By: Claude Sonnet 4.6 --- .../[productSysId]/detail-client.tsx | 40 +++++++++++++++++- .../export/bulk_product_routing/route.ts | 5 ++- src/services/finance/cost-import-api.ts | 10 ++++- src/types/generated/finance/v1/cost_import.ts | 41 ++++++++++++++++++- 4 files changed, 90 insertions(+), 6 deletions(-) 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 e56fd67..41314e7 100644 --- a/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx +++ b/src/app/(dashboard)/finance/product-master/[productSysId]/detail-client.tsx @@ -1,7 +1,10 @@ "use client" import Link from "next/link" -import { ArrowLeft, Package } from "lucide-react" +import { useState } from "react" +import { ArrowLeft, Download, Loader2, Package } from "lucide-react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -15,6 +18,7 @@ import { ProductRoutingTab } from "@/components/finance/cost-product-master/rout import { ProductAuditTab } from "@/components/finance/cost-product-master/audit-tab" import { CostHistoryTab } from "@/components/finance/cost-results/cost-history-tab" import { ProductTypeName } from "@/components/common/product-type-name" +import { exportBulkProductRouting } from "@/services/finance/cost-import-api" interface Props { productSysId: number @@ -22,6 +26,26 @@ interface Props { export default function ProductMasterDetailClient({ productSysId }: Props) { const { data: product, isLoading } = useCostProductMaster(productSysId) + const router = useRouter() + const [exporting, setExporting] = useState(false) + + async function handleExport() { + setExporting(true) + try { + const result = await exportBulkProductRouting({ productSysIds: [productSysId] }) + toast.success(`Export dijadwalkan — Job #${result.jobId}`, { + description: "Termasuk semua intermediate product yang berkaitan.", + action: { + label: "Lihat job", + onClick: () => router.push("/finance/import-jobs"), + }, + }) + } catch { + toast.error("Export gagal, coba lagi.") + } finally { + setExporting(false) + } + } return (
@@ -47,7 +71,19 @@ export default function ProductMasterDetailClient({ productSysId }: Props) { : undefined } /> - {product && } +
+ {product && } + {product && ( + + )} +
{product && ( diff --git a/src/app/api/v1/finance/costing/export/bulk_product_routing/route.ts b/src/app/api/v1/finance/costing/export/bulk_product_routing/route.ts index 588ee69..a104b89 100644 --- a/src/app/api/v1/finance/costing/export/bulk_product_routing/route.ts +++ b/src/app/api/v1/finance/costing/export/bulk_product_routing/route.ts @@ -18,6 +18,9 @@ export async function POST(request: NextRequest) { const productTypeCodes: string[] = Array.isArray(body.productTypeCodes) ? (body.productTypeCodes as string[]) : [] + const productSysIds: number[] = Array.isArray(body.productSysIds) + ? (body.productSysIds as unknown[]).map(Number) + : [] const includeRouting: boolean = typeof body.includeRouting === "boolean" ? body.includeRouting : true @@ -25,7 +28,7 @@ export async function POST(request: NextRequest) { typeof body.activeOnly === "boolean" ? body.activeOnly : false const res = await getCostDataImportClient().exportBulkProductRouting( - { productTypeCodes, includeRouting, activeOnly }, + { productTypeCodes, productSysIds, includeRouting, activeOnly }, metadata, ) diff --git a/src/services/finance/cost-import-api.ts b/src/services/finance/cost-import-api.ts index 7472e96..ef59a92 100644 --- a/src/services/finance/cost-import-api.ts +++ b/src/services/finance/cost-import-api.ts @@ -227,16 +227,22 @@ export async function validateBulkProductRoutingFile( } /** - * Queue an async export of all product master + routing data to MinIO. + * Queue an async export of product master + routing data to MinIO. + * When productSysIds is provided, exports only those products and their full + * transitive dependency closure (intermediates reachable via PRODUCT-type RMs). * Returns a job ID that can be polled or viewed on the import-jobs page. */ export async function exportBulkProductRouting(options?: { productTypeCodes?: string[] + productSysIds?: number[] }): Promise<{ jobId: number; status: string }> { const res = await fetch(`${BASE}/export/bulk_product_routing`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ productTypeCodes: options?.productTypeCodes ?? [] }), + body: JSON.stringify({ + productTypeCodes: options?.productTypeCodes ?? [], + productSysIds: options?.productSysIds ?? [], + }), }) if (!res.ok) throw new Error(`Bulk export failed: ${res.status}`) const json = await res.json() diff --git a/src/types/generated/finance/v1/cost_import.ts b/src/types/generated/finance/v1/cost_import.ts index 53c571b..b1c965f 100644 --- a/src/types/generated/finance/v1/cost_import.ts +++ b/src/types/generated/finance/v1/cost_import.ts @@ -148,6 +148,13 @@ export interface ExportBulkProductRoutingRequest { productTypeCodes: string[]; includeRouting: boolean; activeOnly: boolean; + /** + * product_sys_ids, when non-empty, restricts the export to the transitive + * closure of the given products (the listed products plus every intermediate + * product reachable via PRODUCT-type route RMs). Mutually exclusive with + * product_type_codes; product_sys_ids takes precedence when both are set. + */ + productSysIds: number[]; } export interface ExportBulkProductRoutingResponse { @@ -2419,7 +2426,7 @@ export const ValidateBulkProductRoutingFileResponse: MessageFns = { @@ -2433,6 +2440,11 @@ export const ExportBulkProductRoutingRequest: MessageFns globalThis.Number(e)) + : globalThis.Array.isArray(object?.product_sys_ids) + ? object.product_sys_ids.map((e: any) => globalThis.Number(e)) + : [], }; }, @@ -2507,6 +2542,9 @@ export const ExportBulkProductRoutingRequest: MessageFns Math.round(e)); + } return obj; }, @@ -2518,6 +2556,7 @@ export const ExportBulkProductRoutingRequest: MessageFns e) || []; message.includeRouting = object.includeRouting ?? false; message.activeOnly = object.activeOnly ?? false; + message.productSysIds = object.productSysIds?.map((e) => e) || []; return message; }, }; From e9da86793546455918dcb2d58d503314cbc21203 Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 23 Jun 2026 11:40:19 +0700 Subject: [PATCH 2/2] fix(finance): scope bulk export to filtered products on list page When a search or product-type filter is active, pass the visible page's product sys IDs to the export so only those products and their routing closure are exported instead of the full dataset. Co-Authored-By: Claude Sonnet 4.6 --- .../finance/product-master/product-master-page-client.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 31f011b..0eebcc2 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 @@ -84,7 +84,10 @@ export default function ProductMasterPageClient() { async function handleBulkExport() { setBulkExportLoading(true) try { - const result = await exportBulkProductRouting() + const isFiltered = !!filters.search || !!filters.productTypeId + const visibleItems = data?.items ?? [] + const productSysIds = isFiltered ? visibleItems.map((p) => p.productSysId) : undefined + const result = await exportBulkProductRouting({ productSysIds }) toast.success(`Export dijadwalkan — Job #${result.jobId}`, { description: "File akan tersedia di halaman Import Jobs setelah selesai diproses.", action: {