summaryrefslogtreecommitdiff
path: root/lib/avl
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl')
-rw-r--r--lib/avl/avl-atoms.ts5
-rw-r--r--lib/avl/service.ts1285
-rw-r--r--lib/avl/table/avl-detail-table.tsx479
-rw-r--r--lib/avl/table/avl-registration-area.tsx278
-rw-r--r--lib/avl/table/avl-table-columns.tsx351
-rw-r--r--lib/avl/table/avl-table.tsx514
-rw-r--r--lib/avl/table/columns-detail.tsx680
-rw-r--r--lib/avl/table/project-avl-add-dialog.tsx779
-rw-r--r--lib/avl/table/project-avl-table.tsx724
-rw-r--r--lib/avl/table/standard-avl-table.tsx380
-rw-r--r--lib/avl/table/vendor-pool-table.tsx290
-rw-r--r--lib/avl/types.ts149
-rw-r--r--lib/avl/validations.ts170
13 files changed, 6084 insertions, 0 deletions
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<AvlListItem | null>(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<AvlListItem | null> {
+ 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<AvlDetailItem | null> {
+ 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<ActionResult> {
+ 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<AvlListItem | null> {
+ return await createAvlList(data);
+}
+
+export async function updateAvlListAction(id: number, data: UpdateAvlListInput): Promise<AvlListItem | null> {
+ return await updateAvlList(id, data);
+}
+
+export async function deleteAvlListAction(id: number): Promise<boolean> {
+ return await deleteAvlList(id);
+}
+
+export async function handleAvlActionAction(action: string, data?: any): Promise<ActionResult> {
+ return await handleAvlAction(action, data);
+}
+
+/**
+ * AVL 리스트 생성
+ */
+export async function createAvlList(data: CreateAvlListInput): Promise<AvlListItem | null> {
+ 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<AvlListItem | null> {
+ 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<boolean> {
+ 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<AvlDetailItem | null> {
+ 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<AvlVendorInfoInput>): Promise<AvlDetailItem | null> {
+ 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<boolean> {
+ 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<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ getPendingChanges?: () => Record<string, Partial<AvlDetailItem>>
+ }
+}
+
+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<Record<string, Partial<AvlDetailItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // 빈 행 관리 (신규 등록용)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlDetailItem>>({})
+ 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 (
+ <div className="space-y-4">
+ {/* 상단 정보 표시 영역 */}
+ <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-4">
+ <h2 className="text-lg font-semibold">AVL 상세내역</h2>
+ <span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
+ {avlType}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'})
+ </span>
+ </div>
+ </div>
+
+ {/* 상단 버튼 및 검색 영역 */}
+ <div className="flex items-center justify-between gap-4">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}>
+ AVL양식
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}>
+ 견적요청
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}>
+ Vendor Pool
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('download')}>
+ 다운로드
+ </Button>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <div className="relative">
+ <Input
+ placeholder="검색..."
+ className="w-64"
+ value={searchValue}
+ onChange={(e) => setSearchValue(e.target.value)}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
+ <div className="text-xs text-muted-foreground p-2 bg-muted rounded">
+ <div>Pending Changes: {Object.keys(pendingChanges).length}</div>
+ <div>Empty Rows: {Object.keys(emptyRows).length}</div>
+ </div>
+ )}
+ </div>
+ )
+}
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<string>("")
+ 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 (
+ <Card className={`h-full min-w-full overflow-visible ${disabled ? 'opacity-50 pointer-events-none' : ''}`}>
+ {/* 고정 헤더 영역 */}
+ <div className="sticky top-0 z-10 p-4 border-b">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">AVL 등록 {disabled ? "(비활성화)" : ""}</h3>
+ <div className="flex gap-2">
+ <Button variant="outline" size="sm" disabled={disabled}>
+ AVL 불러오기
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 스크롤되는 콘텐츠 영역 */}
+ <div className="overflow-x-auto overflow-y-hidden">
+ <div className="grid grid-cols-[2.2fr_2fr_2.5fr] gap-0 min-w-[1200px] w-fit">
+ {/* 프로젝트 AVL 테이블 - 9개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <ProjectAvlTable
+ onSelectionChange={handleProjectSelection}
+ resetCounter={resetCounters.project}
+ projectCode={currentProjectCode}
+ avlListId={parseInt(avlListId) || 1}
+ onProjectCodeChange={handleProjectCodeChange}
+ />
+
+ {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */}
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10">
+ <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm">
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="프로젝트AVL로 복사"
+ disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="선종별표준AVL로 복사"
+ disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="벤더풀로 복사"
+ disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0}
+ >
+ <ChevronsRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <StandardAvlTable
+ onSelectionChange={handleStandardSelection}
+ resetCounter={resetCounters.standard}
+ constructionSector={constructionSector}
+ shipType={shipType}
+ avlKind={avlKind}
+ htDivision={htDivision}
+ />
+
+ {/* 이동 버튼들 - 두 번째 border 위에 오버레이 */}
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10">
+ <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm">
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="프로젝트AVL로 복사"
+ disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0}
+ >
+ <ChevronsLeft className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="선종별표준AVL로 복사"
+ disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="벤더풀로 복사"
+ disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* Vendor Pool 테이블 - 10개 컬럼 */}
+ <div className="p-4 relative">
+ <VendorPoolTable
+ onSelectionChange={handleVendorSelection}
+ resetCounter={resetCounters.vendor}
+ />
+ </div>
+ </div>
+ </div>
+ </Card>
+ )
+}
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<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ }
+}
+
+// 테이블 컬럼 정의 함수
+export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ColumnDef<AvlListItem>[] {
+ const columns: ColumnDef<AvlListItem>[] = [
+ // 기본 정보 그룹
+ {
+ header: "기본 정보",
+ columns: [
+ {
+ id: "select",
+ header: () => <div className="text-center">선택</div>,
+ cell: ({ row }) => (
+ <div className="flex justify-center">
+ <Checkbox
+ checked={selectedRows.includes(row.original.id)}
+ onCheckedChange={(checked) => {
+ onRowSelect?.(row.original.id, !!checked)
+ }}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ </div>
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No" />
+ ),
+ cell: ({ getValue }) => <div className="text-center">{getValue() as number}</div>,
+ size: 60,
+ },
+ {
+ accessorKey: "isTemplate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 분류" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as boolean
+ const isModified = getIsModified(table, row.id, "isTemplate")
+ return (
+ <EditableCell
+ value={value ? "표준 AVL" : "프로젝트 AVL"}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: false, label: "프로젝트 AVL" },
+ { value: true, label: "표준 AVL" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "isTemplate", newValue === "true")
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "isTemplate")
+ }}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "constructionSector",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공사부문" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "constructionSector")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: "조선", label: "조선" },
+ { value: "해양", label: "해양" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "constructionSector", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "constructionSector")
+ }}
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "projectCode")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "projectCode", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "projectCode")
+ }}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "shipType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "shipType")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "shipType", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "shipType")
+ }}
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "avlKind",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 종류" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "avlKind")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "avlKind", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "avlKind")
+ }}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "htDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="H/T 구분" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "htDivision")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: "H", label: "H" },
+ { value: "T", label: "T" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "htDivision", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "htDivision")
+ }}
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "rev",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as number
+ const isModified = getIsModified(table, row.id, "rev")
+ return (
+ <EditableCell
+ value={value?.toString() || ""}
+ isModified={isModified}
+ type="number"
+ onUpdate={(newValue) => {
+ 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="등재일" />
+ ),
+ cell: ({ getValue }) => {
+ const date = getValue() as string
+ return <div className="text-center text-sm">{date}</div>
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ getValue }) => {
+ const date = getValue() as string
+ return <div className="text-center text-sm">{date}</div>
+ },
+ size: 100,
+ },
+ ],
+ },
+
+ // 액션 그룹
+ {
+ id: "actions",
+ header: "액션",
+ columns: [
+ {
+ id: "actions",
+ header: () => <div className="text-center">액션</div>,
+ cell: ({ row, table }) => {
+ const isEmptyRow = table.options.meta?.isEmptyRow?.(row.id) || false
+
+ if (isEmptyRow) {
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onSaveEmptyRow?.(row.id)}
+ className="h-8 w-8 p-0"
+ >
+ 저장
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onCancelEmptyRow?.(row.id)}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ 취소
+ </Button>
+ </div>
+ )
+ }
+
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("view-detail", { id: row.original.id })}
+ className="h-8 w-8 p-0"
+ title="상세보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("edit", { id: row.original.id })}
+ className="h-8 w-8 p-0"
+ title="수정"
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("delete", { id: row.original.id })}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ title="삭제"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ },
+ 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<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ getPendingChanges?: () => Record<string, Partial<AvlListItem>>
+ }
+}
+
+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<number[]>([])
+
+ // 수정사항 추적 (일괄 저장용)
+ const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlListItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // 빈 행 관리 (신규 등록용)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlListItem>>({})
+ const [isCreating, setIsCreating] = React.useState(false)
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<AvlListItem>[] = [
+ {
+ 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<AvlListItem>[] = [
+ {
+ 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 (
+ <div className="space-y-4">
+ {/* 툴바 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={filterFields as any}
+ >
+ <div className="flex items-center gap-2">
+ {/* 액션 버튼들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('new-registration')}
+ disabled={isCreating}
+ >
+ 신규등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('standard-registration')}
+ >
+ 표준AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('project-registration')}
+ >
+ 프로젝트AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('bulk-import')}
+ >
+ 파일 업로드
+ </Button>
+
+ {/* 저장 버튼 - 변경사항이 있을 때만 활성화 */}
+ {(hasPendingChanges || hasEmptyRows) && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => handleAction('save')}
+ disabled={isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ )}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ >
+ 새로고침
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
+ <div className="text-xs text-muted-foreground p-2 bg-muted rounded">
+ <div>Pending Changes: {Object.keys(pendingChanges).length}</div>
+ <div>Empty Rows: {Object.keys(emptyRows).length}</div>
+ </div>
+ )}
+ </div>
+ )
+}
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<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ }
+}
+
+// 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<AvlDetailItem>[] = [
+ // 기본 정보 그룹
+ {
+ header: "기본 정보",
+ columns: [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No." />
+ ),
+ size: 60,
+ },
+ {
+ accessorKey: "equipBulkDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="select"
+ onSave={onSave}
+ options={[
+ { label: "EQUIP", value: "EQUIP" },
+ { label: "BULK", value: "BULK" }
+ ]}
+ placeholder="구분 선택"
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "disciplineName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계공종" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="설계공종 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 150,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지 정보" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("packageName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "packageName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="패키지명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹코드" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재그룹코드 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("materialGroupName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "materialGroupName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재그룹명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체코드" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="협력업체코드 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("vendorName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "vendorName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "vendorName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="협력업체명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="AVL 등재업체명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="select"
+ onSave={onSave}
+ options={[
+ { label: "Tier 1", value: "Tier 1" },
+ { label: "Tier 2", value: "Tier 2" },
+ { label: "Tier 3", value: "Tier 3" }
+ ]}
+ placeholder="등급 선택"
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 100,
+ },
+ ],
+ },
+ // FA 정보 그룹
+ {
+ header: "FA 정보",
+ columns: [
+ {
+ accessorKey: "faTarget",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 대상" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "faStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 현황" />
+ ),
+ 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 (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="FA 현황 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ 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 (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ 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 (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ 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 (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 80,
+ },
+ ],
+ },
+ // 액션 컬럼
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row, table }) => {
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ return (
+ <div className="flex items-center gap-2">
+ {!isEmptyRow && (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.('edit', row.original)}
+ className="h-8 w-8 p-0"
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.('delete', row.original)}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </>
+ )}
+ {isEmptyRow && (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onSaveEmptyRow?.(row.original.id)}
+ className="h-8 w-8 p-0"
+ >
+ 저장
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onCancelEmptyRow?.(row.original.id)}
+ className="h-8 w-8 p-0"
+ >
+ 취소
+ </Button>
+ </>
+ )}
+ </div>
+ )
+ },
+ 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<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러
+}
+
+export function ProjectAvlAddDialog({ open, onOpenChange, onAddItem, editingItem, onUpdateItem }: ProjectAvlAddDialogProps) {
+ const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 설계 정보
+ 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<AvlVendorInfoInput, 'avlListId'>)
+
+ 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<AvlVendorInfoInput, 'avlListId'>)
+ onOpenChange(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>{editingItem ? "프로젝트 AVL 항목 수정" : "프로젝트 AVL 항목 추가"}</DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? "AVL 항목을 수정합니다. 필수 항목을 입력해주세요."
+ : "새로운 AVL 항목을 추가합니다. 필수 항목을 입력해주세요."
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx
new file mode 100644
index 00000000..c6dd8064
--- /dev/null
+++ b/lib/avl/table/project-avl-table.tsx
@@ -0,0 +1,724 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { ProjectAvlAddDialog } from "./project-avl-add-dialog"
+import { getProjectAvlVendorInfo, getAvlListById, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo } from "../service"
+import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service"
+import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service"
+import { GetProjectAvlSchema } from "../validations"
+import { AvlDetailItem, AvlListItem, AvlVendorInfoInput } from "../types"
+import { toast } from "sonner"
+
+// 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용
+export type ProjectAvlItem = AvlDetailItem
+
+interface ProjectAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ projectCode?: string // 프로젝트 코드 필터
+ avlListId?: number // AVL 리스트 ID (관리 영역 표시용)
+ onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백
+}
+
+// 프로젝트 AVL 테이블 컬럼
+const getProjectAvlColumns = (): ColumnDef<ProjectAvlItem>[] => [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // 프로젝트 AVL 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.no}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.disciplineName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: "고객사 AVL 자재명",
+ size: 150,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialNameCustomerSide}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.avlVendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "ownerSuggestion",
+ header: "선주제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.ownerSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "shiSuggestion",
+ header: "SHI 제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.shiSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+]
+
+export function ProjectAvlTable({
+ onSelectionChange,
+ resetCounter,
+ projectCode,
+ avlListId,
+ onProjectCodeChange
+}: ProjectAvlTableProps) {
+ const [data, setData] = React.useState<ProjectAvlItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+ const [avlListInfo, setAvlListInfo] = React.useState<AvlListItem | null>(null)
+ const [originalFile, setOriginalFile] = React.useState<string>("")
+ const [localProjectCode, setLocalProjectCode] = React.useState<string>(projectCode || "")
+
+ // 행 추가/수정 다이얼로그 상태
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [editingItem, setEditingItem] = React.useState<AvlDetailItem | undefined>(undefined)
+
+ // 프로젝트 정보 상태
+ const [projectInfo, setProjectInfo] = React.useState<{
+ projectName: string
+ constructionSector: string
+ shipType: string
+ htDivision: string
+ } | null>(null)
+
+ // 프로젝트 검색 상태
+ const [projectSearchStatus, setProjectSearchStatus] = React.useState<'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error'>('idle')
+
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema>) => {
+ try {
+ setLoading(true)
+ const params: any = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ flags: searchParams.flags ?? [],
+ projectCode: localProjectCode || "",
+ equipBulkDivision: searchParams.equipBulkDivision ?? "",
+ disciplineCode: searchParams.disciplineCode ?? "",
+ disciplineName: searchParams.disciplineName ?? "",
+ materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "",
+ packageCode: searchParams.packageCode ?? "",
+ packageName: searchParams.packageName ?? "",
+ materialGroupCode: searchParams.materialGroupCode ?? "",
+ materialGroupName: searchParams.materialGroupName ?? "",
+ vendorName: searchParams.vendorName ?? "",
+ vendorCode: searchParams.vendorCode ?? "",
+ avlVendorName: searchParams.avlVendorName ?? "",
+ tier: searchParams.tier ?? "",
+ filters: searchParams.filters ?? [],
+ joinOperator: searchParams.joinOperator ?? "and",
+ search: searchParams.search ?? "",
+ }
+ const result = await getProjectAvlVendorInfo(params)
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("프로젝트 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [localProjectCode])
+
+ // AVL 리스트 정보 로드
+ React.useEffect(() => {
+ const loadAvlListInfo = async () => {
+ if (avlListId) {
+ try {
+ const info = await getAvlListById(avlListId)
+ setAvlListInfo(info)
+ } catch (error) {
+ console.error("AVL 리스트 정보 로드 실패:", error)
+ }
+ }
+ }
+
+ loadAvlListInfo()
+ }, [avlListId])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ if (localProjectCode) {
+ loadData({})
+ }
+ }, [loadData, localProjectCode])
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ setOriginalFile(file.name)
+ // TODO: 실제 파일 업로드 로직 구현
+ console.log("파일 업로드:", file.name)
+ }
+ }, [])
+
+ // 프로젝트 코드 변경 핸들러
+ const handleProjectCodeChange = React.useCallback(async (value: string) => {
+ setLocalProjectCode(value)
+ onProjectCodeChange?.(value)
+
+ // 프로젝트 코드가 입력된 경우 프로젝트 정보 조회
+ if (value.trim()) {
+ setProjectSearchStatus('searching') // 검색 시작 상태로 변경
+
+ try {
+ // 1. projects 테이블에서 먼저 검색
+ let projectData = null
+ let searchSource = 'projects'
+
+ try {
+ projectData = await getProjectInfoFromProjects(value.trim())
+ // projects에서 찾았을 때만 즉시 성공 상태로 변경
+ setProjectSearchStatus('success-projects')
+ } catch (projectsError) {
+ // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색
+ try {
+ projectData = await getProjectInfoFromBiddingProjects(value.trim())
+ if (projectData) {
+ searchSource = 'bidding-projects'
+ setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경
+ } else {
+ // 둘 다 실패한 경우에만 에러 상태로 변경
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+ } catch (biddingError) {
+ // biddingProjects에서도 에러가 발생한 경우
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+ }
+
+ if (projectData) {
+ setProjectInfo({
+ projectName: projectData.projectName || "",
+ constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경)
+ shipType: projectData.shipType || projectData.projectMsrm || "",
+ htDivision: projectData.projectHtDivision || ""
+ })
+
+ const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트'
+ toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`)
+
+ // 프로젝트 검증 성공 시 해당 프로젝트의 AVL 데이터 로드
+ loadData({})
+ }
+ } catch (error) {
+ console.error("프로젝트 정보 조회 실패:", error)
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("프로젝트 정보를 불러오는데 실패했습니다.")
+ }
+ } else {
+ // 프로젝트 코드가 비어있는 경우 프로젝트 정보 초기화 및 데이터 클리어
+ setProjectInfo(null)
+ setProjectSearchStatus('idle')
+ setData([])
+ setPageCount(0)
+ }
+ }, [onProjectCodeChange])
+
+ // 행 추가 핸들러
+ const handleAddRow = React.useCallback(() => {
+ if (!localProjectCode.trim()) {
+ toast.error("프로젝트 코드를 먼저 입력해주세요.")
+ return
+ }
+ if (!projectInfo) {
+ toast.error("프로젝트 정보를 불러올 수 없습니다.")
+ return
+ }
+ setIsAddDialogOpen(true)
+ }, [localProjectCode, projectInfo])
+
+
+ // 다이얼로그에서 항목 추가 핸들러
+ const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ // AVL 리스트 ID 확인
+ if (!avlListId) {
+ toast.error("AVL 리스트가 선택되지 않았습니다.")
+ return
+ }
+
+ // DB에 실제 저장할 데이터 준비
+ const saveData: AvlVendorInfoInput = {
+ ...itemData,
+ avlListId: avlListId // 현재 AVL 리스트 ID 설정
+ }
+
+ // DB에 저장
+ const result = await createAvlVendorInfo(saveData)
+
+ if (result) {
+ toast.success("새 항목이 성공적으로 추가되었습니다.")
+
+ // 데이터 새로고침
+ loadData({})
+ } else {
+ toast.error("항목 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 추가 실패:", error)
+ toast.error("항목 추가 중 오류가 발생했습니다.")
+ }
+ }, [avlListId, loadData])
+
+ // 다이얼로그에서 항목 수정 핸들러
+ const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ // DB에 실제 수정
+ const result = await updateAvlVendorInfo(id, itemData)
+
+ if (result) {
+ toast.success("항목이 성공적으로 수정되었습니다.")
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 다이얼로그 닫기 및 수정 모드 해제
+ setIsAddDialogOpen(false)
+ setEditingItem(undefined)
+ } else {
+ toast.error("항목 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 수정 실패:", error)
+ toast.error("항목 수정 중 오류가 발생했습니다.")
+ }
+ }, [loadData])
+
+ // 테이블 메타 설정
+ const tableMeta = React.useMemo(() => ({}), [])
+
+ const table = useReactTable({
+ data,
+ columns: getProjectAvlColumns(),
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ initialState: {
+ pagination: {
+ pageSize: 10,
+ },
+ },
+ onPaginationChange: (updater) => {
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ },
+ meta: tableMeta,
+ })
+
+ // 항목 수정 핸들러 (버튼 클릭)
+ const handleEditItem = React.useCallback(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length !== 1) {
+ toast.error("수정할 항목을 하나만 선택해주세요.")
+ return
+ }
+
+ const selectedItem = selectedRows[0].original
+ setEditingItem(selectedItem)
+ setIsAddDialogOpen(true)
+ }, [table])
+
+ // 항목 삭제 핸들러
+ const handleDeleteItems = React.useCallback(async () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length === 0) {
+ toast.error("삭제할 항목을 선택해주세요.")
+ return
+ }
+
+ // 사용자 확인
+ const confirmed = window.confirm(`선택한 ${selectedRows.length}개 항목을 정말 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ // 선택된 항목들을 DB에서 삭제
+ const deletePromises = selectedRows.map(async (row) => {
+ await deleteAvlVendorInfo(row.original.id)
+ })
+
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedRows.length}개 항목이 삭제되었습니다.`)
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } catch (error) {
+ console.error("항목 삭제 실패:", error)
+ toast.error("항목 삭제 중 오류가 발생했습니다.")
+ }
+ }, [table, loadData])
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ onSelectionChange?.(selectedRows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">프로젝트 AVL</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm" onClick={handleAddRow}>
+ 행 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditItem}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ 항목 수정
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 자동 매핑
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 강제 매핑
+ </Button>
+ <Button variant="outline" size="sm" onClick={handleDeleteItems}>
+ 항목 삭제
+ </Button>
+
+ {/* 최종 확정 버튼 */}
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 최종 확정
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 조회대상 관리영역 */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="flex gap-4 overflow-x-auto pb-2">
+ {/* 프로젝트 코드 */}
+ <div className="space-y-2 min-w-[250px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 프로젝트 코드
+ {projectSearchStatus === 'success-projects' && <span className="ml-1 text-xs">(프로젝트)</span>}
+ {projectSearchStatus === 'success-bidding' && <span className="ml-1 text-xs">(견적프로젝트)</span>}
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(검색 중...)</span>}
+ {projectSearchStatus === 'error' && <span className="ml-1 text-xs">(찾을 수 없음)</span>}
+ </label>
+ <Input
+ value={localProjectCode}
+ onChange={(e) => handleProjectCodeChange(e.target.value)}
+ placeholder="프로젝트 코드를 입력하세요"
+ // disabled={projectSearchStatus === 'searching'}
+ className={`h-8 text-sm ${
+ projectSearchStatus === 'error' ? 'border-red-300 focus:border-red-500 focus:ring-red-500/20' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300 focus:border-green-500 focus:ring-green-500/20' :
+ projectSearchStatus === 'searching' ? 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' :
+ ''
+ }`}
+ />
+ </div>
+
+ {/* 프로젝트명 */}
+ <div className="space-y-2 min-w-[250px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 프로젝트명
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.projectName || '-')}
+ </div>
+ </div>
+
+ {/* 원본파일 */}
+ <div className="space-y-2 min-w-[200px] flex-shrink-0">
+ <label className="text-sm font-medium text-muted-foreground">원본파일</label>
+ <div className="flex items-center gap-2 min-h-[32px]">
+ {originalFile ? (
+ <span className="text-sm text-blue-600">{originalFile}</span>
+ ) : (
+ <div className="relative">
+ <input
+ type="file"
+ onChange={handleFileUpload}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ accept=".xlsx,.xls,.csv"
+ />
+ <Button variant="outline" size="sm" className="text-xs">
+ 파일 선택
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 공사부문 */}
+ <div className="space-y-2 min-w-[120px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 공사부문
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.constructionSector || '-')}
+ </div>
+ </div>
+
+ {/* 선종 */}
+ <div className="space-y-2 min-w-[120px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 선종
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.shipType || '-')}
+ </div>
+ </div>
+
+ {/* H/T 구분 */}
+ <div className="space-y-2 min-w-[140px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ H/T 구분
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' :
+ (projectInfo?.htDivision === 'H' ? 'Hull (H)' :
+ projectInfo?.htDivision === 'T' ? 'Topside (T)' : '-')}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+
+ {/* 행 추가/수정 다이얼로그 */}
+ <ProjectAvlAddDialog
+ open={isAddDialogOpen}
+ onOpenChange={(open) => {
+ setIsAddDialogOpen(open)
+ if (!open) {
+ setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제
+ }
+ }}
+ onAddItem={handleAddItem}
+ editingItem={editingItem}
+ onUpdateItem={handleUpdateItem}
+ />
+ </div>
+ )
+}
diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx
new file mode 100644
index 00000000..924b972a
--- /dev/null
+++ b/lib/avl/table/standard-avl-table.tsx
@@ -0,0 +1,380 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { getStandardAvlVendorInfo } from "../service"
+import { GetStandardAvlSchema } from "../validations"
+import { AvlDetailItem } from "../types"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Search } from "lucide-react"
+import { toast } from "sonner"
+
+// 검색 옵션들
+const constructionSectorOptions = [
+ { value: "all", label: "전체" },
+ { value: "조선", label: "조선" },
+ { value: "해양", label: "해양" },
+]
+
+const shipTypeOptions = [
+ { value: "all", label: "전체" },
+ { value: "컨테이너선", label: "컨테이너선" },
+ { value: "유조선", label: "유조선" },
+ { value: "LNG선", label: "LNG선" },
+ { value: "LPG선", label: "LPG선" },
+ { value: "벌크선", label: "벌크선" },
+ { value: "여객선", label: "여객선" },
+]
+
+const avlKindOptions = [
+ { value: "all", label: "전체" },
+ { value: "표준", label: "표준" },
+ { value: "특별", label: "특별" },
+ { value: "임시", label: "임시" },
+]
+
+const htDivisionOptions = [
+ { value: "all", label: "전체" },
+ { value: "H", label: "Hull (H)" },
+ { value: "T", label: "Topside (T)" },
+]
+
+// 선종별 표준 AVL 테이블에서는 AvlDetailItem을 사용
+export type StandardAvlItem = AvlDetailItem
+
+interface StandardAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ constructionSector?: string // 공사부문 필터
+ shipType?: string // 선종 필터
+ avlKind?: string // AVL 종류 필터
+ htDivision?: string // H/T 구분 필터
+}
+
+// 선종별 표준 AVL 테이블 컬럼
+const standardAvlColumns: ColumnDef<StandardAvlItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // 선종별 표준 AVL 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ },
+ {
+ accessorKey: "headquarterLocation",
+ header: "본사 위치 (국가)",
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: "등급 (Tier)",
+ size: 120,
+ },
+]
+
+export function StandardAvlTable({
+ onSelectionChange,
+ resetCounter,
+ constructionSector: initialConstructionSector,
+ shipType: initialShipType,
+ avlKind: initialAvlKind,
+ htDivision: initialHtDivision
+}: StandardAvlTableProps) {
+ const [data, setData] = React.useState<StandardAvlItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+
+ // 검색 상태
+ const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "all")
+ const [searchShipType, setSearchShipType] = React.useState(initialShipType || "all")
+ const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "all")
+ const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "all")
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema>) => {
+ try {
+ setLoading(true)
+ const params: GetStandardAvlSchema = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ constructionSector: searchConstructionSector === "all" ? "" : searchConstructionSector || "",
+ shipType: searchShipType === "all" ? "" : searchShipType || "",
+ avlKind: searchAvlKind === "all" ? "" : searchAvlKind || "",
+ htDivision: searchHtDivision === "all" ? "" : searchHtDivision || "",
+ search: "",
+ ...searchParams,
+ }
+ const result = await getStandardAvlVendorInfo(params)
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("선종별 표준 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ loadData({})
+ }, [loadData])
+
+ // 검색 초기화 핸들러
+ const handleResetSearch = React.useCallback(() => {
+ setSearchConstructionSector("all")
+ setSearchShipType("all")
+ setSearchAvlKind("all")
+ setSearchHtDivision("all")
+ loadData({})
+ }, [loadData])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ loadData({})
+ }, [loadData])
+
+ const table = useReactTable({
+ data,
+ columns: standardAvlColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ initialState: {
+ pagination: {
+ pageSize: 10,
+ },
+ },
+ onPaginationChange: (updater) => {
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ },
+ })
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">선종별 표준 AVL</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 신규업체 추가
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 일괄입력
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 항목삭제
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 저장
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 최종 확정
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 검색 UI */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
+ {/* 공사부문 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">공사부문</label>
+ <Select value={searchConstructionSector} onValueChange={setSearchConstructionSector}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {constructionSectorOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선종 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선종</label>
+ <Select value={searchShipType} onValueChange={setSearchShipType}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* AVL종류 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">AVL종류</label>
+ <Select value={searchAvlKind} onValueChange={setSearchAvlKind}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {avlKindOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* H/T */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">H/T 구분</label>
+ <Select value={searchHtDivision} onValueChange={setSearchHtDivision}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {htDivisionOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 검색 버튼들 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium opacity-0">버튼</label>
+ <div className="flex gap-1">
+ <Button
+ onClick={handleSearch}
+ disabled={loading}
+ size="sm"
+ className="px-3"
+ >
+ <Search className="w-4 h-4 mr-1" />
+ 조회
+ </Button>
+ <Button
+ onClick={handleResetSearch}
+ variant="outline"
+ size="sm"
+ className="px-3"
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+ </div>
+ )
+}
diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx
new file mode 100644
index 00000000..1a0a5fca
--- /dev/null
+++ b/lib/avl/table/vendor-pool-table.tsx
@@ -0,0 +1,290 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { Search } from "lucide-react"
+import { getVendorPools } from "../../vendor-pool/service"
+import { GetVendorPoolSchema } from "../../vendor-pool/validations"
+import { VendorPool } from "../../vendor-pool/types"
+
+// Vendor Pool 데이터 타입 (실제 VendorPool 타입 사용)
+export type VendorPoolItem = VendorPool
+
+interface VendorPoolTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+}
+
+// Vendor Pool 테이블 컬럼
+const vendorPoolColumns: ColumnDef<VendorPoolItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // Vendor Pool 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "designCategory",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹코드",
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 정보",
+ size: 130,
+ },
+ {
+ accessorKey: "tier",
+ header: "업체분류",
+ size: 100,
+ },
+ {
+ accessorKey: "faStatus",
+ header: "FA현황",
+ size: 100,
+ },
+ {
+ accessorKey: "recentQuoteNumber",
+ header: "최근견적번호",
+ size: 130,
+ },
+ {
+ accessorKey: "recentOrderNumber",
+ header: "최근발주번호",
+ size: 130,
+ },
+]
+
+// 실제 데이터는 API에서 가져옴
+
+export function VendorPoolTable({
+ onSelectionChange,
+ resetCounter
+}: VendorPoolTableProps) {
+ const [data, setData] = React.useState<VendorPoolItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+
+ // 검색 상태
+ const [searchText, setSearchText] = React.useState("")
+ const [showAll, setShowAll] = React.useState(false)
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema>) => {
+ try {
+ setLoading(true)
+ const params: GetVendorPoolSchema = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "registrationDate", desc: true }],
+ search: searchText || "",
+ ...searchParams,
+ }
+ const result = await getVendorPools(params)
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("Vendor Pool 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchText])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ if (showAll) {
+ // 전체보기 모드에서는 페이징 없이 전체 데이터 로드
+ loadData({ perPage: 1000 }) // 충분히 큰 숫자로 전체 데이터 가져오기
+ } else {
+ loadData({})
+ }
+ }, [loadData, showAll])
+
+ // 전체보기 토글 핸들러
+ const handleShowAllToggle = React.useCallback((checked: boolean) => {
+ setShowAll(checked)
+ if (checked) {
+ // 전체보기 활성화 시 전체 데이터 로드
+ loadData({ perPage: 1000 })
+ setSearchText("")
+ } else {
+ // 전체보기 비활성화 시 일반 페이징으로 전환
+ loadData({})
+ }
+ }, [loadData])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ loadData({})
+ }, [loadData])
+
+ const table = useReactTable({
+ data,
+ columns: vendorPoolColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화
+ pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용
+ initialState: {
+ pagination: {
+ pageSize: showAll ? data.length : 10, // 전체보기 시 모든 데이터 표시
+ },
+ },
+ onPaginationChange: (updater) => {
+ if (!showAll) {
+ // 전체보기가 아닐 때만 페이징 변경 처리
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ }
+ },
+ })
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">Vendor Pool</h4>
+ <div className="flex gap-1">
+ {/* <Button variant="outline" size="sm">
+ 신규업체 추가
+ </Button> */}
+ </div>
+ </div>
+ </div>
+
+ {/* 검색 UI */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
+ {/* 전체보기 체크박스 */}
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="showAll"
+ checked={showAll}
+ onCheckedChange={handleShowAllToggle}
+ />
+ <label
+ htmlFor="showAll"
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 전체보기
+ </label>
+ </div>
+
+ {/* 검색어 입력 */}
+ {!showAll && (
+ <div className="flex gap-2 flex-1 max-w-md">
+ <Input
+ placeholder="설계공종, 업체명, 자재그룹 등으로 검색..."
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ className="flex-1"
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ handleSearch()
+ }
+ }}
+ />
+ <Button
+ onClick={handleSearch}
+ disabled={loading}
+ size="sm"
+ className="px-3"
+ >
+ <Search className="w-4 h-4" />
+ </Button>
+ </div>
+ )}
+
+ {/* 검색 결과 정보 */}
+ <div className="text-sm text-muted-foreground">
+ {showAll ? (
+ `전체 ${data.length}개 항목 표시 중`
+ ) : (
+ `${data.length}개 항목${searchText ? ` (검색어: "${searchText}")` : ""}`
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+ </div>
+ )
+}
diff --git a/lib/avl/types.ts b/lib/avl/types.ts
new file mode 100644
index 00000000..640299ef
--- /dev/null
+++ b/lib/avl/types.ts
@@ -0,0 +1,149 @@
+// AVL 관련 타입 정의
+import { AvlList, AvlVendorInfo } from "@/db/schema/avl/avl";
+
+// AVL 리스트 아이템 (UI에서 사용하는 타입)
+export interface AvlListItem extends Omit<AvlList, 'createdAt' | 'updatedAt'> {
+ no: number;
+ selected: boolean;
+ createdAt: string; // UI에서 사용하기 위해 string으로 변환
+ updatedAt: string; // UI에서 사용하기 위해 string으로 변환
+
+ // 추가 표시용 필드들 (실제로는 AvlVendorInfo에서 가져와야 함)
+ projectInfo?: string;
+ shipType?: string;
+ avlType?: string;
+ htDivision?: string;
+ rev?: number;
+ pkg?: string;
+ materialGroup?: string;
+ vendor?: string;
+ tier?: string;
+ ownerSuggestion?: string;
+ shiSuggestion?: string;
+ registrant?: string;
+ lastModifier?: string;
+}
+
+// AVL 상세 아이템 (UI에서 사용하는 타입)
+export interface AvlDetailItem extends Omit<AvlVendorInfo, 'createdAt' | 'updatedAt'> {
+ no: number;
+ selected: boolean;
+ createdAt: string;
+ updatedAt: string;
+
+ // UI 표시용 추가 필드들
+ equipBulkDivision: 'EQUIP' | 'BULK'; // UI에서 표시하기 위한 변환
+ faTarget: boolean; // UI에서 표시하기 위한 변환
+ faStatus: string;
+ agentStatus: string; // UI에서 표시하기 위한 변환
+ shiAvl: boolean; // hasAvl로 매핑
+ shiBlacklist: boolean; // isBlacklist로 매핑
+ shiBcc: boolean; // isBcc로 매핑
+ salesQuoteNumber: string; // techQuoteNumber로 매핑
+ quoteCode: string; // quoteCode로 매핑
+ salesVendorInfo: string; // quoteVendorName으로 매핑
+ salesCountry: string; // quoteCountry로 매핑
+ totalAmount: string; // quoteTotalAmount로 매핑 (string으로 변환)
+ quoteReceivedDate: string; // quoteReceivedDate로 매핑
+ recentQuoteDate: string; // recentQuoteDate로 매핑
+ recentQuoteNumber: string; // recentQuoteNumber로 매핑
+ recentOrderDate: string; // recentOrderDate로 매핑
+ recentOrderNumber: string; // recentOrderNumber로 매핑
+ remarks: string; // remark으로 매핑
+}
+
+// AVL 생성을 위한 입력 타입
+export interface CreateAvlListInput extends Omit<AvlList, 'id' | 'createdAt' | 'updatedAt'> {
+ // UI에서 입력받을 추가 필드들
+ projectInfo?: string;
+ shipType?: string;
+ avlType?: string;
+}
+
+// AVL 업데이트를 위한 입력 타입
+export interface UpdateAvlListInput extends Partial<CreateAvlListInput> {
+ id: number;
+}
+
+
+// AVL Vendor Info UI 입력을 위한 인터페이스
+export interface AvlVendorInfoInput {
+ // AVL 리스트 ID (생성 시 필수, UI에서는 선택적으로 사용)
+ 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;
+
+ // AVL 정보
+ avlVendorName?: string;
+ tier?: string;
+
+ // 제안방향
+ ownerSuggestion?: boolean;
+ shiSuggestion?: boolean;
+
+ // 위치 정보
+ headquarterLocation?: string;
+ manufacturingLocation?: string;
+
+ // FA 정보
+ faTarget?: boolean;
+ faStatus?: string;
+
+ // Agent 정보
+ isAgent?: boolean;
+
+ // 계약 서명주체
+ contractSignerId?: number;
+ contractSignerName?: string;
+ contractSignerCode?: string;
+
+ // SHI Qualification
+ shiAvl?: boolean;
+ shiBlacklist?: boolean;
+ shiBcc?: boolean;
+
+ // 기술영업 견적결과
+ salesQuoteNumber?: string;
+ quoteCode?: string;
+ quoteVendorId?: number;
+ salesVendorInfo?: string;
+ quoteVendorCode?: string;
+ salesCountry?: string;
+ totalAmount?: string;
+ quoteReceivedDate?: string;
+
+ // 업체 실적 현황
+ recentQuoteDate?: string;
+ recentQuoteNumber?: string;
+ recentOrderDate?: string;
+ recentOrderNumber?: string;
+
+ // 기타
+ remarks?: string;
+}
+
+// 액션 처리 결과 타입
+export interface ActionResult {
+ success: boolean;
+ message: string;
+ data?: any;
+}
diff --git a/lib/avl/validations.ts b/lib/avl/validations.ts
new file mode 100644
index 00000000..367a5db1
--- /dev/null
+++ b/lib/avl/validations.ts
@@ -0,0 +1,170 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { AvlListItem, AvlDetailItem } from "./types"
+
+// AVL 리스트 검색 파라미터 캐시
+export const avlListSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (등재일 기준 내림차순)
+ sort: getSortingStateParser<AvlListItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // AVL 기본 정보 필드
+ isTemplate: parseAsStringEnum(["true", "false"]).withDefault(""), // 표준 AVL 여부
+ constructionSector: parseAsString.withDefault(""), // 공사부문
+ projectCode: parseAsString.withDefault(""), // 프로젝트코드
+ shipType: parseAsString.withDefault(""), // 선종
+ avlKind: parseAsString.withDefault(""), // AVL 종류
+ htDivision: parseAsStringEnum(["H", "T"]).withDefault(""), // H/T구분
+ rev: parseAsString.withDefault(""), // 리비전
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// AVL 상세 검색 파라미터 캐시
+export const avlDetailSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // AVL Vendor Info 관련 필드들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""), // Equip/Bulk 구분
+ disciplineCode: parseAsString.withDefault(""), // 설계공종코드
+ disciplineName: parseAsString.withDefault(""), // 설계공종명
+ materialNameCustomerSide: parseAsString.withDefault(""), // 고객사 AVL 자재명
+ packageCode: parseAsString.withDefault(""), // 패키지 코드
+ packageName: parseAsString.withDefault(""), // 패키지 명
+ materialGroupCode: parseAsString.withDefault(""), // 자재그룹 코드
+ materialGroupName: parseAsString.withDefault(""), // 자재그룹 명
+ vendorName: parseAsString.withDefault(""), // 협력업체 명
+ vendorCode: parseAsString.withDefault(""), // 협력업체 코드
+ avlVendorName: parseAsString.withDefault(""), // AVL 등재업체명
+ tier: parseAsString.withDefault(""), // 등급
+ faTarget: parseAsStringEnum(["true", "false"]).withDefault(""), // FA 대상
+ faStatus: parseAsString.withDefault(""), // FA 현황
+ isAgent: parseAsStringEnum(["true", "false"]).withDefault(""), // Agent 여부
+ contractSignerName: parseAsString.withDefault(""), // 계약 서명주체
+ headquarterLocation: parseAsString.withDefault(""), // 본사 위치
+ manufacturingLocation: parseAsString.withDefault(""), // 제작/선적지
+ hasAvl: parseAsStringEnum(["true", "false"]).withDefault(""), // AVL 존재
+ isBlacklist: parseAsStringEnum(["true", "false"]).withDefault(""), // Blacklist
+ isBcc: parseAsStringEnum(["true", "false"]).withDefault(""), // BCC
+ techQuoteNumber: parseAsString.withDefault(""), // 기술영업 견적번호
+ quoteCode: parseAsString.withDefault(""), // 견적서 Code
+ quoteCountry: parseAsString.withDefault(""), // 국가
+ remark: parseAsString.withDefault(""), // 비고
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 프로젝트 AVL 검색 파라미터 캐시 (프로젝트별 AVL Vendor Info)
+export const projectAvlSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // 필수 필터: 프로젝트 코드
+ projectCode: parseAsString.withDefault(""),
+
+ // 추가 필터들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""),
+ disciplineCode: parseAsString.withDefault(""),
+ disciplineName: parseAsString.withDefault(""),
+ materialNameCustomerSide: parseAsString.withDefault(""),
+ packageCode: parseAsString.withDefault(""),
+ packageName: parseAsString.withDefault(""),
+ materialGroupCode: parseAsString.withDefault(""),
+ materialGroupName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ avlVendorName: parseAsString.withDefault(""),
+ tier: parseAsString.withDefault(""),
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 표준 AVL 검색 파라미터 캐시 (선종별 표준 AVL Vendor Info)
+export const standardAvlSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T)
+ constructionSector: parseAsString.withDefault(""),
+ shipType: parseAsString.withDefault(""),
+ avlKind: parseAsString.withDefault(""),
+ htDivision: parseAsStringEnum(["H", "T"]).withDefault(""),
+
+ // 추가 필터들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""),
+ disciplineCode: parseAsString.withDefault(""),
+ disciplineName: parseAsString.withDefault(""),
+ materialNameCustomerSide: parseAsString.withDefault(""),
+ packageCode: parseAsString.withDefault(""),
+ packageName: parseAsString.withDefault(""),
+ materialGroupCode: parseAsString.withDefault(""),
+ materialGroupName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ avlVendorName: parseAsString.withDefault(""),
+ tier: parseAsString.withDefault(""),
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 최종 타입 추론
+export type GetAvlListSchema = Awaited<ReturnType<typeof avlListSearchParamsCache.parse>>
+export type GetAvlDetailSchema = Awaited<ReturnType<typeof avlDetailSearchParamsCache.parse>>
+export type GetProjectAvlSchema = Awaited<ReturnType<typeof projectAvlSearchParamsCache.parse>>
+export type GetStandardAvlSchema = Awaited<ReturnType<typeof standardAvlSearchParamsCache.parse>>