summaryrefslogtreecommitdiff
path: root/lib/vendor-pool/table/vendor-pool-virtual-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-pool/table/vendor-pool-virtual-table.tsx')
-rw-r--r--lib/vendor-pool/table/vendor-pool-virtual-table.tsx779
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>
+ )
+}