diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-14 13:27:37 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-14 13:27:37 +0000 |
| commit | 3f293c90beb58ce206a66ff444d7acfc41b56429 (patch) | |
| tree | 7e0eb2f07b211b856d44c6bddad67d72759e1f47 /lib | |
| parent | de81b281d9a3c2883a623c3f25e2889ec10a091b (diff) | |
(김준회) Vendor Pool 구현
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/vendor-pool/service.ts | 825 | ||||
| -rw-r--r-- | lib/vendor-pool/table/bulk-import-dialog.tsx | 242 | ||||
| -rw-r--r-- | lib/vendor-pool/table/columns.tsx | 1687 | ||||
| -rw-r--r-- | lib/vendor-pool/table/vendor-pool-table.tsx | 806 | ||||
| -rw-r--r-- | lib/vendor-pool/types.ts | 109 | ||||
| -rw-r--r-- | lib/vendor-pool/validations.ts | 92 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 33 |
7 files changed, 3792 insertions, 2 deletions
diff --git a/lib/vendor-pool/service.ts b/lib/vendor-pool/service.ts new file mode 100644 index 00000000..1933c199 --- /dev/null +++ b/lib/vendor-pool/service.ts @@ -0,0 +1,825 @@ +"use server"; + +import { GetVendorPoolSchema } from "./validations"; +import { VendorPool } from "./types"; +import db from "@/db/db"; +import { vendorPool } from "@/db/schema/avl/vendor-pool"; +import { eq, and, or, ilike, count, desc, asc, sql } from "drizzle-orm"; +import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils"; +import { revalidateTag, unstable_cache } from "next/cache"; + +/** + * Vendor Pool 목록 조회 + * vendor_pool 테이블에서 실제 데이터를 조회합니다. + */ +const _getVendorPools = async (input: GetVendorPoolSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('Vendor Pool 목록 조회 시작', { input, offset }); + + // 검색 조건 구성 + const whereConditions: any[] = []; + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(vendorPool.constructionSector, searchTerm), + ilike(vendorPool.designCategory, searchTerm), + ilike(vendorPool.vendorName, searchTerm), + ilike(vendorPool.materialGroupName, searchTerm), + ilike(vendorPool.packageName, searchTerm), + ilike(vendorPool.avlVendorName, searchTerm), + ilike(vendorPool.similarVendorName, searchTerm) + ) + ); + } + + // 필터 조건 추가 + if (input.constructionSector) { + whereConditions.push(eq(vendorPool.constructionSector, input.constructionSector)); + } + if (input.htDivision) { + whereConditions.push(eq(vendorPool.htDivision, input.htDivision)); + } + if (input.designCategoryCode) { + whereConditions.push(ilike(vendorPool.designCategoryCode, `%${input.designCategoryCode}%`)); + } + if (input.designCategory) { + whereConditions.push(ilike(vendorPool.designCategory, `%${input.designCategory}%`)); + } + if (input.equipBulkDivision) { + whereConditions.push(eq(vendorPool.equipBulkDivision, input.equipBulkDivision)); + } + if (input.packageCode) { + whereConditions.push(ilike(vendorPool.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(vendorPool.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(vendorPool.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(vendorPool.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(vendorPool.vendorCode, `%${input.vendorCode}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(vendorPool.vendorName, `%${input.vendorName}%`)); + } + if (input.faStatus) { + whereConditions.push(ilike(vendorPool.faStatus, `%${input.faStatus}%`)); + } + if (input.tier) { + whereConditions.push(ilike(vendorPool.tier, `%${input.tier}%`)); + } + if (input.hasAvl === "true") { + whereConditions.push(eq(vendorPool.hasAvl, true)); + } else if (input.hasAvl === "false") { + whereConditions.push(eq(vendorPool.hasAvl, false)); + } + if (input.isAgent === "true") { + whereConditions.push(eq(vendorPool.isAgent, true)); + } else if (input.isAgent === "false") { + whereConditions.push(eq(vendorPool.isAgent, false)); + } + if (input.isBlacklist === "true") { + whereConditions.push(eq(vendorPool.isBlacklist, true)); + } else if (input.isBlacklist === "false") { + whereConditions.push(eq(vendorPool.isBlacklist, false)); + } + if (input.isBcc === "true") { + whereConditions.push(eq(vendorPool.isBcc, true)); + } else if (input.isBcc === "false") { + whereConditions.push(eq(vendorPool.isBcc, false)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof vendorPool; + + // id 컬럼의 경우 특별 처리 (No. 컬럼 정렬용) + if (column === 'id') { + if (sortItem.desc) { + orderByConditions.push(sql`${vendorPool.id} desc`); + } else { + orderByConditions.push(sql`${vendorPool.id} asc`); + } + } else if (column && vendorPool[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${vendorPool[column]} desc`); + } else { + orderByConditions.push(sql`${vendorPool[column]} asc`); + } + } + }); + + // 기본 정렬 (등재일 내림차순) + if (orderByConditions.length === 0) { + orderByConditions.push(desc(vendorPool.registrationDate)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(vendorPool) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(vendorPool) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 (timestamp -> string) + const transformedData = data.map((item, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '', + lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '', + // string 필드들의 null 처리 + packageCode: item.packageCode || '', + packageName: item.packageName || '', + materialGroupCode: item.materialGroupCode || '', + materialGroupName: item.materialGroupName || '', + smCode: item.smCode || '', + similarMaterialNamePurchase: item.similarMaterialNamePurchase || '', + similarMaterialNameOther: item.similarMaterialNameOther || '', + vendorCode: item.vendorCode || '', + vendorName: item.vendorName || '', + taxId: item.taxId || '', + faStatus: item.faStatus || '', + faRemark: item.faRemark || '', + tier: item.tier || '', + contractSignerCode: item.contractSignerCode || '', + contractSignerName: item.contractSignerName || '', + headquarterLocation: item.headquarterLocation || '', + manufacturingLocation: item.manufacturingLocation || '', + avlVendorName: item.avlVendorName || '', + similarVendorName: item.similarVendorName || '', + purchaseOpinion: item.purchaseOpinion || '', + picName: item.picName || '', + picEmail: item.picEmail || '', + picPhone: item.picPhone || '', + agentName: item.agentName || '', + agentEmail: item.agentEmail || '', + agentPhone: item.agentPhone || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + registrant: item.registrant || '', + lastModifier: item.lastModifier || '', + // boolean 필드들을 적절히 처리 + faTarget: item.faTarget ?? false, + hasAvl: item.hasAvl ?? false, + isAgent: item.isAgent ?? false, + isBlacklist: item.isBlacklist ?? false, + isBcc: item.isBcc ?? false, + // 선종 적용 정보 + shipTypeCommon: item.shipTypeCommon ?? false, + shipTypeAmax: item.shipTypeAmax ?? false, + shipTypeSmax: item.shipTypeSmax ?? false, + shipTypeVlcc: item.shipTypeVlcc ?? false, + shipTypeLngc: item.shipTypeLngc ?? false, + shipTypeCont: item.shipTypeCont ?? false, + offshoreTypeCommon: item.offshoreTypeCommon ?? false, + offshoreTypeFpso: item.offshoreTypeFpso ?? false, + offshoreTypeFlng: item.offshoreTypeFlng ?? false, + offshoreTypeFpu: item.offshoreTypeFpu ?? false, + offshoreTypePlatform: item.offshoreTypePlatform ?? false, + offshoreTypeWtiv: item.offshoreTypeWtiv ?? false, + offshoreTypeGom: item.offshoreTypeGom ?? false, + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('Vendor Pool 목록 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('Vendor Pool 목록 조회 실패', { error: err, input }); + console.error("Error in getVendorPools:", err); + return { data: [], pageCount: 0 }; + } +}; + +// 캐시된 버전 export - 동일한 입력에 대해 캐시 사용 +export const getVendorPools = unstable_cache( + _getVendorPools, + ['vendor-pool-list'], + { + tags: ['vendor-pool-list'], + revalidate: 300, // 5분 캐시 + } +); + +/** + * Vendor Pool 상세 정보 조회 + */ +export async function getVendorPoolById(id: number): Promise<VendorPool | null> { + try { + const data = await db + .select() + .from(vendorPool) + .where(eq(vendorPool.id, id)) + .limit(1); + + if (data.length === 0) { + return null; + } + + const item = data[0]; + + // 데이터 변환 (timestamp -> string) + const transformedData: VendorPool = { + ...item, + selected: false, + registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '', + lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '', + // string 필드들의 null 처리 + packageCode: item.packageCode || '', + packageName: item.packageName || '', + materialGroupCode: item.materialGroupCode || '', + materialGroupName: item.materialGroupName || '', + smCode: item.smCode || '', + similarMaterialNamePurchase: item.similarMaterialNamePurchase || '', + similarMaterialNameOther: item.similarMaterialNameOther || '', + vendorCode: item.vendorCode || '', + vendorName: item.vendorName || '', + taxId: item.taxId || '', + faStatus: item.faStatus || '', + faRemark: item.faRemark || '', + tier: item.tier || '', + contractSignerCode: item.contractSignerCode || '', + contractSignerName: item.contractSignerName || '', + headquarterLocation: item.headquarterLocation || '', + manufacturingLocation: item.manufacturingLocation || '', + avlVendorName: item.avlVendorName || '', + similarVendorName: item.similarVendorName || '', + purchaseOpinion: item.purchaseOpinion || '', + picName: item.picName || '', + picEmail: item.picEmail || '', + picPhone: item.picPhone || '', + agentName: item.agentName || '', + agentEmail: item.agentEmail || '', + agentPhone: item.agentPhone || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + registrant: item.registrant || '', + lastModifier: item.lastModifier || '', + // boolean 필드들을 적절히 처리 + faTarget: item.faTarget ?? false, + hasAvl: item.hasAvl ?? false, + isAgent: item.isAgent ?? false, + isBlacklist: item.isBlacklist ?? false, + isBcc: item.isBcc ?? false, + // 선종 적용 정보 + shipTypeCommon: item.shipTypeCommon ?? false, + shipTypeAmax: item.shipTypeAmax ?? false, + shipTypeSmax: item.shipTypeSmax ?? false, + shipTypeVlcc: item.shipTypeVlcc ?? false, + shipTypeLngc: item.shipTypeLngc ?? false, + shipTypeCont: item.shipTypeCont ?? false, + offshoreTypeCommon: item.offshoreTypeCommon ?? false, + offshoreTypeFpso: item.offshoreTypeFpso ?? false, + offshoreTypeFlng: item.offshoreTypeFlng ?? false, + offshoreTypeFpu: item.offshoreTypeFpu ?? false, + offshoreTypePlatform: item.offshoreTypePlatform ?? false, + offshoreTypeWtiv: item.offshoreTypeWtiv ?? false, + offshoreTypeGom: item.offshoreTypeGom ?? false, + }; + + return transformedData; + } catch (err) { + console.error("Error in getVendorPoolById:", err); + return null; + } +} + +/** + * Vendor Pool 액션 처리 + * 신규등록, 일괄입력, 저장 등의 액션을 처리 + */ +export async function handleVendorPoolAction( + action: string, + data?: any +): Promise<{ success: boolean; message: string; data?: any }> { + try { + switch (action) { + case "new-registration": + // 신규 등록은 createVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 + return { success: true, message: "신규 등록 모달이 열렸습니다." }; + + case "bulk-import": + // TODO: 파일 업로드 및 일괄 데이터 처리 로직 구현 필요 + // 현재는 임시 구현 - 실제로는 파일 파싱 및 배치 삽입 로직이 필요 + if (!data?.file) { + return { success: false, message: "업로드할 파일이 없습니다." }; + } + console.log("일괄 입력 처리:", data.file); + // 실제 구현 시: 파일 파싱 -> 데이터 검증 -> 배치 삽입 + return { success: true, message: "일괄 입력 처리가 시작되었습니다." }; + + case "fa-detail": + // FA 상세 정보 조회 - 실제로는 별도의 FA 조회 로직이 필요할 수 있음 + if (!data?.id) { + return { success: false, message: "FA 대상 ID가 없습니다." }; + } + console.log("FA 상세 조회:", data.id); + return { success: true, message: "FA 상세 정보가 조회되었습니다.", data: { id: data.id } }; + + case "save": + // 변경사항 저장 - 실제로는 변경된 데이터들을 배치 업데이트하는 로직이 필요 + console.log("변경사항 저장:", data); + // TODO: 변경된 항목들 검증 및 저장 로직 구현 + return { success: true, message: "변경사항이 저장되었습니다." }; + + case "fixed-values": + // 고정값 설정 - 실제로는 고정값 관리 모달과 설정 로직이 필요 + console.log("고정값 설정:", data); + // TODO: 고정값 설정 모달 및 저장 로직 구현 + return { success: true, message: "고정값 설정이 완료되었습니다." }; + + case "edit": + // 수정은 updateVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 + if (!data?.id) { + return { success: false, message: "수정할 항목 ID가 없습니다." }; + } + return { success: true, message: "수정 모달이 열렸습니다.", data: { id: data.id } }; + + case "delete": + // 삭제는 deleteVendorPool 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 + if (!data?.id) { + return { success: false, message: "삭제할 항목 ID가 없습니다." }; + } + return { success: true, message: "항목이 삭제되었습니다.", data: { id: data.id } }; + + case "view-detail": + // 상세 조회는 getVendorPoolById 함수를 통해 처리되므로 여기서는 성공 메시지만 반환 + if (!data?.id) { + return { success: false, message: "조회할 항목 ID가 없습니다." }; + } + return { success: true, message: "상세 정보가 조회되었습니다.", data: { id: data.id } }; + + default: + return { success: false, message: `알 수 없는 액션입니다: ${action}` }; + } + } catch (err) { + console.error("Error in handleVendorPoolAction:", err); + return { success: false, message: "액션 처리 중 오류가 발생했습니다." }; + } +} + +/** + * Vendor Pool 생성 + */ +export async function createVendorPool(data: Omit<VendorPool, 'id' | 'registrationDate' | 'lastModifiedDate'>): Promise<VendorPool | null> { + try { + debugLog('Vendor Pool 생성 시작', { inputData: data }); + + debugLog('데이터 검증 시작', { data, requiredFields: ['constructionSector', 'htDivision', 'designCategory', 'vendorName'] }); + + const currentTimestamp = new Date(); + + // 데이터베이스에 삽입할 데이터 준비 + const insertData = { + // 기본 정보 + constructionSector: data.constructionSector, + htDivision: data.htDivision, + + // 설계 정보 + designCategoryCode: data.designCategoryCode, + designCategory: data.designCategory, + equipBulkDivision: data.equipBulkDivision, + + // 패키지 정보 + packageCode: data.packageCode, + packageName: data.packageName, + + // 자재그룹 정보 + materialGroupCode: data.materialGroupCode, + materialGroupName: data.materialGroupName, + + // 자재 관련 정보 + smCode: data.smCode, + similarMaterialNamePurchase: data.similarMaterialNamePurchase, + similarMaterialNameOther: data.similarMaterialNameOther, + + // 협력업체 정보 + vendorCode: data.vendorCode, + vendorName: data.vendorName, + + // 사업 및 인증 정보 + taxId: data.taxId, + faTarget: data.faTarget ?? false, + faStatus: data.faStatus, + faRemark: data.faRemark, + tier: data.tier, + isAgent: data.isAgent ?? false, + + // 계약 정보 + contractSignerCode: data.contractSignerCode, + contractSignerName: data.contractSignerName, + + // 위치 정보 + headquarterLocation: data.headquarterLocation, + manufacturingLocation: data.manufacturingLocation, + + // AVL 관련 정보 + avlVendorName: data.avlVendorName, + similarVendorName: data.similarVendorName, + hasAvl: data.hasAvl ?? false, + + // 상태 정보 + isBlacklist: data.isBlacklist ?? false, + isBcc: data.isBcc ?? false, + purchaseOpinion: data.purchaseOpinion, + + // AVL 적용 선종(조선) + shipTypeCommon: data.shipTypeCommon ?? false, + shipTypeAmax: data.shipTypeAmax ?? false, + shipTypeSmax: data.shipTypeSmax ?? false, + shipTypeVlcc: data.shipTypeVlcc ?? false, + shipTypeLngc: data.shipTypeLngc ?? false, + shipTypeCont: data.shipTypeCont ?? false, + + // AVL 적용 선종(해양) + offshoreTypeCommon: data.offshoreTypeCommon ?? false, + offshoreTypeFpso: data.offshoreTypeFpso ?? false, + offshoreTypeFlng: data.offshoreTypeFlng ?? false, + offshoreTypeFpu: data.offshoreTypeFpu ?? false, + offshoreTypePlatform: data.offshoreTypePlatform ?? false, + offshoreTypeWtiv: data.offshoreTypeWtiv ?? false, + offshoreTypeGom: data.offshoreTypeGom ?? false, + + // eVCP 미등록 정보 + picName: data.picName, + picEmail: data.picEmail, + picPhone: data.picPhone, + agentName: data.agentName, + agentEmail: data.agentEmail, + agentPhone: data.agentPhone, + + // 업체 실적 현황 + recentQuoteDate: data.recentQuoteDate, + recentQuoteNumber: data.recentQuoteNumber, + recentOrderDate: data.recentOrderDate, + recentOrderNumber: data.recentOrderNumber, + + // 업데이트 히스토리 + registrationDate: currentTimestamp, + registrant: data.registrant || 'system', + lastModifiedDate: currentTimestamp, + lastModifier: data.lastModifier || 'system', + }; + + debugLog('DB INSERT 시작', { table: 'vendor_pool', data: insertData }); + + // 데이터베이스에 삽입 + const result = await db + .insert(vendorPool) + .values(insertData) + .returning(); + + if (result.length === 0) { + debugError('DB 삽입 실패: 결과가 없음', { insertData }); + throw new Error("Failed to create vendor pool"); + } + + debugSuccess('DB INSERT 완료', { table: 'vendor_pool', result: result[0] }); + + const createdItem = result[0]; + + // 생성된 데이터를 VendorPool 타입으로 변환 + const transformedData: VendorPool = { + ...createdItem, + selected: false, + registrationDate: createdItem.registrationDate ? createdItem.registrationDate.toISOString().split('T')[0] : '', + lastModifiedDate: createdItem.lastModifiedDate ? createdItem.lastModifiedDate.toISOString().split('T')[0] : '', + // string 필드들의 null 처리 + packageCode: createdItem.packageCode || '', + packageName: createdItem.packageName || '', + materialGroupCode: createdItem.materialGroupCode || '', + materialGroupName: createdItem.materialGroupName || '', + smCode: createdItem.smCode || '', + similarMaterialNamePurchase: createdItem.similarMaterialNamePurchase || '', + similarMaterialNameOther: createdItem.similarMaterialNameOther || '', + vendorCode: createdItem.vendorCode || '', + vendorName: createdItem.vendorName || '', + taxId: createdItem.taxId || '', + faStatus: createdItem.faStatus || '', + faRemark: createdItem.faRemark || '', + tier: createdItem.tier || '', + contractSignerCode: createdItem.contractSignerCode || '', + contractSignerName: createdItem.contractSignerName || '', + headquarterLocation: createdItem.headquarterLocation || '', + manufacturingLocation: createdItem.manufacturingLocation || '', + avlVendorName: createdItem.avlVendorName || '', + similarVendorName: createdItem.similarVendorName || '', + purchaseOpinion: createdItem.purchaseOpinion || '', + picName: createdItem.picName || '', + picEmail: createdItem.picEmail || '', + picPhone: createdItem.picPhone || '', + agentName: createdItem.agentName || '', + agentEmail: createdItem.agentEmail || '', + agentPhone: createdItem.agentPhone || '', + recentQuoteDate: createdItem.recentQuoteDate || '', + recentQuoteNumber: createdItem.recentQuoteNumber || '', + recentOrderDate: createdItem.recentOrderDate || '', + recentOrderNumber: createdItem.recentOrderNumber || '', + registrant: createdItem.registrant || '', + lastModifier: createdItem.lastModifier || '', + // boolean 필드들을 적절히 처리 + faTarget: createdItem.faTarget ?? false, + hasAvl: createdItem.hasAvl ?? false, + isAgent: createdItem.isAgent ?? false, + isBlacklist: createdItem.isBlacklist ?? false, + isBcc: createdItem.isBcc ?? false, + // 선종 적용 정보 + shipTypeCommon: createdItem.shipTypeCommon ?? false, + shipTypeAmax: createdItem.shipTypeAmax ?? false, + shipTypeSmax: createdItem.shipTypeSmax ?? false, + shipTypeVlcc: createdItem.shipTypeVlcc ?? false, + shipTypeLngc: createdItem.shipTypeLngc ?? false, + shipTypeCont: createdItem.shipTypeCont ?? false, + offshoreTypeCommon: createdItem.offshoreTypeCommon ?? false, + offshoreTypeFpso: createdItem.offshoreTypeFpso ?? false, + offshoreTypeFlng: createdItem.offshoreTypeFlng ?? false, + offshoreTypeFpu: createdItem.offshoreTypeFpu ?? false, + offshoreTypePlatform: createdItem.offshoreTypePlatform ?? false, + offshoreTypeWtiv: createdItem.offshoreTypeWtiv ?? false, + offshoreTypeGom: createdItem.offshoreTypeGom ?? false, + }; + + debugSuccess('Vendor Pool 생성 완료', { result: transformedData }); + + // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 + revalidateTag('vendor-pool-list'); + revalidateTag('vendor-pool-stats'); + + debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); + + return transformedData; + } catch (err) { + debugError('Vendor Pool 생성 실패', { error: err, inputData: data }); + console.error("Error in createVendorPool:", err); + return null; + } +} + +/** + * Vendor Pool 업데이트 + */ +export async function updateVendorPool(id: number, data: Partial<VendorPool>): Promise<VendorPool | null> { + try { + debugLog('Vendor Pool 업데이트 시작', { id, updateData: data }); + + const currentTimestamp = new Date(); + + // 업데이트할 데이터 준비 (id, registrationDate, registrant는 제외) + const updateData: any = {}; + + // 기본 정보 + if (data.constructionSector !== undefined) updateData.constructionSector = data.constructionSector; + if (data.htDivision !== undefined) updateData.htDivision = data.htDivision; + + // 설계 정보 + if (data.designCategoryCode !== undefined) updateData.designCategoryCode = data.designCategoryCode; + if (data.designCategory !== undefined) updateData.designCategory = data.designCategory; + if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision; + + // 패키지 정보 + if (data.packageCode !== undefined) updateData.packageCode = data.packageCode; + if (data.packageName !== undefined) updateData.packageName = data.packageName; + + // 자재그룹 정보 + if (data.materialGroupCode !== undefined) updateData.materialGroupCode = data.materialGroupCode; + if (data.materialGroupName !== undefined) updateData.materialGroupName = data.materialGroupName; + + // 자재 관련 정보 + if (data.smCode !== undefined) updateData.smCode = data.smCode; + if (data.similarMaterialNamePurchase !== undefined) updateData.similarMaterialNamePurchase = data.similarMaterialNamePurchase; + if (data.similarMaterialNameOther !== undefined) updateData.similarMaterialNameOther = data.similarMaterialNameOther; + + // 협력업체 정보 + if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode; + if (data.vendorName !== undefined) updateData.vendorName = data.vendorName; + + // 사업 및 인증 정보 + if (data.taxId !== undefined) updateData.taxId = data.taxId; + if (data.faTarget !== undefined) updateData.faTarget = data.faTarget; + if (data.faStatus !== undefined) updateData.faStatus = data.faStatus; + if (data.faRemark !== undefined) updateData.faRemark = data.faRemark; + if (data.tier !== undefined) updateData.tier = data.tier; + if (data.isAgent !== undefined) updateData.isAgent = data.isAgent; + + // 계약 정보 + if (data.contractSignerCode !== undefined) updateData.contractSignerCode = data.contractSignerCode; + if (data.contractSignerName !== undefined) updateData.contractSignerName = data.contractSignerName; + + // 위치 정보 + if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation; + if (data.manufacturingLocation !== undefined) updateData.manufacturingLocation = data.manufacturingLocation; + + // AVL 관련 정보 + if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName; + if (data.similarVendorName !== undefined) updateData.similarVendorName = data.similarVendorName; + if (data.hasAvl !== undefined) updateData.hasAvl = data.hasAvl; + + // 상태 정보 + if (data.isBlacklist !== undefined) updateData.isBlacklist = data.isBlacklist; + if (data.isBcc !== undefined) updateData.isBcc = data.isBcc; + if (data.purchaseOpinion !== undefined) updateData.purchaseOpinion = data.purchaseOpinion; + + // AVL 적용 선종(조선) + if (data.shipTypeCommon !== undefined) updateData.shipTypeCommon = data.shipTypeCommon; + if (data.shipTypeAmax !== undefined) updateData.shipTypeAmax = data.shipTypeAmax; + if (data.shipTypeSmax !== undefined) updateData.shipTypeSmax = data.shipTypeSmax; + if (data.shipTypeVlcc !== undefined) updateData.shipTypeVlcc = data.shipTypeVlcc; + if (data.shipTypeLngc !== undefined) updateData.shipTypeLngc = data.shipTypeLngc; + if (data.shipTypeCont !== undefined) updateData.shipTypeCont = data.shipTypeCont; + + // AVL 적용 선종(해양) + if (data.offshoreTypeCommon !== undefined) updateData.offshoreTypeCommon = data.offshoreTypeCommon; + if (data.offshoreTypeFpso !== undefined) updateData.offshoreTypeFpso = data.offshoreTypeFpso; + if (data.offshoreTypeFlng !== undefined) updateData.offshoreTypeFlng = data.offshoreTypeFlng; + if (data.offshoreTypeFpu !== undefined) updateData.offshoreTypeFpu = data.offshoreTypeFpu; + if (data.offshoreTypePlatform !== undefined) updateData.offshoreTypePlatform = data.offshoreTypePlatform; + if (data.offshoreTypeWtiv !== undefined) updateData.offshoreTypeWtiv = data.offshoreTypeWtiv; + if (data.offshoreTypeGom !== undefined) updateData.offshoreTypeGom = data.offshoreTypeGom; + + // eVCP 미등록 정보 + if (data.picName !== undefined) updateData.picName = data.picName; + if (data.picEmail !== undefined) updateData.picEmail = data.picEmail; + if (data.picPhone !== undefined) updateData.picPhone = data.picPhone; + if (data.agentName !== undefined) updateData.agentName = data.agentName; + if (data.agentEmail !== undefined) updateData.agentEmail = data.agentEmail; + if (data.agentPhone !== undefined) updateData.agentPhone = data.agentPhone; + + // 업체 실적 현황 + if (data.recentQuoteDate !== undefined) updateData.recentQuoteDate = data.recentQuoteDate; + if (data.recentQuoteNumber !== undefined) updateData.recentQuoteNumber = data.recentQuoteNumber; + if (data.recentOrderDate !== undefined) updateData.recentOrderDate = data.recentOrderDate; + if (data.recentOrderNumber !== undefined) updateData.recentOrderNumber = data.recentOrderNumber; + + // 업데이트 히스토리 + updateData.lastModifiedDate = currentTimestamp; + updateData.lastModifier = data.lastModifier || 'system'; + + // 업데이트할 데이터가 없는 경우 + if (Object.keys(updateData).length === 2) { // lastModifiedDate, lastModifier만 있는 경우 + // 기존 데이터를 반환 + return await getVendorPoolById(id); + } + + // 데이터베이스 업데이트 + const result = await db + .update(vendorPool) + .set(updateData) + .where(eq(vendorPool.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Vendor pool not found or update failed"); + } + + const updatedItem = result[0]; + + // 업데이트된 데이터를 VendorPool 타입으로 변환 + const transformedData: VendorPool = { + ...updatedItem, + selected: false, + registrationDate: updatedItem.registrationDate ? updatedItem.registrationDate.toISOString().split('T')[0] : '', + lastModifiedDate: updatedItem.lastModifiedDate ? updatedItem.lastModifiedDate.toISOString().split('T')[0] : '', + // string 필드들의 null 처리 + packageCode: updatedItem.packageCode || '', + packageName: updatedItem.packageName || '', + materialGroupCode: updatedItem.materialGroupCode || '', + materialGroupName: updatedItem.materialGroupName || '', + smCode: updatedItem.smCode || '', + similarMaterialNamePurchase: updatedItem.similarMaterialNamePurchase || '', + similarMaterialNameOther: updatedItem.similarMaterialNameOther || '', + vendorCode: updatedItem.vendorCode || '', + vendorName: updatedItem.vendorName || '', + taxId: updatedItem.taxId || '', + faStatus: updatedItem.faStatus || '', + faRemark: updatedItem.faRemark || '', + tier: updatedItem.tier || '', + contractSignerCode: updatedItem.contractSignerCode || '', + contractSignerName: updatedItem.contractSignerName || '', + headquarterLocation: updatedItem.headquarterLocation || '', + manufacturingLocation: updatedItem.manufacturingLocation || '', + avlVendorName: updatedItem.avlVendorName || '', + similarVendorName: updatedItem.similarVendorName || '', + purchaseOpinion: updatedItem.purchaseOpinion || '', + picName: updatedItem.picName || '', + picEmail: updatedItem.picEmail || '', + picPhone: updatedItem.picPhone || '', + agentName: updatedItem.agentName || '', + agentEmail: updatedItem.agentEmail || '', + agentPhone: updatedItem.agentPhone || '', + recentQuoteDate: updatedItem.recentQuoteDate || '', + recentQuoteNumber: updatedItem.recentQuoteNumber || '', + recentOrderDate: updatedItem.recentOrderDate || '', + recentOrderNumber: updatedItem.recentOrderNumber || '', + registrant: updatedItem.registrant || '', + lastModifier: updatedItem.lastModifier || 'system', + // boolean 필드들을 적절히 처리 + faTarget: updatedItem.faTarget ?? false, + hasAvl: updatedItem.hasAvl ?? false, + isAgent: updatedItem.isAgent ?? false, + isBlacklist: updatedItem.isBlacklist ?? false, + isBcc: updatedItem.isBcc ?? false, + // 선종 적용 정보 + shipTypeCommon: updatedItem.shipTypeCommon ?? false, + shipTypeAmax: updatedItem.shipTypeAmax ?? false, + shipTypeSmax: updatedItem.shipTypeSmax ?? false, + shipTypeVlcc: updatedItem.shipTypeVlcc ?? false, + shipTypeLngc: updatedItem.shipTypeLngc ?? false, + shipTypeCont: updatedItem.shipTypeCont ?? false, + offshoreTypeCommon: updatedItem.offshoreTypeCommon ?? false, + offshoreTypeFpso: updatedItem.offshoreTypeFpso ?? false, + offshoreTypeFlng: updatedItem.offshoreTypeFlng ?? false, + offshoreTypeFpu: updatedItem.offshoreTypeFpu ?? false, + offshoreTypePlatform: updatedItem.offshoreTypePlatform ?? false, + offshoreTypeWtiv: updatedItem.offshoreTypeWtiv ?? false, + offshoreTypeGom: updatedItem.offshoreTypeGom ?? false, + }; + + debugSuccess('Vendor Pool 업데이트 완료', { id, result: transformedData }); + + // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 + revalidateTag('vendor-pool-list'); + revalidateTag('vendor-pool-stats'); + + debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); + + return transformedData; + } catch (err) { + debugError('Vendor Pool 업데이트 실패', { error: err, id, updateData: data }); + console.error("Error in updateVendorPool:", err); + return null; + } +} + +/** + * Vendor Pool 삭제 + */ +export async function deleteVendorPool(id: number): Promise<boolean> { + try { + debugLog('Vendor Pool 삭제 시작', { id }); + + // 데이터베이스에서 삭제 + const result = await db + .delete(vendorPool) + .where(eq(vendorPool.id, id)); + + // Drizzle에서는 delete의 반환값이 삭제된 행의 수를 나타냄 + // result.rowsAffected 또는 다른 방식으로 확인 + // 실제로는 affectedRows나 rowCount 등을 확인해야 하지만, + // drizzle의 delete는 성공 시 빈 배열이나 특정 값을 반환할 수 있음 + + // 삭제가 성공했는지 확인하기 위해 다시 조회해보기 + const checkDeleted = await db + .select({ id: vendorPool.id }) + .from(vendorPool) + .where(eq(vendorPool.id, id)) + .limit(1); + + // 조회 결과가 없으면 삭제 성공 + const isDeleted = checkDeleted.length === 0; + + if (isDeleted) { + debugSuccess('Vendor Pool 삭제 완료', { id }); + + // 캐시 무효화 - 모든 Vendor Pool 관련 캐시를 갱신 + revalidateTag('vendor-pool-list'); + revalidateTag('vendor-pool-stats'); + + debugSuccess('Vendor Pool 캐시 무효화 완료', { tags: ['vendor-pool-list', 'vendor-pool-stats'] }); + } else { + debugWarn('Vendor Pool 삭제 실패: 항목이 존재함', { id }); + } + + return isDeleted; + } catch (err) { + debugError('Vendor Pool 삭제 실패', { error: err, id }); + console.error("Error in deleteVendorPool:", err); + return false; + } +} diff --git a/lib/vendor-pool/table/bulk-import-dialog.tsx b/lib/vendor-pool/table/bulk-import-dialog.tsx new file mode 100644 index 00000000..50c20d08 --- /dev/null +++ b/lib/vendor-pool/table/bulk-import-dialog.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface BulkImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (data: Record<string, any>) => void +} + +export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDialogProps) { + const [formData, setFormData] = React.useState<Record<string, any>>({ + equipBulkDivision: "", + similarMaterialNamePurchase: "", + faTarget: null, + tier: "", + isAgent: null, + headquarterLocation: "", + manufacturingLocation: "", + avlVendorName: "", + isBlacklist: null, + isBcc: null, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + // 빈 값이나 기본값은 제외하고 실제 변경할 값만 전달 + const filteredData: Record<string, any> = {} + + Object.entries(formData).forEach(([key, value]) => { + if (value !== "" && value !== null && value !== undefined) { + filteredData[key] = value + } + }) + + if (Object.keys(filteredData).length === 0) { + return + } + + onSubmit(filteredData) + // 폼 초기화 + setFormData({ + equipBulkDivision: "", + similarMaterialNamePurchase: "", + faTarget: null, + tier: "", + isAgent: null, + headquarterLocation: "", + manufacturingLocation: "", + avlVendorName: "", + isBlacklist: null, + isBcc: null, + }) + } + + const handleCancel = () => { + setFormData({ + equipBulkDivision: "", + similarMaterialNamePurchase: "", + faTarget: null, + tier: "", + isAgent: null, + headquarterLocation: "", + manufacturingLocation: "", + avlVendorName: "", + isBlacklist: null, + isBcc: null, + }) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>일괄 입력</DialogTitle> + <DialogDescription> + 선택된 행들에 동일한 값을 입력합니다. 빈 칸은 변경하지 않습니다. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + {/* Equip/Bulk 구분 */} + <div className="space-y-2"> + <Label htmlFor="equipBulkDivision">Equip/Bulk 구분</Label> + <Select + value={formData.equipBulkDivision} + onValueChange={(value) => setFormData(prev => ({ ...prev, equipBulkDivision: value }))} + > + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="E">E (Equip)</SelectItem> + <SelectItem value="B">B (Bulk)</SelectItem> + <SelectItem value="S">S (강재)</SelectItem> + </SelectContent> + </Select> + </div> + + {/* 유사자재명(구매) */} + <div className="space-y-2"> + <Label htmlFor="similarMaterialNamePurchase">유사자재명(구매)</Label> + <Input + id="similarMaterialNamePurchase" + value={formData.similarMaterialNamePurchase} + onChange={(e) => setFormData(prev => ({ ...prev, similarMaterialNamePurchase: e.target.value }))} + placeholder="유사자재명 입력" + /> + </div> + + {/* FA대상 */} + <div className="space-y-2"> + <Label>FA대상</Label> + <div className="flex items-center space-x-2"> + <Checkbox + id="faTarget" + checked={formData.faTarget === true} + onCheckedChange={(checked) => setFormData(prev => ({ ...prev, faTarget: checked ? true : null }))} + /> + <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> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="submit"> + 적용 + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/vendor-pool/table/columns.tsx b/lib/vendor-pool/table/columns.tsx new file mode 100644 index 00000000..0a6b0c8f --- /dev/null +++ b/lib/vendor-pool/table/columns.tsx @@ -0,0 +1,1687 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react" +import { type ColumnDef, TableMeta } from "@tanstack/react-table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { EditableCell } from "@/components/data-table/editable-cell" + +// 수정 여부 확인 헬퍼 함수 +const getIsModified = (table: any, rowId: string, fieldName: string) => { + const pendingChanges = table.options.meta?.getPendingChanges?.() || {} + return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)] +} + +// 테이블 메타 타입 확장 +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 + onTaxIdChange?: (id: string, taxId: string) => Promise<void> + onMaterialGroupCodeChange?: (id: string, materialGroupCode: string) => Promise<void> + } +} + +// Vendor Pool 데이터 타입 +export type VendorPoolItem = { + id: string + no: number + selected: boolean + constructionSector: string // 공사부문: 조선 또는 해양 + htDivision: string // H/T구분: H 또는 T + designCategoryCode: string // 설계기능(공종) 코드: 2자리 영문대문자 + designCategory: string // 설계기능(공종): 전장 등 + equipBulkDivision: string // Equip/Bulk 구분: E 또는 B + // 패키지 정보 (스키마: packageCode, packageName) + packageCode: string + packageName: string + // 자재그룹 (스키마: materialGroupCode, materialGroupName) + materialGroupCode: string + materialGroupName: string + smCode: string // SM Code + similarMaterialNamePurchase: string // 유사자재명 (구매) + similarMaterialNameOther: string // 유사자재명 (구매 외) + // 협력업체 정보 (스키마: vendorCode, vendorName) + vendorCode: string + vendorName: string + taxId: string // 사업자번호(Tax ID) + faTarget: boolean // FA대상 + faStatus: string // FA현황 + faRemark: string // FA상세(Remark) + tier: string // 등급(Tier) + isAgent: boolean // Agent 여부 + // 계약서명주체 (스키마: contractSignerCode, contractSignerName) + contractSignerCode: string + contractSignerName: string + headquarterLocation: string // 본사 위치(국가) + manufacturingLocation: string // 제작/선적지(국가) + avlVendorName: string // AVL 등재업체명 + similarVendorName: string // 유사업체명(기술영업) + hasAvl: boolean // AVL: 존재여부 + isBlacklist: boolean // Blacklist + isBcc: boolean // BCC + purchaseOpinion: string // 구매의견 + // AVL 적용 선종(조선) + shipTypeCommon: boolean // 공통 + shipTypeAmax: boolean // A-max + shipTypeSmax: boolean // S-max + shipTypeVlcc: boolean // VLCC + shipTypeLngc: boolean // LNGC + shipTypeCont: boolean // CONT + // AVL 적용 선종(해양) + offshoreTypeCommon: boolean // 공통 + offshoreTypeFpso: boolean // FPSO + offshoreTypeFlng: boolean // FLNG + offshoreTypeFpu: boolean // FPU + offshoreTypePlatform: boolean // Platform + offshoreTypeWtiv: boolean // WTIV + offshoreTypeGom: boolean // GOM + // eVCP 미등록 정보 + picName: string // PIC(담당자) + picEmail: string // PIC(E-mail) + picPhone: string // PIC(Phone) + agentName: string // Agent(담당자) + agentEmail: string // Agent(E-mail) + agentPhone: string // Agent(Phone) + // 업체 실적 현황 + recentQuoteDate: string // 최근견적일 + recentQuoteNumber: string // 최근견적번호 + recentOrderDate: string // 최근발주일 + recentOrderNumber: string // 최근발주번호 + // 업데이트 히스토리 + registrationDate: string // 등재일 + registrant: string // 등재자 + lastModifiedDate: string // 최종변경일 + lastModifier: string // 최종변경자 +} + +// 테이블 컬럼 정의 +export const columns: ColumnDef<VendorPoolItem>[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "id", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="No." /> + ), + cell: ({ row }) => { + const id = String(row.original.id) + + // 빈 행의 경우 No. 표시하지 않음 + if (id.startsWith('temp-')) { + return <div className="text-sm text-muted-foreground italic">신규</div> + } + + // vendor_pool 테이블의 실제 id 표시 + return <div className="text-sm font-mono">{id}</div> + }, + size: 60, + }, + { + accessorKey: "constructionSector", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">공사부문 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("constructionSector") + 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, "constructionSector", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "constructionSector") + + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "조선", value: "조선" }, + { label: "해양", value: "해양" } + ]} + placeholder="공사부문 선택" + autoSave={true} + disabled={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 100, + }, + { + accessorKey: "htDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">H/T구분 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("htDivision") + 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, "htDivision", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "htDivision") + + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "H", value: "H" }, + { label: "T", value: "T" }, + { label: "공통", value: "공통" }, + ]} + placeholder="H/T 선택" + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 80, + }, + { + accessorKey: "designCategoryCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계기능코드" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("designCategoryCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "designCategoryCode", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "designCategoryCode") + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="설계기능코드 입력" + maxLength={10} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "designCategory", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계기능(공종) *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("designCategory") + 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, "designCategory", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "designCategory") + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="설계기능(공종) 입력" + maxLength={50} + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "equipBulkDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("equipBulkDivision") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "equipBulkDivision", newValue) + } + } + + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "E (Equip)", value: "E" }, + { label: "B (Bulk)", value: "B" }, + { label: "S (강재)", value: "S" } + ]} + placeholder="구분 선택" + autoSave={false} + /> + ) + }, + 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, + }, + { + 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) + } + } + + // 수정 여부 확인 + 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} + /> + ) + }, + size: 120, + }, + { + accessorKey: "materialGroupCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 코드 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("materialGroupCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "materialGroupCode") + + const onChange = async (newValue: any) => { + if (table.options.meta?.onMaterialGroupCodeChange) { + await table.options.meta.onMaterialGroupCodeChange(row.original.id, newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + onChange={onChange} + placeholder="자재그룹 코드 입력" + maxLength={50} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + accessorKey: "materialGroupName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("materialGroupName") + 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, "materialGroupName", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "materialGroupName") + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="자재그룹 명 입력" + maxLength={100} + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 120, + }, + { + 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: 100, + }, + ] + }, + // 자재 정보 그룹 + { + header: "자재 정보", + columns: [ + { + accessorKey: "similarMaterialNamePurchase", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="유사자재명(구매)" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("similarMaterialNamePurchase") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNamePurchase", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="유사자재명(구매) 입력" + maxLength={100} + autoSave={false} + /> + ) + }, + size: 140, + }, + { + 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, + }, + ] + }, + // 협력업체 정보 그룹 + { + header: "협력업체 정보", + columns: [ + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체 코드" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("vendorCode") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "vendorCode", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "vendorCode") + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="협력업체 코드 입력" + maxLength={50} + autoSave={false} + isModified={isModified} + /> + ) + }, + size: 130, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">협력업체 명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("vendorName") + 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, "vendorName", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "vendorName") + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="협력업체 명 입력" + maxLength={100} + autoSave={false} + initialEditMode={isEmptyRow} + isModified={isModified} + /> + ) + }, + size: 130, + }, + { + accessorKey: "taxId", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">사업자번호 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("taxId") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "taxId", newValue) + } + } + + const onChange = async (newValue: any) => { + if (table.options.meta?.onTaxIdChange) { + await table.options.meta.onTaxIdChange(row.original.id, newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + onChange={onChange} + placeholder="사업자번호 입력" + maxLength={50} + /> + ) + }, + size: 120, + }, + { + accessorKey: "faTarget", + header: "FA대상", + cell: ({ row, table }) => { + const value = row.getValue("faTarget") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "faTarget", newValue) + } + } + + // 수정 여부 확인 + const isModified = getIsModified(table, row.original.id, "faTarget") + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + isModified={isModified} + /> + ) + }, + enableSorting: false, + size: 80, + }, + { + accessorKey: "faStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="FA현황" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("faStatus") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "faStatus", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="FA현황 입력" + maxLength={50} + /> + ) + }, + size: 100, + }, + { + 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 }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">등급 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("tier") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "tier", newValue) + } + } + + return ( + <EditableCell + value={value} + type="select" + onSave={onSave} + options={[ + { label: "Tier 1", value: "Tier 1" }, + { label: "Tier 2", value: "Tier 2" }, + { label: "Tier 3", value: "Tier 3" }, + { label: "Tier 4", value: "Tier 4" }, + ]} + placeholder="등급 선택" + /> + ) + }, + size: 80, + }, + { + 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: "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>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("headquarterLocation") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "headquarterLocation", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="본사 위치 입력" + maxLength={50} + /> + ) + }, + size: 100, + }, + { + accessorKey: "manufacturingLocation", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">제작/선적지 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("manufacturingLocation") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "manufacturingLocation", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="제작/선적지 입력" + maxLength={50} + /> + ) + }, + size: 110, + }, + { + accessorKey: "avlVendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">AVL 등재업체명 *</span>} /> + ), + cell: ({ row, table }) => { + const value = row.getValue("avlVendorName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "avlVendorName", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="AVL 등재업체명 입력" + maxLength={100} + /> + ) + }, + size: 140, + }, + { + accessorKey: "similarVendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="유사업체명" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("similarVendorName") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "similarVendorName", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="유사업체명 입력" + maxLength={100} + /> + ) + }, + 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, + }, + { + accessorKey: "isBlacklist", + header: "Blacklist", + cell: ({ row, table }) => { + const value = row.getValue("isBlacklist") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "isBlacklist", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 100, + }, + { + accessorKey: "isBcc", + header: "BCC", + cell: ({ row, table }) => { + const value = row.getValue("isBcc") as boolean + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "isBcc", newValue) + } + } + + return ( + <EditableCell + value={value} + type="checkbox" + onSave={onSave} + autoSave={false} + /> + ) + }, + enableSorting: false, + size: 80, + }, + { + accessorKey: "purchaseOpinion", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매의견" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("purchaseOpinion") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "purchaseOpinion", newValue) + } + } + + return ( + <EditableCell + value={value} + type="textarea" + onSave={onSave} + placeholder="구매의견 입력" + maxLength={500} + /> + ) + }, + size: 120, + }, + ] + }, + // AVL 적용 선종(조선) 그룹 + { + header: "AVL 적용 선종(조선)", + columns: [ + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 80, + }, + ] + }, + // AVL 적용 선종(해양) 그룹 + { + header: "AVL 적용 선종(해양)", + columns: [ + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 80, + }, + { + 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: 100, + }, + { + 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: 80, + }, + { + 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: 80, + }, + ] + }, + // eVCP 미등록 정보 그룹 + { + header: "eVCP 미등록 정보", + columns: [ + { + 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="PIC 담당자명 입력" + maxLength={50} + /> + ) + }, + size: 120, + }, + { + accessorKey: "picEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="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="PIC 이메일 입력" + maxLength={100} + /> + ) + }, + size: 140, + }, + { + accessorKey: "picPhone", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="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="PIC 전화번호 입력" + 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="Agent 담당자명 입력" + 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="Agent 이메일 입력" + 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="Agent 전화번호 입력" + maxLength={20} + /> + ) + }, + size: 120, + }, + ] + }, + // 업체 실적 현황 그룹 + { + header: "업체 실적 현황", + columns: [ + { + accessorKey: "recentQuoteDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근견적일" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("recentQuoteDate") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "recentQuoteDate", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="최근견적일 입력 (YYYY-MM-DD)" + maxLength={20} + autoSave={false} + /> + ) + }, + size: 120, + }, + { + accessorKey: "recentQuoteNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근견적번호" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("recentQuoteNumber") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "recentQuoteNumber", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="최근견적번호 입력" + maxLength={50} + /> + ) + }, + size: 130, + }, + { + accessorKey: "recentOrderDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근발주일" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("recentOrderDate") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "recentOrderDate", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="최근발주일 입력 (YYYY-MM-DD)" + maxLength={20} + autoSave={false} + /> + ) + }, + size: 120, + }, + { + accessorKey: "recentOrderNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최근발주번호" /> + ), + cell: ({ row, table }) => { + const value = row.getValue("recentOrderNumber") + const onSave = async (newValue: any) => { + if (table.options.meta?.onCellUpdate) { + await table.options.meta.onCellUpdate(row.original.id, "recentOrderNumber", newValue) + } + } + + return ( + <EditableCell + value={value} + type="text" + onSave={onSave} + placeholder="최근발주번호 입력" + maxLength={50} + /> + ) + }, + size: 130, + }, + ] + }, + // 업데이트 히스토리 그룹 + { + header: "업데이트 히스토리", + columns: [ + { + accessorKey: "registrationDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등재일" /> + ), + size: 120, + }, + { + accessorKey: "registrant", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등재자" /> + ), + cell: ({ row }) => { + const value = row.getValue("registrant") as string + return <div className="text-sm">{value || ""}</div> + }, + size: 100, + }, + { + accessorKey: "lastModifiedDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종변경일" /> + ), + size: 120, + }, + { + accessorKey: "lastModifier", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종변경자" /> + ), + cell: ({ row }) => { + const value = row.getValue("lastModifier") as string + return <div className="text-sm">{value || ""}</div> + }, + size: 120, + }, + ] + }, + // 액션 그룹 + { + id: "actions", + header: "액션", + cell: ({ row, table }) => { + const data = row.original + const isEmptyRow = (table.options.meta as any)?.isEmptyRow?.(String(data.id)) + + if (isEmptyRow) { + // 빈 행의 경우 저장/취소 버튼 표시 + return ( + <div className="flex items-center gap-2 overflow-visible relative"> + <Button + variant="default" + size="sm" + onClick={() => { + const onSaveEmptyRow = (table.options.meta as any)?.onSaveEmptyRow + onSaveEmptyRow?.(data.id) + }} + title="저장" + className="bg-green-600 hover:bg-green-700" + > + <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" /> + </svg> + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const onCancelEmptyRow = (table.options.meta as any)?.onCancelEmptyRow + onCancelEmptyRow?.(data.id) + }} + title="취소" + className="border-red-300 text-red-600 hover:bg-red-50" + > + <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" /> + </svg> + </Button> + </div> + ) + } + + // 일반 행의 경우 기존 액션 버튼들 표시 + return ( + <div className="flex items-center gap-2 overflow-visible relative"> + <Button + variant="ghost" + size="sm" + onClick={() => { + const onAction = (table.options.meta as any)?.onAction + onAction?.('delete', data) + }} + title="삭제" + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + ) + }, + size: 120, + enableSorting: false, + enableHiding: false, + }, +] 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} + /> + </> + ) +} diff --git a/lib/vendor-pool/types.ts b/lib/vendor-pool/types.ts new file mode 100644 index 00000000..c6501d59 --- /dev/null +++ b/lib/vendor-pool/types.ts @@ -0,0 +1,109 @@ +/** + * Vendor Pool 데이터 타입 정의 + */ + +export interface VendorPool { + id: number + // 선택 체크박스 + selected?: boolean + + // 기본 정보 + constructionSector: string // 공사부문: 조선 또는 해양 + htDivision: string // H/T구분: H 또는 T + + // 설계 정보 + designCategoryCode: string // 설계기능(공종) 코드: 2자리 영문대문자 + designCategory: string // 설계기능(공종): 전장 등 + equipBulkDivision: string // Equip/Bulk 구분: E 또는 B + + // 패키지 정보 + packageCode: string + packageName: string + + // 자재그룹 정보 + materialGroupCode: string + materialGroupName: string + + // 자재 관련 정보 + smCode: string + similarMaterialNamePurchase: string // 유사자재명 (구매) + similarMaterialNameOther: string // 유사자재명 (구매 외) + + // 협력업체 정보 + vendorCode: string + vendorName: string + + // 사업 및 인증 정보 + taxId: string // 사업자번호 + faTarget: boolean // FA대상 + faStatus: string // FA현황 + faRemark: string // FA상세 + tier: string // 등급 + isAgent: boolean // Agent 여부 + + // 계약 정보 + contractSignerCode: string + contractSignerName: string + + // 위치 정보 + headquarterLocation: string // 본사 위치 (국가) + manufacturingLocation: string // 제작/선적지 (국가) + + // AVL 관련 정보 + avlVendorName: string // AVL 등재업체명 + similarVendorName: string // 유사업체명(기술영업) + hasAvl: boolean // AVL 존재여부 + + // 상태 정보 + isBlacklist: boolean // Blacklist + isBcc: boolean // BCC + purchaseOpinion: string // 구매의견 + + // AVL 적용 선종(조선) + shipTypeCommon: boolean // 공통 + shipTypeAmax: boolean // A-max + shipTypeSmax: boolean // S-max + shipTypeVlcc: boolean // VLCC + shipTypeLngc: boolean // LNGC + shipTypeCont: boolean // CONT + + // AVL 적용 선종(해양) + offshoreTypeCommon: boolean // 공통 + offshoreTypeFpso: boolean // FPSO + offshoreTypeFlng: boolean // FLNG + offshoreTypeFpu: boolean // FPU + offshoreTypePlatform: boolean // Platform + offshoreTypeWtiv: boolean // WTIV + offshoreTypeGom: boolean // GOM + + // eVCP 미등록 정보 + picName: string // PIC(담당자) + picEmail: string // PIC(E-mail) + picPhone: string // PIC(Phone) + agentName: string // Agent(담당자) + agentEmail: string // Agent(E-mail) + agentPhone: string // Agent(Phone) + + // 업체 실적 현황 + recentQuoteDate: string // 최근견적일 + recentQuoteNumber: string // 최근견적번호 + recentOrderDate: string // 최근발주일 + recentOrderNumber: string // 최근발주번호 + + // 업데이트 히스토리 + registrationDate: string // 등재일 + registrant: string // 등재자 + lastModifiedDate: string // 최종변경일 + lastModifier: string // 최종변경자 +} + +// Vendor Pool 액션 타입들 +export type VendorPoolActionType = + | "new-registration" // 신규등록 + | "bulk-import" // 일괄입력 + | "fa-detail" // FA상세 + | "save" // 저장 + | "fixed-values" // 고정값 설정 + | "edit" // 수정 + | "delete" // 삭제 + | "view-detail" // 상세보기 diff --git a/lib/vendor-pool/validations.ts b/lib/vendor-pool/validations.ts new file mode 100644 index 00000000..642ccc47 --- /dev/null +++ b/lib/vendor-pool/validations.ts @@ -0,0 +1,92 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { VendorPool } from "./types" + +export const vendorPoolSearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (등재일 기준 내림차순) + sort: getSortingStateParser<VendorPool>().withDefault([ + { id: "registrationDate", desc: true }, + ]), + + // Vendor Pool 관련 필드 + constructionSector: parseAsStringEnum(["조선", "해양"]).withDefault(""), // 공사부문 + htDivision: parseAsStringEnum(["H", "T"]).withDefault(""), // H/T구분 + designCategoryCode: parseAsString.withDefault(""), // 설계기능코드 + designCategory: parseAsString.withDefault(""), // 설계기능(공종) + equipBulkDivision: parseAsStringEnum(["E", "B"]).withDefault(""), // Equip/Bulk 구분 + + // 패키지/자재 정보 + packageCode: parseAsString.withDefault(""), // 패키지 코드 + packageName: parseAsString.withDefault(""), // 패키지 명 + materialGroupCode: parseAsString.withDefault(""), // 자재그룹 코드 + materialGroupName: parseAsString.withDefault(""), // 자재그룹 명 + smCode: parseAsString.withDefault(""), // SM Code + + // 유사자재명 + similarMaterialNamePurchase: parseAsString.withDefault(""), // 유사자재명 (구매) + similarMaterialNameOther: parseAsString.withDefault(""), // 유사자재명 (구매 외) + + // 협력업체 정보 + vendorCode: parseAsString.withDefault(""), // 협력업체 코드 + vendorName: parseAsString.withDefault(""), // 협력업체 명 + taxId: parseAsString.withDefault(""), // 사업자번호 + + // 인증/상태 정보 + faStatus: parseAsString.withDefault(""), // FA현황 + tier: parseAsString.withDefault(""), // 등급 + isAgent: parseAsStringEnum(["true", "false"]).withDefault(""), // Agent 여부 + + // 계약 정보 + contractSignerCode: parseAsString.withDefault(""), // 계약서명주체 코드 + contractSignerName: parseAsString.withDefault(""), // 계약서명주체 명 + + // 위치 정보 + headquarterLocation: parseAsString.withDefault(""), // 본사 위치 + manufacturingLocation: parseAsString.withDefault(""), // 제작/선적지 + + // AVL 정보 + avlVendorName: parseAsString.withDefault(""), // AVL 등재업체명 + similarVendorName: parseAsString.withDefault(""), // 유사업체명 + hasAvl: parseAsStringEnum(["true", "false"]).withDefault(""), // AVL 존재여부 + + // 상태 정보 + isBlacklist: parseAsStringEnum(["true", "false"]).withDefault(""), // Blacklist + isBcc: parseAsStringEnum(["true", "false"]).withDefault(""), // BCC + + // eVCP 미등록 정보 + picName: parseAsString.withDefault(""), // PIC(담당자) + picEmail: parseAsString.withDefault(""), // PIC(E-mail) + agentName: parseAsString.withDefault(""), // Agent(담당자) + agentEmail: parseAsString.withDefault(""), // Agent(E-mail) + + // 실적 정보 + recentQuoteNumber: parseAsString.withDefault(""), // 최근견적번호 + recentOrderNumber: parseAsString.withDefault(""), // 최근발주번호 + + // 업데이트 히스토리 + registrant: parseAsString.withDefault(""), // 등재자 + lastModifier: parseAsString.withDefault(""), // 최종변경자 + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 최종 타입 +export type GetVendorPoolSchema = Awaited<ReturnType<typeof vendorPoolSearchParamsCache.parse>> diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 22d58ae1..45a0e0b5 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -3101,9 +3101,38 @@ export async function saveNdaAttachments(input: { console.error("비밀유지 계약서 첨부파일 저장 중 오류 발생:", error); return { success: false, - error: error instanceof Error - ? error.message + error: error instanceof Error + ? error.message : "첨부파일 저장 처리 중 오류가 발생했습니다." }; } } + +// 사업자번호(taxId)로 벤더 정보 검색 +export async function getVendorByTaxId(taxId: string) { + unstable_noStore(); + + try { + const result = await db + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + taxId: vendors.taxId, + }) + .from(vendors) + .where(eq(vendors.taxId, taxId)) + .limit(1); + + return { + data: result[0] || null, + error: null + }; + } catch (err) { + console.error("Error getting vendor by taxId:", err); + return { + data: null, + error: getErrorMessage(err) + }; + } +} |
