summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-14 13:27:37 +0000
committerjoonhoekim <26rote@gmail.com>2025-09-14 13:27:37 +0000
commit3f293c90beb58ce206a66ff444d7acfc41b56429 (patch)
tree7e0eb2f07b211b856d44c6bddad67d72759e1f47 /lib
parentde81b281d9a3c2883a623c3f25e2889ec10a091b (diff)
(김준회) Vendor Pool 구현
Diffstat (limited to 'lib')
-rw-r--r--lib/vendor-pool/service.ts825
-rw-r--r--lib/vendor-pool/table/bulk-import-dialog.tsx242
-rw-r--r--lib/vendor-pool/table/columns.tsx1687
-rw-r--r--lib/vendor-pool/table/vendor-pool-table.tsx806
-rw-r--r--lib/vendor-pool/types.ts109
-rw-r--r--lib/vendor-pool/validations.ts92
-rw-r--r--lib/vendors/service.ts33
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)
+ };
+ }
+}