diff options
Diffstat (limited to 'lib/vendor-pool/table')
| -rw-r--r-- | lib/vendor-pool/table/bulk-insert-dialog.tsx (renamed from lib/vendor-pool/table/bulk-import-dialog.tsx) | 204 | ||||
| -rw-r--r-- | lib/vendor-pool/table/import-result-dialog.tsx | 8 | ||||
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-excel-import-button.tsx | 371 | ||||
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-table-columns.tsx | 851 | ||||
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-table.tsx | 75 | ||||
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-virtual-table.tsx | 779 |
6 files changed, 954 insertions, 1334 deletions
diff --git a/lib/vendor-pool/table/bulk-import-dialog.tsx b/lib/vendor-pool/table/bulk-insert-dialog.tsx index 50c20d08..ca32fd34 100644 --- a/lib/vendor-pool/table/bulk-import-dialog.tsx +++ b/lib/vendor-pool/table/bulk-insert-dialog.tsx @@ -20,27 +20,32 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { DisciplineHardcodedSelector } from "@/components/common/discipline-hardcoded/discipline-hardcoded-selector" +import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single" +import { VendorTierSelector } from "@/components/common/selectors/vendor-tier/vendor-tier-selector" +import type { MaterialSearchItem } from "@/lib/material/material-group-service" -interface BulkImportDialogProps { +interface BulkInsertDialogProps { open: boolean onOpenChange: (open: boolean) => void onSubmit: (data: Record<string, any>) => void } -export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDialogProps) { +export function BulkInsertDialog({ open, onOpenChange, onSubmit }: BulkInsertDialogProps) { const [formData, setFormData] = React.useState<Record<string, any>>({ + constructionSector: "", + discipline: "", equipBulkDivision: "", + materialGroupCode: "", + materialGroupName: "", similarMaterialNamePurchase: "", faTarget: null, tier: "", - isAgent: null, - headquarterLocation: "", - manufacturingLocation: "", - avlVendorName: "", - isBlacklist: null, - isBcc: null, }) + // 자재그룹 선택 상태 관리 (UI 표시용) + const [selectedMaterial, setSelectedMaterial] = React.useState<MaterialSearchItem | null>(null) + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -58,37 +63,46 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia } onSubmit(filteredData) - // 폼 초기화 + handleReset() + } + + const handleReset = () => { setFormData({ + constructionSector: "", + discipline: "", equipBulkDivision: "", + materialGroupCode: "", + materialGroupName: "", similarMaterialNamePurchase: "", faTarget: null, tier: "", - isAgent: null, - headquarterLocation: "", - manufacturingLocation: "", - avlVendorName: "", - isBlacklist: null, - isBcc: null, }) + setSelectedMaterial(null) } const handleCancel = () => { - setFormData({ - equipBulkDivision: "", - similarMaterialNamePurchase: "", - faTarget: null, - tier: "", - isAgent: null, - headquarterLocation: "", - manufacturingLocation: "", - avlVendorName: "", - isBlacklist: null, - isBcc: null, - }) + handleReset() onOpenChange(false) } + // 자재그룹 선택 핸들러 + const handleMaterialSelect = (material: MaterialSearchItem | null) => { + setSelectedMaterial(material) + if (material) { + setFormData(prev => ({ + ...prev, + materialGroupCode: material.materialGroupCode, + materialGroupName: material.materialGroupDescription + })) + } else { + setFormData(prev => ({ + ...prev, + materialGroupCode: "", + materialGroupName: "" + })) + } + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[500px]"> @@ -101,6 +115,33 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia <form onSubmit={handleSubmit} className="space-y-4"> <div className="grid grid-cols-2 gap-4"> + {/* 공사부문 */} + <div className="space-y-2"> + <Label htmlFor="constructionSector">공사부문</Label> + <Select + value={formData.constructionSector} + onValueChange={(value) => setFormData(prev => ({ ...prev, constructionSector: value }))} + > + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="조선">조선</SelectItem> + <SelectItem value="해양">해양</SelectItem> + </SelectContent> + </Select> + </div> + + {/* 설계공종 */} + <div className="space-y-2"> + <Label htmlFor="discipline">설계공종</Label> + <DisciplineHardcodedSelector + selectedDiscipline={formData.discipline} + onDisciplineSelect={(value) => setFormData(prev => ({ ...prev, discipline: value }))} + placeholder="설계공종 선택" + /> + </div> + {/* Equip/Bulk 구분 */} <div className="space-y-2"> <Label htmlFor="equipBulkDivision">Equip/Bulk 구분</Label> @@ -119,8 +160,29 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia </Select> </div> - {/* 유사자재명(구매) */} + {/* 등급 */} <div className="space-y-2"> + <Label htmlFor="tier">등급</Label> + <VendorTierSelector + value={formData.tier} + onValueChange={(value) => setFormData(prev => ({ ...prev, tier: value }))} + placeholder="등급 선택" + /> + </div> + + {/* 자재그룹 - 전체 너비 사용 */} + <div className="space-y-2 col-span-2"> + <Label>자재그룹</Label> + <MaterialGroupSelectorDialogSingle + selectedMaterial={selectedMaterial} + onMaterialSelect={handleMaterialSelect} + triggerLabel="자재그룹 선택" + placeholder="자재그룹 검색" + /> + </div> + + {/* 유사자재명(구매) */} + <div className="space-y-2 col-span-2"> <Label htmlFor="similarMaterialNamePurchase">유사자재명(구매)</Label> <Input id="similarMaterialNamePurchase" @@ -131,7 +193,7 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia </div> {/* FA대상 */} - <div className="space-y-2"> + <div className="space-y-2 col-span-2"> <Label>FA대상</Label> <div className="flex items-center space-x-2"> <Checkbox @@ -142,89 +204,6 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia <Label htmlFor="faTarget" className="text-sm">대상</Label> </div> </div> - - {/* 등급 */} - <div className="space-y-2"> - <Label htmlFor="tier">등급</Label> - <Input - id="tier" - value={formData.tier} - onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} - placeholder="등급 입력" - /> - </div> - - {/* Agent 여부 */} - <div className="space-y-2"> - <Label>Agent 여부</Label> - <div className="flex items-center space-x-2"> - <Checkbox - id="isAgent" - checked={formData.isAgent === true} - onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isAgent: checked ? true : null }))} - /> - <Label htmlFor="isAgent" className="text-sm">Agent</Label> - </div> - </div> - - {/* 본사위치(국가) */} - <div className="space-y-2"> - <Label htmlFor="headquarterLocation">본사위치(국가)</Label> - <Input - id="headquarterLocation" - value={formData.headquarterLocation} - onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} - placeholder="국가명 입력" - /> - </div> - - {/* 제작/선적지(국가) */} - <div className="space-y-2"> - <Label htmlFor="manufacturingLocation">제작/선적지(국가)</Label> - <Input - id="manufacturingLocation" - value={formData.manufacturingLocation} - onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} - placeholder="국가명 입력" - /> - </div> - - {/* AVL등재업체명 */} - <div className="space-y-2"> - <Label htmlFor="avlVendorName">AVL등재업체명</Label> - <Input - id="avlVendorName" - value={formData.avlVendorName} - onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} - placeholder="업체명 입력" - /> - </div> - - {/* Blacklist */} - <div className="space-y-2"> - <Label>Blacklist</Label> - <div className="flex items-center space-x-2"> - <Checkbox - id="isBlacklist" - checked={formData.isBlacklist === true} - onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isBlacklist: checked ? true : null }))} - /> - <Label htmlFor="isBlacklist" className="text-sm">등록</Label> - </div> - </div> - - {/* BCC */} - <div className="space-y-2"> - <Label>BCC</Label> - <div className="flex items-center space-x-2"> - <Checkbox - id="isBcc" - checked={formData.isBcc === true} - onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isBcc: checked ? true : null }))} - /> - <Label htmlFor="isBcc" className="text-sm">등록</Label> - </div> - </div> </div> <DialogFooter> @@ -240,3 +219,4 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia </Dialog> ) } + diff --git a/lib/vendor-pool/table/import-result-dialog.tsx b/lib/vendor-pool/table/import-result-dialog.tsx index 2e541271..db3d6282 100644 --- a/lib/vendor-pool/table/import-result-dialog.tsx +++ b/lib/vendor-pool/table/import-result-dialog.tsx @@ -21,7 +21,7 @@ export interface ImportResultItem { data?: { vendorName?: string materialGroupName?: string - designCategory?: string + discipline?: string } } @@ -62,7 +62,7 @@ export function ImportResultDialog({ open, onOpenChange, result }: ImportResultD case 'error': return <Badge variant="destructive">실패</Badge> case 'duplicate': - return <Badge variant="secondary" className="bg-yellow-600 text-white">중복</Badge> + return <Badge variant="secondary" className="bg-yellow-600 text-white">중복(업데이트)</Badge> case 'warning': return <Badge variant="secondary" className="bg-orange-600 text-white">경고</Badge> } @@ -134,8 +134,8 @@ export function ImportResultDialog({ open, onOpenChange, result }: ImportResultD {item.data.materialGroupName && ( <div>• 자재그룹: {item.data.materialGroupName}</div> )} - {item.data.designCategory && ( - <div>• 설계기능: {item.data.designCategory}</div> + {item.data.discipline && ( + <div>• 설계공종: {item.data.discipline}</div> )} </div> )} diff --git a/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx index cb39419d..3378c832 100644 --- a/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx +++ b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx @@ -9,39 +9,26 @@ import ExcelJS from 'exceljs' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Upload, Loader } from 'lucide-react' -import { createVendorPool } from '../service' +import { processBulkImport } from '../service' import { Input } from '@/components/ui/input' import { useSession } from "next-auth/react" import { getCellValueAsString, - parseBoolean, getAccessorKeyByHeader, - vendorPoolExcelColumns } from '../excel-utils' import { decryptWithServerAction } from '@/components/drm/drmUtils' import { debugLog, debugError, debugWarn, debugSuccess, debugProcess } from '@/lib/debug-utils' -import { enrichVendorPoolData } from '../enrichment-service' -import { ImportResultDialog, ImportResult, ImportResultItem } from './import-result-dialog' -import { ImportProgressDialog } from './import-progress-dialog' +import { ImportResult } from './import-result-dialog' interface ImportExcelProps { - onSuccess?: () => void + onImportComplete: (result: ImportResult) => void } -export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { +export function ImportVendorPoolButton({ onImportComplete }: ImportExcelProps) { const fileInputRef = useRef<HTMLInputElement>(null) const [isImporting, setIsImporting] = React.useState(false) const { data: session } = useSession() - const [importResult, setImportResult] = React.useState<ImportResult | null>(null) - const [showResultDialog, setShowResultDialog] = React.useState(false) - // Progress 상태 - const [showProgressDialog, setShowProgressDialog] = React.useState(false) - const [totalRows, setTotalRows] = React.useState(0) - const [processedRows, setProcessedRows] = React.useState(0) - - // 헬퍼 함수들은 excel-utils에서 import - const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0] if (!file) { @@ -87,6 +74,7 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { if (!worksheet) { debugError('[Import] 워크시트를 찾을 수 없습니다.') toast.error("No worksheet found in the spreadsheet") + setIsImporting(false) return } debugLog('[Import] 워크시트 확인 완료:', { @@ -133,7 +121,7 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { // Process data rows debugProcess('[Import] 데이터 행 처리 시작') - const rows: any[] = []; + const rows: Record<string, any>[] = []; const startRow = headerRowIndex + 1; let skippedRows = 0; @@ -191,337 +179,28 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { return } - // Progress Dialog 표시 - setTotalRows(rows.length) - setProcessedRows(0) - setShowProgressDialog(true) - - // Process each row - debugProcess('[Import] 데이터베이스 저장 시작') - let successCount = 0; - let errorCount = 0; - let duplicateCount = 0; - const resultItems: ImportResultItem[] = []; // 실패한 건만 포함 - - // Create promises for all vendor pool creation operations - const promises = rows.map(async (row, rowIndex) => { - // Excel 컬럼 설정을 기반으로 데이터 매핑 (catch 블록에서도 사용하기 위해 밖에서 선언) - const vendorPoolData: any = {}; - - try { - debugLog(`[Import] 행 ${rowIndex + 1}/${rows.length} 처리 시작 - 원본 데이터:`, row) - - vendorPoolExcelColumns.forEach(column => { - const { accessorKey, type } = column; - const value = row[accessorKey] || ''; - - if (type === 'boolean') { - vendorPoolData[accessorKey] = parseBoolean(String(value)); - } else if (value === '') { - // 빈 문자열은 null로 설정 (스키마에 맞게) - vendorPoolData[accessorKey] = null; - } else { - vendorPoolData[accessorKey] = String(value); - } - }); - - // 현재 사용자 정보 추가 - vendorPoolData.registrant = session?.user?.name || 'system'; - vendorPoolData.lastModifier = session?.user?.name || 'system'; - - debugLog(`[Import] 행 ${rowIndex + 1} 데이터 매핑 완료 - 전체 필드:`, { - '공사부문': vendorPoolData.constructionSector, - 'H/T구분': vendorPoolData.htDivision, - '설계기능코드': vendorPoolData.designCategoryCode, - '설계기능': vendorPoolData.designCategory, - 'Equip/Bulk구분': vendorPoolData.equipBulkDivision, - '협력업체코드': vendorPoolData.vendorCode, - '협력업체명': vendorPoolData.vendorName, - '자재그룹코드': vendorPoolData.materialGroupCode, - '자재그룹명': vendorPoolData.materialGroupName, - '계약서명주체코드': vendorPoolData.contractSignerCode, - '계약서명주체명': vendorPoolData.contractSignerName, - '사업자번호': vendorPoolData.taxId, - '패키지코드': vendorPoolData.packageCode, - '패키지명': vendorPoolData.packageName - }) - - // 코드를 기반으로 자동완성 수행 - debugProcess(`[Import] 행 ${rowIndex + 1} enrichment 시작`); - const enrichmentResult = await enrichVendorPoolData({ - designCategoryCode: vendorPoolData.designCategoryCode, - designCategory: vendorPoolData.designCategory, - materialGroupCode: vendorPoolData.materialGroupCode, - materialGroupName: vendorPoolData.materialGroupName, - vendorCode: vendorPoolData.vendorCode, - vendorName: vendorPoolData.vendorName, - contractSignerCode: vendorPoolData.contractSignerCode, - contractSignerName: vendorPoolData.contractSignerName, - }); - - // enrichment 결과 적용 - if (enrichmentResult.enrichedFields.length > 0) { - debugSuccess(`[Import] 행 ${rowIndex + 1} enrichment 완료 - 자동완성된 필드: ${enrichmentResult.enrichedFields.join(', ')}`); - - // enrichment된 데이터를 vendorPoolData에 반영 - if (enrichmentResult.enriched.designCategory) { - vendorPoolData.designCategory = enrichmentResult.enriched.designCategory; - } - if (enrichmentResult.enriched.materialGroupName) { - vendorPoolData.materialGroupName = enrichmentResult.enriched.materialGroupName; - } - if (enrichmentResult.enriched.vendorName) { - vendorPoolData.vendorName = enrichmentResult.enriched.vendorName; - } - if (enrichmentResult.enriched.contractSignerName) { - vendorPoolData.contractSignerName = enrichmentResult.enriched.contractSignerName; - } - } - - // enrichment 경고 메시지 로깅만 (resultItems에 추가하지 않음) - if (enrichmentResult.warnings.length > 0) { - debugWarn(`[Import] 행 ${rowIndex + 1} enrichment 경고:`, enrichmentResult.warnings); - enrichmentResult.warnings.forEach(warning => { - console.warn(`Row ${rowIndex + 1}: ${warning}`); - }); - } - - // Validate required fields (필수 필드 검증) - // 자동완성 가능한 필드는 코드가 있으면 명칭은 optional로 처리 - const requiredFieldsCheck = { - constructionSector: { value: vendorPoolData.constructionSector, label: '공사부문' }, - htDivision: { value: vendorPoolData.htDivision, label: 'H/T구분' }, - designCategoryCode: { value: vendorPoolData.designCategoryCode, label: '설계기능코드' } - }; - - // 설계기능: 설계기능코드가 없으면 설계기능명 필수 - if (!vendorPoolData.designCategoryCode && !vendorPoolData.designCategory) { - requiredFieldsCheck['designCategory'] = { value: vendorPoolData.designCategory, label: '설계기능' }; - } - - // 협력업체명: 협력업체코드가 없으면 협력업체명 필수 - if (!vendorPoolData.vendorCode && !vendorPoolData.vendorName) { - requiredFieldsCheck['vendorName'] = { value: vendorPoolData.vendorName, label: '협력업체명' }; - } - - const missingRequiredFields = Object.entries(requiredFieldsCheck) - .filter(([_, field]) => !field.value) - .map(([key, field]) => `${field.label}(${key})`); - - if (missingRequiredFields.length > 0) { - debugError(`[Import] 행 ${rowIndex + 1} 필수 필드 누락 [${missingRequiredFields.length}개]:`, { - missingFields: missingRequiredFields, - currentData: vendorPoolData - }); - console.error(`Missing required fields in row ${rowIndex + 1}:`, missingRequiredFields.join(', ')); - errorCount++; - resultItems.push({ - rowNumber: rowIndex + 1, - status: 'error', - message: `필수 필드 누락: ${missingRequiredFields.join(', ')}`, - data: { - vendorName: vendorPoolData.vendorName, - materialGroupName: vendorPoolData.materialGroupName, - designCategory: vendorPoolData.designCategory, - } - }); - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return null; - } - - debugSuccess(`[Import] 행 ${rowIndex + 1} 필수 필드 검증 통과`); - - // Validate field lengths and formats (필드 길이 및 형식 검증) - const validationErrors: string[] = []; - - if (vendorPoolData.designCategoryCode && vendorPoolData.designCategoryCode.length > 2) { - validationErrors.push(`설계기능코드는 2자리 이하여야 합니다: ${vendorPoolData.designCategoryCode}`); - } - - if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) { - validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`); - } - - if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) { - validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`); - } - - if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) { - validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`); - } - - if (validationErrors.length > 0) { - debugError(`[Import] 행 ${rowIndex + 1} 검증 실패 [${validationErrors.length}개 오류]:`, { - errors: validationErrors, - problematicFields: { - designCategoryCode: vendorPoolData.designCategoryCode, - equipBulkDivision: vendorPoolData.equipBulkDivision, - constructionSector: vendorPoolData.constructionSector, - htDivision: vendorPoolData.htDivision - } - }); - console.error(`Validation errors in row ${rowIndex + 1}:`, validationErrors.join(' | ')); - errorCount++; - resultItems.push({ - rowNumber: rowIndex + 1, - status: 'error', - message: `검증 실패: ${validationErrors.join(', ')}`, - data: { - vendorName: vendorPoolData.vendorName, - materialGroupName: vendorPoolData.materialGroupName, - designCategory: vendorPoolData.designCategory, - } - }); - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return null; - } - - debugSuccess(`[Import] 행 ${rowIndex + 1} 형식 검증 통과`); - - if (!session || !session.user || !session.user.id) { - debugError(`[Import] 행 ${rowIndex + 1} 세션 오류: 로그인 정보 없음`); - toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") - return - } - - // Create the vendor pool entry - debugProcess(`[Import] 행 ${rowIndex + 1} 데이터베이스 저장 시도`); - const result = await createVendorPool(vendorPoolData as any) - - if (!result) { - debugError(`[Import] 행 ${rowIndex + 1} 저장 실패: createVendorPool returned null`); - console.error(`Failed to import row - createVendorPool returned null:`, vendorPoolData); - errorCount++; - resultItems.push({ - rowNumber: rowIndex + 1, - status: 'error', - message: '데이터 저장 실패', - data: { - vendorName: vendorPoolData.vendorName, - materialGroupName: vendorPoolData.materialGroupName, - designCategory: vendorPoolData.designCategory, - } - }); - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return null; - } - - debugSuccess(`[Import] 행 ${rowIndex + 1} 저장 성공 (ID: ${result.id})`); - successCount++; - // 성공한 건은 resultItems에 추가하지 않음 - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return result; - } catch (error) { - debugError(`[Import] 행 ${rowIndex + 1} 처리 중 예외 발생:`, error); - console.error("Error processing row:", error, row); - - // Unique 제약 조건 위반 감지 (중복 데이터) - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage === 'DUPLICATE_VENDOR_POOL') { - debugWarn(`[Import] 행 ${rowIndex + 1} 중복 데이터 감지:`, { - constructionSector: vendorPoolData.constructionSector, - htDivision: vendorPoolData.htDivision, - materialGroupCode: vendorPoolData.materialGroupCode, - vendorName: vendorPoolData.vendorName - }); - duplicateCount++; - // 중복 건은 resultItems에 추가하지 않음 - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return null; - } - - // 다른 에러의 경우 에러 카운트 증가 - errorCount++; - resultItems.push({ - rowNumber: rowIndex + 1, - status: 'error', - message: `처리 중 오류 발생: ${errorMessage}`, - data: { - vendorName: vendorPoolData.vendorName, - materialGroupName: vendorPoolData.materialGroupName, - designCategory: vendorPoolData.designCategory, - } - }); - - // Progress 업데이트 - setProcessedRows(prev => prev + 1); - - return null; - } - }); - - // Wait for all operations to complete - debugProcess('[Import] 모든 Promise 완료 대기 중...') - await Promise.all(promises); - debugSuccess('[Import] 모든 데이터 처리 완료') - - debugLog('[Import] 최종 결과:', { - totalRows: rows.length, - successCount, - errorCount, - duplicateCount + // 서버로 Bulk Import 요청 + debugProcess('[Import] 서버 Bulk Import 요청 시작') + toast.info(`${rows.length}개의 데이터를 처리하고 있습니다...`) + + const registrant = session?.user?.name || 'system'; + const result = await processBulkImport(rows, registrant); + + debugSuccess('[Import] 서버 처리 완료', { + success: result.successCount, + error: result.errorCount }) - // Progress Dialog 닫기 - setShowProgressDialog(false) - - // Import 결과 Dialog 데이터 생성 (실패한 건만 포함) - const result: ImportResult = { - totalRows: rows.length, - successCount, - errorCount, - duplicateCount, - items: resultItems // 실패한 건만 포함됨 - } - - // Show results - if (successCount > 0) { - debugSuccess(`[Import] 임포트 성공: ${successCount}개 항목`); - toast.success(`${successCount}개 항목이 성공적으로 가져와졌습니다.`); - - if (errorCount > 0) { - debugWarn(`[Import] 일부 실패: ${errorCount}개 항목`); - } - // Call the success callback to refresh data - onSuccess?.(); - } else if (errorCount > 0) { - debugError(`[Import] 모든 항목 실패: ${errorCount}개`); - toast.error(`모든 ${errorCount}개 항목 가져오기에 실패했습니다. 데이터 형식을 확인하세요.`); - } - - if (duplicateCount > 0) { - debugWarn(`[Import] 중복 데이터: ${duplicateCount}개`); - toast.warning(`${duplicateCount}개의 중복 데이터가 감지되었습니다.`); - } - - // Import 결과 Dialog 표시 - setImportResult(result); - setShowResultDialog(true); + // 결과 처리 및 콜백 호출 + onImportComplete(result) } catch (error) { debugError('[Import] 전체 임포트 프로세스 실패:', error); console.error("Import error:", error); toast.error("Error importing data. Please check file format."); - setShowProgressDialog(false); } finally { debugLog('[Import] 임포트 프로세스 종료'); setIsImporting(false); - setShowProgressDialog(false); // Reset the file input if (fileInputRef.current) { fileInputRef.current.value = ''; @@ -554,20 +233,6 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) { {isImporting ? "Importing..." : "Import"} </span> </Button> - - {/* Import Progress Dialog */} - <ImportProgressDialog - open={showProgressDialog} - totalRows={totalRows} - processedRows={processedRows} - /> - - {/* Import 결과 Dialog */} - <ImportResultDialog - open={showResultDialog} - onOpenChange={setShowResultDialog} - result={importResult} - /> </> ) } diff --git a/lib/vendor-pool/table/vendor-pool-table-columns.tsx b/lib/vendor-pool/table/vendor-pool-table-columns.tsx index 5676250b..9d6c506f 100644 --- a/lib/vendor-pool/table/vendor-pool-table-columns.tsx +++ b/lib/vendor-pool/table/vendor-pool-table-columns.tsx @@ -23,7 +23,7 @@ interface VendorPoolTableMeta { // Vendor Pool 데이터 타입 - 스키마 기반 + 테이블용 추가 필드 import type { VendorPool } from "@/db/schema/avl/vendor-pool" -import { DisciplineCode, EngineeringDisciplineSelector } from "@/components/common/discipline" +import { DisciplineHardcodedSelector } from "@/components/common/discipline-hardcoded" import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single" import type { MaterialSearchItem } from "@/lib/material/material-group-service" import { VendorTierSelector } from "@/components/common/selectors/vendor-tier/vendor-tier-selector" @@ -64,10 +64,12 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ ), enableSorting: false, enableHiding: false, + enableColumnFilter: false, size: 40, }, { accessorKey: "id", + accessorFn: (row) => String(row.id), header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="ID" /> ), @@ -82,7 +84,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ // 실제 ID 표시 return <div className="text-sm font-mono">{id}</div> }, - size: 60, + size: 120, }, { accessorKey: "constructionSector", @@ -120,7 +122,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 100, + size: 160, }, { accessorKey: "htDivision", @@ -156,46 +158,37 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 80, + size: 120, }, { - accessorKey: "designCategory", + accessorKey: "discipline", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계기능(공종) *</span>} /> + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계공종 *</span>} /> ), cell: ({ row, table }) => { - const designCategoryCode = row.original.designCategoryCode as string - const designCategory = row.original.designCategory as string + const discipline = row.original.discipline as string - // 현재 선택된 discipline 구성 - const selectedDiscipline: DisciplineCode | undefined = designCategoryCode && designCategory ? { - CD: designCategoryCode, - USR_DF_CHAR_18: designCategory - } : undefined - - const onDisciplineSelect = async (discipline: DisciplineCode) => { - console.log('선택된 설계공종:', discipline) + const onDisciplineSelect = async (newDiscipline: string) => { + console.log('선택된 설계공종:', newDiscipline) console.log('행 ID:', row.original.id) - // 설계기능코드와 설계기능(공종) 필드 모두 업데이트 if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "designCategoryCode", discipline.CD) - await table.options.meta.onCellUpdate(row.original.id, "designCategory", discipline.USR_DF_CHAR_18) + await table.options.meta.onCellUpdate(row.original.id, "discipline", newDiscipline) } else { console.error('onCellUpdate가 정의되지 않음') } } return ( - <EngineeringDisciplineSelector - selectedDiscipline={selectedDiscipline} + <DisciplineHardcodedSelector + selectedDiscipline={discipline} onDisciplineSelect={onDisciplineSelect} disabled={false} placeholder="설계공종을 선택하세요" /> ) }, - size: 260, + size: 200, }, { accessorKey: "equipBulkDivision", @@ -225,72 +218,17 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 120, - }, - { - accessorKey: "packageCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="패키지 코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("packageCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "packageCode", newValue) - } - } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "packageCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="패키지 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, + size: 180, }, { - accessorKey: "packageName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="패키지 명" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("packageName") - const isEmptyRow = String(row.original.id).startsWith('temp-') - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue) - } + // accessorKey: "materialGroupName", + id: "materialGroupName", + accessorFn: (row) => { + if (row.materialGroupCode && row.materialGroupName) { + return `${row.materialGroupCode} - ${row.materialGroupName}` } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "packageName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="패키지 명 입력" - maxLength={100} - autoSave={false} - initialEditMode={isEmptyRow} - isModified={isModified} - /> - ) + return row.materialGroupName || row.materialGroupCode || "" }, - size: 200, - }, - { - accessorKey: "materialGroupName", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 *</span>} /> ), @@ -338,31 +276,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ size: 400, }, { - accessorKey: "smCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="SM Code" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("smCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "smCode", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="SM Code 입력" - maxLength={50} - /> - ) - }, - size: 200, - }, - { accessorKey: "similarMaterialNamePurchase", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="자재명 (검색 키워드)" /> @@ -390,32 +303,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ size: 250, }, { - accessorKey: "similarMaterialNameOther", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유사자재명(구매외)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("similarMaterialNameOther") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNameOther", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="유사자재명(구매외) 입력" - maxLength={100} - autoSave={false} - /> - ) - }, - size: 140, - }, - { accessorKey: "vendorSelector", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="협력업체 선택" /> @@ -465,7 +352,8 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 150, + enableColumnFilter: false, + size: 200, }, { accessorKey: "vendorCode", @@ -495,15 +383,22 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 130, + size: 200, }, { - accessorKey: "vendorName", + // accessorKey: "vendorName", + id: "vendorName", + accessorFn: (row) => { + if (row.vendorCode && row.vendorName) { + return `${row.vendorCode} - ${row.vendorName}` + } + return row.vendorName || row.vendorCode || "" + }, header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">협력업체 명 *</span>} /> ), cell: ({ row, table }) => { - const value = row.getValue("vendorName") + const value = row.original.vendorName // accessorFn을 썼으므로 getValue() 대신 original 참조가 더 안전 const isEmptyRow = String(row.original.id).startsWith('temp-') const onSave = async (newValue: any) => { if (table.options.meta?.onCellUpdate) { @@ -527,7 +422,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 130, + size: 200, }, { accessorKey: "faTarget", @@ -554,53 +449,8 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ ) }, enableSorting: false, - size: 80, - }, - { - accessorKey: "faStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="FA현황" /> - ), - cell: ({ row }) => { - const value = row.original.faStatus as string - - // 'O'인 경우에만 'O'를 표시, 그 외에는 빈 셀 - const displayValue = value === "O" ? "O" : "" - - return ( - <div className="px-2 py-1 text-sm text-center"> - {displayValue} - </div> - ) - }, size: 120, }, - // { - // accessorKey: "faRemark", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="FA상세" /> - // ), - // cell: ({ row, table }) => { - // const value = row.getValue("faRemark") - // const onSave = async (newValue: any) => { - // if (table.options.meta?.onCellUpdate) { - // await table.options.meta.onCellUpdate(row.original.id, "faRemark", newValue) - // } - // } - - // return ( - // <EditableCell - // value={value} - // type="textarea" - // onSave={onSave} - // placeholder="FA상세 입력" - // maxLength={500} - // autoSave={false} - // /> - // ) - // }, - // size: 120, - // }, { accessorKey: "tier", header: ({ column }) => ( @@ -624,144 +474,9 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 120, - }, - { - accessorKey: "isAgent", - header: "Agent 여부", - cell: ({ row, table }) => { - const value = row.getValue("isAgent") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "isAgent", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 100, - }, - { - accessorKey: "contractSignerCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="계약서명주체 코드" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("contractSignerCode") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", newValue) - } - } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "contractSignerCode") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="계약서명주체 코드 입력" - maxLength={50} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { - accessorKey: "contractSignerSelector", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="계약서명주체 선택" /> - ), - cell: ({ row, table }) => { - const contractSignerCode = row.original.contractSignerCode as string - const contractSignerName = row.original.contractSignerName as string - - // 현재 선택된 contract signer 구성 - const selectedVendor: VendorSearchItem | null = contractSignerCode && contractSignerName ? { - id: 0, // 실제로는 vendorId가 있어야 하지만 여기서는 임시로 0 사용 - vendorName: contractSignerName, - vendorCode: contractSignerCode || null, - taxId: null, // 사업자번호는 vendor-pool에서 관리하지 않음 - status: "ACTIVE", // 임시 값 - displayText: contractSignerName + (contractSignerCode ? ` (${contractSignerCode})` : "") - } : null - - const onVendorSelect = async (vendor: VendorSearchItem | null) => { - console.log('선택된 계약서명주체:', vendor) - - if (vendor) { - // 계약서명주체코드와 계약서명주체명 필드 업데이트 - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", vendor.vendorCode || "") - await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", vendor.vendorName) - } - } else { - // 선택 해제 시 빈 값으로 설정 - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", "") - await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", "") - } - } - } - - return ( - <VendorSelectorDialogSingle - selectedVendor={selectedVendor} - onVendorSelect={onVendorSelect} - disabled={false} - triggerLabel="계약서명주체 선택" - placeholder="계약서명주체를 검색하세요..." - title="계약서명주체 선택" - description="계약서명주체를 검색하고 선택해주세요." - statusFilter="ACTIVE" - /> - ) - }, size: 150, }, { - accessorKey: "contractSignerName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">계약서명주체 명 *</span>} /> - ), - cell: ({ row, table }) => { - const value = row.getValue("contractSignerName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", newValue) - } - } - - // 수정 여부 확인 - const isModified = getIsModified(table, row.original.id, "contractSignerName") - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="계약서명주체 명 입력" - maxLength={100} - autoSave={false} - isModified={isModified} - /> - ) - }, - size: 120, - }, - { accessorKey: "headquarterLocation", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">본사 위치 *</span>} /> @@ -792,7 +507,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 180, + size: 220, }, { accessorKey: "manufacturingLocation", @@ -850,7 +565,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 140, + size: 220, }, { accessorKey: "similarVendorName", @@ -876,30 +591,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ /> ) }, - size: 130, - }, - { - accessorKey: "hasAvl", - header: "AVL", - cell: ({ row, table }) => { - const value = row.getValue("hasAvl") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "hasAvl", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 60, + size: 280, }, { accessorKey: "isBlacklist", @@ -922,7 +614,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ ) }, enableSorting: false, - size: 60, + size: 100, }, { accessorKey: "isBcc", @@ -945,7 +637,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ ) }, enableSorting: false, - size: 60, + size: 100, }, { accessorKey: "purchaseOpinion", @@ -972,448 +664,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ }, size: 300, }, - { - accessorKey: "shipTypeCommon", - header: "공통", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeCommon") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeCommon", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 80, - }, - { - accessorKey: "shipTypeAmax", - header: "A-max", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeAmax") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeAmax", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 60, - }, - { - accessorKey: "shipTypeSmax", - header: "S-max", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeSmax") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeSmax", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 60, - }, - { - accessorKey: "shipTypeVlcc", - header: "VLCC", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeVlcc") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeVlcc", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - enableSorting: false, - size: 60, - }, - { - accessorKey: "shipTypeLngc", - header: "LNGC", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeLngc") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeLngc", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "shipTypeCont", - header: "CONT", - cell: ({ row, table }) => { - const value = row.getValue("shipTypeCont") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "shipTypeCont", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeCommon", - header: "공통", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeCommon") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeCommon", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeFpso", - header: "FPSO", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFpso") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpso", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeFlng", - header: "FLNG", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFlng") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFlng", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeFpu", - header: "FPU", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeFpu") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpu", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypePlatform", - header: "Platform", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypePlatform") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypePlatform", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeWtiv", - header: "WTIV", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeWtiv") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeWtiv", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "offshoreTypeGom", - header: "GOM", - cell: ({ row, table }) => { - const value = row.getValue("offshoreTypeGom") as boolean - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeGom", newValue) - } - } - - return ( - <EditableCell - value={value} - type="checkbox" - onSave={onSave} - autoSave={false} - /> - ) - }, - size: 60, - }, - { - accessorKey: "picName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="협력업체담당자" /> - // 이전에는 컬럼명이 PIC(담당자) 였음. - ), - cell: ({ row, table }) => { - const value = row.getValue("picName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picName", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={50} - /> - ) - }, - size: 120, - }, - { - accessorKey: "picEmail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="협력업체담당자(E-mail)" /> - // 이전에는 컬럼명이 PIC(E-mail) 였음. - ), - cell: ({ row, table }) => { - const value = row.getValue("picEmail") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picEmail", newValue) - } - } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={100} - /> - ) - }, - size: 140, - }, - { - accessorKey: "picPhone", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="협력업체담당자(Phone)" /> - // 이전에는 컬럼명이 PIC(Phone) 였음. - ), - cell: ({ row, table }) => { - const value = row.getValue("picPhone") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "picPhone", newValue) - } - } - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={20} - /> - ) - }, - size: 120, - }, - { - accessorKey: "agentName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(담당자)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentName") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentName", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={50} - /> - ) - }, - size: 120, - }, - { - accessorKey: "agentEmail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(E-mail)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentEmail") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentEmail", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={100} - /> - ) - }, - size: 140, - }, - { - accessorKey: "agentPhone", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Agent(Phone)" /> - ), - cell: ({ row, table }) => { - const value = row.getValue("agentPhone") - const onSave = async (newValue: any) => { - if (table.options.meta?.onCellUpdate) { - await table.options.meta.onCellUpdate(row.original.id, "agentPhone", newValue) - } - } - - return ( - <EditableCell - value={value} - type="text" - onSave={onSave} - placeholder="입력가능" - maxLength={20} - /> - ) - }, - size: 120, - }, { accessorKey: "recentQuoteDate", header: ({ column }) => ( @@ -1427,7 +678,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ </div> ) }, - size: 120, + size: 200, }, { accessorKey: "recentQuoteNumber", @@ -1442,7 +693,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ </div> ) }, - size: 130, + size: 200, }, { accessorKey: "recentOrderDate", @@ -1457,7 +708,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ </div> ) }, - size: 120, + size: 150, }, { accessorKey: "recentOrderNumber", @@ -1472,14 +723,14 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ </div> ) }, - size: 130, + size: 200, }, { accessorKey: "registrationDate", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="등록일" /> ), - size: 120, + size: 150, }, { accessorKey: "registrant", @@ -1490,14 +741,14 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ const value = row.getValue("registrant") as string return <div className="text-sm">{value || ""}</div> }, - size: 100, + size: 150, }, { accessorKey: "lastModifiedDate", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="최종변경일" /> ), - size: 120, + size: 150, }, { accessorKey: "lastModifier", @@ -1508,7 +759,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ const value = row.getValue("lastModifier") as string return <div className="text-sm">{value || ""}</div> }, - size: 120, + size: 150, }, // 액션 그룹 { @@ -1530,7 +781,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ onSaveEmptyRow?.(data.id) }} title="저장" - className="bg-green-600 hover:bg-green-700" + className="bg-green-600 hover:bg-green-700 whitespace-normal h-auto" > <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> @@ -1544,7 +795,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ onCancelEmptyRow?.(data.id) }} title="취소" - className="border-red-300 text-red-600 hover:bg-red-50" + className="border-red-300 text-red-600 hover:bg-red-50 whitespace-normal h-auto" > <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> @@ -1574,6 +825,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [ size: 120, enableSorting: false, enableHiding: false, + enableColumnFilter: false, }, ] - diff --git a/lib/vendor-pool/table/vendor-pool-table.tsx b/lib/vendor-pool/table/vendor-pool-table.tsx index 336c93f5..e41c0fd5 100644 --- a/lib/vendor-pool/table/vendor-pool-table.tsx +++ b/lib/vendor-pool/table/vendor-pool-table.tsx @@ -12,7 +12,7 @@ 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 { BulkImportDialog } from "./bulk-import-dialog" +import { BulkInsertDialog } from "./bulk-insert-dialog" import { columns, type VendorPoolItem } from "./vendor-pool-table-columns" import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service" @@ -45,50 +45,22 @@ const createEmptyVendorPoolBase = (): Omit<VendorPool, 'id'> & { id?: string | n designCategoryCode: "", designCategory: "", equipBulkDivision: "", - packageCode: null, - packageName: null, materialGroupCode: null, materialGroupName: null, - smCode: null, similarMaterialNamePurchase: null, - similarMaterialNameOther: null, vendorCode: null, vendorName: "", taxId: null, faTarget: false, faStatus: null, - faRemark: null, tier: null, - isAgent: false, - contractSignerCode: null, - contractSignerName: "", headquarterLocation: "", manufacturingLocation: "", avlVendorName: null, similarVendorName: null, - hasAvl: false, isBlacklist: false, isBcc: false, purchaseOpinion: null, - shipTypeCommon: false, - shipTypeAmax: false, - shipTypeSmax: false, - shipTypeVlcc: false, - shipTypeLngc: false, - shipTypeCont: false, - offshoreTypeCommon: false, - offshoreTypeFpso: false, - offshoreTypeFlng: false, - offshoreTypeFpu: false, - offshoreTypePlatform: false, - offshoreTypeWtiv: false, - offshoreTypeGom: false, - picName: null, - picEmail: null, - picPhone: null, - agentName: null, - agentEmail: null, - agentPhone: null, recentQuoteDate: null, recentQuoteNumber: null, recentOrderDate: null, @@ -111,7 +83,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP const [isCreating, setIsCreating] = React.useState(false) // 일괄입력 다이얼로그 상태 - const [bulkImportDialogOpen, setBulkImportDialogOpen] = React.useState(false) + const [bulkInsertDialogOpen, setBulkInsertDialogOpen] = React.useState(false) @@ -309,7 +281,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP const finalData = { ...rowData, ...changes } // 필수 필드 검증 (최종 데이터 기준) - const requiredFields = ['constructionSector', 'htDivision', 'designCategory', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'contractSignerName', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName'] + const requiredFields = ['constructionSector', 'htDivision', 'designCategory', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName'] // 필드명과 한국어 레이블 매핑 const fieldLabels: Record<string, string> = { @@ -320,7 +292,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP materialGroupCode: '자재그룹코드', materialGroupName: '자재그룹명', tier: '등급(Tier)', - contractSignerName: '계약서명주체명', headquarterLocation: '위치(국가)', manufacturingLocation: '제작/선적지(국가)', avlVendorName: 'AVL등재업체명' @@ -436,22 +407,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP ] }, { - id: "isAgent", - label: "Agent 여부", - options: [ - { label: "Agent", value: "true" }, - { label: "일반", value: "false" }, - ] - }, - { - id: "hasAvl", - label: "AVL 존재", - options: [ - { label: "있음", value: "true" }, - { label: "없음", value: "false" }, - ] - }, - { id: "isBlacklist", label: "Blacklist", options: [ @@ -500,16 +455,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP type: "text", }, { - id: "packageCode", - label: "패키지 코드", - type: "text", - }, - { - id: "packageName", - label: "패키지 명", - type: "text", - }, - { id: "materialGroupCode", label: "자재그룹 코드", type: "text", @@ -638,7 +583,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP break case 'bulk-import': - setBulkImportDialogOpen(true) + setBulkInsertDialogOpen(true) break case 'save': @@ -698,7 +643,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP }, [table, onRefresh]) // 일괄입력 핸들러 - const handleBulkImport = React.useCallback(async (bulkData: Record<string, any>) => { + const handleBulkInsert = React.useCallback(async (bulkData: Record<string, any>) => { const selectedRows = table.getFilteredSelectedRowModel().rows if (selectedRows.length === 0) { @@ -720,7 +665,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP } toast.success(`${selectedRows.length}개 행에 일괄 입력이 적용되었습니다.`) - setBulkImportDialogOpen(false) + setBulkInsertDialogOpen(false) } catch (error) { console.error('일괄입력 처리 실패:', error) toast.error('일괄입력 처리 중 오류가 발생했습니다.') @@ -821,10 +766,10 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP </DataTableAdvancedToolbar> </DataTable> - <BulkImportDialog - open={bulkImportDialogOpen} - onOpenChange={setBulkImportDialogOpen} - onSubmit={handleBulkImport} + <BulkInsertDialog + open={bulkInsertDialogOpen} + onOpenChange={setBulkInsertDialogOpen} + onSubmit={handleBulkInsert} /> </> ) 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> + ) +} |
