diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
| commit | 5036cf2908792cef45f06256e71f10920f647f49 (patch) | |
| tree | 3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/vendor-response/table | |
| parent | 7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff) | |
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/vendor-response/table')
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx | 365 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx | 143 |
2 files changed, 508 insertions, 0 deletions
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx new file mode 100644 index 00000000..5c6971cc --- /dev/null +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Edit } from "lucide-react" +import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { + TechSalesVendorQuotations, + TECH_SALES_QUOTATION_STATUS_CONFIG +} from "@/db/schema" +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" + +interface QuotationWithRfqCode extends TechSalesVendorQuotations { + rfqCode?: string; + materialCode?: string; + dueDate?: Date; + rfqStatus?: string; + itemName?: string; + projNm?: string; + quotationCode?: string | null; + quotationVersion?: number | null; + rejectionReason?: string | null; + acceptedAt?: Date | null; +} + +interface GetColumnsProps { + router: AppRouterInstance; +} + +export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "id", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="ID" /> + ), + cell: ({ row }) => ( + <div className="w-20"> + <span className="font-mono text-xs">{row.getValue("id")}</span> + </div> + ), + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="RFQ 번호" /> + ), + cell: ({ row }) => { + const rfqCode = row.getValue("rfqCode") as string; + return ( + <div className="min-w-32"> + <span className="font-mono text-sm">{rfqCode || "N/A"}</span> + </div> + ); + }, + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: "materialCode", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="자재 코드" /> + ), + cell: ({ row }) => { + const materialCode = row.getValue("materialCode") as string; + return ( + <div className="min-w-32"> + <span className="font-mono text-sm">{materialCode || "N/A"}</span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "itemName", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="품목명" /> + ), + cell: ({ row }) => { + const itemName = row.getValue("itemName") as string; + return ( + <div className="min-w-48 max-w-64"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="truncate block text-sm"> + {itemName || "N/A"} + </span> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs">{itemName || "N/A"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); + }, + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: "projNm", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="프로젝트명" /> + ), + cell: ({ row }) => { + const projNm = row.getValue("projNm") as string; + return ( + <div className="min-w-48 max-w-64"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="truncate block text-sm"> + {projNm || "N/A"} + </span> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs">{projNm || "N/A"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); + }, + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + + const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || { + label: status, + variant: "secondary" as const + }; + + return ( + <div className="w-24"> + <Badge variant={statusConfig.variant} className="text-xs"> + {statusConfig.label} + </Badge> + </div> + ); + }, + enableSorting: true, + enableHiding: false, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="통화" /> + ), + cell: ({ row }) => { + const currency = row.getValue("currency") as string; + return ( + <div className="w-16"> + <span className="font-mono text-sm">{currency || "N/A"}</span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "totalPrice", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="총액" /> + ), + cell: ({ row }) => { + const totalPrice = row.getValue("totalPrice") as string; + const currency = row.getValue("currency") as string; + + if (!totalPrice || totalPrice === "0") { + return ( + <div className="w-32 text-right"> + <span className="text-muted-foreground text-sm">미입력</span> + </div> + ); + } + + return ( + <div className="w-32 text-right"> + <span className="font-mono text-sm"> + {formatCurrency(parseFloat(totalPrice), currency || "USD")} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "validUntil", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="유효기간" /> + ), + cell: ({ row }) => { + const validUntil = row.getValue("validUntil") as Date; + return ( + <div className="w-28"> + <span className="text-sm"> + {validUntil ? formatDate(validUntil) : "N/A"} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="제출일" /> + ), + cell: ({ row }) => { + const submittedAt = row.getValue("submittedAt") as Date; + return ( + <div className="w-36"> + <span className="text-sm"> + {submittedAt ? formatDateTime(submittedAt) : "미제출"} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="마감일" /> + ), + cell: ({ row }) => { + const dueDate = row.getValue("dueDate") as Date; + const isOverdue = dueDate && new Date() > new Date(dueDate); + + return ( + <div className="w-28"> + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {dueDate ? formatDate(dueDate) : "N/A"} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="생성일" /> + ), + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as Date; + return ( + <div className="w-36"> + <span className="text-sm"> + {createdAt ? formatDateTime(createdAt) : "N/A"} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="수정일" /> + ), + cell: ({ row }) => { + const updatedAt = row.getValue("updatedAt") as Date; + return ( + <div className="w-36"> + <span className="text-sm"> + {updatedAt ? formatDateTime(updatedAt) : "N/A"} + </span> + </div> + ); + }, + enableSorting: true, + enableHiding: true, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const quotation = row.original; + const rfqCode = quotation.rfqCode || "N/A"; + const tooltipText = `${rfqCode} 견적서 작성`; + + return ( + <div className="w-16"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => { + router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); + }} + className="h-8 w-8" + > + <Edit className="h-4 w-4" /> + <span className="sr-only">견적서 작성</span> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{tooltipText}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); + }, + enableSorting: false, + enableHiding: false, + }, + ]; +}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx new file mode 100644 index 00000000..63d4674b --- /dev/null +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -0,0 +1,143 @@ +// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema" +import { useRouter } from "next/navigation" +import { getColumns } from "./vendor-quotations-table-columns" + +interface QuotationWithRfqCode extends TechSalesVendorQuotations { + rfqCode?: string; + materialCode?: string; + dueDate?: Date; + rfqStatus?: string; + itemName?: string; + projNm?: string; + quotationCode?: string | null; + quotationVersion?: number | null; + rejectionReason?: string | null; + acceptedAt?: Date | null; +} + +interface VendorQuotationsTableProps { + promises: Promise<[{ data: any[], pageCount: number, total?: number }]>; +} + +export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) { + + // TODO: 안정화 이후 삭제 + console.log("렌더링 사이클 점검용 로그: VendorQuotationsTable 렌더링됨"); + + const [{ data, pageCount }] = React.use(promises); + const router = useRouter(); + + // 데이터 안정성을 위한 메모이제이션 - 핵심 속성만 비교 + const stableData = React.useMemo(() => { + return data; + }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); + + // 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성 + const columns = React.useMemo(() => getColumns({ + router, + }), [router]); + + // 필터 필드 - 중앙화된 상태 상수 사용 + const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [ + { + id: "status", + label: "상태", + options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ + label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, + value: statusValue, + })) + }, + { + id: "rfqCode", + label: "RFQ 번호", + placeholder: "RFQ 번호 검색...", + }, + { + id: "materialCode", + label: "자재 코드", + placeholder: "자재 코드 검색...", + } + ], []); + + // 고급 필터 필드 - 중앙화된 상태 상수 사용 + const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [ + { + id: "rfqCode", + label: "RFQ 번호", + type: "text", + }, + { + id: "materialCode", + label: "자재 코드", + type: "text", + }, + { + id: "status", + label: "상태", + type: "multi-select", + options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ + label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, + value: statusValue, + })), + }, + { + id: "validUntil", + label: "유효기간", + type: "date", + }, + { + id: "submittedAt", + label: "제출일", + type: "date", + }, + ], []); + + // useDataTable 훅 사용 + const { table } = useDataTable({ + data: stableData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + defaultColumn: { + minSize: 50, + maxSize: 500, + }, + }); + + return ( + <div className="w-full"> + <div className="overflow-x-auto"> + <DataTable + table={table} + className="min-w-full" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + ); +}
\ No newline at end of file |
