"use client" import * as React from "react" import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef, type SortingState, type ColumnFiltersState, flexRender, type Column, } from "@tanstack/react-table" import { useVirtualizer } from "@tanstack/react-virtual" import { useSession } from "next-auth/react" import { toast } from "sonner" import { ChevronDown, ChevronUp, Search, Download, FileSpreadsheet, Upload } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { columns, type VendorPoolItem } from "./vendor-pool-table-columns" import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service" import type { VendorPool } from "@/db/schema/avl/vendor-pool" import { BulkInsertDialog } from "./bulk-insert-dialog" import { ImportVendorPoolButton } from "./vendor-pool-excel-import-button" import { exportVendorPoolToExcel, createVendorPoolTemplate } from "../excel-utils" import { ImportResultDialog, type ImportResult } from "./import-result-dialog" // 테이블 메타 타입 interface VendorPoolTableMeta { onCellUpdate?: (id: string | number, field: string, newValue: any) => Promise onCellCancel?: (id: string | number, field: string) => void onAction?: (action: string, data?: any) => void onSaveEmptyRow?: (tempId: string) => Promise onCancelEmptyRow?: (tempId: string) => void isEmptyRow?: (id: string) => boolean getPendingChanges?: () => Record> } interface VendorPoolVirtualTableProps { data: VendorPoolItem[] onRefresh?: () => void } // 빈 행 기본값 const createEmptyVendorPoolBase = (): Omit & { id?: string | number } => ({ constructionSector: "", htDivision: "", discipline: "", equipBulkDivision: "", materialGroupCode: null, materialGroupName: null, similarMaterialNamePurchase: null, vendorCode: null, vendorName: "", taxId: null, faTarget: false, faStatus: null, tier: null, headquarterLocation: "", manufacturingLocation: "", avlVendorName: null, similarVendorName: null, isBlacklist: false, isBcc: false, purchaseOpinion: null, recentQuoteDate: null, recentQuoteNumber: null, recentOrderDate: null, recentOrderNumber: null, registrationDate: null, registrant: null, lastModifiedDate: null, lastModifier: null, }) function Filter({ column }: { column: Column }) { const columnFilterValue = column.getFilterValue() const id = column.id // Boolean 필터 (faTarget, isBlacklist, isBcc 등) if (id === 'faTarget' || id === 'isBlacklist' || id === 'isBcc') { return (
e.stopPropagation()} className="mt-2">
) } // FA Status 필터 (O 또는 빈 값) if (id === 'faStatus') { return (
e.stopPropagation()} className="mt-2">
) } // 일반 텍스트 검색 return (
e.stopPropagation()} className="mt-2"> column.setFilterValue(e.target.value)} placeholder="Search..." className="h-8 w-full font-normal bg-background" />
) } export function VendorPoolVirtualTable({ data, onRefresh }: VendorPoolVirtualTableProps) { const { data: session } = useSession() // onRefresh를 ref로 관리하여 무한 루프 방지 const onRefreshRef = React.useRef(onRefresh) React.useEffect(() => { onRefreshRef.current = onRefresh }, [onRefresh]) // 상태 관리 const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState([]) const [globalFilter, setGlobalFilter] = React.useState("") const [pendingChanges, setPendingChanges] = React.useState>>({}) const [isSaving, setIsSaving] = React.useState(false) const [emptyRows, setEmptyRows] = React.useState>({}) const [isCreating, setIsCreating] = React.useState(false) const [bulkInsertDialogOpen, setBulkInsertDialogOpen] = React.useState(false) const [importResult, setImportResult] = React.useState(null) const [showImportResultDialog, setShowImportResultDialog] = React.useState(false) const handleImportComplete = React.useCallback((result: ImportResult) => { setImportResult(result) setShowImportResultDialog(true) }, []) const handleImportDialogClose = React.useCallback((open: boolean) => { setShowImportResultDialog(open) if (!open && importResult && importResult.successCount > 0) { onRefreshRef.current?.() } }, [importResult]) // 인라인 편집 핸들러 const handleCellUpdate = React.useCallback(async (id: string | number, field: string, newValue: any) => { const isEmptyRow = String(id).startsWith('temp-') if (isEmptyRow) { setEmptyRows(prev => ({ ...prev, [id]: { ...prev[id], [field]: newValue } })) } setPendingChanges(prev => ({ ...prev, [id]: { ...prev[id], [field]: newValue } })) }, []) // 편집 취소 핸들러 const handleCellCancel = React.useCallback((id: string | number, field: string) => { const isEmptyRow = String(id).startsWith('temp-') if (isEmptyRow) { setEmptyRows(prev => ({ ...prev, [id]: { ...prev[id], [field]: prev[id][field] } })) setPendingChanges(prev => { const itemChanges = { ...prev[id] } delete itemChanges[field] if (Object.keys(itemChanges).length === 0) { const newChanges = { ...prev } delete newChanges[id] return newChanges } return { ...prev, [id]: itemChanges } }) } else { setPendingChanges(prev => { const itemChanges = { ...prev[id] } delete itemChanges[field] if (Object.keys(itemChanges).length === 0) { const newChanges = { ...prev } delete newChanges[id] return newChanges } return { ...prev, [id]: itemChanges } }) } }, []) // 일괄 저장 핸들러 const handleBatchSave = React.useCallback(async () => { if (Object.keys(pendingChanges).length === 0) return setIsSaving(true) let successCount = 0 let errorCount = 0 let duplicateErrors: string[] = [] try { for (const [id, changes] of Object.entries(pendingChanges)) { try { const { id: _, no: __, selected: ___, ...updateData } = changes const updateDataWithModifier: any = { ...updateData, lastModifier: session?.user?.name || null } const result = await updateVendorPool(Number(id), updateDataWithModifier) if (result) { successCount++ } else { errorCount++ } } catch (error) { console.error(`항목 ${id} 저장 실패:`, error) const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage === 'DUPLICATE_VENDOR_POOL') { const changes = pendingChanges[id] duplicateErrors.push(`항목 ${id}: 공사부문(${changes.constructionSector}), H/T(${changes.htDivision}), 자재그룹코드(${changes.materialGroupCode}), 협력업체명(${changes.vendorName})`) } errorCount++ } } setPendingChanges({}) if (successCount > 0) { toast.success(`${successCount}개 항목이 저장되었습니다.`) onRefreshRef.current?.() } if (duplicateErrors.length > 0) { duplicateErrors.forEach(errorMsg => { toast.error(`중복된 항목입니다. ${errorMsg}`) }) } const generalErrorCount = errorCount - duplicateErrors.length if (generalErrorCount > 0) { toast.error(`${generalErrorCount}개 항목 저장에 실패했습니다.`) } } catch (error) { console.error("Batch save error:", error) toast.error("저장 중 오류가 발생했습니다.") } finally { setIsSaving(false) } }, [pendingChanges, session]) // ✅ onRefresh 제거 // 빈 행 생성 const createEmptyRow = React.useCallback(() => { if (isCreating) return const tempId = `temp-${Date.now()}` const userName = session?.user?.name || null const emptyRow: VendorPoolItem = { ...createEmptyVendorPoolBase(), id: tempId, no: 0, selected: false, registrationDate: "", registrant: userName || "", lastModifiedDate: "", lastModifier: userName || "", } as unknown as VendorPoolItem setEmptyRows(prev => ({ ...prev, [tempId]: emptyRow })) setIsCreating(true) setPendingChanges(prev => ({ ...prev, [tempId]: { ...emptyRow } })) }, [isCreating, session]) // 빈 행 저장 const saveEmptyRow = React.useCallback(async (tempId: string) => { const rowData = emptyRows[tempId] const changes = pendingChanges[tempId] if (!rowData || !changes) { console.error('rowData 또는 changes가 없음') return } const finalData = { ...rowData, ...changes } const requiredFields = ['constructionSector', 'htDivision', 'discipline', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName'] const fieldLabels: Record = { constructionSector: '공사부문', htDivision: 'H/T구분', discipline: '설계공종', vendorName: '협력업체명', materialGroupCode: '자재그룹코드', materialGroupName: '자재그룹명', tier: '등급(Tier)', headquarterLocation: '위치(국가)', manufacturingLocation: '제작/선적지(국가)', avlVendorName: 'AVL등재업체명' } const missingFields = requiredFields.filter(field => { const value = finalData[field as keyof VendorPoolItem] return !value || value === '' }) if (missingFields.length > 0) { const missingFieldLabels = missingFields.map(field => fieldLabels[field]).join(', ') toast.error(`필수 항목을 입력해주세요: ${missingFieldLabels}`) return } try { setIsSaving(true) const { id: _, no: __, selected: ___, registrationDate: ____, lastModifiedDate: _____, ...createData } = finalData const result = await createVendorPool(createData as any) if (result) { toast.success("새 항목이 추가되었습니다.") setEmptyRows(prev => { const newRows = { ...prev } delete newRows[tempId] return newRows }) setPendingChanges(prev => { const newChanges = { ...prev } delete newChanges[tempId] return newChanges }) setIsCreating(false) onRefreshRef.current?.() } } catch (error) { console.error("빈 행 저장 실패:", error) const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage === 'DUPLICATE_VENDOR_POOL') { toast.error(`중복된 항목입니다. (공사부문: ${finalData.constructionSector}, H/T: ${finalData.htDivision}, 자재그룹코드: ${finalData.materialGroupCode}, 협력업체명: ${finalData.vendorName})`) } else { toast.error("저장 중 오류가 발생했습니다.") } } finally { setIsSaving(false) } }, [emptyRows, pendingChanges]) // ✅ onRefresh 제거 // 빈 행 취소 const cancelEmptyRow = React.useCallback((tempId: string) => { setEmptyRows(prev => { const newRows = { ...prev } delete newRows[tempId] return newRows }) setPendingChanges(prev => { const newChanges = { ...prev } delete newChanges[tempId] return newChanges }) setIsCreating(false) toast.info("새 항목 추가가 취소되었습니다.") }, []) // 데이터 병합 (빈 행 + 기존 데이터) const combinedData = React.useMemo(() => { const emptyRowList = Object.values(emptyRows) const updatedEmptyRows = emptyRowList.map((row, index) => ({ ...row, no: -(emptyRowList.length - index) })) // 최적화: 변경사항이 없으면 기존 객체 재사용 const updatedExistingData = data.map((row) => { const rowId = String(row.id) const pendingChange = pendingChanges[rowId] if (pendingChange) { return { ...row, ...pendingChange } } return row }) return [...updatedEmptyRows, ...updatedExistingData] }, [data, emptyRows, pendingChanges]) // 액션 핸들러 const handleAction = React.useCallback(async (action: string, data?: any) => { try { switch (action) { case 'new-registration': createEmptyRow() break case 'bulk-import': setBulkInsertDialogOpen(true) break case 'excel-export': try { await exportVendorPoolToExcel( combinedData, `vendor-pool-${new Date().toISOString().split('T')[0]}.xlsx`, true ) toast.success('Excel 파일이 다운로드되었습니다.') } catch (error) { console.error('Excel export 실패:', error) toast.error('Excel 내보내기에 실패했습니다.') } break case 'excel-template': try { await createVendorPoolTemplate( `vendor-pool-template-${new Date().toISOString().split('T')[0]}.xlsx` ) toast.success('Excel 템플릿이 다운로드되었습니다.') } catch (error) { console.error('Excel template export 실패:', error) toast.error('Excel 템플릿 다운로드에 실패했습니다.') } break case 'delete': if (data?.id && confirm('정말 삭제하시겠습니까?')) { const success = await deleteVendorPool(Number(data.id)) if (success) { toast.success('삭제가 완료되었습니다.') onRefreshRef.current?.() } else { toast.error('삭제에 실패했습니다.') } } break default: console.log('알 수 없는 액션:', action) toast.error('알 수 없는 액션입니다.') } } catch (error) { console.error('액션 처리 실패:', error) toast.error('액션 처리 중 오류가 발생했습니다.') } }, [createEmptyRow, combinedData]) // ✅ onRefresh 제거, combinedData 추가 // 테이블 메타 const tableMeta: VendorPoolTableMeta = { onAction: handleAction, onCellUpdate: handleCellUpdate, onCellCancel: handleCellCancel, onSaveEmptyRow: saveEmptyRow, onCancelEmptyRow: cancelEmptyRow, isEmptyRow: (id: string) => String(id).startsWith('temp-'), getPendingChanges: () => pendingChanges } // TanStack Table 설정 const table = useReactTable({ data: combinedData, columns, state: { sorting, columnFilters, globalFilter, }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, columnResizeMode: "onChange", getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getRowId: (originalRow) => String(originalRow.id), meta: tableMeta, }) // 일괄입력 핸들러 const handleBulkInsert = React.useCallback(async (bulkData: Record) => { const selectedRows = table.getFilteredSelectedRowModel().rows if (selectedRows.length === 0) { toast.error('일괄 입력할 행이 선택되지 않았습니다.') return } try { for (const row of selectedRows) { const rowId = String(row.original.id) Object.entries(bulkData).forEach(([field, value]) => { if (value !== undefined && value !== null && value !== '') { handleCellUpdate(rowId, field as keyof VendorPool, value) } }) } toast.success(`${selectedRows.length}개 행에 일괄 입력이 적용되었습니다.`) setBulkInsertDialogOpen(false) } catch (error) { console.error('일괄입력 처리 실패:', error) toast.error('일괄입력 처리 중 오류가 발생했습니다.') } }, [table, handleCellUpdate]) // table dependency 추가 // Virtual Scrolling 설정 const tableContainerRef = React.useRef(null) const { rows } = table.getRowModel() const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => 50, // 행 높이 추정값 overscan: 10, // 화면 밖 렌더링할 행 수 }) const virtualRows = rowVirtualizer.getVirtualItems() const totalSize = rowVirtualizer.getTotalSize() const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0 const paddingBottom = virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0 const hasPendingChanges = Object.keys(pendingChanges).length > 0 return (
{/* 툴바 */}
setGlobalFilter(e.target.value)} className="pl-8" />
전체 {combinedData.length}건 중 {rows.length}건 표시
{/* 테이블 */}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} ))} {paddingTop > 0 && ( )} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] const isEmptyRow = String(row.original.id).startsWith('temp-') return ( {row.getVisibleCells().map((cell) => ( ))} ) })} {paddingBottom > 0 && ( )}
{header.isPlaceholder ? null : ( <>
{flexRender( header.column.columnDef.header, header.getContext() )} {header.column.getCanSort() && (
{header.column.getIsSorted() === "asc" ? ( ) : header.column.getIsSorted() === "desc" ? ( ) : (
)}
)}
{header.column.getCanFilter() && ( )}
)}
{flexRender( cell.column.columnDef.cell, cell.getContext() )}
) }