summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx')
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx1028
1 files changed, 524 insertions, 504 deletions
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