"use client" import * as React from "react" import type { DataTableAdvancedFilterField, DataTableFilterField, } from "@/types/table" import { useSession } from "next-auth/react" 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 { Button } from "@/components/ui/button" import { toast } from "sonner" import { BulkInsertDialog } from "./bulk-insert-dialog" 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 { Download, FileSpreadsheet, Upload } from "lucide-react" import { ImportVendorPoolButton } from "./vendor-pool-excel-import-button" import { exportVendorPoolToExcel, createVendorPoolTemplate } from "../excel-utils" // vendor-pool 테이블 메타 타입 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 VendorPoolTableProps { data: VendorPoolItem[] pageCount?: number onRefresh?: () => void // 데이터 새로고침 콜백 } // 빈 행 기본값 객체 const createEmptyVendorPoolBase = (): Omit & { id?: string | number } => ({ constructionSector: "", htDivision: "", designCategoryCode: "", designCategory: "", 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, }) export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableProps) { const { data: session } = useSession() // 수정사항 추적 (일괄 저장용) 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 handleCellUpdate = React.useCallback(async (id: string | number, field: string, newValue: any) => { const isEmptyRow = String(id).startsWith('temp-') if (isEmptyRow) { // 빈 행의 경우 emptyRows 상태도 업데이트 setEmptyRows(prev => ({ ...prev, [id]: { ...prev[id], [field]: newValue } })) } // pendingChanges에 변경사항 저장 (실시간 표시용) 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) { // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소 setEmptyRows(prev => ({ ...prev, [id]: { ...prev[id], [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용) } })) 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 { // changes에서 id 필드 제거 (서버에서 자동 생성) 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) // Unique 제약 조건 위반 감지 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++ } } // 저장 완료 후 pendingChanges 초기화 setPendingChanges({}) if (successCount > 0) { toast.success(`${successCount}개 항목이 저장되었습니다.`) // 편집 모드 종료를 위해 테이블 상태 리셋 및 데이터 새로고침 table.resetRowSelection() onRefresh?.() // 데이터 새로고침 } // 중복 에러가 있는 경우 개별적으로 표시 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, onRefresh]) // 수정사항 존재 여부 const hasPendingChanges = Object.keys(pendingChanges).length > 0 // 빈 행 생성 함수 const createEmptyRow = React.useCallback(() => { if (isCreating) return // 이미 생성 중이면 중복 생성 방지 const tempId = `temp-${Date.now()}` const userName = session?.user?.name || null const emptyRow: VendorPoolItem = { ...createEmptyVendorPoolBase(), id: tempId, // string 타입으로 설정 no: 0, // 나중에 계산 selected: false, registrationDate: "", // 빈 행에서는 string으로 표시 registrant: userName, lastModifiedDate: "", lastModifier: userName, } as unknown as VendorPoolItem setEmptyRows(prev => ({ ...prev, [tempId]: emptyRow })) setIsCreating(true) // 빈 행의 초기값들을 pendingChanges에 설정 (임시 저장용) // emptyRow의 실제 값들을 반영 setPendingChanges(prev => ({ ...prev, [tempId]: { ...emptyRow } })) }, [isCreating]) // 빈 행 저장 함수 const saveEmptyRow = React.useCallback(async (tempId: string) => { const rowData = emptyRows[tempId] const changes = pendingChanges[tempId] if (!rowData || !changes) { console.error('rowData 또는 changes가 없음') return } // emptyRows와 pendingChanges를 병합한 최종 데이터 const finalData = { ...rowData, ...changes } // 필수 필드 검증 (최종 데이터 기준) const requiredFields = ['constructionSector', 'htDivision', 'designCategory', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName'] // 필드명과 한국어 레이블 매핑 const fieldLabels: Record = { constructionSector: '공사부문', htDivision: 'H/T구분', designCategory: '설계기능', 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) // id, no, selected 필드 제거 및 타입 변환 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) // 데이터 새로고침 onRefresh?.() } } catch (error) { console.error("빈 행 저장 실패:", error) // Unique 제약 조건 위반 감지 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 filterFields: DataTableFilterField[] = [ { id: "constructionSector", label: "공사부문", options: [ { label: "조선", value: "조선" }, { label: "해양", value: "해양" }, ] }, { id: "htDivision", label: "H/T구분", options: [ { label: "H", value: "H" }, { label: "T", value: "T" }, { label: "공통", value: "공통" }, ] }, { id: "equipBulkDivision", label: "Equip/Bulk 구분", options: [ { label: "E (Equip)", value: "E" }, { label: "B (Bulk)", value: "B" }, ] }, { id: "faTarget", label: "FA대상", options: [ { label: "대상", value: "true" }, { label: "비대상", value: "false" }, ] }, { id: "isBlacklist", label: "Blacklist", options: [ { label: "등록됨", value: "true" }, { label: "미등록", value: "false" }, ] }, { id: "isBcc", label: "BCC", options: [ { label: "등록됨", value: "true" }, { label: "미등록", value: "false" }, ] } ] const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "constructionSector", label: "공사부문", type: "select", options: [ { label: "조선", value: "조선" }, { label: "해양", value: "해양" }, ] }, { id: "htDivision", label: "H/T구분", type: "select", options: [ { label: "H", value: "H" }, { label: "T", value: "T" }, { label: "공통", value: "공통" }, ] }, { id: "designCategoryCode", label: "설계기능코드", type: "text", }, { id: "designCategory", label: "설계기능(공종)", type: "text", }, { id: "materialGroupCode", label: "자재그룹 코드", type: "text", }, { id: "materialGroupName", label: "자재그룹 명", type: "text", }, { id: "vendorCode", label: "협력업체 코드", type: "text", }, { id: "vendorName", label: "협력업체 명", type: "text", }, { id: "taxId", label: "사업자번호", type: "text", }, { id: "faStatus", label: "FA현황", type: "text", }, { id: "tier", label: "등급", type: "text", }, { id: "headquarterLocation", label: "본사 위치", type: "text", }, { id: "manufacturingLocation", label: "제작/선적지", type: "text", }, { id: "avlVendorName", label: "AVL 등재업체명", type: "text", }, { id: "registrant", label: "등재자", type: "text", }, { id: "lastModifier", label: "최종변경자", type: "text", }, { id: "registrationDate", label: "등재일", type: "date", }, { id: "lastModifiedDate", label: "최종변경일", type: "date", }, ] // 빈 행들을 기존 데이터와 합치기 (빈 행들을 최상단에 배치) const combinedData = React.useMemo(() => { const existingData = [...data] const emptyRowList = Object.values(emptyRows) // 빈 행들의 no 필드 업데이트 (음수로 설정하여 최상단에 배치) const updatedEmptyRows = emptyRowList.map((row, index) => ({ ...row, no: -(emptyRowList.length - index) // -3, -2, -1 순으로 설정 })) // 기존 데이터의 no 필드도 1부터 재설정 const updatedExistingData = existingData.map((row, index) => { const originalRow = existingData[index] const rowId = String(originalRow.id) const pendingChange = pendingChanges[rowId] // pendingChanges의 값으로 데이터 병합 const mergedRow = pendingChange ? { ...originalRow, ...pendingChange } : originalRow return { ...mergedRow, no: index + 1 } }) // 빈 행들을 최상단에 배치 return [...updatedEmptyRows, ...updatedExistingData] }, [data, emptyRows, pendingChanges]) const { table } = useDataTable({ data: combinedData, columns, pageCount: pageCount || 0, filterFields, enablePinning: true, enableAdvancedFilter: true, // enableColumnResizing: true, columnResizeMode: "onChange", initialState: { sorting: [{ id: "registrationDate", desc: true }], columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, clearOnDefault: true, }) // 액션 핸들러 const handleAction = React.useCallback(async (action: string, data?: any) => { try { switch (action) { case 'new-registration': createEmptyRow() break case 'bulk-import': setBulkInsertDialogOpen(true) break case 'save': toast.info('저장 기능은 개발 중입니다.') break case 'excel-export': try { // 현재 테이블 데이터를 Excel로 내보내기 (ID 포함) const currentData = table.getFilteredRowModel().rows.map(row => row.original) await exportVendorPoolToExcel( currentData, `vendor-pool-${new Date().toISOString().split('T')[0]}.xlsx`, true // ID 포함 ) toast.success('Excel 파일이 다운로드되었습니다.') } catch (error) { console.error('Excel export 실패:', error) toast.error('Excel 내보내기에 실패했습니다.') } break case 'excel-template': try { // 템플릿 파일 다운로드 (데이터 없음, ID 컬럼 제외) 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('삭제가 완료되었습니다.') onRefresh?.() // 데이터 새로고침 } else { toast.error('삭제에 실패했습니다.') } } break default: console.log('알 수 없는 액션:', action) toast.error('알 수 없는 액션입니다.') } } catch (error) { console.error('액션 처리 실패:', error) toast.error('액션 처리 중 오류가 발생했습니다.') } }, [table, onRefresh]) // 일괄입력 핸들러 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) // 제공된 값들만 적용 (빈 값이나 undefined는 건너뜀) 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]) // 테이블 메타에 핸들러 설정 const tableMeta: VendorPoolTableMeta = { onAction: handleAction, onCellUpdate: handleCellUpdate, onCellCancel: handleCellCancel, onSaveEmptyRow: saveEmptyRow, onCancelEmptyRow: cancelEmptyRow, isEmptyRow: (id: string) => String(id).startsWith('temp-'), getPendingChanges: () => pendingChanges } table.options.meta = tableMeta as any // 툴바 액션 핸들러들 const handleToolbarAction = React.useCallback((action: string, data?: any) => { handleAction(action, data) }, [handleAction]) // 저장 버튼 핸들러 const handleSaveChanges = React.useCallback(() => { handleBatchSave() }, [handleBatchSave]) return ( <>
{/* */}
) }