From e7818a457371849e29519497ebf046f385f05ab6 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 15 Sep 2025 01:23:00 +0000 Subject: (김준회) AVL 기능 구현 1차 및 벤더풀 E/B 구분 개선 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/avl/avl-atoms.ts | 5 + lib/avl/service.ts | 1285 +++++++++++++++++++++++++++ lib/avl/table/avl-detail-table.tsx | 479 ++++++++++ lib/avl/table/avl-registration-area.tsx | 278 ++++++ lib/avl/table/avl-table-columns.tsx | 351 ++++++++ lib/avl/table/avl-table.tsx | 514 +++++++++++ lib/avl/table/columns-detail.tsx | 680 ++++++++++++++ lib/avl/table/project-avl-add-dialog.tsx | 779 ++++++++++++++++ lib/avl/table/project-avl-table.tsx | 724 +++++++++++++++ lib/avl/table/standard-avl-table.tsx | 380 ++++++++ lib/avl/table/vendor-pool-table.tsx | 290 ++++++ lib/avl/types.ts | 149 ++++ lib/avl/validations.ts | 170 ++++ lib/bidding-projects/service.ts | 40 + lib/projects/service.ts | 24 +- lib/vendor-pool/table/vendor-pool-table.tsx | 4 +- 16 files changed, 6148 insertions(+), 4 deletions(-) create mode 100644 lib/avl/avl-atoms.ts create mode 100644 lib/avl/service.ts create mode 100644 lib/avl/table/avl-detail-table.tsx create mode 100644 lib/avl/table/avl-registration-area.tsx create mode 100644 lib/avl/table/avl-table-columns.tsx create mode 100644 lib/avl/table/avl-table.tsx create mode 100644 lib/avl/table/columns-detail.tsx create mode 100644 lib/avl/table/project-avl-add-dialog.tsx create mode 100644 lib/avl/table/project-avl-table.tsx create mode 100644 lib/avl/table/standard-avl-table.tsx create mode 100644 lib/avl/table/vendor-pool-table.tsx create mode 100644 lib/avl/types.ts create mode 100644 lib/avl/validations.ts (limited to 'lib') diff --git a/lib/avl/avl-atoms.ts b/lib/avl/avl-atoms.ts new file mode 100644 index 00000000..26836413 --- /dev/null +++ b/lib/avl/avl-atoms.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; +import type { AvlListItem } from '@/lib/avl/types'; + +// AVL 페이지에서 선택된 AVL 레코드 +export const selectedAvlRecordAtom = atom(null); diff --git a/lib/avl/service.ts b/lib/avl/service.ts new file mode 100644 index 00000000..6a873ac1 --- /dev/null +++ b/lib/avl/service.ts @@ -0,0 +1,1285 @@ +"use server"; + +import { GetAvlListSchema, GetAvlDetailSchema, GetProjectAvlSchema, GetStandardAvlSchema } from "./validations"; +import { AvlListItem, AvlDetailItem, CreateAvlListInput, UpdateAvlListInput, ActionResult, AvlVendorInfoInput } from "./types"; +import type { NewAvlVendorInfo, AvlVendorInfo } from "@/db/schema/avl/avl"; +import db from "@/db/db"; +import { avlList, avlVendorInfo } from "@/db/schema/avl/avl"; +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"; + +/** + * AVL 리스트 조회 + * avl_list 테이블에서 실제 데이터를 조회합니다. + */ +const _getAvlLists = async (input: GetAvlListSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('AVL 리스트 조회 시작', { input, offset }); + + // 검색 조건 구성 + const whereConditions: any[] = []; + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlList.constructionSector, searchTerm), + ilike(avlList.projectCode, searchTerm), + ilike(avlList.shipType, searchTerm), + ilike(avlList.avlKind, searchTerm) + ) + ); + } + + // 필터 조건 추가 + if (input.isTemplate === "true") { + whereConditions.push(eq(avlList.isTemplate, true)); + } else if (input.isTemplate === "false") { + whereConditions.push(eq(avlList.isTemplate, false)); + } + + if (input.constructionSector) { + whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`)); + } + if (input.projectCode) { + whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`)); + } + if (input.shipType) { + whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`)); + } + if (input.avlKind) { + whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`)); + } + if (input.htDivision) { + whereConditions.push(eq(avlList.htDivision, input.htDivision)); + } + if (input.rev) { + whereConditions.push(eq(avlList.rev, parseInt(input.rev))); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlList; + + if (column && avlList[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlList[column]} desc`); + } else { + orderByConditions.push(sql`${avlList[column]} asc`); + } + } + }); + + // 기본 정렬 (등재일 내림차순) + if (orderByConditions.length === 0) { + orderByConditions.push(desc(avlList.createdAt)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlList) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(avlList) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 (timestamp -> string) + const transformedData: AvlListItem[] = data.map((item, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + // 추가 필드들 (실제로는 JOIN이나 별도 쿼리로 가져와야 함) + projectInfo: item.projectCode || '', + shipType: item.shipType || '', + avlType: item.avlKind || '', + htDivision: item.htDivision || '', + rev: item.rev || 1, + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('AVL 리스트 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('AVL 리스트 조회 실패', { error: err, input }); + console.error("Error in getAvlLists:", err); + return { data: [], pageCount: 0 }; + } +}; + +// 캐시된 버전 export - 동일한 입력에 대해 캐시 사용 +export const getAvlLists = unstable_cache( + _getAvlLists, + ['avl-list'], + { + tags: ['avl-list'], + revalidate: 300, // 5분 캐시 + } +); + +/** + * AVL 상세 정보 조회 (특정 AVL ID의 모든 vendor info) + */ +const _getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('AVL 상세 조회 시작', { input, offset }); + + // 검색 조건 구성 + const whereConditions: any[] = []; + + // AVL 리스트 ID 필터 (필수) + whereConditions.push(eq(avlVendorInfo.avlListId, input.avlListId)); + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm) + ) + ); + } + + // 필터 조건 추가 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + if (input.faTarget === "true") { + whereConditions.push(eq(avlVendorInfo.faTarget, true)); + } else if (input.faTarget === "false") { + whereConditions.push(eq(avlVendorInfo.faTarget, false)); + } + if (input.faStatus) { + whereConditions.push(ilike(avlVendorInfo.faStatus, `%${input.faStatus}%`)); + } + if (input.isAgent === "true") { + whereConditions.push(eq(avlVendorInfo.isAgent, true)); + } else if (input.isAgent === "false") { + whereConditions.push(eq(avlVendorInfo.isAgent, false)); + } + if (input.contractSignerName) { + whereConditions.push(ilike(avlVendorInfo.contractSignerName, `%${input.contractSignerName}%`)); + } + if (input.headquarterLocation) { + whereConditions.push(ilike(avlVendorInfo.headquarterLocation, `%${input.headquarterLocation}%`)); + } + if (input.manufacturingLocation) { + whereConditions.push(ilike(avlVendorInfo.manufacturingLocation, `%${input.manufacturingLocation}%`)); + } + if (input.hasAvl === "true") { + whereConditions.push(eq(avlVendorInfo.hasAvl, true)); + } else if (input.hasAvl === "false") { + whereConditions.push(eq(avlVendorInfo.hasAvl, false)); + } + if (input.isBlacklist === "true") { + whereConditions.push(eq(avlVendorInfo.isBlacklist, true)); + } else if (input.isBlacklist === "false") { + whereConditions.push(eq(avlVendorInfo.isBlacklist, false)); + } + if (input.isBcc === "true") { + whereConditions.push(eq(avlVendorInfo.isBcc, true)); + } else if (input.isBcc === "false") { + whereConditions.push(eq(avlVendorInfo.isBcc, false)); + } + if (input.techQuoteNumber) { + whereConditions.push(ilike(avlVendorInfo.techQuoteNumber, `%${input.techQuoteNumber}%`)); + } + if (input.quoteCode) { + whereConditions.push(ilike(avlVendorInfo.quoteCode, `%${input.quoteCode}%`)); + } + if (input.quoteCountry) { + whereConditions.push(ilike(avlVendorInfo.quoteCountry, `%${input.quoteCountry}%`)); + } + if (input.remark) { + whereConditions.push(ilike(avlVendorInfo.remark, `%${input.remark}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(avlVendorInfo) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 (timestamp -> string, DB 필드 -> UI 필드) + const transformedData: AvlDetailItem[] = data.map((item, index) => ({ + ...(item as any), + no: offset + index + 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('AVL 상세 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('AVL 상세 조회 실패', { error: err, input }); + console.error("Error in getAvlDetail:", err); + return { data: [], pageCount: 0 }; + } +}; + +// 캐시된 버전 export +export const getAvlDetail = unstable_cache( + _getAvlDetail, + ['avl-detail'], + { + tags: ['avl-detail'], + revalidate: 300, // 5분 캐시 + } +); + +/** + * AVL 리스트 상세 정보 조회 (단일) + */ +export async function getAvlListById(id: number): Promise { + try { + const data = await db + .select() + .from(avlList) + .where(eq(avlList.id, id)) + .limit(1); + + if (data.length === 0) { + return null; + } + + const item = data[0]; + + // 데이터 변환 + const transformedData: AvlListItem = { + ...item, + no: 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + projectInfo: item.projectCode || '', + shipType: item.shipType || '', + avlType: item.avlKind || '', + htDivision: item.htDivision || '', + rev: item.rev || 1, + }; + + return transformedData; + } catch (err) { + console.error("Error in getAvlListById:", err); + return null; + } +} + +/** + * AVL Vendor Info 상세 정보 조회 (단일) + */ +export async function getAvlVendorInfoById(id: number): Promise { + try { + const data = await db + .select() + .from(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)) + .limit(1); + + if (data.length === 0) { + return null; + } + + const item = data[0]; + + // 데이터 변환 + const transformedData: AvlDetailItem = { + ...(item as any), + no: 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + }; + + return transformedData; + } catch (err) { + console.error("Error in getAvlVendorInfoById:", err); + return null; + } +} + +/** + * AVL 액션 처리 + * 신규등록, 일괄입력, 저장 등의 액션을 처리 + */ +export async function handleAvlAction( + action: string, + data?: any +): Promise { + try { + switch (action) { + case "new-registration": + return { success: true, message: "신규 AVL 등록 모드" }; + + case "standard-registration": + return { success: true, message: "표준 AVL 등재 모드" }; + + case "project-registration": + return { success: true, message: "프로젝트 AVL 등재 모드" }; + + case "bulk-import": + if (!data?.file) { + return { success: false, message: "업로드할 파일이 없습니다." }; + } + console.log("일괄 입력 처리:", data.file); + return { success: true, message: "일괄 입력 처리가 시작되었습니다." }; + + case "save": + console.log("변경사항 저장:", data); + return { success: true, message: "변경사항이 저장되었습니다." }; + + case "edit": + if (!data?.id) { + return { success: false, message: "수정할 항목 ID가 없습니다." }; + } + return { success: true, message: "수정 모달이 열렸습니다.", data: { id: data.id } }; + + case "delete": + if (!data?.id) { + return { success: false, message: "삭제할 항목 ID가 없습니다." }; + } + // 실제 삭제 처리 + const deleteResult = await deleteAvlList(data.id); + if (deleteResult) { + return { success: true, message: "항목이 삭제되었습니다.", data: { id: data.id } }; + } else { + return { success: false, message: "항목 삭제에 실패했습니다." }; + } + + case "view-detail": + 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 handleAvlAction:", err); + return { success: false, message: "액션 처리 중 오류가 발생했습니다." }; + } +} + +// 클라이언트에서 호출할 수 있는 서버 액션 래퍼들 +export async function createAvlListAction(data: CreateAvlListInput): Promise { + return await createAvlList(data); +} + +export async function updateAvlListAction(id: number, data: UpdateAvlListInput): Promise { + return await updateAvlList(id, data); +} + +export async function deleteAvlListAction(id: number): Promise { + return await deleteAvlList(id); +} + +export async function handleAvlActionAction(action: string, data?: any): Promise { + return await handleAvlAction(action, data); +} + +/** + * AVL 리스트 생성 + */ +export async function createAvlList(data: CreateAvlListInput): Promise { + try { + debugLog('AVL 리스트 생성 시작', { inputData: data }); + + const currentTimestamp = new Date(); + + // 데이터베이스에 삽입할 데이터 준비 + const insertData = { + isTemplate: data.isTemplate ?? false, + constructionSector: data.constructionSector, + projectCode: data.projectCode, + shipType: data.shipType, + avlKind: data.avlKind, + htDivision: data.htDivision, + rev: data.rev ?? 1, + createdBy: data.createdBy || 'system', + updatedBy: data.updatedBy || 'system', + }; + + debugLog('DB INSERT 시작', { table: 'avl_list', data: insertData }); + + // 데이터베이스에 삽입 + const result = await db + .insert(avlList) + .values(insertData) + .returning(); + + if (result.length === 0) { + debugError('DB 삽입 실패: 결과가 없음', { insertData }); + throw new Error("Failed to create AVL list"); + } + + debugSuccess('DB INSERT 완료', { table: 'avl_list', result: result[0] }); + + const createdItem = result[0]; + + // 생성된 데이터를 AvlListItem 타입으로 변환 + const transformedData: AvlListItem = { + ...createdItem, + no: 1, + selected: false, + createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '', + projectInfo: createdItem.projectCode || '', + shipType: createdItem.shipType || '', + avlType: createdItem.avlKind || '', + htDivision: createdItem.htDivision || '', + rev: createdItem.rev || 1, + }; + + debugSuccess('AVL 리스트 생성 완료', { result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-list'); + + debugSuccess('AVL 캐시 무효화 완료', { tags: ['avl-list'] }); + + return transformedData; + } catch (err) { + debugError('AVL 리스트 생성 실패', { error: err, inputData: data }); + console.error("Error in createAvlList:", err); + return null; + } +} + +/** + * AVL 리스트 업데이트 + */ +export async function updateAvlList(id: number, data: UpdateAvlListInput): Promise { + try { + debugLog('AVL 리스트 업데이트 시작', { id, updateData: data }); + + const currentTimestamp = new Date(); + + // 업데이트할 데이터 준비 + const updateData: any = {}; + + if (data.isTemplate !== undefined) updateData.isTemplate = data.isTemplate; + if (data.constructionSector !== undefined) updateData.constructionSector = data.constructionSector; + if (data.projectCode !== undefined) updateData.projectCode = data.projectCode; + if (data.shipType !== undefined) updateData.shipType = data.shipType; + if (data.avlKind !== undefined) updateData.avlKind = data.avlKind; + if (data.htDivision !== undefined) updateData.htDivision = data.htDivision; + if (data.rev !== undefined) updateData.rev = data.rev; + if (data.createdBy !== undefined) updateData.createdBy = data.createdBy; + if (data.updatedBy !== undefined) updateData.updatedBy = data.updatedBy; + + updateData.updatedAt = currentTimestamp; + + // 업데이트할 데이터가 없는 경우 + if (Object.keys(updateData).length <= 1) { + return await getAvlListById(id); + } + + // 데이터베이스 업데이트 + const result = await db + .update(avlList) + .set(updateData) + .where(eq(avlList.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("AVL list not found or update failed"); + } + + const updatedItem = result[0]; + + // 업데이트된 데이터를 AvlListItem 타입으로 변환 + const transformedData: AvlListItem = { + ...updatedItem, + no: 1, + selected: false, + createdAt: updatedItem.createdAt ? updatedItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: updatedItem.updatedAt ? updatedItem.updatedAt.toISOString().split('T')[0] : '', + projectInfo: updatedItem.projectCode || '', + shipType: updatedItem.shipType || '', + avlType: updatedItem.avlKind || '', + htDivision: updatedItem.htDivision || '', + rev: updatedItem.rev || 1, + }; + + debugSuccess('AVL 리스트 업데이트 완료', { id, result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-list'); + + return transformedData; + } catch (err) { + debugError('AVL 리스트 업데이트 실패', { error: err, id, updateData: data }); + console.error("Error in updateAvlList:", err); + return null; + } +} + +/** + * AVL 리스트 삭제 + */ +export async function deleteAvlList(id: number): Promise { + try { + debugLog('AVL 리스트 삭제 시작', { id }); + + // 데이터베이스에서 삭제 + const result = await db + .delete(avlList) + .where(eq(avlList.id, id)); + + // 삭제 확인을 위한 재조회 + const checkDeleted = await db + .select({ id: avlList.id }) + .from(avlList) + .where(eq(avlList.id, id)) + .limit(1); + + const isDeleted = checkDeleted.length === 0; + + if (isDeleted) { + debugSuccess('AVL 리스트 삭제 완료', { id }); + revalidateTag('avl-list'); + } else { + debugWarn('AVL 리스트 삭제 실패: 항목이 존재함', { id }); + } + + return isDeleted; + } catch (err) { + debugError('AVL 리스트 삭제 실패', { error: err, id }); + console.error("Error in deleteAvlList:", err); + return false; + } +} + +/** + * AVL Vendor Info 생성 + */ +export async function createAvlVendorInfo(data: AvlVendorInfoInput): Promise { + try { + debugLog('AVL Vendor Info 생성 시작', { inputData: data }); + + const currentTimestamp = new Date(); + + // UI 필드를 DB 필드로 변환 + const insertData: NewAvlVendorInfo = { + avlListId: data.avlListId, + ownerSuggestion: data.ownerSuggestion ?? false, + shiSuggestion: data.shiSuggestion ?? false, + equipBulkDivision: data.equipBulkDivision === "EQUIP" ? "E" : "B", + disciplineCode: data.disciplineCode || null, + disciplineName: data.disciplineName, + materialNameCustomerSide: data.materialNameCustomerSide, + packageCode: data.packageCode || null, + packageName: data.packageName || null, + materialGroupCode: data.materialGroupCode || null, + materialGroupName: data.materialGroupName || null, + vendorId: data.vendorId || null, + vendorName: data.vendorName || null, + vendorCode: data.vendorCode || null, + avlVendorName: data.avlVendorName || null, + tier: data.tier || null, + faTarget: data.faTarget ?? false, + faStatus: data.faStatus || null, + isAgent: data.isAgent ?? false, + contractSignerId: data.contractSignerId || null, + contractSignerName: data.contractSignerName || null, + contractSignerCode: data.contractSignerCode || null, + headquarterLocation: data.headquarterLocation || null, + manufacturingLocation: data.manufacturingLocation || null, + hasAvl: data.shiAvl ?? false, + isBlacklist: data.shiBlacklist ?? false, + isBcc: data.shiBcc ?? false, + techQuoteNumber: data.salesQuoteNumber || null, + quoteCode: data.quoteCode || null, + quoteVendorId: data.quoteVendorId || null, + quoteVendorName: data.salesVendorInfo || null, + quoteVendorCode: data.quoteVendorCode || null, + quoteCountry: data.salesCountry || null, + quoteTotalAmount: data.totalAmount ? data.totalAmount.replace(/,/g, '') as any : null, + quoteReceivedDate: data.quoteReceivedDate || null, + recentQuoteDate: data.recentQuoteDate || null, + recentQuoteNumber: data.recentQuoteNumber || null, + recentOrderDate: data.recentOrderDate || null, + recentOrderNumber: data.recentOrderNumber || null, + remark: data.remarks || null, + }; + + debugLog('DB INSERT 시작', { table: 'avl_vendor_info', data: insertData }); + + // 데이터베이스에 삽입 + const result = await db + .insert(avlVendorInfo) + .values(insertData as any) + .returning(); + + if (result.length === 0) { + debugError('DB 삽입 실패: 결과가 없음', { insertData }); + throw new Error("Failed to create AVL vendor info"); + } + + debugSuccess('DB INSERT 완료', { table: 'avl_vendor_info', result: result[0] }); + + const createdItem = result[0]; + + // 생성된 데이터를 AvlDetailItem 타입으로 변환 + const transformedData: AvlDetailItem = { + ...(createdItem as any), + no: 1, + selected: false, + createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '', + equipBulkDivision: createdItem.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: createdItem.faTarget ?? false, + agentStatus: createdItem.isAgent ? "예" : "아니오", + shiAvl: createdItem.hasAvl ?? false, + shiBlacklist: createdItem.isBlacklist ?? false, + shiBcc: createdItem.isBcc ?? false, + salesQuoteNumber: createdItem.techQuoteNumber || '', + quoteCode: createdItem.quoteCode || '', + salesVendorInfo: createdItem.quoteVendorName || '', + salesCountry: createdItem.quoteCountry || '', + totalAmount: createdItem.quoteTotalAmount ? createdItem.quoteTotalAmount.toString() : '', + quoteReceivedDate: createdItem.quoteReceivedDate || '', + recentQuoteDate: createdItem.recentQuoteDate || '', + recentQuoteNumber: createdItem.recentQuoteNumber || '', + recentOrderDate: createdItem.recentOrderDate || '', + recentOrderNumber: createdItem.recentOrderNumber || '', + remarks: createdItem.remark || '', + }; + + debugSuccess('AVL Vendor Info 생성 완료', { result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-detail'); + + return transformedData; + } catch (err) { + debugError('AVL Vendor Info 생성 실패', { error: err, inputData: data }); + console.error("Error in createAvlVendorInfo:", err); + return null; + } +} + +/** + * AVL Vendor Info 업데이트 + */ +export async function updateAvlVendorInfo(id: number, data: Partial): Promise { + try { + debugLog('AVL Vendor Info 업데이트 시작', { id, data }); + + // 간단한 필드 매핑 + const updateData: any = { updatedAt: new Date() }; + + // ownerSuggestion과 shiSuggestion 추가 + if (data.ownerSuggestion !== undefined) updateData.ownerSuggestion = data.ownerSuggestion; + if (data.shiSuggestion !== undefined) updateData.shiSuggestion = data.shiSuggestion; + + if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision === "EQUIP" ? "E" : "B"; + if (data.disciplineCode !== undefined) updateData.disciplineCode = data.disciplineCode; + if (data.disciplineName !== undefined) updateData.disciplineName = data.disciplineName; + if (data.materialNameCustomerSide !== undefined) updateData.materialNameCustomerSide = data.materialNameCustomerSide; + 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.vendorId !== undefined) updateData.vendorId = data.vendorId; + if (data.vendorName !== undefined) updateData.vendorName = data.vendorName; + if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode; + if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName; + if (data.tier !== undefined) updateData.tier = data.tier; + if (data.faTarget !== undefined) updateData.faTarget = data.faTarget; + if (data.faStatus !== undefined) updateData.faStatus = data.faStatus; + if (data.isAgent !== undefined) updateData.isAgent = data.isAgent; + if (data.contractSignerId !== undefined) updateData.contractSignerId = data.contractSignerId; + if (data.contractSignerName !== undefined) updateData.contractSignerName = data.contractSignerName; + if (data.contractSignerCode !== undefined) updateData.contractSignerCode = data.contractSignerCode; + if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation; + if (data.manufacturingLocation !== undefined) updateData.manufacturingLocation = data.manufacturingLocation; + if (data.shiAvl !== undefined) updateData.hasAvl = data.shiAvl; + if (data.shiBlacklist !== undefined) updateData.isBlacklist = data.shiBlacklist; + if (data.shiBcc !== undefined) updateData.isBcc = data.shiBcc; + if (data.salesQuoteNumber !== undefined) updateData.techQuoteNumber = data.salesQuoteNumber; + if (data.quoteCode !== undefined) updateData.quoteCode = data.quoteCode; + if (data.quoteVendorId !== undefined) updateData.quoteVendorId = data.quoteVendorId; + if (data.quoteVendorCode !== undefined) updateData.quoteVendorCode = data.quoteVendorCode; + if (data.salesCountry !== undefined) updateData.quoteCountry = data.salesCountry; + if (data.quoteReceivedDate !== undefined) updateData.quoteReceivedDate = data.quoteReceivedDate; + 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; + if (data.remarks !== undefined) updateData.remark = data.remarks; + + // 숫자 변환 + if (data.totalAmount !== undefined) { + updateData.quoteTotalAmount = data.totalAmount ? parseFloat(data.totalAmount.replace(/,/g, '')) || null : null; + } + + // 문자열 필드 + if (data.salesVendorInfo !== undefined) updateData.quoteVendorName = data.salesVendorInfo; + + debugLog('업데이트할 데이터', { updateData }); + + // 데이터베이스 업데이트 + const result = await db + .update(avlVendorInfo) + .set(updateData) + .where(eq(avlVendorInfo.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("AVL vendor info not found"); + } + + debugSuccess('AVL Vendor Info 업데이트 성공', { id }); + + revalidateTag('avl-detail'); + + // 업데이트된 데이터 조회해서 반환 + return await getAvlVendorInfoById(id); + } catch (err) { + debugError('AVL Vendor Info 업데이트 실패', { id, error: err }); + console.error("Error in updateAvlVendorInfo:", err); + return null; + } +} + +/** + * AVL Vendor Info 삭제 + */ +export async function deleteAvlVendorInfo(id: number): Promise { + try { + debugLog('AVL Vendor Info 삭제 시작', { id }); + + // 데이터베이스에서 삭제 + const result = await db + .delete(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)); + + // 삭제 확인을 위한 재조회 + const checkDeleted = await db + .select({ id: avlVendorInfo.id }) + .from(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)) + .limit(1); + + const isDeleted = checkDeleted.length === 0; + + if (isDeleted) { + debugSuccess('AVL Vendor Info 삭제 완료', { id }); + revalidateTag('avl-detail'); + } else { + debugWarn('AVL Vendor Info 삭제 실패: 항목이 존재함', { id }); + } + + return isDeleted; + } catch (err) { + debugError('AVL Vendor Info 삭제 실패', { error: err, id }); + console.error("Error in deleteAvlVendorInfo:", err); + return false; + } +} + +/** + * 프로젝트 AVL Vendor Info 조회 (프로젝트별, isTemplate=false) + * avl_list와 avlVendorInfo를 JOIN하여 프로젝트별 AVL 데이터를 조회합니다. + */ +const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('프로젝트 AVL Vendor Info 조회 시작', { input, offset }); + + // 기본 JOIN 쿼리 구성 (프로젝트 AVL이므로 isTemplate=false) + // 실제 쿼리는 아래에서 구성됨 + + // 검색 조건 구성 + const whereConditions: any[] = [eq(avlList.isTemplate, false)]; // 기본 조건 + + // 필수 필터: 프로젝트 코드 + if (input.projectCode) { + whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`)); + } + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm), + ilike(avlVendorInfo.packageName, searchTerm), + ilike(avlVendorInfo.materialGroupName, searchTerm) + ) + ); + } + + // 추가 필터 조건들 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)); + + // 데이터 조회 - JOIN 결과에서 필요한 필드들을 명시적으로 선택 + const data = await db + .select({ + // avlVendorInfo의 모든 필드 + id: avlVendorInfo.id, + avlListId: avlVendorInfo.avlListId, + ownerSuggestion: avlVendorInfo.ownerSuggestion, + shiSuggestion: avlVendorInfo.shiSuggestion, + equipBulkDivision: avlVendorInfo.equipBulkDivision, + disciplineCode: avlVendorInfo.disciplineCode, + disciplineName: avlVendorInfo.disciplineName, + materialNameCustomerSide: avlVendorInfo.materialNameCustomerSide, + packageCode: avlVendorInfo.packageCode, + packageName: avlVendorInfo.packageName, + materialGroupCode: avlVendorInfo.materialGroupCode, + materialGroupName: avlVendorInfo.materialGroupName, + vendorId: avlVendorInfo.vendorId, + vendorName: avlVendorInfo.vendorName, + vendorCode: avlVendorInfo.vendorCode, + avlVendorName: avlVendorInfo.avlVendorName, + tier: avlVendorInfo.tier, + faTarget: avlVendorInfo.faTarget, + faStatus: avlVendorInfo.faStatus, + isAgent: avlVendorInfo.isAgent, + contractSignerId: avlVendorInfo.contractSignerId, + contractSignerName: avlVendorInfo.contractSignerName, + contractSignerCode: avlVendorInfo.contractSignerCode, + headquarterLocation: avlVendorInfo.headquarterLocation, + manufacturingLocation: avlVendorInfo.manufacturingLocation, + hasAvl: avlVendorInfo.hasAvl, + isBlacklist: avlVendorInfo.isBlacklist, + isBcc: avlVendorInfo.isBcc, + techQuoteNumber: avlVendorInfo.techQuoteNumber, + quoteCode: avlVendorInfo.quoteCode, + quoteVendorId: avlVendorInfo.quoteVendorId, + quoteVendorName: avlVendorInfo.quoteVendorName, + quoteVendorCode: avlVendorInfo.quoteVendorCode, + quoteCountry: avlVendorInfo.quoteCountry, + quoteTotalAmount: avlVendorInfo.quoteTotalAmount, + quoteReceivedDate: avlVendorInfo.quoteReceivedDate, + recentQuoteDate: avlVendorInfo.recentQuoteDate, + recentQuoteNumber: avlVendorInfo.recentQuoteNumber, + recentOrderDate: avlVendorInfo.recentOrderDate, + recentOrderNumber: avlVendorInfo.recentOrderNumber, + remark: avlVendorInfo.remark, + createdAt: avlVendorInfo.createdAt, + updatedAt: avlVendorInfo.updatedAt, + }) + .from(avlVendorInfo) + .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 + const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + faStatus: item.faStatus || '', + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('프로젝트 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('프로젝트 AVL Vendor Info 조회 실패', { error: err, input }); + console.error("Error in getProjectAvlVendorInfo:", err); + return { data: [], pageCount: 0 }; + } +}; + +// 캐시된 버전 export +export const getProjectAvlVendorInfo = unstable_cache( + _getProjectAvlVendorInfo, + ['project-avl-vendor-info'], + { + tags: ['project-avl-vendor-info'], + revalidate: 300, // 5분 캐시 + } +); + +/** + * 표준 AVL Vendor Info 조회 (선종별 표준 AVL, isTemplate=true) + * avl_list와 avlVendorInfo를 JOIN하여 표준 AVL 데이터를 조회합니다. + */ +const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('표준 AVL Vendor Info 조회 시작', { input, offset }); + + // 기본 JOIN 쿼리 구성 (표준 AVL이므로 isTemplate=true) + // 실제 쿼리는 아래에서 구성됨 + + // 검색 조건 구성 + const whereConditions: any[] = [eq(avlList.isTemplate, true)]; // 기본 조건 + + // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) + if (input.constructionSector) { + whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`)); + } + if (input.shipType) { + whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`)); + } + if (input.avlKind) { + whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`)); + } + if (input.htDivision) { + whereConditions.push(eq(avlList.htDivision, input.htDivision)); + } + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm), + ilike(avlVendorInfo.packageName, searchTerm), + ilike(avlVendorInfo.materialGroupName, searchTerm) + ) + ); + } + + // 추가 필터 조건들 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(avlVendorInfo) + .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 + const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({ + ...(item.avl_vendor_info || item), + no: offset + index + 1, + selected: false, + createdAt: ((item.avl_vendor_info || item).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item.avl_vendor_info || item).updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: (item.avl_vendor_info || item).equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: (item.avl_vendor_info || item).faTarget ?? false, + faStatus: (item.avl_vendor_info || item).faStatus || '', + agentStatus: (item.avl_vendor_info || item).isAgent ? "예" : "아니오", + shiAvl: (item.avl_vendor_info || item).hasAvl ?? false, + shiBlacklist: (item.avl_vendor_info || item).isBlacklist ?? false, + shiBcc: (item.avl_vendor_info || item).isBcc ?? false, + salesQuoteNumber: (item.avl_vendor_info || item).techQuoteNumber || '', + quoteCode: (item.avl_vendor_info || item).quoteCode || '', + salesVendorInfo: (item.avl_vendor_info || item).quoteVendorName || '', + salesCountry: (item.avl_vendor_info || item).quoteCountry || '', + totalAmount: (item.avl_vendor_info || item).quoteTotalAmount ? (item.avl_vendor_info || item).quoteTotalAmount.toString() : '', + quoteReceivedDate: (item.avl_vendor_info || item).quoteReceivedDate || '', + recentQuoteDate: (item.avl_vendor_info || item).recentQuoteDate || '', + recentQuoteNumber: (item.avl_vendor_info || item).recentQuoteNumber || '', + recentOrderDate: (item.avl_vendor_info || item).recentOrderDate || '', + recentOrderNumber: (item.avl_vendor_info || item).recentOrderNumber || '', + remarks: (item.avl_vendor_info || item).remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('표준 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('표준 AVL Vendor Info 조회 실패', { error: err, input }); + console.error("Error in getStandardAvlVendorInfo:", err); + return { data: [], pageCount: 0 }; + } +}; + +// 캐시된 버전 export +export const getStandardAvlVendorInfo = unstable_cache( + _getStandardAvlVendorInfo, + ['standard-avl-vendor-info'], + { + tags: ['standard-avl-vendor-info'], + revalidate: 300, // 5분 캐시 + } +); diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx new file mode 100644 index 00000000..ba15c6ef --- /dev/null +++ b/lib/avl/table/avl-detail-table.tsx @@ -0,0 +1,479 @@ +"use client" + +import * as React from "react" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" + +import { columns, type AvlDetailItem } from "./columns-detail" +import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, handleAvlAction } from "../service" +import type { AvlDetailItem as AvlDetailType } from "../types" + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record> + } +} + +interface AvlDetailTableProps { + data: AvlDetailItem[] + pageCount?: number + avlListId: number // 상위 AVL 리스트 ID + onRefresh?: () => void // 데이터 새로고침 콜백 + avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입 + projectCode?: string // 프로젝트 코드 + shipOwnerName?: string // 선주명 + businessType?: string // 사업 유형 (예: 조선/해양) +} + +export function AvlDetailTable({ + data, + pageCount, + avlListId, + onRefresh, + avlType = '프로젝트AVL', + projectCode, + shipOwnerName, + businessType = '조선' +}: AvlDetailTableProps) { + // 수정사항 추적 (일괄 저장용) + const [pendingChanges, setPendingChanges] = React.useState>>({}) + const [isSaving, setIsSaving] = React.useState(false) + + // 빈 행 관리 (신규 등록용) + const [emptyRows, setEmptyRows] = React.useState>({}) + const [isCreating, setIsCreating] = React.useState(false) + + // 검색 상태 + const [searchValue, setSearchValue] = React.useState("") + + + // 인라인 편집 핸들러 (일괄 저장용) + const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlDetailItem, newValue: any) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows 상태도 업데이트 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + } + + // pendingChanges에 변경사항 저장 (실시간 표시용) + setPendingChanges(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + }, []) + + // 편집 취소 핸들러 + const handleCellCancel = React.useCallback((id: string, field: keyof AvlDetailItem) => { + 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 + } + }) + } + }, []) + + // 액션 핸들러 + const handleAction = React.useCallback(async (action: string, data?: any) => { + try { + switch (action) { + case 'new-vendor': + // 신규 협력업체 추가 - 빈 행 추가 + const tempId = `temp-${Date.now()}` + const newEmptyRow: AvlDetailItem = { + id: tempId, + no: 0, + selected: false, + avlListId: avlListId, + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorId: undefined, + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + faTarget: false, + faStatus: "", + isAgent: false, + agentStatus: "아니오", + contractSignerId: undefined, + contractSignerName: "", + contractSignerCode: "", + headquarterLocation: "", + manufacturingLocation: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "", + createdAt: new Date().toISOString().split('T')[0], + updatedAt: new Date().toISOString().split('T')[0], + } + + setEmptyRows(prev => ({ + ...prev, + [tempId]: newEmptyRow + })) + toast.success("신규 협력업체 행이 추가되었습니다.") + break + + case 'bulk-import': + // 일괄 입력 + const bulkResult = await handleAvlAction('bulk-import') + if (bulkResult.success) { + toast.success(bulkResult.message) + } else { + toast.error(bulkResult.message) + } + break + + case 'save': + // 변경사항 저장 + if (Object.keys(pendingChanges).length === 0) { + toast.info("저장할 변경사항이 없습니다.") + return + } + + setIsSaving(true) + try { + // 각 변경사항을 순차적으로 저장 + for (const [id, changes] of Object.entries(pendingChanges)) { + if (String(id).startsWith('temp-')) continue // 빈 행은 제외 + + const numericId = Number(id) + if (isNaN(numericId)) { + throw new Error(`유효하지 않은 ID: ${id}`) + } + + const result = await updateAvlVendorInfo(numericId, changes) + if (!result) { + throw new Error(`항목 ${id} 저장 실패`) + } + } + + setPendingChanges({}) + toast.success("변경사항이 저장되었습니다.") + onRefresh?.() + } catch (error) { + console.error('저장 실패:', error) + toast.error("저장 중 오류가 발생했습니다.") + } finally { + setIsSaving(false) + } + break + + case 'edit': + // 수정 모달 열기 (현재는 간단한 토스트로 처리) + toast.info(`${data?.id} 항목 수정`) + break + + case 'delete': + // 삭제 확인 및 실행 + if (!data?.id || String(data.id).startsWith('temp-')) return + + const numericId = Number(data.id) + if (isNaN(numericId)) { + toast.error("유효하지 않은 항목 ID입니다.") + return + } + + const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`) + if (!confirmed) return + + try { + const result = await deleteAvlVendorInfo(numericId) + if (result) { + toast.success("항목이 삭제되었습니다.") + onRefresh?.() + } else { + toast.error("삭제에 실패했습니다.") + } + } catch (error) { + console.error('삭제 실패:', error) + toast.error("삭제 중 오류가 발생했습니다.") + } + break + + case 'avl-form': + // AVL 양식 다운로드/보기 + toast.info("AVL 양식을 준비 중입니다.") + // TODO: AVL 양식 다운로드 로직 구현 + break + + case 'quote-request': + // 견적 요청 + toast.info("견적 요청을 처리 중입니다.") + // TODO: 견적 요청 로직 구현 + break + + case 'vendor-pool': + // Vendor Pool 관리 + toast.info("Vendor Pool을 열고 있습니다.") + // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현 + break + + case 'download': + // 데이터 다운로드 + toast.info("데이터를 다운로드 중입니다.") + // TODO: 데이터 다운로드 로직 구현 + break + + default: + toast.error(`알 수 없는 액션: ${action}`) + } + } catch (error) { + console.error('액션 처리 실패:', error) + toast.error("액션 처리 중 오류가 발생했습니다.") + } + }, [pendingChanges, onRefresh, avlListId]) + + // 빈 행 저장 핸들러 + const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { + const emptyRow = emptyRows[tempId] + if (!emptyRow) return + + try { + setIsCreating(true) + + // 필수 필드 검증 + if (!emptyRow.disciplineName || !emptyRow.vendorName) { + toast.error("설계공종과 협력업체명은 필수 입력 항목입니다.") + return + } + + // 빈 행 데이터를 생성 데이터로 변환 + const createData = { + avlListId: emptyRow.avlListId, + equipBulkDivision: emptyRow.equipBulkDivision, + disciplineCode: emptyRow.disciplineCode || undefined, + disciplineName: emptyRow.disciplineName, + materialNameCustomerSide: emptyRow.materialNameCustomerSide || undefined, + packageCode: emptyRow.packageCode || undefined, + packageName: emptyRow.packageName || undefined, + materialGroupCode: emptyRow.materialGroupCode || undefined, + materialGroupName: emptyRow.materialGroupName || undefined, + vendorId: emptyRow.vendorId, + vendorName: emptyRow.vendorName, + vendorCode: emptyRow.vendorCode || undefined, + avlVendorName: emptyRow.avlVendorName || undefined, + tier: emptyRow.tier || undefined, + faTarget: emptyRow.faTarget ?? false, + faStatus: emptyRow.faStatus || undefined, + isAgent: emptyRow.isAgent ?? false, + contractSignerId: emptyRow.contractSignerId, + contractSignerName: emptyRow.contractSignerName || undefined, + contractSignerCode: emptyRow.contractSignerCode || undefined, + headquarterLocation: emptyRow.headquarterLocation || undefined, + manufacturingLocation: emptyRow.manufacturingLocation || undefined, + hasAvl: emptyRow.shiAvl ?? false, + isBlacklist: emptyRow.shiBlacklist ?? false, + isBcc: emptyRow.shiBcc ?? false, + techQuoteNumber: emptyRow.salesQuoteNumber || undefined, + quoteCode: emptyRow.quoteCode || undefined, + quoteVendorId: emptyRow.vendorId, + quoteVendorName: emptyRow.salesVendorInfo || undefined, + quoteVendorCode: emptyRow.vendorCode, + quoteCountry: emptyRow.salesCountry || undefined, + quoteTotalAmount: emptyRow.totalAmount ? parseFloat(emptyRow.totalAmount.replace(/,/g, '')) : undefined, + quoteReceivedDate: emptyRow.quoteReceivedDate || undefined, + recentQuoteDate: emptyRow.recentQuoteDate || undefined, + recentQuoteNumber: emptyRow.recentQuoteNumber || undefined, + recentOrderDate: emptyRow.recentOrderDate || undefined, + recentOrderNumber: emptyRow.recentOrderNumber || undefined, + remark: emptyRow.remarks || undefined, + } + + const result = await createAvlVendorInfo(createData) + if (result) { + // 빈 행 제거 및 성공 메시지 + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + // pendingChanges에서도 제거 + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.success("새 협력업체가 등록되었습니다.") + onRefresh?.() + } else { + toast.error("등록에 실패했습니다.") + } + } catch (error) { + console.error('빈 행 저장 실패:', error) + toast.error("등록 중 오류가 발생했습니다.") + } finally { + setIsCreating(false) + } + }, [emptyRows, onRefresh]) + + // 빈 행 취소 핸들러 + const handleCancelEmptyRow = React.useCallback((tempId: string) => { + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.info("등록이 취소되었습니다.") + }, []) + + // 빈 행 포함한 전체 데이터 + const allData = React.useMemo(() => { + const emptyRowArray = Object.values(emptyRows) + return [...data, ...emptyRowArray] + }, [data, emptyRows]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({ + onCellUpdate: handleCellUpdate, + onCellCancel: handleCellCancel, + onAction: handleAction, + onSaveEmptyRow: handleSaveEmptyRow, + onCancelEmptyRow: handleCancelEmptyRow, + isEmptyRow: (id: string) => String(id).startsWith('temp-'), + getPendingChanges: () => pendingChanges, + }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges]) + + // 데이터 테이블 설정 + const { table } = useDataTable({ + data: allData, + columns, + pageCount, + initialState: { + sorting: [{ id: "no", desc: false }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (row) => String(row.id), + meta: tableMeta, + }) + + // 변경사항이 있는지 확인 + const hasPendingChanges = Object.keys(pendingChanges).length > 0 + const hasEmptyRows = Object.keys(emptyRows).length > 0 + + return ( +
+ {/* 상단 정보 표시 영역 */} +
+
+

AVL 상세내역

+ + {avlType} + + + [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'}) + +
+
+ + {/* 상단 버튼 및 검색 영역 */} +
+
+ + + + +
+ +
+
+ setSearchValue(e.target.value)} + /> +
+
+
+ + {/* 데이터 테이블 */} + + + {/* 디버그 정보 (개발 환경에서만 표시) */} + {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && ( +
+
Pending Changes: {Object.keys(pendingChanges).length}
+
Empty Rows: {Object.keys(emptyRows).length}
+
+ )} +
+ ) +} diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx new file mode 100644 index 00000000..def3d30a --- /dev/null +++ b/lib/avl/table/avl-registration-area.tsx @@ -0,0 +1,278 @@ +"use client" + +import * as React from "react" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" +import { useAtom } from "jotai" +import { ProjectAvlTable } from "./project-avl-table" +import { StandardAvlTable } from "./standard-avl-table" +import { VendorPoolTable } from "./vendor-pool-table" +import { selectedAvlRecordAtom } from "../avl-atoms" +import type { AvlListItem } from "../types" + +// 선택된 테이블 타입 +type SelectedTable = 'project' | 'standard' | 'vendor' | null + +// 선택 상태 액션 타입 +type SelectionAction = + | { type: 'SELECT_PROJECT'; count: number } + | { type: 'SELECT_STANDARD'; count: number } + | { type: 'SELECT_VENDOR'; count: number } + | { type: 'CLEAR_SELECTION' } + +// 선택 상태 +interface SelectionState { + selectedTable: SelectedTable + selectedRowCount: number + resetCounters: { + project: number + standard: number + vendor: number + } +} + +// 선택 상태 리듀서 +const selectionReducer = (state: SelectionState, action: SelectionAction): SelectionState => { + switch (action.type) { + case 'SELECT_PROJECT': + if (action.count > 0) { + return { + selectedTable: 'project', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + standard: state.selectedTable !== 'project' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + vendor: state.selectedTable !== 'project' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'project') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_STANDARD': + if (action.count > 0) { + return { + selectedTable: 'standard', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'standard' ? state.resetCounters.project + 1 : state.resetCounters.project, + vendor: state.selectedTable !== 'standard' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'standard') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_VENDOR': + if (action.count > 0) { + return { + selectedTable: 'vendor', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'vendor' ? state.resetCounters.project + 1 : state.resetCounters.project, + standard: state.selectedTable !== 'vendor' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + } + } + } else if (state.selectedTable === 'vendor') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + default: + return state + } +} + +interface AvlRegistrationAreaProps { + disabled?: boolean // 비활성화 상태 +} + +export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) { + // 선택된 AVL 레코드 구독 + const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom) + + // 단일 선택 상태 관리 (useReducer 사용) + const [selectionState, dispatch] = React.useReducer(selectionReducer, { + selectedTable: null, + selectedRowCount: 0, + resetCounters: { + project: 0, + standard: 0, + vendor: 0, + }, + }) + + // 선택 핸들러들 + const handleProjectSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_PROJECT', count }) + }, []) + + const handleStandardSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_STANDARD', count }) + }, []) + + const handleVendorSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_VENDOR', count }) + }, []) + + const { selectedTable, selectedRowCount, resetCounters } = selectionState + + // 선택된 AVL에 따른 필터 값들 + const [currentProjectCode, setCurrentProjectCode] = React.useState("") + const constructionSector = selectedAvlRecord?.constructionSector || "" + const shipType = selectedAvlRecord?.shipType || "" + const avlKind = selectedAvlRecord?.avlKind || "" + const htDivision = selectedAvlRecord?.htDivision || "" + const avlListId = selectedAvlRecord?.id ? String(selectedAvlRecord.id) : "" + + // 선택된 AVL 레코드가 변경될 때 프로젝트 코드 초기화 + React.useEffect(() => { + setCurrentProjectCode(selectedAvlRecord?.projectCode || "") + }, [selectedAvlRecord?.projectCode]) + + // 프로젝트 코드 변경 핸들러 + const handleProjectCodeChange = React.useCallback((projectCode: string) => { + setCurrentProjectCode(projectCode) + }, []) + + return ( + + {/* 고정 헤더 영역 */} +
+
+

AVL 등록 {disabled ? "(비활성화)" : ""}

+
+ +
+
+
+ + {/* 스크롤되는 콘텐츠 영역 */} +
+
+ {/* 프로젝트 AVL 테이블 - 9개 컬럼 */} +
+ + + {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */} +
+
+ + + + + + + +
+
+
+ + {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */} +
+ + + {/* 이동 버튼들 - 두 번째 border 위에 오버레이 */} +
+
+ + + + + +
+
+
+ + {/* Vendor Pool 테이블 - 10개 컬럼 */} +
+ +
+
+
+
+ ) +} diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx new file mode 100644 index 00000000..77361f36 --- /dev/null +++ b/lib/avl/table/avl-table-columns.tsx @@ -0,0 +1,351 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { 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" +import { AvlListItem } from "../types" + +interface GetColumnsProps { + selectedRows?: number[] + onRowSelect?: (id: number, selected: boolean) => void +} + +// 수정 여부 확인 헬퍼 함수 +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 { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + } +} + +// 테이블 컬럼 정의 함수 +export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ColumnDef[] { + const columns: ColumnDef[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + id: "select", + header: () =>
선택
, + cell: ({ row }) => ( +
+ { + onRowSelect?.(row.original.id, !!checked) + }} + aria-label="행 선택" + className="translate-y-[2px]" + /> +
+ ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "no", + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue() as number}
, + size: 60, + }, + { + accessorKey: "isTemplate", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as boolean + const isModified = getIsModified(table, row.id, "isTemplate") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "isTemplate", newValue === "true") + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "isTemplate") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "constructionSector", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "constructionSector") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "constructionSector", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "constructionSector") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "projectCode") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "projectCode", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "projectCode") + }} + /> + ) + }, + size: 140, + }, + { + accessorKey: "shipType", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "shipType") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "shipType", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "shipType") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "avlKind", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "avlKind") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "avlKind", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "avlKind") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "htDivision", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "htDivision") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "htDivision", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "htDivision") + }} + /> + ) + }, + size: 80, + }, + { + accessorKey: "rev", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as number + const isModified = getIsModified(table, row.id, "rev") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "rev", parseInt(newValue)) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "rev") + }} + /> + ) + }, + size: 80, + }, + ], + }, + + // 등록 정보 그룹 + { + header: "등록 정보", + columns: [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const date = getValue() as string + return
{date}
+ }, + size: 100, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const date = getValue() as string + return
{date}
+ }, + size: 100, + }, + ], + }, + + // 액션 그룹 + { + id: "actions", + header: "액션", + columns: [ + { + id: "actions", + header: () =>
액션
, + cell: ({ row, table }) => { + const isEmptyRow = table.options.meta?.isEmptyRow?.(row.id) || false + + if (isEmptyRow) { + return ( +
+ + +
+ ) + } + + return ( +
+ + + +
+ ) + }, + enableSorting: false, + enableHiding: false, + size: 120, + }, + ], + }, + ] + + return columns +} diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx new file mode 100644 index 00000000..a6910ef5 --- /dev/null +++ b/lib/avl/table/avl-table.tsx @@ -0,0 +1,514 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, +} from "@/types/table" + +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 { Checkbox } from "@/components/ui/checkbox" +import { toast } from "sonner" + +import { getColumns } from "./avl-table-columns" +import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service" +import type { AvlListItem } from "../types" + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record> + } +} + +interface AvlTableProps { + data: AvlListItem[] + pageCount?: number + onRefresh?: () => void // 데이터 새로고침 콜백 + isLoading?: boolean // 로딩 상태 + onRegistrationModeChange?: (mode: 'standard' | 'project') => void // 등록 모드 변경 콜백 + onRowSelect?: (selectedRow: AvlListItem | null) => void // 행 선택 콜백 +} + +export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistrationModeChange, onRowSelect }: AvlTableProps) { + + // 단일 선택을 위한 상태 (shi-vendor-po 방식) + const [selectedRows, setSelectedRows] = React.useState([]) + + // 수정사항 추적 (일괄 저장용) + const [pendingChanges, setPendingChanges] = React.useState>>({}) + const [isSaving, setIsSaving] = React.useState(false) + + // 빈 행 관리 (신규 등록용) + const [emptyRows, setEmptyRows] = React.useState>({}) + const [isCreating, setIsCreating] = React.useState(false) + + // 필터 필드 정의 + const filterFields: DataTableFilterField[] = [ + { + id: "isTemplate", + label: "AVL 분류", + placeholder: "AVL 분류 선택...", + options: [ + { label: "프로젝트 AVL", value: "false" }, + { label: "표준 AVL", value: "true" }, + ], + }, + { + id: "constructionSector", + label: "공사부문", + placeholder: "공사부문 선택...", + options: [ + { label: "조선", value: "조선" }, + { label: "해양", value: "해양" }, + ], + }, + { + id: "htDivision", + label: "H/T 구분", + placeholder: "H/T 구분 선택...", + options: [ + { label: "H", value: "H" }, + { label: "T", value: "T" }, + ], + }, + ] + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "projectCode", + label: "프로젝트 코드", + type: "text", + placeholder: "프로젝트 코드 입력...", + }, + { + id: "shipType", + label: "선종", + type: "text", + placeholder: "선종 입력...", + }, + { + id: "avlKind", + label: "AVL 종류", + type: "text", + placeholder: "AVL 종류 입력...", + }, + { + id: "createdBy", + label: "등재자", + type: "text", + placeholder: "등재자 입력...", + }, + ] + + // 인라인 편집 핸들러 (일괄 저장용) + const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows 상태도 업데이트 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + } + + // pendingChanges에 변경사항 저장 (실시간 표시용) + setPendingChanges(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + }, []) + + // 편집 취소 핸들러 + const handleCellCancel = React.useCallback((id: string, field: keyof AvlListItem) => { + 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 + } + }) + } + }, []) + + // 액션 핸들러 + const handleAction = React.useCallback(async (action: string, data?: any) => { + try { + switch (action) { + case 'new-registration': + // 신규 등록 - 빈 행 추가 + const tempId = `temp-${Date.now()}` + const newEmptyRow: AvlListItem = { + id: tempId as any, + no: 0, + selected: false, + isTemplate: false, + constructionSector: "", + projectCode: "", + shipType: "", + avlKind: "", + htDivision: "", + rev: 1, + createdAt: new Date().toISOString().split('T')[0], + updatedAt: new Date().toISOString().split('T')[0], + createdBy: "system", + updatedBy: "system", + registrant: "system", + lastModifier: "system", + } + + setEmptyRows(prev => ({ + ...prev, + [tempId]: newEmptyRow + })) + toast.success("신규 등록 행이 추가되었습니다.") + break + + case 'standard-registration': + // 표준 AVL 등록 + const result = await handleAvlActionAction('standard-registration') + if (result.success) { + toast.success(result.message) + onRegistrationModeChange?.('standard') // 등록 모드 변경 콜백 호출 + } else { + toast.error(result.message) + } + break + + case 'project-registration': + // 프로젝트 AVL 등록 + const projectResult = await handleAvlActionAction('project-registration') + if (projectResult.success) { + toast.success(projectResult.message) + onRegistrationModeChange?.('project') // 등록 모드 변경 콜백 호출 + } else { + toast.error(projectResult.message) + } + break + + case 'bulk-import': + // 일괄 입력 + const bulkResult = await handleAvlActionAction('bulk-import') + if (bulkResult.success) { + toast.success(bulkResult.message) + } else { + toast.error(bulkResult.message) + } + break + + case 'save': + // 변경사항 저장 + if (Object.keys(pendingChanges).length === 0) { + toast.info("저장할 변경사항이 없습니다.") + return + } + + setIsSaving(true) + try { + // 각 변경사항을 순차적으로 저장 + for (const [id, changes] of Object.entries(pendingChanges)) { + if (String(id).startsWith('temp-')) continue // 빈 행은 제외 + + const result = await updateAvlListAction(Number(id), changes as any) + if (!result) { + throw new Error(`항목 ${id} 저장 실패`) + } + } + + setPendingChanges({}) + toast.success("변경사항이 저장되었습니다.") + onRefresh?.() + } catch (error) { + console.error('저장 실패:', error) + toast.error("저장 중 오류가 발생했습니다.") + } finally { + setIsSaving(false) + } + break + + case 'edit': + // 수정 모달 열기 (현재는 간단한 토스트로 처리) + toast.info(`${data?.id} 항목 수정`) + break + + case 'delete': + // 삭제 확인 및 실행 + if (!data?.id || String(data.id).startsWith('temp-')) return + + const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`) + if (!confirmed) return + + try { + const result = await deleteAvlListAction(Number(data.id)) + if (result) { + toast.success("항목이 삭제되었습니다.") + onRefresh?.() + } else { + toast.error("삭제에 실패했습니다.") + } + } catch (error) { + console.error('삭제 실패:', error) + toast.error("삭제 중 오류가 발생했습니다.") + } + break + + case 'view-detail': + // 상세 조회 (페이지 이동) + if (data?.id && !String(data.id).startsWith('temp-')) { + window.location.href = `/evcp/avl/${data.id}` + } + break + + default: + toast.error(`알 수 없는 액션: ${action}`) + } + } catch (error) { + console.error('액션 처리 실패:', error) + toast.error("액션 처리 중 오류가 발생했습니다.") + } + }, [pendingChanges, onRefresh]) + + // 빈 행 저장 핸들러 + const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { + const emptyRow = emptyRows[tempId] + if (!emptyRow) return + + try { + setIsCreating(true) + + // 필수 필드 검증 + if (!emptyRow.constructionSector || !emptyRow.avlKind) { + toast.error("공사부문과 AVL 종류는 필수 입력 항목입니다.") + return + } + + // 빈 행 데이터를 생성 데이터로 변환 + const createData = { + isTemplate: emptyRow.isTemplate, + constructionSector: emptyRow.constructionSector, + projectCode: emptyRow.projectCode || undefined, + shipType: emptyRow.shipType || undefined, + avlKind: emptyRow.avlKind, + htDivision: emptyRow.htDivision || undefined, + rev: emptyRow.rev, + createdBy: "system", + updatedBy: "system", + } + + const result = await createAvlListAction(createData as any) + if (result) { + // 빈 행 제거 및 성공 메시지 + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + // pendingChanges에서도 제거 + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.success("새 항목이 등록되었습니다.") + onRefresh?.() + } else { + toast.error("등록에 실패했습니다.") + } + } catch (error) { + console.error('빈 행 저장 실패:', error) + toast.error("등록 중 오류가 발생했습니다.") + } finally { + setIsCreating(false) + } + }, [emptyRows, onRefresh]) + + // 빈 행 취소 핸들러 + const handleCancelEmptyRow = React.useCallback((tempId: string) => { + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.info("등록이 취소되었습니다.") + }, []) + + // 빈 행 포함한 전체 데이터 + const allData = React.useMemo(() => { + // 로딩 중에는 빈 데이터를 표시 + if (isLoading) { + return [] + } + const emptyRowArray = Object.values(emptyRows) + return [...data, ...emptyRowArray] + }, [data, emptyRows, isLoading]) + + // 행 선택 처리 (1개만 선택 가능 - shi-vendor-po 방식) + const handleRowSelect = React.useCallback((id: number, selected: boolean) => { + if (selected) { + setSelectedRows([id]) // 1개만 선택 + // 선택된 레코드 찾아서 부모 콜백 호출 + const allData = isLoading ? [] : [...data, ...Object.values(emptyRows)] + const selectedRow = allData.find(row => row.id === id) + if (selectedRow) { + onRowSelect?.(selectedRow) + } + } else { + setSelectedRows([]) + onRowSelect?.(null) + } + }, [data, emptyRows, isLoading, onRowSelect]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({ + onCellUpdate: handleCellUpdate, + onCellCancel: handleCellCancel, + onAction: handleAction, + onSaveEmptyRow: handleSaveEmptyRow, + onCancelEmptyRow: handleCancelEmptyRow, + isEmptyRow: (id: string) => String(id).startsWith('temp-'), + getPendingChanges: () => pendingChanges, + }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges]) + + + // 데이터 테이블 설정 + const { table } = useDataTable({ + data: allData, + columns: getColumns({ selectedRows, onRowSelect: handleRowSelect }), + pageCount: pageCount || 1, + filterFields, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (row) => String(row.id), + meta: tableMeta, + }) + + // 변경사항이 있는지 확인 + const hasPendingChanges = Object.keys(pendingChanges).length > 0 + const hasEmptyRows = Object.keys(emptyRows).length > 0 + + return ( +
+ {/* 툴바 */} + +
+ {/* 액션 버튼들 */} + + + + + + + + + {/* 저장 버튼 - 변경사항이 있을 때만 활성화 */} + {(hasPendingChanges || hasEmptyRows) && ( + + )} + + {/* 새로고침 버튼 */} + +
+
+ + {/* 데이터 테이블 */} + + + {/* 디버그 정보 (개발 환경에서만 표시) */} + {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && ( +
+
Pending Changes: {Object.keys(pendingChanges).length}
+
Empty Rows: {Object.keys(emptyRows).length}
+
+ )} +
+ ) +} diff --git a/lib/avl/table/columns-detail.tsx b/lib/avl/table/columns-detail.tsx new file mode 100644 index 00000000..204d34f5 --- /dev/null +++ b/lib/avl/table/columns-detail.tsx @@ -0,0 +1,680 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { 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 { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + } +} + +// AVL 상세 아이템 타입 +export type AvlDetailItem = { + id: string + no: number + selected: boolean + // AVL 리스트 ID (외래키) + avlListId: number + // 설계 정보 + equipBulkDivision: 'EQUIP' | 'BULK' + disciplineCode: string + disciplineName: string + // 자재 정보 + materialNameCustomerSide: string + packageCode: string + packageName: string + materialGroupCode: string + materialGroupName: string + // 협력업체 정보 + vendorId?: number + vendorName: string + vendorCode: string + avlVendorName: string + tier: string + // FA 정보 + faTarget: boolean + faStatus: string + // Agent 정보 + isAgent: boolean + agentStatus: string // UI 표시용 + // 계약 서명주체 + contractSignerId?: number + contractSignerName: string + contractSignerCode: string + // 위치 정보 + headquarterLocation: string + manufacturingLocation: string + // SHI Qualification + shiAvl: boolean + shiBlacklist: boolean + shiBcc: boolean + // 기술영업 견적결과 + salesQuoteNumber: string + quoteCode: string + salesVendorInfo: string + salesCountry: string + totalAmount: string + quoteReceivedDate: string + // 업체 실적 현황(구매) + recentQuoteDate: string + recentQuoteNumber: string + recentOrderDate: string + recentOrderNumber: string + // 기타 + remarks: string + // 타임스탬프 + createdAt: string + updatedAt: string +} + +// 테이블 컬럼 정의 +export const columns: ColumnDef[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: ({ column }) => ( + + ), + size: 60, + }, + { + accessorKey: "equipBulkDivision", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("equipBulkDivision") as string + 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, "equipBulkDivision", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "equipBulkDivision") + + return ( + + ) + }, + size: 120, + }, + { + accessorKey: "disciplineName", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("disciplineName") + 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, "disciplineName", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "disciplineName") + + return ( + + ) + }, + size: 120, + }, + { + accessorKey: "materialNameCustomerSide", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("materialNameCustomerSide") + 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, "materialNameCustomerSide", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "materialNameCustomerSide") + + return ( + + ) + }, + size: 150, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + + ), + 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 ( + + ) + }, + size: 130, + }, + { + accessorKey: "materialGroupCode", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("materialGroupCode") + 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, "materialGroupCode", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "materialGroupCode") + + return ( + + ) + }, + size: 120, + }, + { + accessorKey: "materialGroupName", + header: ({ column }) => ( + + ), + 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 ( + + ) + }, + size: 130, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("vendorCode") + 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, "vendorCode", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "vendorCode") + + return ( + + ) + }, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + 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 ( + + ) + }, + size: 140, + }, + { + accessorKey: "avlVendorName", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("avlVendorName") + 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, "avlVendorName", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "avlVendorName") + + return ( + + ) + }, + size: 140, + }, + { + accessorKey: "tier", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("tier") as string + 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, "tier", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "tier") + + return ( + + ) + }, + size: 100, + }, + ], + }, + // FA 정보 그룹 + { + header: "FA 정보", + columns: [ + { + accessorKey: "faTarget", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("faTarget") as boolean + 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, "faTarget", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "faTarget") + + return ( + + ) + }, + size: 80, + }, + { + accessorKey: "faStatus", + header: ({ column }) => ( + + ), + cell: ({ row, table }) => { + const value = row.getValue("faStatus") + 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, "faStatus", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "faStatus") + + return ( + + ) + }, + size: 100, + }, + ], + }, + // SHI Qualification 그룹 + { + header: "SHI Qualification", + columns: [ + { + accessorKey: "shiAvl", + header: "AVL", + cell: ({ row, table }) => { + const value = row.getValue("shiAvl") as boolean + 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, "shiAvl", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "shiAvl") + + return ( + + ) + }, + size: 80, + }, + { + accessorKey: "shiBlacklist", + header: "Blacklist", + cell: ({ row, table }) => { + const value = row.getValue("shiBlacklist") as boolean + 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, "shiBlacklist", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "shiBlacklist") + + return ( + + ) + }, + size: 100, + }, + { + accessorKey: "shiBcc", + header: "BCC", + cell: ({ row, table }) => { + const value = row.getValue("shiBcc") as boolean + 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, "shiBcc", newValue) + } + } + + const isModified = getIsModified(table, row.original.id, "shiBcc") + + return ( + + ) + }, + size: 80, + }, + ], + }, + // 액션 컬럼 + { + id: "actions", + header: "액션", + cell: ({ row, table }) => { + const isEmptyRow = String(row.original.id).startsWith('temp-') + + return ( +
+ {!isEmptyRow && ( + <> + + + + )} + {isEmptyRow && ( + <> + + + + )} +
+ ) + }, + size: 100, + enableSorting: false, + enableHiding: false, + }, +] diff --git a/lib/avl/table/project-avl-add-dialog.tsx b/lib/avl/table/project-avl-add-dialog.tsx new file mode 100644 index 00000000..509e4258 --- /dev/null +++ b/lib/avl/table/project-avl-add-dialog.tsx @@ -0,0 +1,779 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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 { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { toast } from "sonner" +import type { AvlVendorInfoInput, AvlDetailItem } from "../types" + +interface ProjectAvlAddDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onAddItem: (item: Omit) => Promise + editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) + onUpdateItem?: (id: number, item: Omit) => Promise // 수정 핸들러 +} + +export function ProjectAvlAddDialog({ open, onOpenChange, onAddItem, editingItem, onUpdateItem }: ProjectAvlAddDialogProps) { + const [formData, setFormData] = React.useState>({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + }) + + // 수정 모드일 때 폼 데이터 초기화 + React.useEffect(() => { + if (editingItem) { + setFormData({ + // 설계 정보 + equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", + disciplineCode: editingItem.disciplineCode || "", + disciplineName: editingItem.disciplineName || "", + + // 자재 정보 + materialNameCustomerSide: editingItem.materialNameCustomerSide || "", + + // 패키지 정보 + packageCode: editingItem.packageCode || "", + packageName: editingItem.packageName || "", + + // 자재그룹 정보 + materialGroupCode: editingItem.materialGroupCode || "", + materialGroupName: editingItem.materialGroupName || "", + + // 협력업체 정보 + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || "", + + // AVL 정보 + avlVendorName: editingItem.avlVendorName || "", + tier: editingItem.tier || "", + + // 제안방향 + ownerSuggestion: editingItem.ownerSuggestion || false, + shiSuggestion: editingItem.shiSuggestion || false, + + // 위치 정보 + headquarterLocation: editingItem.headquarterLocation || "", + manufacturingLocation: editingItem.manufacturingLocation || "", + + // FA 정보 + faTarget: editingItem.faTarget || false, + faStatus: editingItem.faStatus || "", + + // Agent 정보 + isAgent: editingItem.isAgent || false, + + // 계약 서명주체 + contractSignerName: editingItem.contractSignerName || "", + contractSignerCode: editingItem.contractSignerCode || "", + + // SHI Qualification + shiAvl: editingItem.shiAvl || false, + shiBlacklist: editingItem.shiBlacklist || false, + shiBcc: editingItem.shiBcc || false, + + // 기술영업 견적결과 + salesQuoteNumber: editingItem.salesQuoteNumber || "", + quoteCode: editingItem.quoteCode || "", + salesVendorInfo: editingItem.salesVendorInfo || "", + salesCountry: editingItem.salesCountry || "", + totalAmount: editingItem.totalAmount || "", + quoteReceivedDate: editingItem.quoteReceivedDate || "", + + // 업체 실적 현황 + recentQuoteDate: editingItem.recentQuoteDate || "", + recentQuoteNumber: editingItem.recentQuoteNumber || "", + recentOrderDate: editingItem.recentOrderDate || "", + recentOrderNumber: editingItem.recentOrderNumber || "", + + // 기타 + remarks: editingItem.remarks || "" + }) + } + }, [editingItem]) + + const handleSubmit = async () => { + // 필수 필드 검증 + if (!formData.disciplineName || !formData.materialNameCustomerSide) { + toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") + return + } + + try { + if (editingItem && onUpdateItem) { + // 수정 모드 + await onUpdateItem(editingItem.id, formData) + } else { + // 추가 모드 + await onAddItem(formData) + } + + // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만) + setFormData({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit) + + onOpenChange(false) + } catch (error) { + // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음 + } + } + + const handleCancel = () => { + setFormData({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit) + onOpenChange(false) + } + + return ( + + + + {editingItem ? "프로젝트 AVL 항목 수정" : "프로젝트 AVL 항목 추가"} + + {editingItem + ? "AVL 항목을 수정합니다. 필수 항목을 입력해주세요." + : "새로운 AVL 항목을 추가합니다. 필수 항목을 입력해주세요." + } * 표시된 항목은 필수 입력사항입니다. + + +
+ {/* 기본 정보 */} +
+

기본 정보

+
+
+ + +
+
+ + setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} + placeholder="설계공종코드를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} + placeholder="설계공종명을 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} + placeholder="고객사 AVL 자재명을 입력하세요" + /> +
+
+
+ + {/* 패키지 정보 */} +
+

패키지 정보

+
+
+ + setFormData(prev => ({ ...prev, packageCode: e.target.value }))} + placeholder="패키지 코드를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, packageName: e.target.value }))} + placeholder="패키지 명을 입력하세요" + /> +
+
+
+ + {/* 자재그룹 정보 */} +
+

자재그룹 정보

+
+
+ + setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} + placeholder="자재그룹 코드를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} + placeholder="자재그룹 명을 입력하세요" + /> +
+
+
+ + {/* 협력업체 정보 */} +
+

협력업체 정보

+
+
+ + setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} + placeholder="협력업체 코드를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, vendorName: e.target.value }))} + placeholder="협력업체 명을 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} + placeholder="AVL 등재업체명을 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, tier: e.target.value }))} + placeholder="등급을 입력하세요" + /> +
+
+
+ + {/* 제안방향 */} +
+

제안방향

+
+
+ + setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) + } + /> + +
+
+ + setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) + } + /> + +
+
+
+ + {/* 위치 정보 */} +
+

위치 정보

+
+
+ + setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} + placeholder="본사 위치를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} + placeholder="제작/선적지를 입력하세요" + /> +
+
+
+ + {/* FA 정보 */} +
+

FA 정보

+
+
+ + setFormData(prev => ({ ...prev, faTarget: !!checked })) + } + /> + +
+
+ + setFormData(prev => ({ ...prev, faStatus: e.target.value }))} + placeholder="FA 현황을 입력하세요" + /> +
+
+
+ + {/* Agent 정보 */} +
+

Agent 정보

+
+ + setFormData(prev => ({ ...prev, isAgent: !!checked })) + } + /> + +
+
+ + {/* 계약 서명주체 */} +
+

계약 서명주체

+
+
+ + setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} + placeholder="계약서명주체 코드를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} + placeholder="계약서명주체 명을 입력하세요" + /> +
+
+
+ + {/* SHI Qualification */} +
+

SHI Qualification

+
+
+ + setFormData(prev => ({ ...prev, shiAvl: !!checked })) + } + /> + +
+
+ + setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) + } + /> + +
+
+ + setFormData(prev => ({ ...prev, shiBcc: !!checked })) + } + /> + +
+
+
+ + {/* 기술영업 견적결과 */} +
+

기술영업 견적결과

+
+
+ + setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} + placeholder="기술영업 견적번호를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} + placeholder="견적서 Code를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} + placeholder="견적 협력업체 명을 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} + placeholder="국가를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} + placeholder="총 금액을 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> +
+
+
+ + {/* 업체 실적 현황 */} +
+

업체 실적 현황

+
+
+ + setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} + placeholder="최근견적번호를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> +
+
+ + setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} + placeholder="최근발주번호를 입력하세요" + /> +
+
+ + setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> +
+
+
+ + {/* 기타 */} +
+

기타

+
+ +