From 2c8e622841fd463aea668edb38f1e924c0fa01fd Mon Sep 17 00:00:00 2001 From: ilhom Date: Tue, 23 Jun 2026 16:09:23 +0700 Subject: [PATCH 01/21] feat(cost-erp): add ERP item management page + routing metadata fields + parameter display_order ERP Items page (/finance/erp-items): full CRUD table with create/edit/delete dialogs, active/inactive filter, search, and pagination. Hooks and BFF routes wired to the new CreateCostErpItem, UpdateCostErpItem, DeleteCostErpItem RPCs. Route graph editor: AddStageDialog now accepts optional metadata (route_name, route_item_code, route_shade_code, route_shade_name) in a collapsible section. AddRmDialog accepts optional rm_shade_code, rm_shade_name, notes, sub_type. Route head notes textarea added below the status/version line (edit-mode only). Parameter dialog: display_order numeric input added per-row so operators can set the display order when adding applicable parameters to a product. Co-Authored-By: Claude Sonnet 4.6 --- .../erp-items/erp-items-page-client.tsx | 173 +++++ .../(dashboard)/finance/erp-items/loading.tsx | 14 + .../(dashboard)/finance/erp-items/page.tsx | 8 + .../finance/cost-erp/items/[itemId]/route.ts | 50 ++ .../api/v1/finance/cost-erp/items/route.ts | 25 +- .../cost-erp-items/erp-item-delete-dialog.tsx | 45 ++ .../cost-erp-items/erp-item-filters.tsx | 102 +++ .../cost-erp-items/erp-item-form-dialog.tsx | 257 ++++++++ .../finance/cost-erp-items/erp-item-table.tsx | 99 +++ .../finance/cost-erp-items/index.ts | 4 + .../add-parameter-dialog.tsx | 23 +- .../finance/cost-route/route-graph-editor.tsx | 105 ++- src/hooks/finance/use-cost-erp.ts | 100 ++- src/types/finance/cost-erp.ts | 38 +- src/types/generated/finance/v1/cost_erp.ts | 599 +++++++++++++++++- 15 files changed, 1624 insertions(+), 18 deletions(-) create mode 100644 src/app/(dashboard)/finance/erp-items/erp-items-page-client.tsx create mode 100644 src/app/(dashboard)/finance/erp-items/loading.tsx create mode 100644 src/app/(dashboard)/finance/erp-items/page.tsx create mode 100644 src/app/api/v1/finance/cost-erp/items/[itemId]/route.ts create mode 100644 src/components/finance/cost-erp-items/erp-item-delete-dialog.tsx create mode 100644 src/components/finance/cost-erp-items/erp-item-filters.tsx create mode 100644 src/components/finance/cost-erp-items/erp-item-form-dialog.tsx create mode 100644 src/components/finance/cost-erp-items/erp-item-table.tsx create mode 100644 src/components/finance/cost-erp-items/index.ts diff --git a/src/app/(dashboard)/finance/erp-items/erp-items-page-client.tsx b/src/app/(dashboard)/finance/erp-items/erp-items-page-client.tsx new file mode 100644 index 0000000..159b714 --- /dev/null +++ b/src/app/(dashboard)/finance/erp-items/erp-items-page-client.tsx @@ -0,0 +1,173 @@ +"use client" + +import { useState, Suspense } from "react" +import { Plus, Loader2 } from "lucide-react" + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { PageHeader } from "@/components/common/page-header" + +import { + ErpItemTable, + ErpItemFilters, + ErpItemFormDialog, + ErpItemDeleteDialog, +} from "@/components/finance/cost-erp-items" +import { DataTablePagination } from "@/components/shared" + +import { useErpItems } from "@/hooks/finance/use-cost-erp" +import { useUrlState } from "@/lib/hooks" +import type { CostErpItem, ListErpItemsParams } from "@/types/finance/cost-erp" + +const defaultFilters: ListErpItemsParams = { + page: 1, + pageSize: 10, + search: "", + itemType: "", + activeFilter: "active", +} + +function ErpItemsPageContent() { + const [filters, setFilters] = useUrlState({ + defaultValues: defaultFilters, + }) + + const [isFormOpen, setIsFormOpen] = useState(false) + const [isDeleteOpen, setIsDeleteOpen] = useState(false) + const [selectedItem, setSelectedItem] = useState(null) + + const { data, isLoading, isError, error } = useErpItems(filters) + + const handleAddNew = () => { + setSelectedItem(null) + setIsFormOpen(true) + } + + const handleEdit = (item: CostErpItem) => { + setSelectedItem(item) + setIsFormOpen(true) + } + + const handleDelete = (item: CostErpItem) => { + setSelectedItem(item) + setIsDeleteOpen(true) + } + + const handleFormClose = (open: boolean) => { + setIsFormOpen(open) + if (!open) setSelectedItem(null) + } + + const handleDeleteClose = (open: boolean) => { + setIsDeleteOpen(open) + if (!open) setSelectedItem(null) + } + + const handlePageChange = (page: number) => { + setFilters((prev) => ({ ...prev, page })) + } + + const handlePageSizeChange = (pageSize: number) => { + setFilters((prev) => ({ ...prev, pageSize, page: 1 })) + } + + const totalItems = Number(data?.pagination?.totalItems ?? 0) + + return ( +
+ + + + + + + ERP Item List + + {isLoading ? "Loading..." : `${totalItems} total ERP items`} + + + + + + {isError && ( +
+ {error instanceof Error ? error.message : "Failed to load ERP items"} +
+ )} + + + + +
+
+ + + + +
+ ) +} + +function ErpItemsPageSkeleton() { + return ( +
+ + + + + + ERP Item List + Loading... + + +
+ +
+
+
+
+ ) +} + +export default function ErpItemsPageClient() { + return ( + }> + + + ) +} diff --git a/src/app/(dashboard)/finance/erp-items/loading.tsx b/src/app/(dashboard)/finance/erp-items/loading.tsx new file mode 100644 index 0000000..b618f53 --- /dev/null +++ b/src/app/(dashboard)/finance/erp-items/loading.tsx @@ -0,0 +1,14 @@ +import { TableSkeleton } from "@/components/loading" + +export default function ErpItemsLoading() { + return ( +
+
+
+
+
+
+ +
+ ) +} diff --git a/src/app/(dashboard)/finance/erp-items/page.tsx b/src/app/(dashboard)/finance/erp-items/page.tsx new file mode 100644 index 0000000..f9e03a5 --- /dev/null +++ b/src/app/(dashboard)/finance/erp-items/page.tsx @@ -0,0 +1,8 @@ +import { generateMetadata as genMeta } from "@/config/site" +import ErpItemsPageClient from "./erp-items-page-client" + +export const metadata = genMeta("ERP Items") + +export default function ErpItemsPage() { + return +} diff --git a/src/app/api/v1/finance/cost-erp/items/[itemId]/route.ts b/src/app/api/v1/finance/cost-erp/items/[itemId]/route.ts new file mode 100644 index 0000000..1d22b7d --- /dev/null +++ b/src/app/api/v1/finance/cost-erp/items/[itemId]/route.ts @@ -0,0 +1,50 @@ +// CostErpItem — get, update, delete by ID. +import { NextRequest, NextResponse } from "next/server" +import { getCostErpClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ itemId: string }> } + +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { itemId } = await context.params + const metadata = createMetadataFromRequest(request) + const response = await getCostErpClient().getCostErpItem({ itemId: Number(itemId) }, 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 get ERP item", validationErrors: [] } }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest, context: RouteContext) { + try { + const { itemId } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const response = await getCostErpClient().updateCostErpItem( + { + itemId: Number(itemId), + itemName: body.itemName ?? body.item_name ?? undefined, + itemType: body.itemType ?? body.item_type ?? undefined, + isActive: body.isActive ?? body.is_active ?? undefined, + }, + 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 item", validationErrors: [] } }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const { itemId } = await context.params + const metadata = createMetadataFromRequest(request) + const response = await getCostErpClient().deleteCostErpItem({ itemId: Number(itemId) }, metadata) + return NextResponse.json({ base: response.base }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to delete ERP item", validationErrors: [] } }, { 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 index 6c09a30..df044a5 100644 --- a/src/app/api/v1/finance/cost-erp/items/route.ts +++ b/src/app/api/v1/finance/cost-erp/items/route.ts @@ -1,4 +1,4 @@ -// CostErpLookup — list items (read-only). +// CostErpLookup — list items (GET) and create item (POST). import { NextRequest, NextResponse } from "next/server" import { getCostErpClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" @@ -11,7 +11,7 @@ export async function GET(request: NextRequest) { { search: searchParams.get("search") || "", itemType: searchParams.get("itemType") || searchParams.get("item_type") || "", - activeFilter: searchParams.get("activeFilter") || searchParams.get("active_filter") || "active", + activeFilter: (() => { const af = searchParams.get("activeFilter") || searchParams.get("active_filter") || ""; return af === "all" ? "" : af; })(), pagination: { page: Number(searchParams.get("page")) || 1, pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 50, @@ -25,3 +25,24 @@ export async function GET(request: NextRequest) { return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to list ERP items", validationErrors: [] }, data: [] }, { status: 500 }) } } + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getCostErpClient() + const response = await client.createCostErpItem( + { + itemCode: body.itemCode || body.item_code || "", + itemName: body.itemName || body.item_name || "", + itemType: body.itemType || body.item_type || "", + isActive: body.isActive ?? body.is_active ?? true, + }, + 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 create ERP item", validationErrors: [] } }, { status: 500 }) + } +} diff --git a/src/components/finance/cost-erp-items/erp-item-delete-dialog.tsx b/src/components/finance/cost-erp-items/erp-item-delete-dialog.tsx new file mode 100644 index 0000000..621dbb2 --- /dev/null +++ b/src/components/finance/cost-erp-items/erp-item-delete-dialog.tsx @@ -0,0 +1,45 @@ +"use client" + +import { ConfirmDialog } from "@/components/shared" +import type { CostErpItem } from "@/types/finance/cost-erp" +import { useDeleteErpItem } from "@/hooks/finance/use-cost-erp" + +interface ErpItemDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + item: CostErpItem | null + onSuccess?: () => void +} + +export function ErpItemDeleteDialog({ + open, + onOpenChange, + item, + onSuccess, +}: ErpItemDeleteDialogProps) { + const deleteMutation = useDeleteErpItem() + + const handleDelete = async () => { + if (!item) return + try { + await deleteMutation.mutateAsync(item.itemId) + onOpenChange(false) + onSuccess?.() + } catch { + // toast is handled inside the mutation hook + } + } + + return ( + + ) +} diff --git a/src/components/finance/cost-erp-items/erp-item-filters.tsx b/src/components/finance/cost-erp-items/erp-item-filters.tsx new file mode 100644 index 0000000..c20bfee --- /dev/null +++ b/src/components/finance/cost-erp-items/erp-item-filters.tsx @@ -0,0 +1,102 @@ +"use client" + +import { useCallback } from "react" +import { X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { DebouncedSearchInput } from "@/components/common" + +import { + type ListErpItemsParams, + ERP_ACTIVE_FILTER_OPTIONS, + ERP_ITEM_TYPE_OPTIONS, +} from "@/types/finance/cost-erp" + +interface ErpItemFiltersProps { + filters: ListErpItemsParams + onFiltersChange: (filters: ListErpItemsParams) => void +} + +export function ErpItemFilters({ filters, onFiltersChange }: ErpItemFiltersProps) { + const handleSearchChange = useCallback( + (value: string) => { + onFiltersChange({ ...filters, search: value, page: 1 }) + }, + [filters, onFiltersChange], + ) + + const handleItemTypeChange = (value: string) => { + onFiltersChange({ ...filters, itemType: value === "all" ? "" : value, page: 1 }) + } + + const handleActiveFilterChange = (value: string) => { + onFiltersChange({ ...filters, activeFilter: value as ListErpItemsParams["activeFilter"], page: 1 }) + } + + const handleClearFilters = () => { + onFiltersChange({ page: 1, pageSize: filters.pageSize, search: "", itemType: "", activeFilter: "all" }) + } + + const hasActiveFilters = filters.search || filters.itemType || (filters.activeFilter && filters.activeFilter !== "all") + + return ( +
+ + +
+ + + + + {hasActiveFilters && ( + + )} +
+
+ ) +} diff --git a/src/components/finance/cost-erp-items/erp-item-form-dialog.tsx b/src/components/finance/cost-erp-items/erp-item-form-dialog.tsx new file mode 100644 index 0000000..eb04df1 --- /dev/null +++ b/src/components/finance/cost-erp-items/erp-item-form-dialog.tsx @@ -0,0 +1,257 @@ +"use client" + +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +import type { CostErpItem } from "@/types/finance/cost-erp" +import { ERP_ITEM_TYPE_OPTIONS } from "@/types/finance/cost-erp" +import { useCreateErpItem, useUpdateErpItem } from "@/hooks/finance/use-cost-erp" + +const formSchema = z.object({ + itemCode: z + .string() + .min(1, "Code is required") + .max(20, "Code must be at most 20 characters"), + itemName: z + .string() + .min(1, "Name is required") + .max(255, "Name must be at most 255 characters"), + itemType: z.string().max(10, "Type must be at most 10 characters"), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface ErpItemFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + item?: CostErpItem | null + onSuccess?: () => void +} + +export function ErpItemFormDialog({ + open, + onOpenChange, + item, + onSuccess, +}: ErpItemFormDialogProps) { + const isEditing = !!item + const createMutation = useCreateErpItem() + const updateMutation = useUpdateErpItem() + + const form = useForm({ + resolver: zodResolver(formSchema) as never, + defaultValues: { + itemCode: "", + itemName: "", + itemType: "", + isActive: true, + }, + }) + + useEffect(() => { + if (open) { + if (item) { + form.reset({ + itemCode: item.itemCode || "", + itemName: item.itemName || "", + itemType: item.itemType || "", + isActive: item.isActive ?? true, + }) + } else { + form.reset({ itemCode: "", itemName: "", itemType: "", isActive: true }) + } + } + }, [open, item, form]) + + const onSubmit = async (values: FormValues) => { + try { + if (isEditing && item) { + await updateMutation.mutateAsync({ + itemId: item.itemId, + data: { + itemName: values.itemName, + itemType: values.itemType || undefined, + isActive: values.isActive, + }, + }) + } else { + await createMutation.mutateAsync({ + itemCode: values.itemCode, + itemName: values.itemName, + itemType: values.itemType, + isActive: values.isActive, + }) + } + onOpenChange(false) + onSuccess?.() + } catch { + // toast is handled inside the mutation hook + } + } + + const isPending = createMutation.isPending || updateMutation.isPending + + return ( + + + + {isEditing ? "Edit ERP Item" : "Add New ERP Item"} + + {isEditing + ? "Update the ERP item details. Code cannot be changed." + : "Create a new ERP item for the costing system."} + + + +
+ + ( + + + Code * + + + field.onChange(e.target.value.toUpperCase())} + /> + + Unique item code (max 20 chars) + + + )} + /> + + ( + + + Name * + + + + + Display name (max 255 chars) + + + )} + /> + + ( + + Item Type + + Classification of the item + + + )} + /> + + ( + +
+ Active + + Inactive items will not be available for selection in costing + +
+ + + +
+ )} + /> + + + + + + + +
+
+ ) +} diff --git a/src/components/finance/cost-erp-items/erp-item-table.tsx b/src/components/finance/cost-erp-items/erp-item-table.tsx new file mode 100644 index 0000000..944befd --- /dev/null +++ b/src/components/finance/cost-erp-items/erp-item-table.tsx @@ -0,0 +1,99 @@ +"use client" + +import { Pencil, Trash2 } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { DataTable, type ColumnDef, type RowAction } from "@/components/shared" + +import type { CostErpItem } from "@/types/finance/cost-erp" + +interface ErpItemTableProps { + data: CostErpItem[] + isLoading?: boolean + onEdit: (item: CostErpItem) => void + onDelete: (item: CostErpItem) => void +} + +export function ErpItemTable({ data, isLoading, onEdit, onDelete }: ErpItemTableProps) { + const columns: ColumnDef[] = [ + { + id: "itemCode", + header: "Code", + width: "w-[140px]", + cell: (row) => ( + {row.itemCode || "-"} + ), + }, + { + id: "itemName", + header: "Name", + accessorKey: "itemName", + }, + { + id: "itemType", + header: "Type", + width: "w-[100px]", + cell: (row) => ( + + {row.itemType || "-"} + + ), + }, + { + id: "isActive", + header: "Status", + width: "w-[100px]", + cell: (row) => ( + + {row.isActive ? "Active" : "Inactive"} + + ), + }, + { + id: "syncedAt", + header: "Last Synced", + hideOnMobile: true, + cellClassName: "text-muted-foreground text-xs", + cell: (row) => { + if (!row.syncedAt) return "-" + try { + return new Date(row.syncedAt).toLocaleDateString("id-ID", { + year: "numeric", + month: "short", + day: "numeric", + }) + } catch { + return row.syncedAt + } + }, + }, + ] + + const actions: RowAction[] = [ + { + id: "edit", + label: "Edit", + icon: , + onClick: onEdit, + }, + { + id: "delete", + label: "Delete", + icon: , + onClick: onDelete, + variant: "destructive", + }, + ] + + return ( + + ) +} diff --git a/src/components/finance/cost-erp-items/index.ts b/src/components/finance/cost-erp-items/index.ts new file mode 100644 index 0000000..aefcfe3 --- /dev/null +++ b/src/components/finance/cost-erp-items/index.ts @@ -0,0 +1,4 @@ +export { ErpItemTable } from "./erp-item-table" +export { ErpItemFilters } from "./erp-item-filters" +export { ErpItemFormDialog } from "./erp-item-form-dialog" +export { ErpItemDeleteDialog } from "./erp-item-delete-dialog" 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 })) + } + />
+ {!locked && ( +
+