diff options
Diffstat (limited to 'lib/vendor-pool/table/vendor-pool-virtual-table.tsx')
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-virtual-table.tsx | 779 |
1 files changed, 779 insertions, 0 deletions
diff --git a/lib/vendor-pool/table/vendor-pool-virtual-table.tsx b/lib/vendor-pool/table/vendor-pool-virtual-table.tsx new file mode 100644 index 00000000..81ac804f --- /dev/null +++ b/lib/vendor-pool/table/vendor-pool-virtual-table.tsx @@ -0,0 +1,779 @@ +"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<void> + onCellCancel?: (id: string | number, field: string) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record<string, Partial<VendorPoolItem>> +} + +interface VendorPoolVirtualTableProps { + data: VendorPoolItem[] + onRefresh?: () => void +} + +// 빈 행 기본값 +const createEmptyVendorPoolBase = (): Omit<VendorPool, 'id'> & { 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<any, unknown> }) { + const columnFilterValue = column.getFilterValue() + const id = column.id + + // Boolean 필터 (faTarget, isBlacklist, isBcc 등) + if (id === 'faTarget' || id === 'isBlacklist' || id === 'isBcc') { + return ( + <div onClick={(e) => e.stopPropagation()} className="mt-2"> + <Select + value={(columnFilterValue as string) ?? "all"} + onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value === "true")} + > + <SelectTrigger className="h-8 w-full"> + <SelectValue placeholder="All" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All</SelectItem> + <SelectItem value="true">Yes</SelectItem> + <SelectItem value="false">No</SelectItem> + </SelectContent> + </Select> + </div> + ) + } + + // FA Status 필터 (O 또는 빈 값) + if (id === 'faStatus') { + return ( + <div onClick={(e) => e.stopPropagation()} className="mt-2"> + <Select + value={(columnFilterValue as string) ?? "all"} + onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value)} + > + <SelectTrigger className="h-8 w-full"> + <SelectValue placeholder="All" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All</SelectItem> + <SelectItem value="O">YES</SelectItem> + <SelectItem value="X">NO</SelectItem> + </SelectContent> + </Select> + </div> + ) + } + + // 일반 텍스트 검색 + return ( + <div onClick={(e) => e.stopPropagation()} className="mt-2"> + <Input + type="text" + value={(columnFilterValue ?? '') as string} + onChange={(e) => column.setFilterValue(e.target.value)} + placeholder="Search..." + className="h-8 w-full font-normal bg-background" + /> + </div> + ) +} + +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<SortingState>([]) + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) + const [globalFilter, setGlobalFilter] = React.useState("") + const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<VendorPoolItem>>>({}) + const [isSaving, setIsSaving] = React.useState(false) + const [emptyRows, setEmptyRows] = React.useState<Record<string, VendorPoolItem>>({}) + const [isCreating, setIsCreating] = React.useState(false) + const [bulkInsertDialogOpen, setBulkInsertDialogOpen] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(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<string, string> = { + 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<string, any>) => { + 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<HTMLDivElement>(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 ( + <div className="flex flex-col flex-1 min-h-0 space-y-4"> + {/* 툴바 */} + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-2 flex-1"> + <div className="relative flex-1 max-w-sm"> + <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + placeholder="전체 검색..." + value={globalFilter ?? ""} + onChange={(e) => setGlobalFilter(e.target.value)} + className="pl-8" + /> + </div> + <div className="text-sm text-muted-foreground"> + 전체 {combinedData.length}건 중 {rows.length}건 표시 + </div> + </div> + + <div className="flex items-center gap-2"> + <Button + onClick={() => handleAction('new-registration')} + disabled={isCreating} + variant="outline" + size="sm" + > + 신규등록 + </Button> + + <Button + onClick={() => handleAction('bulk-import')} + variant="outline" + size="sm" + > + 일괄입력 + </Button> + + <ImportVendorPoolButton onImportComplete={handleImportComplete} /> + + <Button + onClick={() => handleAction('excel-export')} + variant="outline" + size="sm" + > + <Download className="mr-2 h-4 w-4" /> + Excel Export + </Button> + + <Button + onClick={() => handleAction('excel-template')} + variant="outline" + size="sm" + > + <FileSpreadsheet className="mr-2 h-4 w-4" /> + Template + </Button> + + <Button + onClick={handleBatchSave} + disabled={!hasPendingChanges || isSaving} + variant={hasPendingChanges && !isSaving ? "default" : "outline"} + size="sm" + > + {isSaving ? "저장 중..." : `저장${hasPendingChanges ? ` (${Object.keys(pendingChanges).length})` : ""}`} + </Button> + </div> + </div> + + {/* 테이블 */} + <div + ref={tableContainerRef} + className="relative flex-1 overflow-auto border rounded-md" + > + <table + className="table-fixed border-collapse" + style={{ width: table.getTotalSize() }} + > + <thead className="sticky top-0 z-10 bg-muted"> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <th + key={header.id} + className="border-b px-4 py-2 text-left text-sm font-medium relative group" + style={{ width: header.getSize() }} + > + {header.isPlaceholder ? null : ( + <> + <div + className={ + header.column.getCanSort() + ? "flex items-center gap-2 cursor-pointer select-none" + : "" + } + onClick={header.column.getToggleSortingHandler()} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getCanSort() && ( + <div className="flex flex-col"> + {header.column.getIsSorted() === "asc" ? ( + <ChevronUp className="h-4 w-4" /> + ) : header.column.getIsSorted() === "desc" ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <div className="h-4 w-4" /> + )} + </div> + )} + </div> + {header.column.getCanFilter() && ( + <Filter column={header.column} /> + )} + <div + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 ${ + header.column.getIsResizing() ? 'bg-primary' : 'bg-transparent' + }`} + /> + </> + )} + </th> + ))} + </tr> + ))} + </thead> + <tbody> + {paddingTop > 0 && ( + <tr> + <td style={{ height: `${paddingTop}px` }} /> + </tr> + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + const isEmptyRow = String(row.original.id).startsWith('temp-') + + return ( + <tr + key={row.id} + data-index={virtualRow.index} + ref={rowVirtualizer.measureElement} + data-row-id={row.id} + className={isEmptyRow ? "bg-blue-50 border-blue-200" : "hover:bg-muted/50"} + > + {row.getVisibleCells().map((cell) => ( + <td + key={cell.id} + className="border-b px-4 py-2 text-sm whitespace-normal break-words" + style={{ width: cell.column.getSize() }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </td> + ))} + </tr> + ) + })} + {paddingBottom > 0 && ( + <tr> + <td style={{ height: `${paddingBottom}px` }} /> + </tr> + )} + </tbody> + </table> + </div> + + <BulkInsertDialog + open={bulkInsertDialogOpen} + onOpenChange={setBulkInsertDialogOpen} + onSubmit={handleBulkInsert} + /> + + <ImportResultDialog + open={showImportResultDialog} + onOpenChange={handleImportDialogClose} + result={importResult} + /> + </div> + ) +} |
