diff options
Diffstat (limited to 'lib/vendor-pool/table/vendor-pool-table.tsx')
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-table.tsx | 806 |
1 files changed, 806 insertions, 0 deletions
diff --git a/lib/vendor-pool/table/vendor-pool-table.tsx b/lib/vendor-pool/table/vendor-pool-table.tsx new file mode 100644 index 00000000..caf52865 --- /dev/null +++ b/lib/vendor-pool/table/vendor-pool-table.tsx @@ -0,0 +1,806 @@ +"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 { BulkImportDialog } from "./bulk-import-dialog" + +import { columns, type VendorPoolItem } from "./columns" +import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service" +import { getVendorByTaxId } from "@/lib/vendors/service" +import { getMaterialGroupDetail } from "@/lib/material-groups/services" +import type { VendorPool } from "../types" +import { cn } from "@/lib/utils" + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta<TData> { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> + onCellCancel?: (id: string, field: keyof TData) => 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>> + onTaxIdChange?: (id: string, taxId: string) => Promise<void> + onMaterialGroupCodeChange?: (id: string, materialGroupCode: string) => Promise<void> + } +} + +interface VendorPoolTableProps { + data: VendorPoolItem[] + pageCount?: number + onRefresh?: () => void // 데이터 새로고침 콜백 +} + +export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableProps) { + const { data: session } = useSession() + + // 수정사항 추적 (일괄 저장용) + 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 [bulkImportDialogOpen, setBulkImportDialogOpen] = React.useState(false) + + + + // 인라인 편집 핸들러 (일괄 저장용) + const handleCellUpdate = React.useCallback(async (id: string, field: keyof VendorPoolItem, 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 + } + })) + }, []) + + // 사업자번호 변경 시 자동 vendor 검색 핸들러 + const handleTaxIdChange = React.useCallback(async (id: string, taxId: string) => { + if (!taxId || taxId.trim() === '') return + + try { + const result = await getVendorByTaxId(taxId.trim()) + if (result.data) { + // vendor 정보가 있으면 vendorCode와 vendorName을 자동으로 설정 + await handleCellUpdate(id, 'vendorCode', result.data.vendorCode || '') + await handleCellUpdate(id, 'vendorName', result.data.vendorName || '') + toast.success(`사업자번호로 '${result.data.vendorName}' 업체 정보를 자동 입력했습니다.`) + } else { + // vendor 정보가 없으면 vendorCode와 vendorName을 빈 값으로 설정 + await handleCellUpdate(id, 'vendorCode', '') + await handleCellUpdate(id, 'vendorName', '') + } + } catch (error) { + console.error('사업자번호 검색 실패:', error) + toast.error('사업자번호 검색 중 오류가 발생했습니다.') + } + }, [handleCellUpdate]) + + // 자재그룹코드 변경 시 자동 materialGroupName 검색 및 Equip/Bulk 구분 설정 핸들러 + const handleMaterialGroupCodeChange = React.useCallback(async (id: string, materialGroupCode: string) => { + if (!materialGroupCode || materialGroupCode.trim() === '') return + + const code = materialGroupCode.trim() + + try { + const materialGroup = await getMaterialGroupDetail(code) + if (materialGroup) { + // 자재그룹 정보가 있으면 materialGroupName을 자동으로 설정 + await handleCellUpdate(id, 'materialGroupName', materialGroup.materialGroupDesc || '') + toast.success(`자재그룹코드로 '${materialGroup.materialGroupDesc}' 정보를 자동 입력했습니다.`) + } else { + // 자재그룹 정보가 없으면 materialGroupName을 빈 값으로 설정 + await handleCellUpdate(id, 'materialGroupName', '') + } + + // Equip/Bulk 구분 자동 설정 + let equipBulkDivision = '' + if (code.startsWith('A1')) { + equipBulkDivision = 'S' + } else if (code.startsWith('A') || code.startsWith('B7') || code === 'SP1328' || code === 'SP1329') { + equipBulkDivision = 'B' + } else if (code.startsWith('B')) { + equipBulkDivision = 'E' + } else { + equipBulkDivision = null + } + + if (equipBulkDivision) { + await handleCellUpdate(id, 'equipBulkDivision', equipBulkDivision) + toast.success(`자재그룹코드에 따라 Equip/Bulk 구분을 '${equipBulkDivision}'으로 자동 설정했습니다.`) + } else { + toast.info('현 자재그룹코드에 따라 Equip/Bulk 구분을 자동 설정할 수 없습니다.') + } + } catch (error) { + console.error('자재그룹코드 검색 실패:', error) + toast.error('자재그룹코드 검색 중 오류가 발생했습니다.') + } + }, [handleCellUpdate]) + + + // 편집 취소 핸들러 + const handleCellCancel = React.useCallback((id: string, field: keyof VendorPoolItem) => { + 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 + + try { + // 각 항목의 변경사항을 순차적으로 저장 + for (const [id, changes] of Object.entries(pendingChanges)) { + try { + // changes에서 id 필드 제거 (서버에서 자동 생성) + const { id: _, ...updateData } = changes as any + // 최종변경자를 현재 세션 사용자 정보로 설정 + const updateDataWithModifier = { + ...updateData, + lastModifier: session?.user?.name || "" + } + const result = await updateVendorPool(Number(id), updateDataWithModifier as Partial<VendorPool>) + if (result) { + successCount++ + } else { + errorCount++ + } + } catch (error) { + console.error(`항목 ${id} 저장 실패:`, error) + errorCount++ + } + } + + // 저장 완료 후 pendingChanges 초기화 + setPendingChanges({}) + + if (successCount > 0) { + toast.success(`${successCount}개 항목이 저장되었습니다.`) + + // 편집 모드 종료를 위해 테이블 상태 리셋 및 데이터 새로고침 + table.resetRowSelection() + onRefresh?.() // 데이터 새로고침 + } + + if (errorCount > 0) { + toast.error(`${errorCount}개 항목 저장에 실패했습니다.`) + } + } 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 emptyRow: VendorPoolItem = { + id: tempId, + no: 0, // 나중에 계산 + selected: false, + constructionSector: "", + htDivision: "", + designCategoryCode: "", + designCategory: "", + equipBulkDivision: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + smCode: "", + similarMaterialNamePurchase: "", + similarMaterialNameOther: "", + vendorCode: "", + vendorName: "", + taxId: "", + faTarget: false, + faStatus: "", + faRemark: "", + tier: "", + isAgent: false, + contractSignerCode: "", + contractSignerName: "", + headquarterLocation: "", + manufacturingLocation: "", + avlVendorName: "", + similarVendorName: "", + hasAvl: false, + isBlacklist: false, + isBcc: false, + purchaseOpinion: "", + 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: "", + picEmail: "", + picPhone: "", + agentName: "", + agentEmail: "", + agentPhone: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + registrationDate: "", + registrant: session?.user?.name || "", + lastModifiedDate: "", + lastModifier: session?.user?.name || "", + } + + 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', 'taxId', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'contractSignerName', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName'] + + // 필드명과 한국어 레이블 매핑 + const fieldLabels: Record<string, string> = { + constructionSector: '공사부문', + htDivision: 'H/T구분', + designCategory: '설계기능', + taxId: '사업자번호', + vendorName: '협력업체명', + materialGroupCode: '자재그룹코드', + materialGroupName: '자재그룹명', + tier: '등급(Tier)', + contractSignerName: '계약서명주체명', + 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 필드 제거 (서버에서 자동 생성) + const { id: _, no: __, selected: ___, ...createData } = finalData + + const result = await createVendorPool(createData as Omit<VendorPool, 'id' | 'registrationDate' | 'lastModifiedDate'>) + + 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) + 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<VendorPoolItem>[] = [ + { + 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: "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: [ + { label: "등록됨", value: "true" }, + { label: "미등록", value: "false" }, + ] + }, + { + id: "isBcc", + label: "BCC", + options: [ + { label: "등록됨", value: "true" }, + { label: "미등록", value: "false" }, + ] + } + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorPoolItem>[] = [ + { + id: "designCategoryCode", + label: "설계기능코드", + type: "text", + }, + { + id: "designCategory", + label: "설계기능(공종)", + type: "text", + }, + { + id: "packageCode", + label: "패키지 코드", + type: "text", + }, + { + id: "packageName", + 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': + setBulkImportDialogOpen(true) + break + + case 'save': + toast.info('저장 기능은 개발 중입니다.') + break + + case 'fixed-values': + toast.info('고정값 설정 기능은 개발 중입니다.') + 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 handleBulkImport = 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) + + // 제공된 값들만 적용 (빈 값이나 undefined는 건너뜀) + Object.entries(bulkData).forEach(([field, value]) => { + if (value !== undefined && value !== null && value !== '') { + handleCellUpdate(rowId, field as keyof VendorPoolItem, value) + } + }) + } + + toast.success(`${selectedRows.length}개 행에 일괄 입력이 적용되었습니다.`) + setBulkImportDialogOpen(false) + } catch (error) { + console.error('일괄입력 처리 실패:', error) + toast.error('일괄입력 처리 중 오류가 발생했습니다.') + } + }, [table, handleCellUpdate]) + + // 테이블 메타에 핸들러 설정 + table.options.meta = { + onAction: handleAction, + onCellUpdate: handleCellUpdate, + onCellCancel: handleCellCancel, + onSaveEmptyRow: saveEmptyRow, + onCancelEmptyRow: cancelEmptyRow, + isEmptyRow: (id: string) => String(id).startsWith('temp-'), + getPendingChanges: () => pendingChanges, + onTaxIdChange: handleTaxIdChange, + onMaterialGroupCodeChange: handleMaterialGroupCodeChange + } + + + // 툴바 액션 핸들러들 + const handleToolbarAction = React.useCallback((action: string, data?: any) => { + handleAction(action, data) + }, [handleAction]) + + // 저장 버튼 핸들러 + const handleSaveChanges = React.useCallback(() => { + handleBatchSave() + }, [handleBatchSave]) + + return ( + <> + <DataTable + table={table} + className="[&_[data-row-id^='temp-']]:bg-blue-50 [&_[data-row-id^='temp-']]:border-blue-200" + // autoSizeColumns={true} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button + onClick={() => handleToolbarAction('new-registration')} + disabled={isCreating} + variant="outline" + size="sm" + > + 신규등록 + </Button> + + <Button + onClick={() => handleToolbarAction('bulk-import')} + variant="outline" + size="sm" + > + 일괄입력 + </Button> + + <Button + onClick={() => handleToolbarAction('fixed-values')} + variant="outline" + size="sm" + > + FA 상세 + </Button> + + <Button + onClick={handleSaveChanges} + disabled={!hasPendingChanges || isSaving} + variant={hasPendingChanges && !isSaving ? "default" : "outline"} + size="sm" + > + {isSaving ? "저장 중..." : `저장${hasPendingChanges ? ` (${Object.keys(pendingChanges).length})` : ""}`} + </Button> + + {/* <Button + onClick={() => handleToolbarAction('fixed-values')} + variant="outline" + size="sm" + > + 고정값 설정 + </Button> */} + </div> + </DataTableAdvancedToolbar> + </DataTable> + + <BulkImportDialog + open={bulkImportDialogOpen} + onOpenChange={setBulkImportDialogOpen} + onSubmit={handleBulkImport} + /> + </> + ) +} |
