diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/vendor-response/table | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/vendor-response/table')
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx | 1380 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx | 1028 |
2 files changed, 1233 insertions, 1175 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 index 39de94ed..328def80 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -1,672 +1,710 @@ -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { Edit, Paperclip, Package } 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 { - TechSalesVendorQuotations, - TECH_SALES_QUOTATION_STATUS_CONFIG, - TECH_SALES_QUOTATION_STATUSES -} from "@/db/schema" -import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" - -interface QuotationWithRfqCode extends TechSalesVendorQuotations { - // RFQ 관련 정보 - rfqCode?: string; - materialCode?: string; - dueDate?: Date; - rfqStatus?: string; - - // 아이템 정보 - itemName?: string; - itemCount?: number; - - // 프로젝트 정보 - projNm?: string; - pspid?: string; - sector?: string; - - // RFQ 정보 - description?: string; - - // 벤더 정보 - vendorName?: string; - vendorCode?: string; - - // 사용자 정보 - createdByName?: string | null; - updatedByName?: string | null; - - // 첨부파일 개수 - attachmentCount?: number; -} - -interface GetColumnsProps { - router: AppRouterInstance; - openAttachmentsSheet: (rfqId: number) => void; - openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void; -} - -export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: 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 }) => { - const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED; - const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED; - const isDisabled = isRejected || isAccepted; - - return ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="행 선택" - className="translate-y-0.5" - disabled={isDisabled} - /> - ); - }, - enableSorting: false, - enableHiding: false, - }, - // { - // accessorKey: "id", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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: "vendorName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="벤더명" /> - // ), - // cell: ({ row }) => { - // const vendorName = row.getValue("vendorName") as string; - // return ( - // <div className="min-w-32"> - // <span className="text-sm">{vendorName || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: false, - // }, - // { - // accessorKey: "vendorCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - // ), - // cell: ({ row }) => { - // const vendorCode = row.getValue("vendorCode") as string; - // return ( - // <div className="min-w-24"> - // <span className="font-mono text-sm">{vendorCode || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "materialCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple 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 }) => ( - // <DataTableColumnHeaderSimple 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: true, - // enableHiding: true, - // }, - { - accessorKey: "description", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ title" /> - ), - cell: ({ row }) => { - const description = row.getValue("description") as string; - return ( - <div className="min-w-48 max-w-64"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="truncate block text-sm"> - {description || "N/A"} - </span> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{description || "N/A"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "projNm", - header: ({ column }) => ( - <DataTableColumnHeaderSimple 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: true, - enableHiding: true, - }, - // { - // accessorKey: "quotationCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="견적서 번호" /> - // ), - // cell: ({ row }) => { - // const quotationCode = row.getValue("quotationCode") as string; - // return ( - // <div className="min-w-32"> - // <span className="font-mono text-sm">{quotationCode || "미부여"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "quotationVersion", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="버전" /> - // ), - // cell: ({ row }) => { - // const quotationVersion = row.getValue("quotationVersion") as number; - // return ( - // <div className="w-16 text-center"> - // <span className="text-sm">{quotationVersion || 1}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - { - id: "items", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="아이템" /> - ), - cell: ({ row }) => { - const quotation = row.original - const itemCount = quotation.itemCount || 0 - - const handleClick = () => { - const rfq = { - id: quotation.rfqId, - rfqCode: quotation.rfqCode, - status: quotation.rfqStatus, - rfqType: "SHIP" as const, // 기본값 - } - openItemsDialog(rfq) - } - - return ( - <div className="w-20"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={`View ${itemCount} items`} - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {itemCount > 0 && ( - <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> - {itemCount} - </span> - )} - <span className="sr-only"> - {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"} - </span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ) - }, - enableSorting: false, - enableHiding: true, - }, - { - id: "attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> - ), - cell: ({ row }) => { - const quotation = row.original - const attachmentCount = quotation.attachmentCount || 0 - const handleClick = () => { - openAttachmentsSheet(quotation.rfqId) - } - - return ( - <div className="w-20"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {attachmentCount > 0 && ( - <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> - {attachmentCount} - </span> - )} - <span className="sr-only"> - {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"} - </span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ) - }, - enableSorting: false, - enableHiding: true, - }, - { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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: "acceptedAt", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="승인일" /> - // ), - // cell: ({ row }) => { - // const acceptedAt = row.getValue("acceptedAt") as Date; - // return ( - // <div className="w-36"> - // <span className="text-sm"> - // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"} - // </span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple 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: "rejectionReason", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="반려사유" /> - // ), - // cell: ({ row }) => { - // const rejectionReason = row.getValue("rejectionReason") as string; - // return ( - // <div className="min-w-48 max-w-64"> - // {rejectionReason ? ( - // <TooltipProvider> - // <Tooltip> - // <TooltipTrigger asChild> - // <span className="truncate block text-sm text-red-600"> - // {rejectionReason} - // </span> - // </TooltipTrigger> - // <TooltipContent> - // <p className="max-w-xs">{rejectionReason}</p> - // </TooltipContent> - // </Tooltip> - // </TooltipProvider> - // ) : ( - // <span className="text-sm text-muted-foreground">N/A</span> - // )} - // </div> - // ); - // }, - // enableSorting: false, - // enableHiding: true, - // }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple 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 }) => ( - <DataTableColumnHeaderSimple 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, - }, - // { - // accessorKey: "createdByName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="생성자" /> - // ), - // cell: ({ row }) => { - // const createdByName = row.getValue("createdByName") as string; - // return ( - // <div className="w-24"> - // <span className="text-sm">{createdByName || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "updatedByName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="수정자" /> - // ), - // cell: ({ row }) => { - // const updatedByName = row.getValue("updatedByName") as string; - // return ( - // <div className="w-24"> - // <span className="text-sm">{updatedByName || "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} 견적서 작성`; - const isRejected = quotation.status === "Rejected"; - const isAccepted = quotation.status === "Accepted"; - const isDisabled = isRejected || isAccepted; - - return ( - <div className="w-16"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => { - if (!isDisabled) { - router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); - } - }} - className="h-8 w-8" - disabled={isDisabled} - > - <Edit className="h-4 w-4" /> - <span className="sr-only">견적서 작성</span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: false, - enableHiding: false, - }, - ]; +"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Edit, Paperclip, Package, Users } 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 {
+ TechSalesVendorQuotations,
+ TECH_SALES_QUOTATION_STATUS_CONFIG,
+ TECH_SALES_QUOTATION_STATUSES
+} from "@/db/schema"
+import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ // RFQ 관련 정보
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+
+ // 아이템 정보
+ itemName?: string;
+ itemCount?: number;
+
+ // 프로젝트 정보
+ projNm?: string;
+ pspid?: string;
+ sector?: string;
+
+ // RFQ 정보
+ description?: string;
+
+ // 벤더 정보
+ vendorName?: string;
+ vendorCode?: string;
+
+ // 사용자 정보
+ createdByName?: string | null;
+ updatedByName?: string | null;
+
+ // 첨부파일 개수
+ attachmentCount?: number;
+}
+
+interface GetColumnsProps {
+ router: AppRouterInstance;
+ openAttachmentsSheet: (rfqId: number) => void;
+ openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
+ openContactsDialog: (quotationId: number, vendorName?: string) => void;
+}
+
+export function getColumns({ router, openAttachmentsSheet, openItemsDialog, openContactsDialog }: 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 }) => {
+ const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
+ const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-0.5"
+ disabled={isDisabled}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "id",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="text-sm">{vendorName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: false,
+ // },
+ // {
+ // accessorKey: "vendorCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorCode = row.getValue("vendorCode") as string;
+ // return (
+ // <div className="min-w-24">
+ // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "materialCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple 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 }) => (
+ // <DataTableColumnHeaderSimple 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: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "quotationCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationCode = row.getValue("quotationCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "quotationVersion",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="버전" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationVersion = row.getValue("quotationVersion") as number;
+ // return (
+ // <div className="w-16 text-center">
+ // <span className="text-sm">{quotationVersion || 1}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const itemCount = quotation.itemCount || 0
+
+ const handleClick = () => {
+ const rfq = {
+ id: quotation.rfqId,
+ rfqCode: quotation.rfqCode,
+ status: quotation.rfqStatus,
+ rfqType: "SHIP" as const, // 기본값
+ }
+ openItemsDialog(rfq)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={`View ${itemCount} items`}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {itemCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const attachmentCount = quotation.attachmentCount || 0
+ const handleClick = () => {
+ openAttachmentsSheet(quotation.rfqId)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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: "acceptedAt",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="승인일" />
+ // ),
+ // cell: ({ row }) => {
+ // const acceptedAt = row.getValue("acceptedAt") as Date;
+ // return (
+ // <div className="w-36">
+ // <span className="text-sm">
+ // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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: "rejectionReason",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="반려사유" />
+ // ),
+ // cell: ({ row }) => {
+ // const rejectionReason = row.getValue("rejectionReason") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // {rejectionReason ? (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm text-red-600">
+ // {rejectionReason}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{rejectionReason}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // ) : (
+ // <span className="text-sm text-muted-foreground">N/A</span>
+ // )}
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple 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 }) => (
+ <DataTableColumnHeaderSimple 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,
+ },
+ // {
+ // accessorKey: "createdByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="생성자" />
+ // ),
+ // cell: ({ row }) => {
+ // const createdByName = row.getValue("createdByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{createdByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "updatedByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="수정자" />
+ // ),
+ // cell: ({ row }) => {
+ // const updatedByName = row.getValue("updatedByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{updatedByName || "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} 견적서 작성`;
+ const isRejected = quotation.status === "Rejected";
+ const isAccepted = quotation.status === "Accepted";
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <div className="w-16">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => {
+ if (!isDisabled) {
+ router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
+ }
+ }}
+ className="h-8 w-8"
+ disabled={isDisabled}
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ openContactsDialog(quotation.id, quotation.vendorName);
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ ];
}
\ 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 index e79d7c4d..5bb219bf 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -1,505 +1,525 @@ -// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx -"use client" - -import * as React from "react" -import { useSearchParams } from "next/navigation" -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" -import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" -import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog" -import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service" -import { toast } from "sonner" -import { Skeleton } from "@/components/ui/skeleton" -import { Button } from "@/components/ui/button" -import { X } from "lucide-react" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" - -interface QuotationWithRfqCode extends TechSalesVendorQuotations { - rfqCode?: string | null; - materialCode?: string | null; - dueDate?: Date; - rfqStatus?: string; - itemName?: string | null; - projNm?: string | null; - description?: string | null; - attachmentCount?: number; - itemCount?: number; - pspid?: string | null; - sector?: string | null; - vendorName?: string | null; - vendorCode?: string | null; - createdByName?: string | null; - updatedByName?: string | null; -} - -interface VendorQuotationsTableProps { - vendorId: string; - rfqType?: "SHIP" | "TOP" | "HULL"; -} - -// 로딩 스켈레톤 컴포넌트 -function TableLoadingSkeleton() { - return ( - <div className="w-full space-y-3"> - {/* 툴바 스켈레톤 */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-2"> - <Skeleton className="h-10 w-[250px]" /> - <Skeleton className="h-10 w-[100px]" /> - </div> - <div className="flex items-center space-x-2"> - <Skeleton className="h-10 w-[120px]" /> - <Skeleton className="h-10 w-[100px]" /> - </div> - </div> - - {/* 테이블 헤더 스켈레톤 */} - <div className="rounded-md border"> - <div className="border-b p-4"> - <div className="flex items-center space-x-4"> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[150px]" /> - <Skeleton className="h-4 w-[120px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[130px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[80px]" /> - </div> - </div> - - {/* 테이블 행 스켈레톤 */} - {Array.from({ length: 5 }).map((_, index) => ( - <div key={index} className="border-b p-4 last:border-b-0"> - <div className="flex items-center space-x-4"> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[150px]" /> - <Skeleton className="h-4 w-[120px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[130px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[80px]" /> - </div> - </div> - ))} - </div> - - {/* 페이지네이션 스켈레톤 */} - <div className="flex items-center justify-between"> - <Skeleton className="h-8 w-[200px]" /> - <div className="flex items-center space-x-2"> - <Skeleton className="h-8 w-[100px]" /> - <Skeleton className="h-8 w-[60px]" /> - <Skeleton className="h-8 w-[100px]" /> - </div> - </div> - </div> - ) -} - -export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { - const searchParams = useSearchParams() - const router = useRouter() - - // 첨부파일 시트 상태 - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null) - const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([]) - - // 아이템 다이얼로그 상태 - const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) - const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null) - - // 거절 다이얼로그 상태 - const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) - const [rejectionReason, setRejectionReason] = React.useState("") - const [isRejecting, setIsRejecting] = React.useState(false) - - // 데이터 로딩 상태 - const [data, setData] = React.useState<QuotationWithRfqCode[]>([]) - const [pageCount, setPageCount] = React.useState(0) - const [total, setTotal] = React.useState(0) - const [isLoading, setIsLoading] = React.useState(true) - const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분 - - // URL 파라미터에서 설정 읽기 - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams?.get('page') || '1'), - perPage: parseInt(searchParams?.get('perPage') || '10'), - sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], - filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams?.get('search') || '', - from: searchParams?.get('from') || '', - to: searchParams?.get('to') || '', - }), [searchParams]) - - // 데이터 로드 함수 - const loadData = React.useCallback(async () => { - try { - setIsLoading(true) - - console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', { - vendorId, - settings: initialSettings - }) - - const result = await getVendorQuotations({ - page: initialSettings.page, - perPage: initialSettings.perPage, - sort: initialSettings.sort, - filters: initialSettings.filters, - joinOperator: initialSettings.joinOperator, - basicFilters: initialSettings.basicFilters, - basicJoinOperator: initialSettings.basicJoinOperator, - search: initialSettings.search, - from: initialSettings.from, - to: initialSettings.to, - rfqType: rfqType, - }, vendorId) - - console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', { - dataLength: result.data.length, - pageCount: result.pageCount, - total: result.total - }) - - setData(result.data as QuotationWithRfqCode[]) - setPageCount(result.pageCount) - setTotal(result.total) - } catch (error) { - console.error('데이터 로드 오류:', error) - toast.error('데이터를 불러오는 중 오류가 발생했습니다.') - } finally { - setIsLoading(false) - setIsInitialLoad(false) - } - }, [vendorId, initialSettings, rfqType]) - - // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함) - React.useEffect(() => { - loadData() - }, [ - searchParams?.get('page'), - searchParams?.get('perPage'), - searchParams?.get('sort'), - searchParams?.get('filters'), - searchParams?.get('joinOperator'), - searchParams?.get('basicFilters'), - searchParams?.get('basicJoinOperator'), - searchParams?.get('search'), - searchParams?.get('from'), - searchParams?.get('to'), - // vendorId와 rfqType 변경도 감지 - vendorId, - rfqType - ]) - - // 데이터 안정성을 위한 메모이제이션 - const stableData = React.useMemo(() => { - return data; - }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); - - // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회) - const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { - try { - // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기) - const quotationWithRfq = data.find(item => item.rfqId === rfqId) - if (!quotationWithRfq) { - toast.error("RFQ 정보를 찾을 수 없습니다.") - return - } - - // 실제 첨부파일 목록 조회 API 호출 - const result = await getTechSalesRfqAttachments(rfqId) - - if (result.error) { - toast.error(result.error) - return - } - - // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링 - const attachments: ExistingTechSalesAttachment[] = result.data - .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회 - .map(att => ({ - id: att.id, - techSalesRfqId: att.techSalesRfqId || rfqId, - fileName: att.fileName, - originalFileName: att.originalFileName, - filePath: att.filePath, - fileSize: att.fileSize || undefined, - fileType: att.fileType || undefined, - attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", - description: att.description || undefined, - createdBy: att.createdBy, - createdAt: att.createdAt, - })) - - setAttachmentsDefault(attachments) - setSelectedRfqForAttachments({ - id: rfqId, - rfqCode: quotationWithRfq.rfqCode || null, - status: quotationWithRfq.rfqStatus || "Unknown" - }) - setAttachmentsOpen(true) - } catch (error) { - console.error("첨부파일 조회 오류:", error) - toast.error("첨부파일 조회 중 오류가 발생했습니다.") - } - }, [data]) - - // 아이템 다이얼로그 열기 함수 - const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => { - setSelectedRfqForItems(rfq) - setItemsDialogOpen(true) - }, []) - - // 거절 처리 함수 - const handleRejectQuotations = React.useCallback(async () => { - if (!table) return; - - const selectedRows = table.getFilteredSelectedRowModel().rows; - const quotationIds = selectedRows.map(row => row.original.id); - - if (quotationIds.length === 0) { - toast.error("거절할 견적서를 선택해주세요."); - return; - } - - // 거절할 수 없는 상태의 견적서가 있는지 확인 - const invalidStatuses = selectedRows.filter(row => - row.original.status === "Accepted" || row.original.status === "Rejected" - ); - - if (invalidStatuses.length > 0) { - toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다."); - return; - } - - setIsRejecting(true); - - try { - const result = await rejectTechSalesVendorQuotations({ - quotationIds, - rejectionReason: rejectionReason.trim() || undefined, - }); - - if (result.success) { - toast.success(result.message); - setRejectDialogOpen(false); - setRejectionReason(""); - table.resetRowSelection(); - // 데이터 다시 로드 - await loadData(); - } else { - toast.error(result.error || "견적서 거절 중 오류가 발생했습니다."); - } - } catch (error) { - console.error("견적서 거절 오류:", error); - toast.error("견적서 거절 중 오류가 발생했습니다."); - } finally { - setIsRejecting(false); - } - }, [rejectionReason, loadData]); - - // 테이블 컬럼 정의 - const columns = React.useMemo(() => getColumns({ - router, - openAttachmentsSheet, - openItemsDialog, - }), [router, openAttachmentsSheet, openItemsDialog]) - - // 필터 필드 - 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: columns as any, // 타입 오류 임시 해결 - pageCount, - rowCount: total, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - enableColumnResizing: true, - columnResizeMode: 'onChange', - enableRowSelection: true, // 행 선택 활성화 - initialState: { - sorting: initialSettings.sort, - columnPinning: { right: ["actions", "items", "attachments"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - defaultColumn: { - minSize: 50, - maxSize: 500, - }, - }) - - // 최초 로딩 시 전체 스켈레톤 표시 - if (isInitialLoad && isLoading) { - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <TableLoadingSkeleton /> - </div> - </div> - ) - } - - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <div className="relative"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - {/* 선택된 행이 있을 때 거절 버튼 표시 */} - {table && table.getFilteredSelectedRowModel().rows.length > 0 && ( - <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> - <AlertDialogTrigger asChild> - <Button variant="destructive" size="sm"> - <X className="mr-2 h-4 w-4" /> - 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개) - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>견적서 거절</AlertDialogTitle> - <AlertDialogDescription> - 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까? - 거절된 견적서는 다시 되돌릴 수 없습니다. - </AlertDialogDescription> - </AlertDialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label> - <Textarea - id="rejection-reason" - placeholder="거절 사유를 입력하세요..." - value={rejectionReason} - onChange={(e) => setRejectionReason(e.target.value)} - /> - </div> - </div> - <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> - <AlertDialogAction - onClick={handleRejectQuotations} - disabled={isRejecting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isRejecting ? "처리 중..." : "거절"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - - {!isInitialLoad && isLoading && ( - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> - 데이터 업데이트 중... - </div> - )} - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - - {/* 첨부파일 관리 시트 (읽기 전용) */} - <TechSalesRfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachmentsDefault} - rfq={selectedRfqForAttachments} - attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회 - readOnly={true} // 벤더는 항상 읽기 전용 - /> - - {/* 아이템 보기 다이얼로그 */} - <RfqItemsViewDialog - open={itemsDialogOpen} - onOpenChange={setItemsDialogOpen} - rfq={selectedRfqForItems} - /> - </div> - ); +// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+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"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
+import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog"
+import { QuotationContactsViewDialog } from "../../table/detail-table/quotation-contacts-view-dialog"
+import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { X } from "lucide-react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string | null;
+ materialCode?: string | null;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string | null;
+ projNm?: string | null;
+ description?: string | null;
+ attachmentCount?: number;
+ itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
+}
+
+interface VendorQuotationsTableProps {
+ vendorId: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}
+
+// 로딩 스켈레톤 컴포넌트
+function TableLoadingSkeleton() {
+ return (
+ <div className="w-full space-y-3">
+ {/* 툴바 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[250px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[120px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ </div>
+
+ {/* 테이블 헤더 스켈레톤 */}
+ <div className="rounded-md border">
+ <div className="border-b p-4">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+
+ {/* 테이블 행 스켈레톤 */}
+ {Array.from({ length: 5 }).map((_, index) => (
+ <div key={index} className="border-b p-4 last:border-b-0">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 페이지네이션 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-8 w-[200px]" />
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-8 w-[100px]" />
+ <Skeleton className="h-8 w-[60px]" />
+ <Skeleton className="h-8 w-[100px]" />
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null)
+
+ // 담당자 조회 다이얼로그 상태
+ const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = React.useState<{ id: number; vendorName?: string } | null>(null)
+
+ // 거절 다이얼로그 상태
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [rejectionReason, setRejectionReason] = React.useState("")
+ const [isRejecting, setIsRejecting] = React.useState(false)
+
+ // 데이터 로딩 상태
+ const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [total, setTotal] = React.useState(0)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
+
+ // URL 파라미터에서 설정 읽기
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || '',
+ to: searchParams?.get('to') || '',
+ }), [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
+ vendorId,
+ settings: initialSettings
+ })
+
+ const result = await getVendorQuotations({
+ page: initialSettings.page,
+ perPage: initialSettings.perPage,
+ sort: initialSettings.sort,
+ filters: initialSettings.filters,
+ joinOperator: initialSettings.joinOperator,
+ basicFilters: initialSettings.basicFilters,
+ basicJoinOperator: initialSettings.basicJoinOperator,
+ search: initialSettings.search,
+ from: initialSettings.from,
+ to: initialSettings.to,
+ rfqType: rfqType,
+ }, vendorId)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
+ dataLength: result.data.length,
+ pageCount: result.pageCount,
+ total: result.total
+ })
+
+ setData(result.data as QuotationWithRfqCode[])
+ setPageCount(result.pageCount)
+ setTotal(result.total)
+ } catch (error) {
+ console.error('데이터 로드 오류:', error)
+ toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ }
+ }, [vendorId, initialSettings, rfqType])
+
+ // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
+ React.useEffect(() => {
+ loadData()
+ }, [
+ searchParams?.get('page'),
+ searchParams?.get('perPage'),
+ searchParams?.get('sort'),
+ searchParams?.get('filters'),
+ searchParams?.get('joinOperator'),
+ searchParams?.get('basicFilters'),
+ searchParams?.get('basicJoinOperator'),
+ searchParams?.get('search'),
+ searchParams?.get('from'),
+ searchParams?.get('to'),
+ // vendorId와 rfqType 변경도 감지
+ vendorId,
+ rfqType
+ ])
+
+ // 데이터 안정성을 위한 메모이제이션
+ const stableData = React.useMemo(() => {
+ return data;
+ }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+
+ // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
+ const quotationWithRfq = data.find(item => item.rfqId === rfqId)
+ if (!quotationWithRfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
+ const attachments: ExistingTechSalesAttachment[] = result.data
+ .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
+ .map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ id: rfqId,
+ rfqCode: quotationWithRfq.rfqCode || null,
+ status: quotationWithRfq.rfqStatus || "Unknown"
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [data])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const openContactsDialog = React.useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 거절 처리 함수
+ const handleRejectQuotations = React.useCallback(async () => {
+ if (!table) return;
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const quotationIds = selectedRows.map(row => row.original.id);
+
+ if (quotationIds.length === 0) {
+ toast.error("거절할 견적서를 선택해주세요.");
+ return;
+ }
+
+ // 거절할 수 없는 상태의 견적서가 있는지 확인
+ const invalidStatuses = selectedRows.filter(row =>
+ row.original.status === "Accepted" || row.original.status === "Rejected"
+ );
+
+ if (invalidStatuses.length > 0) {
+ toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
+ return;
+ }
+
+ setIsRejecting(true);
+
+ try {
+ const result = await rejectTechSalesVendorQuotations({
+ quotationIds,
+ rejectionReason: rejectionReason.trim() || undefined,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ setRejectDialogOpen(false);
+ setRejectionReason("");
+ table.resetRowSelection();
+ // 데이터 다시 로드
+ await loadData();
+ } else {
+ toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ toast.error("견적서 거절 중 오류가 발생했습니다.");
+ } finally {
+ setIsRejecting(false);
+ }
+ }, [rejectionReason, loadData]);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ router,
+ openAttachmentsSheet,
+ openItemsDialog,
+ openContactsDialog,
+ }), [router, openAttachmentsSheet, openItemsDialog, openContactsDialog])
+
+ // 필터 필드
+ 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: columns as any, // 타입 오류 임시 해결
+ pageCount,
+ rowCount: total,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ enableRowSelection: true, // 행 선택 활성화
+ initialState: {
+ sorting: initialSettings.sort,
+ columnPinning: { right: ["actions", "items", "attachments"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ })
+
+ // 최초 로딩 시 전체 스켈레톤 표시
+ if (isInitialLoad && isLoading) {
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <TableLoadingSkeleton />
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <div className="relative">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 선택된 행이 있을 때 거절 버튼 표시 */}
+ {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
+ <AlertDialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <X className="mr-2 h-4 w-4" />
+ 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적서 거절</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
+ 거절된 견적서는 다시 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
+ <Textarea
+ id="rejection-reason"
+ placeholder="거절 사유를 입력하세요..."
+ value={rejectionReason}
+ onChange={(e) => setRejectionReason(e.target.value)}
+ />
+ </div>
+ </div>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleRejectQuotations}
+ disabled={isRejecting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isRejecting ? "처리 중..." : "거절"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+
+ {!isInitialLoad && isLoading && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
+ 데이터 업데이트 중...
+ </div>
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ {/* 첨부파일 관리 시트 (읽기 전용) */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
+ readOnly={true} // 벤더는 항상 읽기 전용
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ );
}
\ No newline at end of file |
