From 0a73ff7b33a99dd5e86c1924638f2e60159983e1 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 2 Jul 2026 16:24:37 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(finance):=20product=20master=20UX=20ba?= =?UTF-8?q?tch=20=E2=80=94=20sorting,=20column=20visibility,=20view=20draw?= =?UTF-8?q?er,=20multi-type=20filter,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SortableHeader (shared): fa-sort triangle indicator, whole-th click target, aria-sort + keyboard; all 9 product master columns sortable server-side - Column show/hide via useColumnVisibility + ColumnVisibilityMenu (persisted, h-9 trigger); product code not hideable - Clickable rows navigate to detail (actions excluded); code cell plain text - ProductDetailDrawer: sticky header/footer, carded sections (details/routing/ params grouped by display group/cost history), calculated params excluded, lazy per-section queries - ProductTypeMultiCombobox: multi-select filter, CSV in URL, popover clear - Polish: uniform button sizes, h-9 filter row, icon-only header actions below sm, neutral KPI variants, KpiGrid odd-last full-width span, space-y-6 rhythm, table edge padding, 375px responsive floor - Fix: sortBy/sortOrder added to useUrlState defaults (were silently dropped) Co-Authored-By: Claude Fable 5 --- .../product-master-page-client.tsx | 125 +++++-- .../v1/finance/cost-product-masters/route.ts | 12 + src/components/common/kpi-grid.tsx | 14 +- src/components/common/page-header.tsx | 10 +- src/components/finance/comboboxes/index.ts | 1 + .../product-type-multi-combobox.tsx | 132 +++++++ .../finance/cost-product-master/index.ts | 8 +- .../product-detail-drawer.tsx | 352 ++++++++++++++++++ .../product-master-table.tsx | 247 ++++++++---- .../data-table/column-visibility-menu.tsx | 6 +- src/components/shared/data-table/index.ts | 1 + .../shared/data-table/sortable-header.tsx | 85 +++++ src/components/shared/index.ts | 3 + src/hooks/finance/use-cost-product-master.ts | 3 + src/types/finance/cost-product-master.ts | 2 + .../finance/v1/cost_product_master.ts | 37 +- 16 files changed, 926 insertions(+), 112 deletions(-) create mode 100644 src/components/finance/comboboxes/product-type-multi-combobox.tsx create mode 100644 src/components/finance/cost-product-master/product-detail-drawer.tsx create mode 100644 src/components/shared/data-table/sortable-header.tsx 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 48800ad..64a7236 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,13 +21,15 @@ import { } from "@/components/ui/select" import { DeactivateProductMasterDialog, + ProductDetailDrawer, ProductMasterFormDialog, ProductMasterTable, + useProductMasterTableColumns, } from "@/components/finance/cost-product-master" import { BulkImportDialog } from "@/components/finance/costing/bulk-import-dialog" import { ImportDialog } from "@/components/finance/costing/import-dialog" -import { ProductTypeCombobox } from "@/components/finance/comboboxes" -import { DataTablePagination } from "@/components/shared" +import { ProductTypeMultiCombobox } from "@/components/finance/comboboxes" +import { ColumnVisibilityMenu, DataTablePagination } from "@/components/shared" import { useCostProductMasterCounts, useCostProductMasters, costProductMasterKeys } from "@/hooks/finance/use-cost-product-master" import { useExportData } from "@/hooks/finance/use-cost-import" import { useUrlState } from "@/lib/hooks" @@ -36,21 +38,61 @@ import type { CostProductMaster, ListCostProductMastersParams } from "@/types/fi const defaultFilters: ListCostProductMastersParams = { search: "", - productTypeId: 0, + productTypeIds: [], activeFilter: "active", + // sortBy/sortOrder must be listed here — useUrlState only tracks keys present in defaultValues. + sortBy: "", + sortOrder: undefined, page: 1, pageSize: 20, } +type FilterKey = keyof ListCostProductMastersParams +type FilterValue = ListCostProductMastersParams[FilterKey] + +// Custom URL (de)serialization so productTypeIds appears as CSV (?productTypeIds=1,2,3) +// instead of JSON, and is omitted entirely when empty. +function serializeFilters(key: FilterKey, value: FilterValue): string | undefined { + if (key === "productTypeIds") { + return Array.isArray(value) && value.length > 0 ? value.join(",") : undefined + } + if (value === undefined || value === null || value === "") return undefined + return String(value) +} + +function deserializeFilters(key: FilterKey, value: string | null, defaultValue: FilterValue): FilterValue { + if (key === "productTypeIds") { + if (!value) return defaultValue + return value + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isInteger(n) && n > 0) + } + if (value === null) return defaultValue + if (typeof defaultValue === "number") { + const num = Number(value) + return Number.isNaN(num) ? defaultValue : num + } + return value as FilterValue +} + export default function ProductMasterPageClient() { - const [filters, setFilters] = useUrlState({ defaultValues: defaultFilters }) + const [filters, setFilters] = useUrlState({ + defaultValues: defaultFilters, + serialize: serializeFilters, + deserialize: deserializeFilters, + }) const { data, isLoading } = useCostProductMasters(filters) const { data: counts, isLoading: countsLoading } = useCostProductMasterCounts() const queryClient = useQueryClient() const router = useRouter() + const { columns, visibility, toggle, setAll, reset } = useProductMasterTableColumns() + const [formOpen, setFormOpen] = useState(false) const [deactivateOpen, setDeactivateOpen] = useState(false) + const [viewOpen, setViewOpen] = useState(false) + const [viewId, setViewId] = useState(null) const [importOpen, setImportOpen] = useState(false) const [bulkImportOpen, setBulkImportOpen] = useState(false) const [paramsImportOpen, setParamsImportOpen] = useState(false) @@ -72,6 +114,16 @@ export default function ProductMasterPageClient() { setEditing(p) setDeactivateOpen(true) } + function openView(p: CostProductMaster) { + setViewId(p.productSysId) + setViewOpen(true) + } + + function handleSort(sortKey: string) { + // asc → desc toggle on the active column; a new column always starts asc. + const nextOrder = filters.sortBy === sortKey && filters.sortOrder === "asc" ? "desc" : "asc" + setFilters({ ...filters, sortBy: sortKey, sortOrder: nextOrder, page: 1 }) + } function handleExport() { void exportEntity("product_master") @@ -80,7 +132,7 @@ export default function ProductMasterPageClient() { async function handleBulkExport() { setBulkExportLoading(true) try { - const isFiltered = !!filters.search || !!filters.productTypeId + const isFiltered = !!filters.search || (filters.productTypeIds?.length ?? 0) > 0 const visibleItems = data?.items ?? [] const productSysIds = isFiltered ? visibleItems.map((p) => p.productSysId) : undefined const result = await exportBulkProductRouting({ productSysIds }) @@ -102,20 +154,22 @@ export default function ProductMasterPageClient() { const items = data?.items ?? [] const pagination = data?.pagination const totalItems = Number(pagination?.totalItems ?? 0) + const selectedTypeCount = filters.productTypeIds?.length ?? 0 return (
{/* Import dropdown */} - @@ -134,10 +188,10 @@ export default function ProductMasterPageClient() { {/* Export dropdown */} - @@ -150,33 +204,38 @@ export default function ProductMasterPageClient() { - - + -
+ {/* Filter toolbar — equal h-9 controls; the 260px cell hosts the multi-select type combobox. */} +
setFilters({ ...filters, search, page: 1 })} placeholder="Search by code, name, or ERP item…" + containerClassName="min-w-0 sm:col-span-2 lg:col-span-1" + className="h-9" /> - setFilters({ ...filters, productTypeId: typeId, page: 1 })} + setFilters({ ...filters, productTypeIds, page: 1 })} placeholder="All product types" + className="h-9" /> +
+ +
- {filters.productTypeId ? ( + {selectedTypeCount > 0 ? (
- Filtering by product type.{" "} + Filtering by {selectedTypeCount} {selectedTypeCount === 1 ? "product type" : "product types"}.{" "} @@ -205,6 +274,11 @@ export default function ProductMasterPageClient() { isLoading={isLoading} onEdit={openEdit} onDeactivate={openDeactivate} + onView={openView} + sortBy={filters.sortBy} + sortOrder={filters.sortOrder} + onSort={handleSort} + visibility={visibility} /> {totalItems > 0 && ( @@ -234,6 +308,7 @@ export default function ProductMasterPageClient() { /> +
) } diff --git a/src/app/api/v1/finance/cost-product-masters/route.ts b/src/app/api/v1/finance/cost-product-masters/route.ts index a4aa0f7..8c635a7 100644 --- a/src/app/api/v1/finance/cost-product-masters/route.ts +++ b/src/app/api/v1/finance/cost-product-masters/route.ts @@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from "next/server" import { getCostProductMasterClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" +/** Parses a CSV of product type ids (e.g. "1,2,3") into a positive-int array; empty = no filter. */ +function parseProductTypeIds(raw: string): number[] { + return raw + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isInteger(n) && n > 0) +} + export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) @@ -11,6 +19,10 @@ export async function GET(request: NextRequest) { { search: searchParams.get("search") || "", productTypeId: Number(searchParams.get("productTypeId") || searchParams.get("product_type_id")) || 0, + // Multi-select type filter — unioned with the legacy single productTypeId server-side. + productTypeIds: parseProductTypeIds( + searchParams.get("productTypeIds") || searchParams.get("product_type_ids") || "", + ), shadeCode: searchParams.get("shadeCode") || searchParams.get("shade_code") || "", activeFilter: searchParams.get("activeFilter") || searchParams.get("active_filter") || "", sortBy: searchParams.get("sortBy") || searchParams.get("sort_by") || "", diff --git a/src/components/common/kpi-grid.tsx b/src/components/common/kpi-grid.tsx index ed0dc27..b160526 100644 --- a/src/components/common/kpi-grid.tsx +++ b/src/components/common/kpi-grid.tsx @@ -7,10 +7,18 @@ export interface KpiGridProps { className?: string; } +// Every layout is 2 columns below lg. When the card count is odd the last card +// would leave an empty bottom-right hole, so it spans the full row on the 2-col +// layout: `:last-child:nth-child(odd)` only matches when the total count is odd +// (even counts are unaffected). At lg — where 3/4-col layouts kick in — the span +// resets to 1. +const oddLastSpan = + "[&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child:nth-child(odd)]:col-span-1"; + const colsClass: Record, string> = { - 2: "grid-cols-2", - 3: "grid-cols-2 lg:grid-cols-3", - 4: "grid-cols-2 lg:grid-cols-4", + 2: "grid-cols-2 [&>*:last-child:nth-child(odd)]:col-span-2", + 3: `grid-cols-2 lg:grid-cols-3 ${oddLastSpan}`, + 4: `grid-cols-2 lg:grid-cols-4 ${oddLastSpan}`, }; // KpiGrid is the responsive wrapper for a row of KpiCards. diff --git a/src/components/common/page-header.tsx b/src/components/common/page-header.tsx index 8b41c97..9f14dc1 100644 --- a/src/components/common/page-header.tsx +++ b/src/components/common/page-header.tsx @@ -1,14 +1,18 @@ import { ReactNode } from "react" +import { cn } from "@/lib/utils" + export interface PageHeaderProps { title: string subtitle?: string children?: ReactNode + /** Extra classes for the header wrapper (e.g. "pb-0" to keep a page's space-y rhythm uniform). */ + className?: string } -export function PageHeader({ title, subtitle, children }: PageHeaderProps) { +export function PageHeader({ title, subtitle, children, className }: PageHeaderProps) { return ( -
+

{title} @@ -17,7 +21,7 @@ export function PageHeader({ title, subtitle, children }: PageHeaderProps) {

{subtitle}

)}

- {children &&
{children}
} + {children &&
{children}
}
) } diff --git a/src/components/finance/comboboxes/index.ts b/src/components/finance/comboboxes/index.ts index 6d9e1ed..404c0d9 100644 --- a/src/components/finance/comboboxes/index.ts +++ b/src/components/finance/comboboxes/index.ts @@ -1,4 +1,5 @@ export { ProductTypeCombobox } from "./product-type-combobox" +export { ProductTypeMultiCombobox } from "./product-type-multi-combobox" export { RmTypeCombobox } from "./rm-type-combobox" export { ProductMasterCombobox } from "./product-master-combobox" export { RequestTypeCombobox } from "./request-type-combobox" diff --git a/src/components/finance/comboboxes/product-type-multi-combobox.tsx b/src/components/finance/comboboxes/product-type-multi-combobox.tsx new file mode 100644 index 0000000..bf9829e --- /dev/null +++ b/src/components/finance/comboboxes/product-type-multi-combobox.tsx @@ -0,0 +1,132 @@ +"use client" + +// ProductTypeMultiCombobox — multi-select CostProductType filter (check items, count badge, clear). +// Follows the ProductTypeCombobox Popover+Command conventions; the popover stays open while toggling. +import { Check, ChevronsUpDown, Loader2 } from "lucide-react" +import { useMemo, useState } from "react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { useCostProductTypes } from "@/hooks/finance/use-cost-product-type" +import { cn } from "@/lib/utils" + +interface ProductTypeMultiComboboxProps { + value: number[] + onChange: (ids: number[]) => void + placeholder?: string + className?: string +} + +export function ProductTypeMultiCombobox({ + value, + onChange, + placeholder = "All product types", + className, +}: ProductTypeMultiComboboxProps) { + const [open, setOpen] = useState(false) + // Single stable query (no server search) so selected labels always resolve; + // the type master is small, so Command's built-in client-side filtering suffices. + const { data, isLoading } = useCostProductTypes({ activeFilter: "active", pageSize: 50 }) + const items = useMemo(() => data?.items ?? [], [data]) + + const singleSelected = useMemo( + () => (value.length === 1 ? items.find((t) => t.typeId === value[0]) : undefined), + [items, value], + ) + + function toggleType(typeId: number) { + onChange(value.includes(typeId) ? value.filter((id) => id !== typeId) : [...value, typeId]) + } + + return ( + + + + + + + + + {isLoading && ( +
+ Loading… +
+ )} + No product type matches. + + {items.map((t) => ( + toggleType(t.typeId)} + > + + {t.typeCode} + {t.typeName} + + ))} + + {value.length > 0 && ( + <> + + + onChange([])} + > + Clear selection + + + + )} +
+
+
+
+ ) +} diff --git a/src/components/finance/cost-product-master/index.ts b/src/components/finance/cost-product-master/index.ts index 9b99351..3884b7f 100644 --- a/src/components/finance/cost-product-master/index.ts +++ b/src/components/finance/cost-product-master/index.ts @@ -1,3 +1,9 @@ export { ProductMasterFormDialog } from "./product-master-form-dialog" -export { ProductMasterTable } from "./product-master-table" +export { + ProductMasterTable, + useProductMasterTableColumns, + PRODUCT_MASTER_COLUMNS, + PRODUCT_MASTER_TABLE_ID, +} from "./product-master-table" +export { ProductDetailDrawer } from "./product-detail-drawer" export { DeactivateProductMasterDialog } from "./deactivate-dialog" diff --git a/src/components/finance/cost-product-master/product-detail-drawer.tsx b/src/components/finance/cost-product-master/product-detail-drawer.tsx new file mode 100644 index 0000000..a8cd218 --- /dev/null +++ b/src/components/finance/cost-product-master/product-detail-drawer.tsx @@ -0,0 +1,352 @@ +"use client" + +// ProductDetailDrawer — compact read-only "View" drawer for a product master +// row. Modeled on the param-detail-drawer skeleton (sticky header/footer, +// scrollable body). Four independently-loading sections: details, routing, +// non-calculated parameters, and recent cost history. + +import Link from "next/link" +import type { ReactNode } from "react" +import { ArrowUpRight, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { ProductTypeName } from "@/components/common/product-type-name" +import { StatusBadge } from "@/components/common/status-badge" +import { UserName } from "@/components/common/user-name" +import { formatDate, formatNumeric } from "@/components/finance/cost-results/format" +import { useCostProductMaster } from "@/hooks/finance/use-cost-product-master" +import { useCostHistory } from "@/hooks/finance/use-cost-calc" +import { useProductRequiredParams } from "@/hooks/finance/use-cost-product-parameter" +import { useRouteByProduct } from "@/hooks/finance/use-cost-route" +import type { CostProductMaster } from "@/types/finance/cost-product-master" +import type { RequiredParamEntry } from "@/types/finance/cost-product-parameter" + +interface Props { + productSysId: number | null + open: boolean + onOpenChange: (open: boolean) => void +} + +// Each drawer section renders as a distinct card block so the four sections +// (Details / Routing / Parameters / Cost history) read as separate units. +const sectionCardClass = "space-y-2 rounded-lg border bg-card p-4" + +function SectionHeading({ children }: { children: ReactNode }) { + return

{children}

+} + +function SectionError({ children }: { children: ReactNode }) { + return

{children}

+} + +function Field({ label, children, mono }: { label: string; children: ReactNode; mono?: boolean }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +function paramValue(e: RequiredParamEntry): string { + if (!e.hasValue) return "—" + let v: string + switch (e.dataType) { + case "NUMBER": + v = e.valueNumeric + break + case "BOOLEAN": + v = e.valueFlag ? "true" : "false" + break + default: + v = e.valueText + } + return e.uomCode ? `${v} ${e.uomCode}` : v +} + +function DetailsSection({ + product, + isLoading, + isError, +}: { + product: CostProductMaster | null | undefined + isLoading: boolean + isError: boolean +}) { + return ( +
+ Details + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + {!isLoading && (isError || !product) && ( + Failed to load product details. + )} + {!isLoading && product && ( + <> +
+ + {product.productTypeCode ? ( + + {product.productTypeCode} + {product.productTypeName ? ` — ${product.productTypeName}` : ""} + + ) : ( + + )} + + {product.shadeCode || "—"} + {product.gradeCode || "—"} + {product.flex01 || "—"} + {product.flex02 || "—"} + {product.flex03 || "—"} + {product.description && ( +
+ + {product.description} + +
+ )} +
+

+ Created {formatDate(product.createdAt)} + {product.createdBy ? ( + <> + {" by "} + + + ) : null} + {" · Updated "} + {formatDate(product.updatedAt)} + {product.updatedBy ? ( + <> + {" by "} + + + ) : null} +

+ + )} +
+ ) +} + +function RoutingSection({ productSysId }: { productSysId: number | undefined }) { + const { data: head, isLoading } = useRouteByProduct(productSysId) + + return ( +
+ Routing + {isLoading && } + {!isLoading && !head && No routing.} + {!isLoading && head && ( +
+
+ Route #{head.headId} + v{head.version} + +
+ +
+ )} +
+ ) +} + +function ParametersSection({ productSysId }: { productSysId: number | undefined }) { + const { data, isLoading, isError } = useProductRequiredParams(productSysId ?? 0) + // CALCULATED params are engine-filled during costing — excluded from the view. + const params = (data ?? []).filter((e) => e.paramCategory !== "CALCULATED") + + // Group by displayGroup while collecting the minimum displayOrder per group + // (entries arrive pre-sorted by display_group/display_order from the API). + // Groups are ordered by their min displayOrder, named groups first and + // ungrouped ("") last — mirrors the cost-breakdown-modal parameter snapshot. + const groupMinOrder: Record = {} + const grouped: Record = {} + for (const e of params) { + const group = e.displayGroup + if (!grouped[group]) { + grouped[group] = [] + groupMinOrder[group] = e.displayOrder + } else { + groupMinOrder[group] = Math.min(groupMinOrder[group], e.displayOrder) + } + grouped[group].push(e) + } + const groupEntries = Object.keys(grouped) + .sort((a, b) => { + if (!a && !b) return 0 + if (!a) return 1 // ungrouped after all named groups + if (!b) return -1 + return (groupMinOrder[a] ?? 9999) - (groupMinOrder[b] ?? 9999) + }) + .map((g) => [g, grouped[g]] as const) + + return ( +
+ Parameters + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + {!isLoading && isError && Failed to load parameters.} + {!isLoading && !isError && params.length === 0 && ( + No parameters filled for this product. + )} + {!isLoading && !isError && params.length > 0 && ( +
+ {groupEntries.map(([groupName, entries], idx) => ( +
0 ? "mt-4" : ""}> + {groupName !== "" && ( +
+ + {groupName} + + + {entries.length} + +
+ )} +
+ {entries.map((e) => ( +
+ + {e.paramCode} + + + {e.paramName} + + {paramValue(e)} +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} + +function CostHistorySection({ + productSysId, + detailHref, +}: { + productSysId: number | undefined + detailHref: string +}) { + const { data, isLoading, isError } = useCostHistory(productSysId, { page: 1, pageSize: 5 }) + const items = data?.items ?? [] + + return ( +
+
+ Cost history + {items.length > 0 && ( + + View all + + )} +
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + {!isLoading && isError && Failed to load cost history.} + {!isLoading && !isError && items.length === 0 && No cost history yet.} + {!isLoading && !isError && items.length > 0 && ( +
+ {items.map((row) => ( +
+ {row.period} + + {formatNumeric(row.costPerUnit)} + + + + {formatDate(row.calculatedAt).slice(0, 10)} + +
+ ))} +
+ )} +
+ ) +} + +export function ProductDetailDrawer({ productSysId, open, onOpenChange }: Props) { + // All queries are lazy: only fetch while the drawer is open for a product. + const id = open && productSysId ? productSysId : undefined + const { data: product, isLoading, isError } = useCostProductMaster(id) + const detailHref = `/finance/product-master/${productSysId ?? ""}` + + return ( + + + {/* Sticky header */} +
+
+
+ + {product?.productCode ?? "Product"} + + {product && ( + + )} +
+ + {product?.productName ?? "Product master detail"} + +
+ + + +
+ + {/* Scrollable body */} +
+ + + + +
+ + {/* Sticky footer */} +
+ +
+
+
+ ) +} diff --git a/src/components/finance/cost-product-master/product-master-table.tsx b/src/components/finance/cost-product-master/product-master-table.tsx index cdcf834..92309ee 100644 --- a/src/components/finance/cost-product-master/product-master-table.tsx +++ b/src/components/finance/cost-product-master/product-master-table.tsx @@ -1,104 +1,195 @@ "use client" -import Link from "next/link" -import { Edit, Power } from "lucide-react" +import { useMemo } from "react" +import { useRouter } from "next/navigation" +import { Edit, Eye, Package, Power } from "lucide-react" -import { Badge } from "@/components/ui/badge" import { ProductTypeName } from "@/components/common/product-type-name" +import { EmptyState } from "@/components/common/empty-state" +import { StatusBadge } from "@/components/common/status-badge" import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" +import { SortableHeader } from "@/components/shared/data-table/sortable-header" +import { useColumnVisibility } from "@/components/shared/data-table/use-column-visibility" +import type { ColumnDef } from "@/components/shared/data-table/types" import type { CostProductMaster } from "@/types/finance/cost-product-master" +export const PRODUCT_MASTER_TABLE_ID = "finance-product-master" + +// Column ids double as backend sort keys (proto ListCostProductMastersRequest sort_by values). +export const PRODUCT_MASTER_COLUMNS: ColumnDef[] = [ + { id: "product_code", header: "Product code", canHide: false }, + { id: "product_name", header: "Name" }, + { id: "product_type_code", header: "Type" }, + { id: "shade_code", header: "Shade" }, + { id: "grade_code", header: "Grade" }, + { id: "oracle_sys_id", header: "Oracle Sys ID" }, + { id: "erp_compound_key", header: "ERP Compound Key" }, + { id: "type_label", header: "Type Label" }, + { id: "status", header: "Status" }, +] + +/** Page-level hook so the visibility toggle can live in the filter toolbar. */ +export function useProductMasterTableColumns() { + const columns = useMemo(() => PRODUCT_MASTER_COLUMNS, []) + const { visibility, toggle, setAll, reset } = useColumnVisibility(PRODUCT_MASTER_TABLE_ID, columns) + return { columns, visibility, toggle, setAll, reset } +} + interface Props { items: CostProductMaster[] isLoading?: boolean onEdit: (p: CostProductMaster) => void onDeactivate: (p: CostProductMaster) => void + onView: (p: CostProductMaster) => void + sortBy?: string + sortOrder?: "asc" | "desc" + onSort: (sortKey: string) => void + visibility: Record } -export function ProductMasterTable({ items, isLoading, onEdit, onDeactivate }: Props) { +export function ProductMasterTable({ + items, + isLoading, + onEdit, + onDeactivate, + onView, + sortBy, + sortOrder, + onSort, + visibility, +}: Props) { + const router = useRouter() + const show = (id: string) => visibility[id] !== false + const visibleCount = PRODUCT_MASTER_COLUMNS.filter((c) => show(c.id)).length + 1 // +1 actions + + const sortProps = { currentSortBy: sortBy, currentSortOrder: sortOrder, onSort } + return ( -
- - - - Product code - Name - Type - Shade - Grade - Oracle Sys ID - ERP Compound Key - Type Label - Status - Actions - - - - {isLoading && ( +
+
+
+ - - Loading… - + {show("product_code") && ( + + )} + {show("product_name") && ( + + )} + {show("product_type_code") && ( + + )} + {show("shade_code") && ( + + )} + {show("grade_code") && ( + + )} + {show("oracle_sys_id") && ( + + )} + {show("erp_compound_key") && ( + + )} + {show("type_label") && ( + + )} + {show("status") && ( + + )} + Actions - )} - {!isLoading && items.length === 0 && ( - - - No products yet. - - - )} - {items.map((p) => ( - - - - {p.productCode} - - - {p.productName} - - {p.productTypeCode ? ( - {p.productTypeCode} - ) : ( - + + + {isLoading && + Array.from({ length: 5 }).map((_, i) => ( + + {show("product_code") && } + {show("product_name") && } + {show("product_type_code") && } + {show("shade_code") && } + {show("grade_code") && } + {show("oracle_sys_id") && } + {show("erp_compound_key") && } + {show("type_label") && } + {show("status") && } + + + ))} + {!isLoading && items.length === 0 && ( + + + + + + )} + {items.map((p) => ( + router.push(`/finance/product-master/${p.productSysId}`)} + > + {show("product_code") && ( + {p.productCode} + )} + {show("product_name") && {p.productName}} + {show("product_type_code") && ( + + {p.productTypeCode ? ( + {p.productTypeCode} + ) : ( + + )} + )} - - {p.shadeCode || "—"} - {p.gradeCode} - {p.flex02 || "—"} - {p.flex01 || "—"} - {p.flex03 || "—"} - - {p.isActive ? ( - Active - ) : ( - Inactive + {show("shade_code") && {p.shadeCode || "—"}} + {show("grade_code") && {p.gradeCode}} + {show("oracle_sys_id") && ( + {p.flex02 || "—"} )} - - - - - - - ))} - -
+ + + + + + ))} + + +
) } diff --git a/src/components/shared/data-table/column-visibility-menu.tsx b/src/components/shared/data-table/column-visibility-menu.tsx index 16072a4..25787c3 100644 --- a/src/components/shared/data-table/column-visibility-menu.tsx +++ b/src/components/shared/data-table/column-visibility-menu.tsx @@ -3,6 +3,7 @@ import { Settings2 } from "lucide-react" import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" import { DropdownMenu, DropdownMenuCheckboxItem, @@ -21,6 +22,8 @@ interface Props { onToggle: (columnId: string) => void onSetAll: (visible: boolean) => void onReset: () => void + /** Extra classes for the trigger button (e.g. "h-9" to match an h-9 filter row). */ + className?: string } /** Column-visibility dropdown shown above the table when DataTable.tableId is set. */ @@ -30,6 +33,7 @@ export function ColumnVisibilityMenu({ onToggle, onSetAll, onReset, + className, }: Props) { const toggleable = columns.filter((c) => c.canHide !== false) if (toggleable.length === 0) return null @@ -37,7 +41,7 @@ export function ColumnVisibilityMenu({ return ( - diff --git a/src/components/shared/data-table/index.ts b/src/components/shared/data-table/index.ts index 1b88266..c99b1c1 100644 --- a/src/components/shared/data-table/index.ts +++ b/src/components/shared/data-table/index.ts @@ -2,6 +2,7 @@ export { DataTable } from "./data-table" export { DataTablePagination } from "./data-table-pagination" export { ColumnVisibilityMenu } from "./column-visibility-menu" +export { SortableHeader } from "./sortable-header" export { useColumnVisibility } from "./use-column-visibility" export type { ColumnDef, diff --git a/src/components/shared/data-table/sortable-header.tsx b/src/components/shared/data-table/sortable-header.tsx new file mode 100644 index 0000000..5c0d3ae --- /dev/null +++ b/src/components/shared/data-table/sortable-header.tsx @@ -0,0 +1,85 @@ +"use client" + +import { TableHead } from "@/components/ui/table" +import { cn } from "@/lib/utils" + +interface SortableHeaderProps { + /** Visible column label. */ + label: string + /** Sort key sent to the backend (proto sort_by value). */ + sortKey: string + /** Currently active sort key (from filters). */ + currentSortBy?: string + /** Currently active sort direction (from filters). */ + currentSortOrder?: "asc" | "desc" + /** Called with the column's sortKey when the header is clicked. */ + onSort: (sortKey: string) => void + /** Extra classes for the underlying TableHead (widths, edge padding). */ + className?: string +} + +/** + * Stacked-triangles sort indicator (Font Awesome "fa-sort" style). + * Neutral: both triangles muted. Asc: top filled. Desc: bottom filled. + */ +function SortIndicator({ direction }: { direction?: "asc" | "desc" }) { + return ( + + ) +} + +/** + * SortableHeader — table header cell that is clickable across its whole width. + * + * The entire TableHead is the click target (cursor-pointer, no hover + * background); a stacked-triangles indicator shows the sort state and + * `aria-sort` is set for assistive technology. Direction cycling + * (asc → desc, reset to asc on column change) is owned by the caller. + */ +export function SortableHeader({ + label, + sortKey, + currentSortBy, + currentSortOrder, + onSort, + className, +}: SortableHeaderProps) { + const isActive = currentSortBy === sortKey + const direction = isActive ? (currentSortOrder === "desc" ? "desc" : "asc") : undefined + + return ( + onSort(sortKey)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onSort(sortKey) + } + }} + > + + {label} + + + + ) +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 59879f6..23a0127 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -4,6 +4,9 @@ export { DataTable, DataTablePagination, + ColumnVisibilityMenu, + SortableHeader, + useColumnVisibility, type ColumnDef, type RowAction, type DataTableProps, diff --git a/src/hooks/finance/use-cost-product-master.ts b/src/hooks/finance/use-cost-product-master.ts index c2f1fc0..f5317db 100644 --- a/src/hooks/finance/use-cost-product-master.ts +++ b/src/hooks/finance/use-cost-product-master.ts @@ -20,6 +20,9 @@ async function fetchList(params: ListCostProductMastersParams) { const qs = new URLSearchParams() if (params.search) qs.set("search", params.search) if (params.productTypeId) qs.set("productTypeId", String(params.productTypeId)) + if (params.productTypeIds && params.productTypeIds.length > 0) { + qs.set("productTypeIds", params.productTypeIds.join(",")) + } if (params.shadeCode) qs.set("shadeCode", params.shadeCode) if (params.activeFilter) qs.set("activeFilter", params.activeFilter) if (params.sortBy) qs.set("sortBy", params.sortBy) diff --git a/src/types/finance/cost-product-master.ts b/src/types/finance/cost-product-master.ts index 967ec10..ccbd474 100644 --- a/src/types/finance/cost-product-master.ts +++ b/src/types/finance/cost-product-master.ts @@ -22,6 +22,8 @@ export interface CostProductMaster { export interface ListCostProductMastersParams { search?: string productTypeId?: number + /** Multi-select type filter; unioned with productTypeId server-side. Empty = no filter. */ + productTypeIds?: number[] shadeCode?: string activeFilter?: "all" | "active" | "inactive" | "" sortBy?: string diff --git a/src/types/generated/finance/v1/cost_product_master.ts b/src/types/generated/finance/v1/cost_product_master.ts index fb451f8..31a0a53 100644 --- a/src/types/generated/finance/v1/cost_product_master.ts +++ b/src/types/generated/finance/v1/cost_product_master.ts @@ -113,7 +113,7 @@ export interface DeactivateCostProductMasterResponse { } export interface ListCostProductMastersRequest { - /** matches product_code OR product_name OR erp_item_code */ + /** matches product_code OR product_name OR erp_item_code OR oracle sys id (flex_02) */ search: string; productTypeId: number; shadeCode: string; @@ -121,6 +121,8 @@ export interface ListCostProductMastersRequest { pagination: PaginationRequest | undefined; sortBy: string; sortOrder: string; + /** additional type filter, unioned with product_type_id */ + productTypeIds: number[]; } export interface ListCostProductMastersResponse { @@ -1825,6 +1827,7 @@ function createBaseListCostProductMastersRequest(): ListCostProductMastersReques pagination: undefined, sortBy: "", sortOrder: "", + productTypeIds: [], }; } @@ -1851,6 +1854,11 @@ export const ListCostProductMastersRequest: MessageFns globalThis.Number(e)) + : globalThis.Array.isArray(object?.product_type_ids) + ? object.product_type_ids.map((e: any) => globalThis.Number(e)) + : [], }; }, @@ -1981,6 +2012,9 @@ export const ListCostProductMastersRequest: MessageFns Math.round(e)); + } return obj; }, @@ -1998,6 +2032,7 @@ export const ListCostProductMastersRequest: MessageFns e) || []; return message; }, }; From cfcb20761eec74602d7c226e9ecfcbe0ddb1bf6b Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 2 Jul 2026 16:24:48 +0700 Subject: [PATCH 2/2] docs(design-system): codify UI conventions from product master UX batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DESIGN.md: control height parity (h-9 toolbar rows), header action button sizing + icon-only collapse, multi-select combobox pattern, row-click navigation rules - LAYOUT.md: flat list page variant, table edge padding, 375px responsive floor - RULES.md: Mistake 12 (sortBy/sortOrder must be in useUrlState defaults), Mistake 13 (stale typography cardTitle); SortableHeader + StatusBadge usage - CLAUDE.md §10.3: useUrlState defaultValues tracking warning Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + docs/design-system/DESIGN.md | 107 ++++++++++++++++++++++++++++++----- docs/design-system/LAYOUT.md | 19 +++++++ docs/design-system/RULES.md | 47 ++++++++++++++- 4 files changed, 159 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 03c3212..5589e86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -888,6 +888,7 @@ const [filters, setFilters] = useUrlState({ - Uses `router.replace()` (no history per keystroke) - Browser back/forward auto-restores filter state - Always reset `page: 1` when any non-pagination filter changes +- **Only keys present in `defaultValues` are tracked** — pages with server-side sorting MUST include `sortBy`/`sortOrder` in `defaultValues` or sort changes are silently dropped (see RULES.md Mistake 12) ### 10.4 Cache Settings diff --git a/docs/design-system/DESIGN.md b/docs/design-system/DESIGN.md index 72b728e..7cdb8d7 100644 --- a/docs/design-system/DESIGN.md +++ b/docs/design-system/DESIGN.md @@ -264,6 +264,19 @@ When `CardHeader` has a right slot (badge, button), always add `space-y-0` to pr | Icon + text inside button | `mr-2 h-4 w-4` on icon | | Icon + text inside badge | `mr-1 h-3 w-3` on icon | +### Control Height Parity (filter / toolbar rows) + +Every control in a filter/toolbar row is **`h-9` (36px)** — search inputs, comboboxes, selects, the column-visibility trigger, and any adjacent buttons. Never mix `h-8`/`h-10` controls in the same row. + +```tsx + + +... + {/* trigger defaults to h-8 — pass h-9 in filter rows */} +``` + +Reference implementation: the filter toolbar in `src/app/(dashboard)/finance/product-master/product-master-page-client.tsx`. + --- ## 5. Components A–Z @@ -398,6 +411,21 @@ Used for contextual notices that appear inline on a page — not toasts. Two var ``` +#### Header Action Buttons (PageHeader actions slot) + +All buttons in a `PageHeader` actions slot use the **same size (default, 36px)**. Below `sm` they collapse to **icon-only**: wrap the label in `hidden sm:inline`, keep the icon always visible, and add `aria-label` so the icon-only button stays accessible. Dropdown chevrons may stay visible on mobile. + +```tsx + +``` + +- No `mr-2` on the icon here — rely on the Button's built-in `gap-2`, so the hidden label leaves no stray margin on mobile +- The PageHeader actions slot is already `flex flex-wrap items-center gap-2` — never remove the wrap +- Reference: `src/app/(dashboard)/finance/product-master/product-master-page-client.tsx` (Import / Export / New product) + #### Action Bar Pattern (Detail Pages) ```tsx @@ -697,6 +725,15 @@ export function ExampleCombobox({ value, onSelect, placeholder = "Select…", di - Always `CommandEmpty` — "No results." message when search returns nothing - Never use ` - - - - - Name (A-Z) - Name (Z-A) - Newest First - - + ``` -For **column-header click sort**, add `sortKey` to column definition and handle in parent: +Direction cycling is owned by the page client — asc ↔ desc on the active column, a new column always starts asc: + +```tsx +function handleSort(sortKey: string) { + const nextOrder = filters.sortBy === sortKey && filters.sortOrder === "asc" ? "desc" : "asc" + setFilters({ ...filters, sortBy: sortKey, sortOrder: nextOrder, page: 1 }) +} +``` + +Reference implementation: `product-master-table.tsx` + `product-master-page-client.tsx` (`/finance/product-master`). + +In `DataTable` columns, add `sortKey` to the column definition and handle in the parent: ```tsx { id: "name", @@ -1535,6 +1606,14 @@ For **column-header click sort**, add `sortKey` to column definition and handle } ``` +#### Row-Click Navigation + +When list rows navigate to a detail page: + +- `onClick` on the `TableRow` + `cursor-pointer` +- The actions cell must call `stopPropagation` so action buttons don't trigger navigation +- Do **not** also render a `` on the identity column — it stays plain text (no link-inside-clickable-row) + #### Row Actions ```tsx diff --git a/docs/design-system/LAYOUT.md b/docs/design-system/LAYOUT.md index e2ed7df..75657d9 100644 --- a/docs/design-system/LAYOUT.md +++ b/docs/design-system/LAYOUT.md @@ -231,6 +231,13 @@ The `DataTable` component already applies this internally. Do NOT add `overflow- 3. DataTable 4. DataTablePagination +**Vertical rhythm**: the single root `space-y-6` is the ONLY spacing between header → KPI row → filters → table → pagination — no extra ad-hoc margins between these blocks. If `PageHeader`'s built-in bottom padding doubles the gap, pass `className="pb-0"` to it rather than adjusting neighbors. + +**Flat list variant** (no wrapping Card — reference `/finance/product-master`): PageHeader → KpiGrid → filter toolbar → table → DataTablePagination sit directly under the root `space-y-6`. Rules for the flat table: +- Table wrapper: `rounded-md border` with an **inner** `overflow-x-auto` div — the table scrolls inside the border, not the page +- First column gets `pl-4` and last column `pr-4` — on **both** head and body cells — so content aligns with the rounded border +- Every control in the filter toolbar row is `h-9` (see DESIGN.md §4 — Control Height Parity) + --- ### 4.2 Detail Page (Bento Grid) @@ -504,6 +511,18 @@ Action bars always use `flex-wrap`: On mobile (`<640px`), when the container is narrower, buttons wrap to the next line. The `flex-1` spacer collapses and the "Cancel" button appears below the primary actions — this is correct behavior. +### Header Actions Collapse to Icon-Only + +Below `sm` (640px), buttons in the `PageHeader` actions slot collapse to **icon-only**: wrap the label in ``, keep the icon always visible, and add `aria-label` on the button so icon-only remains accessible. Dropdown chevrons may stay visible. Full pattern in DESIGN.md §5.3 (Header Action Buttons); reference: `/finance/product-master` (Import / Export / New product). + +### 375px Responsive Floor + +Every list page must be verified at **375px** viewport width: + +- Zero horizontal page overflow (header and sidebar never scroll sideways) +- Filter controls stack (`grid-cols-1` / `flex-col` at base) +- Tables scroll **inside their own wrapper only** — never the page + ### PageHeader on Mobile The `PageHeader` component handles this automatically: diff --git a/docs/design-system/RULES.md b/docs/design-system/RULES.md index 364fd1d..98ce0c1 100644 --- a/docs/design-system/RULES.md +++ b/docs/design-system/RULES.md @@ -118,6 +118,23 @@ I need a side panel that stays in context I need to pick from a long/async list in a form → Build a Combobox (DESIGN.md §5.6) — NEVER a Select with 50+ items +I need a multi-value filter (e.g. several product types at once) +→ Multi-Select Combobox (DESIGN.md §5.6) — popover stays open on toggle, + count badge in trigger, "Clear selection" CommandItem INSIDE the popover + (never a nested interactive element inside the trigger button) + +I need sortable column headers on a custom table +→ from src/components/shared/data-table/sortable-header.tsx (DESIGN.md §5.24) +→ NEVER ad-hoc sort buttons inside TableHead + +I need show/hide columns on a custom table (>6 columns) +→ useColumnVisibility("-", columns) + (DESIGN.md §5.24) +→ Identity/key column gets canHide: false + +I need list rows that navigate to a detail page +→ onClick on TableRow + cursor-pointer; stopPropagation on the actions cell; + identity column stays plain text — no inside a clickable row (DESIGN.md §5.24) + I need to show multi-line read-only text →

(NOT a Textarea) @@ -185,6 +202,10 @@ For every component/page generated, verify: - [ ] Dialog max-width uses `sm:max-w-[Npx]` (full-width on mobile, capped on desktop) - [ ] Drawer uses `w-full sm:max-w-2xl` - [ ] No hardcoded px widths on main content containers (they should flex/fill) +- [ ] **Control height parity**: every control in a filter/toolbar row is `h-9` (36px) — search input, comboboxes, selects, column-visibility trigger, adjacent buttons; never mix h-8/h-10 in one row (DESIGN.md §4) +- [ ] **PageHeader action buttons**: all same size (default, 36px); below `sm` they collapse to icon-only — `hidden sm:inline` on the label + `aria-label` on the button (DESIGN.md §5.3) +- [ ] **375px floor**: page verified at 375px — zero horizontal page overflow, filters stack, tables scroll inside their wrapper only (LAYOUT.md §8) +- [ ] KpiGrid with an odd card count needs no manual fix — the last card auto-spans the 2-col mobile row --- @@ -221,6 +242,10 @@ For every component/page generated, verify: | `` for 6+ fields | Regular `` that overflows | | `` | `totalItems={response.totalItems}` (string) | | Extend `status-colors.ts` for new statuses | Inline `` | +| `` for header-click sort | Ad-hoc `