summaryrefslogtreecommitdiff
path: root/lib/avl
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl')
-rw-r--r--lib/avl/avl-itb-rfq-service.ts289
-rw-r--r--lib/avl/history-service.ts163
-rw-r--r--lib/avl/service.ts71
-rw-r--r--lib/avl/table/avl-detail-table.tsx127
-rw-r--r--lib/avl/table/avl-table-columns.tsx213
-rw-r--r--lib/avl/table/avl-table.tsx219
-rw-r--r--lib/avl/table/columns-detail.tsx84
-rw-r--r--lib/avl/types.ts16
8 files changed, 756 insertions, 426 deletions
diff --git a/lib/avl/avl-itb-rfq-service.ts b/lib/avl/avl-itb-rfq-service.ts
new file mode 100644
index 00000000..f7662c2e
--- /dev/null
+++ b/lib/avl/avl-itb-rfq-service.ts
@@ -0,0 +1,289 @@
+// AVL 기반 RFQ/ITB 생성 서비스
+'use server'
+
+import { getServerSession } from 'next-auth/next'
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import db from '@/db/db'
+import { users, rfqsLast, rfqPrItems } from '@/db/schema'
+import { eq, desc, sql, and } from 'drizzle-orm'
+import type { AvlDetailItem } from './types'
+
+// RFQ/ITB 코드 생성 헬퍼 함수
+async function generateAvlRfqItbCode(userCode: string, type: 'RFQ' | 'ITB'): Promise<string> {
+ try {
+ // 동일한 userCode를 가진 마지막 RFQ/ITB 번호 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(
+ and(
+ eq(rfqsLast.picCode, userCode),
+ type === 'RFQ'
+ ? sql`${rfqsLast.prNumber} IS NOT NULL AND ${rfqsLast.prNumber} != ''`
+ : sql`${rfqsLast.projectCompany} IS NOT NULL AND ${rfqsLast.projectCompany} != ''`
+ )
+ )
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ // 마지막 코드에서 숫자 부분 추출 (ex: "RFQ001001" -> "001")
+ const codeMatch = lastRfq[0].rfqCode.match(/([A-Z]{3})(\d{3})(\d{3})/);
+ if (codeMatch) {
+ const currentNumber = parseInt(codeMatch[3]);
+ nextNumber = currentNumber + 1;
+ }
+ }
+
+ // 코드 형식: RFQ/ITB + userCode(3자리) + 일련번호(3자리)
+ const prefix = type === 'RFQ' ? 'RFQ' : 'ITB';
+ const paddedNumber = nextNumber.toString().padStart(3, '0');
+
+ return `${prefix}${userCode}${paddedNumber}`;
+ } catch (error) {
+ console.error('RFQ/ITB 코드 생성 오류:', error);
+ // 오류 발생 시 기본 코드 생성
+ const prefix = type === 'RFQ' ? 'RFQ' : 'ITB';
+ return `${prefix}${userCode}001`;
+ }
+}
+
+// AVL 기반 RFQ/ITB 생성을 위한 입력 타입
+export interface CreateAvlRfqItbInput {
+ // AVL 정보
+ avlItems: AvlDetailItem[]
+ businessType: '조선' | '해양' // 조선: RFQ, 해양: ITB
+
+ // RFQ/ITB 공통 정보
+ rfqTitle: string
+ dueDate: Date
+ remark?: string
+
+ // 담당자 정보
+ picUserId: number
+
+ // 추가 정보 (ITB용)
+ projectCompany?: string
+ projectFlag?: string
+ projectSite?: string
+ smCode?: string
+
+ // PR 정보 (RFQ용)
+ prNumber?: string
+ prIssueDate?: Date
+ series?: string
+}
+
+// RFQ/ITB 생성 결과 타입
+export interface CreateAvlRfqItbResult {
+ success: boolean
+ message?: string
+ data?: {
+ id: number
+ rfqCode: string
+ type: 'RFQ' | 'ITB'
+ }
+ error?: string
+}
+
+/**
+ * AVL 기반 RFQ/ITB 생성 서비스
+ * - 조선 사업: RFQ 생성
+ * - 해양 사업: ITB 생성
+ * - rfqLast 테이블에 직접 데이터 삽입
+ */
+export async function createAvlRfqItbAction(input: CreateAvlRfqItbInput): Promise<CreateAvlRfqItbResult> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ error: '로그인이 필요합니다.'
+ }
+ }
+
+ // 입력 검증
+ if (!input.avlItems || input.avlItems.length === 0) {
+ return {
+ success: false,
+ error: '견적 요청할 AVL 아이템이 없습니다.'
+ }
+ }
+
+ if (!input.businessType || !['조선', '해양'].includes(input.businessType)) {
+ return {
+ success: false,
+ error: '올바른 사업 유형을 선택해주세요.'
+ }
+ }
+
+ // 담당자 정보 확인
+ const picUser = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1)
+
+ if (!picUser || picUser.length === 0) {
+ return {
+ success: false,
+ error: '담당자를 찾을 수 없습니다.'
+ }
+ }
+
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ return {
+ success: false,
+ error: '담당자의 userCode가 올바르지 않습니다 (3자리 필요)'
+ }
+ }
+
+ // 사업 유형에 따른 RFQ/ITB 구분 및 데이터 준비
+ const rfqType = input.businessType === '조선' ? 'RFQ' : 'ITB'
+ const rfqTypeLabel = rfqType
+
+ // RFQ/ITB 코드 생성
+ const rfqCode = await generateAvlRfqItbCode(userCode, rfqType)
+
+ // 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.avlItems[0]
+
+ // 트랜잭션으로 RFQ/ITB 생성
+ const result = await db.transaction(async (tx) => {
+ // 1. rfqsLast 테이블에 기본 정보 삽입
+ const [newRfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ status: "RFQ 생성",
+ dueDate: input.dueDate,
+
+ // 대표 아이템 정보
+ itemCode: representativeItem.materialGroupCode || `AVL-${representativeItem.id}`,
+ itemName: representativeItem.materialNameCustomerSide || representativeItem.materialGroupName || 'AVL 아이템',
+
+ // 담당자 정보
+ pic: input.picUserId,
+ picCode: userCode,
+ picName: picUser[0].name || '',
+
+ // 기타 정보
+ remark: input.remark || null,
+ createdBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+
+ // 사업 유형별 추가 필드
+ ...(input.businessType === '조선' && {
+ // RFQ 필드
+ prNumber: input.prNumber || rfqCode, // PR 번호가 없으면 RFQ 코드 사용
+ prIssueDate: input.prIssueDate || new Date(),
+ series: input.series || null
+ }),
+
+ ...(input.businessType === '해양' && {
+ // ITB 필드
+ projectCompany: input.projectCompany || 'AVL 기반 프로젝트',
+ projectFlag: input.projectFlag || null,
+ projectSite: input.projectSite || null,
+ smCode: input.smCode || null
+ })
+ })
+ .returning()
+
+ // 2. rfqPrItems 테이블에 AVL 아이템들 삽입
+ const prItemsData = input.avlItems.map((item, index) => ({
+ rfqsLastId: newRfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: `${index + 1}`.padStart(3, '0'),
+ prNo: rfqCode,
+
+ materialCode: item.materialGroupCode || `AVL-${item.id}`,
+ materialDescription: item.materialNameCustomerSide || item.materialGroupName || `AVL 아이템 ${index + 1}`,
+ materialCategory: item.materialGroupCode || null,
+
+ quantity: 1, // AVL에서는 수량 정보가 없으므로 1로 설정
+ uom: 'EA', // 기본 단위
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+
+ deliveryDate: input.dueDate, // 납기일은 RFQ 마감일과 동일하게 설정
+ }))
+
+ await tx.insert(rfqPrItems).values(prItemsData)
+
+ return newRfq
+ })
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ message: `${rfqTypeLabel}가 성공적으로 생성되었습니다.`,
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode!,
+ type: rfqTypeLabel as 'RFQ' | 'ITB'
+ }
+ }
+
+ } catch (error) {
+ console.error('AVL RFQ/ITB 생성 오류:', error)
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message
+ }
+ }
+
+ return {
+ success: false,
+ error: '알 수 없는 오류가 발생했습니다.'
+ }
+ }
+}
+
+/**
+ * AVL 데이터에서 RFQ/ITB 생성을 위한 기본값 설정 헬퍼 함수
+ */
+export async function prepareAvlRfqItbInput(
+ selectedItems: AvlDetailItem[],
+ businessType: '조선' | '해양',
+ defaultValues?: Partial<CreateAvlRfqItbInput>
+): Promise<CreateAvlRfqItbInput> {
+ const now = new Date()
+ const dueDate = new Date(now.getTime() + (30 * 24 * 60 * 60 * 1000)) // 30일 후
+
+ // 선택된 아이템들의 대표 정보를 추출하여 제목 생성
+ const representativeItem = selectedItems[0]
+ const itemCount = selectedItems.length
+ const titleSuffix = itemCount > 1 ? ` 외 ${itemCount - 1}건` : ''
+ const defaultTitle = `${representativeItem?.materialNameCustomerSide || 'AVL 자재'}${titleSuffix}`
+
+ return {
+ avlItems: selectedItems,
+ businessType,
+ rfqTitle: defaultValues?.rfqTitle || `${businessType} - ${defaultTitle}`,
+ dueDate: defaultValues?.dueDate || dueDate,
+ remark: defaultValues?.remark || `AVL 기반 ${businessType} 견적 요청`,
+ picUserId: defaultValues?.picUserId || 0, // 호출 측에서 설정 필요
+ // ITB용 필드들
+ projectCompany: defaultValues?.projectCompany,
+ projectFlag: defaultValues?.projectFlag,
+ projectSite: defaultValues?.projectSite,
+ smCode: defaultValues?.smCode,
+ // RFQ용 필드들
+ prNumber: defaultValues?.prNumber,
+ prIssueDate: defaultValues?.prIssueDate,
+ series: defaultValues?.series
+ }
+}
diff --git a/lib/avl/history-service.ts b/lib/avl/history-service.ts
new file mode 100644
index 00000000..fdad0ab9
--- /dev/null
+++ b/lib/avl/history-service.ts
@@ -0,0 +1,163 @@
+'use server';
+
+import db from '@/db/db';
+import { avlList } from '@/db/schema/avl/avl';
+import { eq, and, desc } from 'drizzle-orm';
+import { debugLog, debugError } from '@/lib/debug-utils';
+import type {
+ AvlHistoryRecord,
+ VendorSnapshot,
+} from './components/avl-history-modal';
+
+/**
+ * AVL 히스토리 조회 서비스
+ * 프로젝트 AVL과 표준 AVL의 모든 리비전 히스토리를 조회합니다.
+ */
+
+/**
+ * 프로젝트 AVL 히스토리 조회
+ * @param projectCode 프로젝트 코드
+ * @returns 모든 리비전의 히스토리 데이터
+ */
+export async function getProjectAvlHistory(
+ projectCode: string
+): Promise<AvlHistoryRecord[]> {
+ try {
+ debugLog('프로젝트 AVL 히스토리 조회 시작', { projectCode });
+
+ // 동일 프로젝트 코드의 모든 AVL 리스트 조회 (isTemplate = false)
+ const allRevisions = await db.query.avlList.findMany({
+ where: and(
+ eq(avlList.projectCode, projectCode),
+ eq(avlList.isTemplate, false)
+ ),
+ orderBy: [desc(avlList.rev)],
+ });
+
+ debugLog('프로젝트 AVL 히스토리 조회 결과', {
+ projectCode,
+ revisionCount: allRevisions.length,
+ });
+
+ // 히스토리 레코드로 변환
+ const historyData: AvlHistoryRecord[] = allRevisions.map((rev) => ({
+ id: rev.id,
+ rev: rev.rev || 1,
+ createdAt: rev.createdAt?.toISOString() || new Date().toISOString(),
+ createdBy: rev.createdBy || 'system',
+ vendorInfoSnapshot: (rev.vendorInfoSnapshot as VendorSnapshot[]) || [],
+ changeDescription: `리비전 ${
+ rev.rev
+ } - ${rev.createdAt?.toLocaleDateString('ko-KR')}`,
+ }));
+
+ return historyData;
+ } catch (error) {
+ debugError('프로젝트 AVL 히스토리 조회 실패', { projectCode, error });
+ throw new Error(
+ `프로젝트 AVL 히스토리 조회 중 오류가 발생했습니다: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+}
+
+/**
+ * 표준 AVL 히스토리 조회
+ * @param constructionSector 공사부문
+ * @param shipType 선종
+ * @param avlKind AVL 종류
+ * @param htDivision H/T 구분
+ * @returns 모든 리비전의 히스토리 데이터
+ */
+export async function getStandardAvlHistory(
+ constructionSector: string,
+ shipType: string,
+ avlKind: string,
+ htDivision: string
+): Promise<AvlHistoryRecord[]> {
+ try {
+ debugLog('표준 AVL 히스토리 조회 시작', {
+ constructionSector,
+ shipType,
+ avlKind,
+ htDivision,
+ });
+
+ // 동일 조건의 모든 표준 AVL 리스트 조회 (isTemplate = true)
+ const allRevisions = await db.query.avlList.findMany({
+ where: and(
+ eq(avlList.constructionSector, constructionSector),
+ eq(avlList.shipType, shipType),
+ eq(avlList.avlKind, avlKind),
+ eq(avlList.htDivision, htDivision),
+ eq(avlList.isTemplate, true)
+ ),
+ orderBy: [desc(avlList.rev)],
+ });
+
+ debugLog('표준 AVL 히스토리 조회 결과', {
+ constructionSector,
+ shipType,
+ avlKind,
+ htDivision,
+ revisionCount: allRevisions.length,
+ });
+
+ // 히스토리 레코드로 변환
+ const historyData: AvlHistoryRecord[] = allRevisions.map((rev) => ({
+ id: rev.id,
+ rev: rev.rev || 1,
+ createdAt: rev.createdAt?.toISOString() || new Date().toISOString(),
+ createdBy: rev.createdBy || 'system',
+ vendorInfoSnapshot: (rev.vendorInfoSnapshot as VendorSnapshot[]) || [],
+ changeDescription: `리비전 ${
+ rev.rev
+ } - ${rev.createdAt?.toLocaleDateString('ko-KR')}`,
+ }));
+
+ return historyData;
+ } catch (error) {
+ debugError('표준 AVL 히스토리 조회 실패', {
+ constructionSector,
+ shipType,
+ avlKind,
+ htDivision,
+ error,
+ });
+ throw new Error(
+ `표준 AVL 히스토리 조회 중 오류가 발생했습니다: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+}
+
+/**
+ * AVL 아이템에 따른 히스토리 조회 (통합 함수)
+ * @param avlItem AVL 리스트 아이템
+ * @returns 해당 AVL의 모든 리비전 히스토리
+ */
+export async function getAvlHistory(avlItem: any): Promise<AvlHistoryRecord[]> {
+ try {
+ if (avlItem.isTemplate) {
+ // 표준 AVL인 경우
+ return await getStandardAvlHistory(
+ avlItem.constructionSector,
+ avlItem.shipType,
+ avlItem.avlKind,
+ avlItem.htDivision
+ );
+ } else {
+ // 프로젝트 AVL인 경우
+ return await getProjectAvlHistory(avlItem.projectCode);
+ }
+ } catch (error) {
+ debugError('AVL 히스토리 조회 실패', { avlItem, error });
+ throw new Error(
+ `AVL 히스토리 조회 중 오류가 발생했습니다: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+}
diff --git a/lib/avl/service.ts b/lib/avl/service.ts
index 535a0169..3d188f85 100644
--- a/lib/avl/service.ts
+++ b/lib/avl/service.ts
@@ -5,7 +5,7 @@ import { AvlListItem, AvlDetailItem, CreateAvlListInput, UpdateAvlListInput, Act
import type { NewAvlVendorInfo } from "@/db/schema/avl/avl";
import type { NewVendorPool } from "@/db/schema/avl/vendor-pool";
import db from "@/db/db";
-import { avlList, avlVendorInfo } from "@/db/schema/avl/avl";
+import { avlList, avlVendorInfo, avlListSummaryView } from "@/db/schema/avl/avl";
import { vendorPool } from "@/db/schema/avl/vendor-pool";
import { eq, and, or, ilike, count, desc, asc, sql, inArray } from "drizzle-orm";
import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils";
@@ -14,7 +14,7 @@ import { createVendorInfoSnapshot } from "./snapshot-utils";
/**
* AVL 리스트 조회
- * avl_list 테이블에서 실제 데이터를 조회합니다.
+ * avlListSummaryView에서 최신 revision별로 집계된 데이터를 조회합니다.
*/
export const getAvlLists = async (input: GetAvlListSchema) => {
try {
@@ -30,69 +30,69 @@ export const getAvlLists = async (input: GetAvlListSchema) => {
const searchTerm = `%${input.search}%`;
whereConditions.push(
or(
- ilike(avlList.constructionSector, searchTerm),
- ilike(avlList.projectCode, searchTerm),
- ilike(avlList.shipType, searchTerm),
- ilike(avlList.avlKind, searchTerm)
+ ilike(avlListSummaryView.constructionSector, searchTerm),
+ ilike(avlListSummaryView.projectCode, searchTerm),
+ ilike(avlListSummaryView.shipType, searchTerm),
+ ilike(avlListSummaryView.avlKind, searchTerm)
)
);
}
// 필터 조건 추가
if (input.isTemplate === "true") {
- whereConditions.push(eq(avlList.isTemplate, true));
+ whereConditions.push(eq(avlListSummaryView.isTemplate, true));
} else if (input.isTemplate === "false") {
- whereConditions.push(eq(avlList.isTemplate, false));
+ whereConditions.push(eq(avlListSummaryView.isTemplate, false));
}
if (input.constructionSector) {
- whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`));
+ whereConditions.push(ilike(avlListSummaryView.constructionSector, `%${input.constructionSector}%`));
}
if (input.projectCode) {
- whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`));
+ whereConditions.push(ilike(avlListSummaryView.projectCode, `%${input.projectCode}%`));
}
if (input.shipType) {
- whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`));
+ whereConditions.push(ilike(avlListSummaryView.shipType, `%${input.shipType}%`));
}
if (input.avlKind) {
- whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`));
+ whereConditions.push(ilike(avlListSummaryView.avlKind, `%${input.avlKind}%`));
}
if (input.htDivision) {
- whereConditions.push(eq(avlList.htDivision, input.htDivision));
+ whereConditions.push(eq(avlListSummaryView.htDivision, input.htDivision));
}
if (input.rev) {
- whereConditions.push(eq(avlList.rev, parseInt(input.rev)));
+ whereConditions.push(eq(avlListSummaryView.rev, parseInt(input.rev)));
}
// 정렬 조건 구성
const orderByConditions: any[] = [];
input.sort.forEach((sortItem) => {
- const column = sortItem.id as keyof typeof avlList;
+ const column = sortItem.id as keyof typeof avlListSummaryView;
- if (column && avlList[column]) {
+ if (column && avlListSummaryView[column]) {
if (sortItem.desc) {
- orderByConditions.push(sql`${avlList[column]} desc`);
+ orderByConditions.push(sql`${avlListSummaryView[column]} desc`);
} else {
- orderByConditions.push(sql`${avlList[column]} asc`);
+ orderByConditions.push(sql`${avlListSummaryView[column]} asc`);
}
}
});
// 기본 정렬 (등재일 내림차순)
if (orderByConditions.length === 0) {
- orderByConditions.push(desc(avlList.createdAt));
+ orderByConditions.push(desc(avlListSummaryView.createdAt));
}
// 총 개수 조회
const totalCount = await db
.select({ count: count() })
- .from(avlList)
+ .from(avlListSummaryView)
.where(and(...whereConditions));
// 데이터 조회
const data = await db
.select()
- .from(avlList)
+ .from(avlListSummaryView)
.where(and(...whereConditions))
.orderBy(...orderByConditions)
.limit(input.perPage)
@@ -100,17 +100,36 @@ export const getAvlLists = async (input: GetAvlListSchema) => {
// 데이터 변환 (timestamp -> string)
const transformedData: AvlListItem[] = data.map((item, index) => ({
- ...item,
+ // 기본 필드들
+ id: item.id!,
+ isTemplate: item.isTemplate || false,
+ constructionSector: item.constructionSector || '',
+ projectCode: item.projectCode || null,
+ shipType: item.shipType || '',
+ avlKind: item.avlKind || '',
+ htDivision: item.htDivision || '',
+ rev: item.rev || 1,
+ vendorInfoSnapshot: undefined, // 뷰에서는 제공하지 않음
+ createdBy: item.createdBy || null,
+ updatedBy: item.updatedBy || null,
+
+ // UI 전용 필드들
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,
+
+ // 뷰에서 제공하는 집계 필드들 추가
+ PKG: item.pkgCount || 0,
+ materialGroup: item.materialGroupCount || 0,
+ vendor: item.vendorCount || 0,
+ Tier: item.tierCount || 0,
+ ownerSuggestion: item.ownerSuggestionCount || 0,
+ shiSuggestion: item.shiSuggestionCount || 0,
}));
const pageCount = Math.ceil(totalCount[0].count / input.perPage);
diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx
index 04384ec8..4408340a 100644
--- a/lib/avl/table/avl-detail-table.tsx
+++ b/lib/avl/table/avl-detail-table.tsx
@@ -6,42 +6,92 @@ import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
+import { createAvlRfqItbAction, prepareAvlRfqItbInput } from "../avl-itb-rfq-service"
-import { columns, type AvlDetailItem } from "./columns-detail"
+import { columns } from "./columns-detail"
+import type { AvlDetailItem } from "../types"
+import { BackButton } from "@/components/ui/back-button"
+import { useSession } from "next-auth/react"
interface AvlDetailTableProps {
data: AvlDetailItem[]
pageCount?: number
avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입
- projectCode?: string // 프로젝트 코드
- shipOwnerName?: string // 선주명
+ projectInfo?: {
+ code?: string
+ pspid?: string
+ OWN_NM?: string
+ kunnrNm?: string
+ } // 프로젝트 정보
businessType?: string // 사업 유형 (예: 조선/해양)
}
export function AvlDetailTable({
data,
pageCount,
- avlType = '프로젝트AVL',
- projectCode,
- shipOwnerName,
- businessType = '조선'
+ avlType,
+ projectInfo,
+ businessType,
}: AvlDetailTableProps) {
+ // 견적요청 처리 상태 관리
+ const [isProcessingQuote, setIsProcessingQuote] = React.useState(false)
+ const { data: session } = useSession()
+
+ // 견적요청 처리 함수
+ const handleQuoteRequest = React.useCallback(async () => {
+ if (!businessType || !['조선', '해양'].includes(businessType)) {
+ toast.error("공사구분이 올바르지 않습니다. 견적요청 처리 불가.")
+ return
+ }
+
+ if (data.length === 0) {
+ toast.error("견적요청할 AVL 데이터가 없습니다.")
+ return
+ }
+
+ setIsProcessingQuote(true)
+
+ try {
+ // 현재 사용자 세션에서 ID 가져오기
+ const currentUserId = session?.user?.id ? Number(session.user.id) : undefined
+
+ // 견적요청 입력 데이터 준비 (전체 데이터를 사용)
+ const quoteInput = await prepareAvlRfqItbInput(
+ data, // 전체 데이터를 사용
+ businessType as '조선' | '해양',
+ {
+ picUserId: currentUserId,
+ rfqTitle: `${businessType} AVL 견적요청 - ${data[0]?.materialNameCustomerSide || 'AVL 아이템'}${data.length > 1 ? ` 외 ${data.length - 1}건` : ''}`
+ }
+ )
+
+ // 견적요청 실행
+ const result = await createAvlRfqItbAction(quoteInput)
+
+ if (result.success) {
+ toast.success(`${result.data?.type}가 성공적으로 생성되었습니다. (코드: ${result.data?.rfqCode})`)
+ } else {
+ toast.error(result.error || "견적요청 처리 중 오류가 발생했습니다.")
+ }
+
+ } catch (error) {
+ console.error('견적요청 처리 오류:', error)
+ toast.error("견적요청 처리 중 오류가 발생했습니다.")
+ } finally {
+ setIsProcessingQuote(false)
+ }
+ }, [businessType, data, session?.user?.id])
+
// 액션 핸들러
const handleAction = React.useCallback(async (action: string) => {
switch (action) {
- case 'avl-form':
- toast.info("AVL 양식을 준비 중입니다.")
- // TODO: AVL 양식 다운로드 로직 구현
- break
case 'quote-request':
- toast.info("견적 요청을 처리 중입니다.")
- // TODO: 견적 요청 로직 구현
+ await handleQuoteRequest()
break
case 'vendor-pool':
- toast.info("Vendor Pool을 열고 있습니다.")
- // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현
+ window.open('/evcp/vendor-pool', '_blank')
break
case 'download':
@@ -49,13 +99,18 @@ export function AvlDetailTable({
// TODO: 데이터 다운로드 로직 구현
break
+ case 'avl-form':
+ toast.info("AVL 양식을 준비 중입니다.")
+ // TODO: AVL 양식 다운로드 로직 구현
+ break
+
default:
toast.error(`알 수 없는 액션: ${action}`)
}
- }, [])
+ }, [handleQuoteRequest])
- // 테이블 메타 설정 (읽기 전용)
+ // 테이블 메타 설정
const tableMeta = React.useMemo(() => ({
onAction: handleAction,
}), [handleAction])
@@ -76,36 +131,46 @@ export function AvlDetailTable({
meta: tableMeta,
})
-
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 justify-between p-4">
<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">
+ <span className="px-3 py-1 bg-secondary-foreground text-secondary-foreground-foreground rounded-full text-sm font-medium">
{avlType}
</span>
+
<span className="text-sm text-muted-foreground">
- [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'})
+ [{businessType}] {projectInfo?.code || projectInfo?.pspid || '코드정보없음(표준AVL)'} ({projectInfo?.OWN_NM || projectInfo?.kunnrNm || '선주정보 없음'})
</span>
</div>
+
+ <div className="justify-end">
+ <BackButton>목록으로</BackButton>
+ </div>
</div>
{/* 상단 버튼 영역 */}
- <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>
+ <div className="flex items-center gap-2 ml-auto justify-end">
+ {
+ // 표준AVL로는 견적요청하지 않으며, 프로젝트 AVL로만 견적요청처리
+ avlType === '프로젝트AVL' && businessType && ['조선', '해양'].includes(businessType) &&
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('quote-request')}
+ disabled={data.length === 0 || isProcessingQuote}
+ >
+ {isProcessingQuote ? '처리중...' : `${businessType} 견적요청 (${businessType === '조선' ? 'RFQ' : 'ITB'})`}
+ </Button>
+ }
+
+ {/* 단순 이동 버튼 */}
<Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}>
Vendor Pool
</Button>
- <Button variant="outline" size="sm" onClick={() => handleAction('download')}>
- 다운로드
- </Button>
+
</div>
{/* 데이터 테이블 */}
diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx
index 8caf012e..72c59aa9 100644
--- a/lib/avl/table/avl-table-columns.tsx
+++ b/lib/avl/table/avl-table-columns.tsx
@@ -2,9 +2,8 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Eye, Edit, Trash2, History } from "lucide-react"
-import { type ColumnDef, TableMeta } from "@tanstack/react-table"
+import { type ColumnDef } 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 {
@@ -12,18 +11,12 @@ interface GetColumnsProps {
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>
+ onCellUpdate?: (id: string, field: keyof TData, newValue: string | boolean) => Promise<void>
onCellCancel?: (id: string, field: keyof TData) => void
- onAction?: (action: string, data?: any) => void
+ onAction?: (action: string, data?: Partial<AvlListItem>) => void
onSaveEmptyRow?: (tempId: string) => Promise<void>
onCancelEmptyRow?: (tempId: string) => void
isEmptyRow?: (id: string) => boolean
@@ -35,7 +28,7 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
const columns: ColumnDef<AvlListItem>[] = [
// 기본 정보 그룹
{
- header: "기본 정보",
+ header: "AVL 정보",
columns: [
{
id: "select",
@@ -69,25 +62,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="AVL 분류" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value ? "표준 AVL" : "프로젝트 AVL"}
+ </div>
)
},
size: 120,
@@ -97,25 +77,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="공사부문" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value}
+ </div>
)
},
size: 100,
@@ -125,20 +92,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value}
+ </div>
)
},
size: 140,
@@ -148,20 +107,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="선종" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value}
+ </div>
)
},
size: 100,
@@ -171,20 +122,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="AVL 종류" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value}
+ </div>
)
},
size: 120,
@@ -194,25 +137,12 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="H/T 구분" />
),
- cell: ({ getValue, row, table }) => {
+ cell: ({ getValue }) => {
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")
- }}
- />
+ <div className="text-center">
+ {value}
+ </div>
)
},
size: 80,
@@ -246,9 +176,52 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
],
},
+ // 집계 그룹
+ {
+ header: "등록정보",
+ columns: [
+ {
+ accessorKey: "PKG",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PKG" />
+ ),
+ },
+ {
+ accessorKey: "materialGroup",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ },
+ {
+ accessorKey: "vendor",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체" />
+ ),
+ },
+ {
+ accessorKey: "Tier",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tier" />
+ ),
+ },
+ {
+ accessorKey: "ownerSuggestion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선주 제안" />
+ ),
+ },
+ {
+ accessorKey: "shiSuggestion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="SHI 제안" />
+ ),
+ },
+ ],
+ },
+
// 등록 정보 그룹
{
- header: "등록 정보",
+ header: "작성정보",
columns: [
{
accessorKey: "createdAt",
@@ -262,9 +235,31 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
size: 100,
},
{
+ accessorKey: "createdBy",
+ 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="수정일" />
+ <DataTableColumnHeaderSimple column={column} title="최종변경일" />
+ ),
+ cell: ({ getValue }) => {
+ const date = getValue() as string
+ return <div className="text-center text-sm">{date}</div>
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "updatedBy",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종변경자" />
),
cell: ({ getValue }) => {
const date = getValue() as string
@@ -320,24 +315,6 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps):
>
<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>
)
},
diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx
index eb9b2079..45da6268 100644
--- a/lib/avl/table/avl-table.tsx
+++ b/lib/avl/table/avl-table.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import type {
- DataTableFilterField,
+ DataTableAdvancedFilterField
} from "@/types/table"
import { useDataTable } from "@/hooks/use-data-table"
@@ -12,16 +12,17 @@ import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { getColumns } from "./avl-table-columns"
-import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service"
+import { updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service"
import type { AvlListItem } from "../types"
import { AvlHistoryModal, type AvlHistoryRecord } from "@/lib/avl/components/avl-history-modal"
+import { getAvlHistory } from "@/lib/avl/history-service"
// 테이블 메타 타입 확장
declare module "@tanstack/react-table" {
interface TableMeta<TData> {
- onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellUpdate?: (id: string, field: keyof TData, newValue: string | boolean) => Promise<void>
onCellCancel?: (id: string, field: keyof TData) => void
- onAction?: (action: string, data?: any) => void
+ onAction?: (action: string, data?: Partial<AvlListItem>) => void
onSaveEmptyRow?: (tempId: string) => Promise<void>
onCancelEmptyRow?: (tempId: string) => void
isEmptyRow?: (id: string) => boolean
@@ -47,10 +48,6 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
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 [historyModalOpen, setHistoryModalOpen] = React.useState(false)
const [selectedAvlItem, setSelectedAvlItem] = React.useState<AvlListItem | null>(null)
@@ -58,36 +55,11 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
// 히스토리 데이터 로드 함수
const loadHistoryData = React.useCallback(async (avlItem: AvlListItem): Promise<AvlHistoryRecord[]> => {
try {
- // 현재 리비전의 스냅샷 데이터 (실제 저장된 데이터 사용)
- const currentSnapshot = avlItem.vendorInfoSnapshot || []
-
- const historyData: AvlHistoryRecord[] = [
- {
- id: avlItem.id,
- rev: avlItem.rev || 1,
- createdAt: avlItem.createdAt || new Date().toISOString(),
- createdBy: avlItem.createdBy || "system",
- vendorInfoSnapshot: currentSnapshot,
- changeDescription: "최신 리비전 (확정완료)"
- }
- ]
-
- // TODO: 실제 구현에서는 DB에서 이전 리비전들의 스냅샷 데이터를 조회해야 함
- // 현재는 더미 데이터로 이전 리비전들을 시뮬레이션
- if ((avlItem.rev || 1) > 1) {
- for (let rev = (avlItem.rev || 1) - 1; rev >= 1; rev--) {
- historyData.push({
- id: avlItem.id + rev * 1000, // 임시 ID
- rev,
- createdAt: new Date(Date.now() - rev * 24 * 60 * 60 * 1000).toISOString(),
- createdBy: "system",
- vendorInfoSnapshot: [], // 이전 리비전의 스냅샷 데이터 (실제로는 DB에서 조회)
- changeDescription: `리비전 ${rev} 변경사항`
- })
- }
- }
-
- return historyData
+ const historyRecords = await getAvlHistory(avlItem)
+ return historyRecords.map(record => ({
+ ...record,
+ vendorInfoSnapshot: record.vendorInfoSnapshot || []
+ }))
} catch (error) {
console.error('히스토리 로드 실패:', error)
toast.error("히스토리를 불러오는데 실패했습니다.")
@@ -96,7 +68,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
}, [])
// 필터 필드 정의
- const filterFields: DataTableFilterField<AvlListItem>[] = [
+ const filterFields: DataTableAdvancedFilterField<AvlListItem>[] = [
{
id: "isTemplate",
label: "AVL 분류",
@@ -105,6 +77,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
{ label: "프로젝트 AVL", value: "false" },
{ label: "표준 AVL", value: "true" },
],
+ type: "select"
},
{
id: "constructionSector",
@@ -114,6 +87,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
{ label: "조선", value: "조선" },
{ label: "해양", value: "해양" },
],
+ type: "select"
},
{
id: "htDivision",
@@ -123,17 +97,18 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
{ label: "H", value: "H" },
{ label: "T", value: "T" },
],
+ type: "select"
},
]
// 인라인 편집 핸들러 (일괄 저장용)
- const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => {
+ const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: string | boolean) => {
const isEmptyRow = String(id).startsWith('temp-')
if (isEmptyRow) {
- // 빈 행의 경우 emptyRows 상태도 업데이트
- setEmptyRows(prev => ({
+ // 빈 행의 경우 pendingChanges 상태도 업데이트
+ setPendingChanges(prev => ({
...prev,
[id]: {
...prev[id],
@@ -157,15 +132,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
const isEmptyRow = String(id).startsWith('temp-')
if (isEmptyRow) {
- // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소
- setEmptyRows(prev => ({
- ...prev,
- [id]: {
- ...prev[id],
- [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용)
- }
- }))
-
+ // 빈 행의 경우 pendingChanges 취소
setPendingChanges(prev => {
const itemChanges = { ...prev[id] }
delete itemChanges[field]
@@ -185,39 +152,9 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
}, [])
// 액션 핸들러
- const handleAction = React.useCallback(async (action: string, data?: any) => {
+ const handleAction = React.useCallback(async (action: string, data?: Partial<AvlListItem>) => {
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,
- vendorInfoSnapshot: null,
- 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')
@@ -263,7 +200,13 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
for (const [id, changes] of Object.entries(pendingChanges)) {
if (String(id).startsWith('temp-')) continue // 빈 행은 제외
- const result = await updateAvlListAction(Number(id), changes as any)
+ // id 속성을 명시적으로 추가
+ const updateData = {
+ ...changes,
+ id: Number(id)
+ }
+
+ const result = await updateAvlListAction(Number(id), updateData)
if (!result) {
throw new Error(`항목 ${id} 저장 실패`)
}
@@ -330,95 +273,20 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
}
}, [pendingChanges, onRefresh, onRegistrationModeChange])
- // 빈 행 저장 핸들러
- 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])
+ return [...data]
+ }, [data, 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)
@@ -427,18 +295,16 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
setSelectedRows([])
onRowSelect?.(null)
}
- }, [data, emptyRows, isLoading, onRowSelect])
+ }, [allData, 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])
+ }), [handleCellUpdate, handleCellCancel, handleAction, pendingChanges])
// 데이터 테이블 설정
@@ -461,29 +327,19 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
// 변경사항이 있는지 확인
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}
+ filterFields={filterFields}
>
<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등록
@@ -497,16 +353,8 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
프로젝트AVL등록
</Button>
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleAction('bulk-import')}
- >
- 파일 업로드
- </Button>
-
{/* 저장 버튼 - 변경사항이 있을 때만 활성화 */}
- {(hasPendingChanges || hasEmptyRows) && (
+ {(hasPendingChanges) && (
<Button
variant="default"
size="sm"
@@ -543,10 +391,9 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
/>
{/* 디버그 정보 (개발 환경에서만 표시) */}
- {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
+ {process.env.NODE_ENV === 'development' && (hasPendingChanges) && (
<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
index 84ad9d9a..f33ef593 100644
--- a/lib/avl/table/columns-detail.tsx
+++ b/lib/avl/table/columns-detail.tsx
@@ -2,69 +2,35 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { type ColumnDef } from "@tanstack/react-table"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { AvlDetailItem } from "../types"
-// 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>[] = [
+ // 선택 컬럼
+ // {
+ // id: "select",
+ // header: ({ table }) => (
+ // <Checkbox
+ // checked={
+ // table.getIsAllPageRowsSelected() ||
+ // (table.getIsSomePageRowsSelected() && "indeterminate")
+ // }
+ // onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ // aria-label="모든 행 선택"
+ // />
+ // ),
+ // cell: ({ row }) => (
+ // <Checkbox
+ // checked={row.getIsSelected()}
+ // onCheckedChange={(value) => row.toggleSelected(!!value)}
+ // aria-label="행 선택"
+ // />
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
// 기본 정보 그룹
{
header: "기본 정보",
diff --git a/lib/avl/types.ts b/lib/avl/types.ts
index 6a7b5143..2ad1bc5c 100644
--- a/lib/avl/types.ts
+++ b/lib/avl/types.ts
@@ -15,12 +15,16 @@ export interface AvlListItem extends Omit<AvlList, 'createdAt' | 'updatedAt'> {
avlType?: string;
htDivision?: string;
rev?: number;
- pkg?: string;
- materialGroup?: string;
- vendor?: string;
- tier?: string;
- ownerSuggestion?: string;
- shiSuggestion?: string;
+
+ // 집계 필드들 (avlListSummaryView에서 제공)
+ PKG?: number;
+ materialGroup?: number;
+ vendor?: number;
+ Tier?: number;
+ ownerSuggestion?: number;
+ shiSuggestion?: number;
+
+ // 기타 표시용 필드들
registrant?: string;
lastModifier?: string;
}