diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-15 23:42:46 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-15 23:42:46 +0900 |
| commit | 7b0c7c8e56fb027c729c953b0b87dab72156f661 (patch) | |
| tree | c0d968b7157af1e63e3cb083b2872c308b4b3061 | |
| parent | 4ee8b24cfadf47452807fa2af801385ed60ab47c (diff) | |
(김준회) 임시 견적요청 및 AVL detail 관련 수정사항 처리
| -rw-r--r-- | app/[lng]/evcp/(evcp)/avl/[id]/page.tsx | 19 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/avl/page.tsx | 2 | ||||
| -rw-r--r-- | db/schema/avl/avl.ts | 114 | ||||
| -rw-r--r-- | lib/avl/avl-itb-rfq-service.ts | 289 | ||||
| -rw-r--r-- | lib/avl/history-service.ts | 163 | ||||
| -rw-r--r-- | lib/avl/service.ts | 71 | ||||
| -rw-r--r-- | lib/avl/table/avl-detail-table.tsx | 127 | ||||
| -rw-r--r-- | lib/avl/table/avl-table-columns.tsx | 213 | ||||
| -rw-r--r-- | lib/avl/table/avl-table.tsx | 219 | ||||
| -rw-r--r-- | lib/avl/table/columns-detail.tsx | 84 | ||||
| -rw-r--r-- | lib/avl/types.ts | 16 | ||||
| -rw-r--r-- | lib/bidding-projects/service.ts | 9 | ||||
| -rw-r--r-- | lib/projects/service.ts | 8 |
13 files changed, 904 insertions, 430 deletions
diff --git a/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx b/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx index 52ee7b7f..b065919f 100644 --- a/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx @@ -8,6 +8,8 @@ import { getAvlLists, getAvlDetail } from "@/lib/avl/service" import { avlDetailSearchParamsCache } from "@/lib/avl/validations" import { AvlDetailTable } from "@/lib/avl/table/avl-detail-table" import { getAvlListById } from "@/lib/avl/service" +import { getAllProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "@/lib/bidding-projects/service" +import { getAllProjectInfoByProjectCode as getProjectInfoFromProjects } from "@/lib/projects/service" interface AvlDetailPageProps { params: Promise<{ id: string }> @@ -33,6 +35,16 @@ export default async function AvlDetailPage(props: AvlDetailPageProps) { notFound() } + // 프로젝트 테이블 먼저 + let projectInfo = await getProjectInfoFromProjects(avlListInfo.projectCode || '') + // 없으면 견적프로젝트 테이블 조회 + if (!projectInfo) { + projectInfo = await getProjectInfoFromBiddingProjects(avlListInfo.projectCode || '') + } + + // 배열로 오니 첫번째것만 + projectInfo = projectInfo[0] + const promises = Promise.all([ getAvlDetail({ ...search, @@ -66,6 +78,7 @@ export default async function AvlDetailPage(props: AvlDetailPageProps) { promises={promises} avlListId={Number(id)} avlListInfo={avlListInfo} + projectInfo={projectInfo} /> </React.Suspense> </div> @@ -78,11 +91,13 @@ export default async function AvlDetailPage(props: AvlDetailPageProps) { function AvlDetailTableWrapper({ promises, avlListId, - avlListInfo + avlListInfo, + projectInfo }: { promises: Promise<any> avlListId: number avlListInfo: any + projectInfo: any }) { const [{ data, pageCount }] = React.use(promises) @@ -98,7 +113,7 @@ function AvlDetailTableWrapper({ pageCount={pageCount} avlListId={avlListId} avlType={avlType} - projectCode={avlListInfo.projectCode} + projectInfo={projectInfo} shipOwnerName={shipOwnerName} businessType={avlListInfo.constructionSector || '조선'} /> diff --git a/app/[lng]/evcp/(evcp)/avl/page.tsx b/app/[lng]/evcp/(evcp)/avl/page.tsx index a5a5a170..1c345cda 100644 --- a/app/[lng]/evcp/(evcp)/avl/page.tsx +++ b/app/[lng]/evcp/(evcp)/avl/page.tsx @@ -19,7 +19,7 @@ async function getInitialAvlData(searchParams: SearchParams) { // 기본 파라미터로 전체 데이터 조회 const result = await getAvlLists({ page: 1, - perPage: 100, // 충분한 수량으로 조회 + perPage: 10, // 충분한 수량으로 조회 sort: [{ id: "createdAt", desc: true }], flags: [], filters: validFilters, diff --git a/db/schema/avl/avl.ts b/db/schema/avl/avl.ts index d2aac795..0b983168 100644 --- a/db/schema/avl/avl.ts +++ b/db/schema/avl/avl.ts @@ -1,5 +1,6 @@ -import { pgTable, boolean, integer, timestamp, varchar, decimal, json } from "drizzle-orm/pg-core"; +import { pgTable, boolean, integer, timestamp, varchar, decimal, json, pgView } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { sql } from "drizzle-orm"; // AVL 리스트 테이블 (프로젝트 AVL 및 선종별 표준 AVL 리스트) export const avlList = pgTable("avl_list", { @@ -125,8 +126,119 @@ export const selectAvlListSchema = createSelectSchema(avlList); export const insertAvlVendorInfoSchema = createInsertSchema(avlVendorInfo); export const selectAvlVendorInfoSchema = createSelectSchema(avlVendorInfo); +// AVL 리스트 집계 뷰 - 가장 마지막 revision별로 집계 +export const avlListSummaryView = pgView("avl_list_summary_view", { + id: integer("id"), + isTemplate: boolean("is_template"), + constructionSector: varchar("construction_sector", { length: 10 }), + projectCode: varchar("project_code", { length: 50 }), + shipType: varchar("ship_type", { length: 50 }), + avlKind: varchar("avl_kind", { length: 50 }), + htDivision: varchar("ht_division", { length: 10 }), + rev: integer("rev"), + createdAt: timestamp("created_at"), + createdBy: varchar("created_by", { length: 50 }), + updatedAt: timestamp("updated_at"), + updatedBy: varchar("updated_by", { length: 50 }), + pkgCount: integer("pkg_count"), + materialGroupCount: integer("material_group_count"), + vendorCount: integer("vendor_count"), + tierCount: integer("tier_count"), + ownerSuggestionCount: integer("owner_suggestion_count"), + shiSuggestionCount: integer("shi_suggestion_count"), +}).as(sql` + WITH latest_revisions AS ( + -- 표준 AVL의 경우: 공사부문, 선종, AVL종류, H/T 구분별 최신 revision 찾기 + SELECT + construction_sector, + ship_type, + avl_kind, + ht_division, + MAX(rev) as max_rev, + 'standard' as avl_type, + NULL::varchar as project_code + FROM avl_list + WHERE is_template = true + GROUP BY construction_sector, ship_type, avl_kind, ht_division + + UNION ALL + + -- 프로젝트 AVL의 경우: 프로젝트 코드별 최신 revision 찾기 + SELECT + construction_sector, + ship_type, + avl_kind, + ht_division, + MAX(rev) as max_rev, + 'project' as avl_type, + project_code + FROM avl_list + WHERE is_template = false + GROUP BY project_code, construction_sector, ship_type, avl_kind, ht_division + ), + latest_avl_lists AS ( + -- 최신 revision의 실제 AVL 리스트 가져오기 + SELECT + al.*, + CASE + WHEN al.is_template = true THEN 'standard' + ELSE 'project' + END as avl_type + FROM avl_list al + INNER JOIN latest_revisions lr ON ( + (al.is_template = true AND lr.avl_type = 'standard' AND + al.construction_sector = lr.construction_sector AND + al.ship_type = lr.ship_type AND + al.avl_kind = lr.avl_kind AND + al.ht_division = lr.ht_division AND + al.rev = lr.max_rev) + OR + (al.is_template = false AND lr.avl_type = 'project' AND + al.project_code = lr.project_code AND + al.rev = lr.max_rev) + ) + ) + SELECT + al.id, + al.is_template, + al.construction_sector, + al.project_code, + al.ship_type, + al.avl_kind, + al.ht_division, + al.rev, + al.created_at, + al.created_by, + al.updated_at, + al.updated_by, + + -- 집계 필드들 + COALESCE(COUNT(DISTINCT CASE WHEN avi.package_code IS NOT NULL THEN avi.package_code END), 0)::integer as pkg_count, + COALESCE(COUNT(DISTINCT CASE WHEN avi.material_group_code IS NOT NULL THEN avi.material_group_code END), 0)::integer as material_group_count, + COALESCE(COUNT(DISTINCT CASE WHEN avi.vendor_name IS NOT NULL THEN avi.vendor_name END), 0)::integer as vendor_count, + COALESCE(COUNT(DISTINCT CASE WHEN avi.tier IS NOT NULL THEN avi.tier END), 0)::integer as tier_count, + COALESCE(SUM(CASE WHEN avi.owner_suggestion = true THEN 1 ELSE 0 END), 0)::integer as owner_suggestion_count, + COALESCE(SUM(CASE WHEN avi.shi_suggestion = true THEN 1 ELSE 0 END), 0)::integer as shi_suggestion_count + + FROM latest_avl_lists al + LEFT JOIN avl_vendor_info avi ON al.id = avi.avl_list_id + GROUP BY + al.id, al.is_template, al.construction_sector, al.project_code, + al.ship_type, al.avl_kind, al.ht_division, al.rev, + al.created_at, al.created_by, al.updated_at, al.updated_by + ORDER BY + al.is_template ASC, + al.construction_sector, + al.project_code, + al.ship_type, + al.avl_kind, + al.ht_division, + al.rev DESC +`); + // 타입 추론 export type AvlList = typeof avlList.$inferSelect; export type NewAvlList = typeof avlList.$inferInsert; export type AvlVendorInfo = typeof avlVendorInfo.$inferSelect; export type NewAvlVendorInfo = typeof avlVendorInfo.$inferInsert; +export type AvlListSummary = typeof avlListSummaryView.$inferSelect; 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; } diff --git a/lib/bidding-projects/service.ts b/lib/bidding-projects/service.ts index c56b49cf..2f3d17fb 100644 --- a/lib/bidding-projects/service.ts +++ b/lib/bidding-projects/service.ts @@ -154,4 +154,13 @@ export async function getProjectInfoByProjectCode(projectCode: string) { }; return projectInfoForAvl; +} + + +export async function getAllProjectInfoByProjectCode(projectCode: string) { + return await db + .select() + .from(biddingProjects) + .where(eq(biddingProjects.pspid, projectCode)) + .limit(1); }
\ No newline at end of file diff --git a/lib/projects/service.ts b/lib/projects/service.ts index acf8b2f5..3f562e20 100644 --- a/lib/projects/service.ts +++ b/lib/projects/service.ts @@ -104,4 +104,12 @@ export async function getProjectInfoByProjectCode(projectCode: string) { projectHtDivision: projectInfo[0].type || undefined, }; return projectInfoForAvl; +} + +export async function getAllProjectInfoByProjectCode(projectCode: string) { + return await db + .select() + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); }
\ No newline at end of file |
