From e7818a457371849e29519497ebf046f385f05ab6 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 15 Sep 2025 01:23:00 +0000 Subject: (김준회) AVL 기능 구현 1차 및 벤더풀 E/B 구분 개선 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/avl/table/avl-table-columns.tsx | 351 ++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 lib/avl/table/avl-table-columns.tsx (limited to 'lib/avl/table/avl-table-columns.tsx') diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx new file mode 100644 index 00000000..77361f36 --- /dev/null +++ b/lib/avl/table/avl-table-columns.tsx @@ -0,0 +1,351 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Eye, Edit, Trash2 } from "lucide-react" +import { type ColumnDef, TableMeta } from "@tanstack/react-table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { EditableCell } from "@/components/data-table/editable-cell" +import { AvlListItem } from "../types" + +interface GetColumnsProps { + selectedRows?: number[] + onRowSelect?: (id: number, selected: boolean) => void +} + +// 수정 여부 확인 헬퍼 함수 +const getIsModified = (table: any, rowId: string, fieldName: string) => { + const pendingChanges = table.options.meta?.getPendingChanges?.() || {} + return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)] +} + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + } +} + +// 테이블 컬럼 정의 함수 +export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ColumnDef[] { + const columns: ColumnDef[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + id: "select", + header: () =>
선택
, + cell: ({ row }) => ( +
+ { + onRowSelect?.(row.original.id, !!checked) + }} + aria-label="행 선택" + className="translate-y-[2px]" + /> +
+ ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "no", + header: ({ column }) => ( + + ), + cell: ({ getValue }) =>
{getValue() as number}
, + size: 60, + }, + { + accessorKey: "isTemplate", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as boolean + const isModified = getIsModified(table, row.id, "isTemplate") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "isTemplate", newValue === "true") + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "isTemplate") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "constructionSector", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "constructionSector") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "constructionSector", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "constructionSector") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "projectCode") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "projectCode", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "projectCode") + }} + /> + ) + }, + size: 140, + }, + { + accessorKey: "shipType", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "shipType") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "shipType", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "shipType") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "avlKind", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "avlKind") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "avlKind", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "avlKind") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "htDivision", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "htDivision") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "htDivision", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "htDivision") + }} + /> + ) + }, + size: 80, + }, + { + accessorKey: "rev", + header: ({ column }) => ( + + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as number + const isModified = getIsModified(table, row.id, "rev") + return ( + { + table.options.meta?.onCellUpdate?.(row.id, "rev", parseInt(newValue)) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "rev") + }} + /> + ) + }, + size: 80, + }, + ], + }, + + // 등록 정보 그룹 + { + header: "등록 정보", + columns: [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const date = getValue() as string + return
{date}
+ }, + size: 100, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const date = getValue() as string + return
{date}
+ }, + size: 100, + }, + ], + }, + + // 액션 그룹 + { + id: "actions", + header: "액션", + columns: [ + { + id: "actions", + header: () =>
액션
, + cell: ({ row, table }) => { + const isEmptyRow = table.options.meta?.isEmptyRow?.(row.id) || false + + if (isEmptyRow) { + return ( +
+ + +
+ ) + } + + return ( +
+ + + +
+ ) + }, + enableSorting: false, + enableHiding: false, + size: 120, + }, + ], + }, + ] + + return columns +} -- cgit v1.2.3 From 2b490956c9752c1b756780a3461bc1c37b6fe0a7 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 15 Sep 2025 18:58:07 +0900 Subject: (김준회) AVL 관리 및 상세 - 기능 구현 1차 + docker compose 내 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _docker/docker-compose.yml | 2 +- db/schema/avl/avl.ts | 17 +- lib/avl/components/avl-history-modal.tsx | 297 +++++ lib/avl/components/project-field-components.tsx | 113 ++ lib/avl/components/project-field-utils.ts | 45 + lib/avl/service.ts | 1244 ++++++++++++++++++-- lib/avl/snapshot-utils.ts | 190 +++ lib/avl/table/avl-detail-table.tsx | 455 +------ lib/avl/table/avl-registration-area.tsx | 316 ++++- lib/avl/table/avl-table-columns.tsx | 30 +- lib/avl/table/avl-table.tsx | 100 +- lib/avl/table/avl-vendor-add-and-modify-dialog.tsx | 945 +++++++++++++++ lib/avl/table/columns-detail.tsx | 516 +------- lib/avl/table/project-avl-table-columns.tsx | 167 +++ lib/avl/table/project-avl-table.tsx | 720 +++++------ lib/avl/table/standard-avl-add-dialog.tsx | 960 +++++++++++++++ lib/avl/table/standard-avl-table-columns.tsx | 91 ++ lib/avl/table/standard-avl-table.tsx | 603 +++++++--- lib/avl/table/vendor-pool-table-columns.tsx | 96 ++ lib/avl/table/vendor-pool-table.tsx | 241 ++-- lib/avl/types.ts | 16 +- lib/avl/validations.ts | 4 +- 22 files changed, 5481 insertions(+), 1687 deletions(-) create mode 100644 lib/avl/components/avl-history-modal.tsx create mode 100644 lib/avl/components/project-field-components.tsx create mode 100644 lib/avl/components/project-field-utils.ts create mode 100644 lib/avl/snapshot-utils.ts create mode 100644 lib/avl/table/avl-vendor-add-and-modify-dialog.tsx create mode 100644 lib/avl/table/project-avl-table-columns.tsx create mode 100644 lib/avl/table/standard-avl-add-dialog.tsx create mode 100644 lib/avl/table/standard-avl-table-columns.tsx create mode 100644 lib/avl/table/vendor-pool-table-columns.tsx (limited to 'lib/avl/table/avl-table-columns.tsx') diff --git a/_docker/docker-compose.yml b/_docker/docker-compose.yml index 62b6bae5..bf5027ff 100644 --- a/_docker/docker-compose.yml +++ b/_docker/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "5432:5432" # host:container, container는 항상 5432, host 측은 원하는 포트로 설정 volumes: - - postgres_data:/var/lib/postgresql/data + - evcp_postgres_data:/var/lib/postgresql/data restart: always volumes: diff --git a/db/schema/avl/avl.ts b/db/schema/avl/avl.ts index addbba94..d2aac795 100644 --- a/db/schema/avl/avl.ts +++ b/db/schema/avl/avl.ts @@ -1,4 +1,4 @@ -import { pgTable, text, boolean, integer, timestamp, varchar, decimal } from "drizzle-orm/pg-core"; +import { pgTable, boolean, integer, timestamp, varchar, decimal, json } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; // AVL 리스트 테이블 (프로젝트 AVL 및 선종별 표준 AVL 리스트) @@ -17,6 +17,9 @@ export const avlList = pgTable("avl_list", { htDivision: varchar("ht_division", { length: 10 }), // H=Hull, T=Top, 공통 rev: integer("rev").default(1), // 리비전 정보 + // 히스토리 관리 + vendorInfoSnapshot: json("vendor_info_snapshot"), // AVL 생성 시점의 vendorInfo 현황 스냅샷 + // 타임스탬프 createdAt: timestamp("created_at").defaultNow(), // 등재일 createdBy: varchar("created_by", { length: 50 }), // 등재자 @@ -29,10 +32,18 @@ export const avlVendorInfo = pgTable("avl_vendor_info", { // 기본 식별자 id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + isTemplate: boolean("is_template").default(false), // false: 프로젝트 AVL의 레코드임, true: 표준 AVL의 레코드임 + + // 표준 AVL용 필드들 (isTemplate=true일 경우) + constructionSector: varchar("construction_sector", { length: 10 }), // 공사부문 (표준 AVL일 경우) + shipType: varchar("ship_type", { length: 50 }), // 선종 (표준 AVL일 경우) + avlKind: varchar("avl_kind", { length: 50 }), // AVL 종류 (표준 AVL일 경우) + htDivision: varchar("ht_division", { length: 10 }), // H/T 구분 (표준 AVL일 경우) + projectCode: varchar("project_code", { length: 50 }), // 프로젝트코드 (프로젝트 AVL일 경우) - // AVL 리스트와의 관계 (외래키) - avlListId: integer("avl_list_id").references(() => avlList.id), + // AVL 리스트와의 관계 (자식이 먼저 생기므로.. 제약조건 없는 외래키) + avlListId: integer("avl_list_id"), // 제안방향 ownerSuggestion: boolean("owner_suggestion").default(false), // 선주 제안사인 경우 diff --git a/lib/avl/components/avl-history-modal.tsx b/lib/avl/components/avl-history-modal.tsx new file mode 100644 index 00000000..4f0c354b --- /dev/null +++ b/lib/avl/components/avl-history-modal.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Calendar, Users, FileText, ChevronDown, ChevronRight } from "lucide-react" +import type { AvlListItem } from "@/lib/avl/types" + +interface AvlHistoryModalProps { + isOpen: boolean + onClose: () => void + avlItem: AvlListItem | null + historyData?: AvlHistoryRecord[] + onLoadHistory?: (avlItem: AvlListItem) => Promise +} + +export interface VendorSnapshot { + id: number + vendorName?: string + avlVendorName?: string + vendorCode?: string + disciplineName?: string + materialNameCustomerSide?: string + materialGroupCode?: string + materialGroupName?: string + tier?: string + hasAvl?: boolean + faTarget?: boolean + headquarterLocation?: string + ownerSuggestion?: boolean + shiSuggestion?: boolean + [key: string]: unknown // 다른 모든 속성들 +} + +export interface AvlHistoryRecord { + id: number + rev: number + createdAt: string + createdBy: string + vendorInfoSnapshot: VendorSnapshot[] // JSON 데이터 + changeDescription?: string +} + +// 스냅샷 테이블 컴포넌트 +interface SnapshotTableProps { + snapshot: VendorSnapshot[] + isOpen: boolean + onToggle: () => void +} + +function SnapshotTable({ snapshot, isOpen, onToggle }: SnapshotTableProps) { + if (!snapshot || snapshot.length === 0) { + return ( +
+ 스냅샷 데이터가 없습니다. +
+ ) + } + + return ( + + + + + +
+
+ + + + No. + 설계공종 + 고객사 AVL 자재명 + 자재그룹 코드 + 자재그룹 명 + AVL 등재업체명 + 협력업체 코드 + 협력업체 명 + 선주제안 + SHI 제안 + 본사 위치 + 등급 + AVL + FA대상 + + + + {snapshot.map((item, index) => ( + + {index + 1} + {item.disciplineName || '-'} + {item.materialNameCustomerSide || '-'} + {item.materialGroupCode || '-'} + {item.materialGroupName || '-'} + {item.avlVendorName || '-'} + {item.vendorCode || '-'} + {item.vendorName || '-'} + + + {item.ownerSuggestion ? "예" : "아니오"} + + + + + {item.shiSuggestion ? "예" : "아니오"} + + + {item.headquarterLocation || '-'} + + {item.tier ? ( + + {item.tier} + + ) : '-'} + + + + {item.hasAvl ? "Y" : "N"} + + + + + {item.faTarget ? "Y" : "N"} + + + + ))} + +
+
+
+
+
+ ) +} + +export function AvlHistoryModal({ + isOpen, + onClose, + avlItem, + historyData, + onLoadHistory +}: AvlHistoryModalProps) { + const [loading, setLoading] = React.useState(false) + const [history, setHistory] = React.useState([]) + const [openSnapshots, setOpenSnapshots] = React.useState>({}) + + // 히스토리 데이터 로드 + React.useEffect(() => { + if (isOpen && avlItem && onLoadHistory) { + setLoading(true) + onLoadHistory(avlItem) + .then(setHistory) + .catch(console.error) + .finally(() => setLoading(false)) + } else if (historyData) { + setHistory(historyData) + } + }, [isOpen, avlItem, onLoadHistory, historyData]) + + // 스냅샷 테이블 토글 함수 + const toggleSnapshot = (recordId: number) => { + setOpenSnapshots(prev => ({ + ...prev, + [recordId]: !prev[recordId] + })) + } + + if (!avlItem) return null + + return ( + + + + + + AVL 리비전 히스토리 + +
+ {avlItem.isTemplate ? "표준 AVL" : "프로젝트 AVL"} - {avlItem.avlKind} + {avlItem.projectCode && ` (${avlItem.projectCode})`} +
+
+ +
+
+ {loading ? ( +
+
히스토리를 불러오는 중...
+
+ ) : history.length === 0 ? ( +
+
히스토리 데이터가 없습니다.
+
+ ) : ( +
+ {history.map((record, index) => ( +
+ {/* 리비전 헤더 */} +
+
+ + Rev {record.rev} + + {index === 0 && ( + + 현재 + + )} +
+
+
+ + {new Date(record.createdAt).toLocaleDateString('ko-KR')} +
+
+
+ + {/* 변경 설명 */} + {record.changeDescription && ( +
+ {record.changeDescription} +
+ )} + + {/* Vendor Info 요약 */} +
+
+
+ {record.vendorInfoSnapshot?.length || 0} +
+
총 협력업체
+
+
+
+ {record.vendorInfoSnapshot?.filter(v => v.hasAvl).length || 0} +
+
AVL 등재
+
+
+
+ {record.vendorInfoSnapshot?.filter(v => v.faTarget).length || 0} +
+
FA 대상
+
+
+ + {/* 스냅샷 테이블 */} +
+ toggleSnapshot(record.id)} + /> +
+
+ ))} +
+ )} +
+
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/avl/components/project-field-components.tsx b/lib/avl/components/project-field-components.tsx new file mode 100644 index 00000000..95505d08 --- /dev/null +++ b/lib/avl/components/project-field-components.tsx @@ -0,0 +1,113 @@ +"use client" + +import * as React from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { + ProjectSearchStatus, + getLabelStatusClassName, + getDisplayElementStatusClassName, + getInputStatusClassName +} from "./project-field-utils" + +// 타입 재내보내기 +export type { ProjectSearchStatus } from "./project-field-utils" + +// 재사용 가능한 필드 컴포넌트들 +export interface ProjectInputFieldProps { + label: string + value: string + onChange: (value: string) => void + placeholder: string + status: ProjectSearchStatus + statusText?: string + minWidth?: string +} + +export const ProjectInputField: React.FC = ({ + label, + value, + onChange, + placeholder, + status, + statusText, + minWidth = "250px" +}) => ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={`h-8 text-sm ${getInputStatusClassName(status)}`} + /> +
+) + +export interface ProjectDisplayFieldProps { + label: string + value: string + status: ProjectSearchStatus + minWidth?: string + formatter?: (value: string) => string +} + +export const ProjectDisplayField: React.FC = ({ + label, + value, + status, + minWidth = "120px", + formatter +}) => { + const displayValue = status === 'searching' ? '조회 중...' : (formatter ? formatter(value) : (value || '-')) + + return ( +
+ +
+ {displayValue} +
+
+ ) +} + +export interface ProjectFileFieldProps { + label: string + originalFile: string + onFileUpload: (event: React.ChangeEvent) => void + minWidth?: string +} + +export const ProjectFileField: React.FC = ({ + label, + originalFile, + onFileUpload, + minWidth = "200px" +}) => ( +
+ +
+ {originalFile ? ( + {originalFile} + ) : ( +
+ + +
+ )} +
+
+) diff --git a/lib/avl/components/project-field-utils.ts b/lib/avl/components/project-field-utils.ts new file mode 100644 index 00000000..d3d84295 --- /dev/null +++ b/lib/avl/components/project-field-utils.ts @@ -0,0 +1,45 @@ +// 프로젝트 검색 상태 타입 +export type ProjectSearchStatus = 'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error' + +// 프로젝트 상태에 따른 스타일링 유틸리티 함수들 +export const getLabelStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'text-red-600' + case 'success-projects': + case 'success-bidding': + return 'text-green-600' + case 'searching': + return 'text-blue-600' + default: + return 'text-muted-foreground' + } +} + +export const getDisplayElementStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300' + case 'success-projects': + case 'success-bidding': + return 'border-green-300' + case 'searching': + return 'border-blue-300' + default: + return 'border-input' + } +} + +export const getInputStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300 focus:border-red-500 focus:ring-red-500/20' + case 'success-projects': + case 'success-bidding': + return 'border-green-300 focus:border-green-500 focus:ring-green-500/20' + case 'searching': + return 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' + default: + return '' + } +} diff --git a/lib/avl/service.ts b/lib/avl/service.ts index 6a873ac1..535a0169 100644 --- a/lib/avl/service.ts +++ b/lib/avl/service.ts @@ -2,18 +2,21 @@ import { GetAvlListSchema, GetAvlDetailSchema, GetProjectAvlSchema, GetStandardAvlSchema } from "./validations"; import { AvlListItem, AvlDetailItem, CreateAvlListInput, UpdateAvlListInput, ActionResult, AvlVendorInfoInput } from "./types"; -import type { NewAvlVendorInfo, AvlVendorInfo } from "@/db/schema/avl/avl"; +import 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 { eq, and, or, ilike, count, desc, asc, sql } from "drizzle-orm"; +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"; -import { revalidateTag, unstable_cache } from "next/cache"; +import { revalidateTag } from "next/cache"; +import { createVendorInfoSnapshot } from "./snapshot-utils"; /** * AVL 리스트 조회 * avl_list 테이블에서 실제 데이터를 조회합니다. */ -const _getAvlLists = async (input: GetAvlListSchema) => { +export const getAvlLists = async (input: GetAvlListSchema) => { try { const offset = (input.page - 1) * input.perPage; @@ -125,20 +128,11 @@ const _getAvlLists = async (input: GetAvlListSchema) => { } }; -// 캐시된 버전 export - 동일한 입력에 대해 캐시 사용 -export const getAvlLists = unstable_cache( - _getAvlLists, - ['avl-list'], - { - tags: ['avl-list'], - revalidate: 300, // 5분 캐시 - } -); /** * AVL 상세 정보 조회 (특정 AVL ID의 모든 vendor info) */ -const _getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) => { +export const getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) => { try { const offset = (input.page - 1) * input.perPage; @@ -326,15 +320,6 @@ const _getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) } }; -// 캐시된 버전 export -export const getAvlDetail = unstable_cache( - _getAvlDetail, - ['avl-detail'], - { - tags: ['avl-detail'], - revalidate: 300, // 5분 캐시 - } -); /** * AVL 리스트 상세 정보 조회 (단일) @@ -522,11 +507,17 @@ export async function createAvlList(data: CreateAvlListInput): Promise { } } +/** + * 프로젝트 AVL 최종 확정 + * 1. 주어진 프로젝트 정보로 avlList에 레코드를 생성한다. + * 2. 현재 avlVendorInfo 레코드들의 avlListId를 새로 생성된 AVL 리스트 ID로 업데이트한다. + */ +export async function finalizeProjectAvl( + projectCode: string, + projectInfo: { + projectName: string; + constructionSector: string; + shipType: string; + htDivision: string; + }, + avlVendorInfoIds: number[], + currentUser?: string +): Promise<{ success: boolean; avlListId?: number; message: string }> { + try { + debugLog('프로젝트 AVL 최종 확정 시작', { + projectCode, + projectInfo, + avlVendorInfoIds: avlVendorInfoIds.length, + currentUser + }); + + // 1. 기존 AVL 리스트의 최고 revision 확인 + const existingAvlLists = await db + .select({ rev: avlList.rev }) + .from(avlList) + .where(and( + eq(avlList.projectCode, projectCode), + eq(avlList.isTemplate, false) + )) + .orderBy(desc(avlList.rev)) + .limit(1); + + const nextRevision = existingAvlLists.length > 0 ? (existingAvlLists[0].rev || 0) + 1 : 1; + + debugLog('AVL 리스트 revision 계산', { + projectCode, + existingRevision: existingAvlLists.length > 0 ? existingAvlLists[0].rev : 0, + nextRevision + }); + + // 2. AVL 리스트 생성을 위한 데이터 준비 + const createAvlListData: CreateAvlListInput = { + isTemplate: false, // 프로젝트 AVL이므로 false + constructionSector: projectInfo.constructionSector, + projectCode: projectCode, + shipType: projectInfo.shipType, + avlKind: "프로젝트 AVL", // 기본값으로 설정 + htDivision: projectInfo.htDivision, + rev: nextRevision, // 계산된 다음 리비전 + createdBy: currentUser || 'system', + updatedBy: currentUser || 'system', + }; + + debugLog('AVL 리스트 생성 데이터', { createAvlListData }); + + // 2. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) + debugLog('AVL Vendor Info 스냅샷 생성 시작', { + vendorInfoIdsCount: avlVendorInfoIds.length, + vendorInfoIds: avlVendorInfoIds + }); + const vendorInfoSnapshot = await createVendorInfoSnapshot(avlVendorInfoIds); + debugSuccess('AVL Vendor Info 스냅샷 생성 완료', { + snapshotCount: vendorInfoSnapshot.length, + snapshotSize: JSON.stringify(vendorInfoSnapshot).length, + sampleSnapshot: vendorInfoSnapshot.slice(0, 1) // 첫 번째 항목만 로깅 + }); + + // 스냅샷을 AVL 리스트 생성 데이터에 추가 + createAvlListData.vendorInfoSnapshot = vendorInfoSnapshot; + debugLog('스냅샷이 createAvlListData에 추가됨', { + hasSnapshot: !!createAvlListData.vendorInfoSnapshot, + snapshotLength: createAvlListData.vendorInfoSnapshot?.length + }); + + // 3. AVL 리스트 생성 + const newAvlList = await createAvlList(createAvlListData); + + if (!newAvlList) { + throw new Error("AVL 리스트 생성에 실패했습니다."); + } + + debugSuccess('AVL 리스트 생성 완료', { avlListId: newAvlList.id }); + + // 3. avlVendorInfo 레코드들의 avlListId 업데이트 + if (avlVendorInfoIds.length > 0) { + debugLog('AVL Vendor Info 업데이트 시작', { + count: avlVendorInfoIds.length, + newAvlListId: newAvlList.id + }); + + const updateResults = await Promise.all( + avlVendorInfoIds.map(async (vendorInfoId) => { + try { + const result = await db + .update(avlVendorInfo) + .set({ + avlListId: newAvlList.id, + projectCode: projectCode, + updatedAt: new Date() + }) + .where(eq(avlVendorInfo.id, vendorInfoId)) + .returning({ id: avlVendorInfo.id }); + + return { id: vendorInfoId, success: true, result }; + } catch (error) { + debugError('AVL Vendor Info 업데이트 실패', { vendorInfoId, error }); + return { id: vendorInfoId, success: false, error }; + } + }) + ); + + // 업데이트 결과 검증 + const successCount = updateResults.filter(r => r.success).length; + const failCount = updateResults.filter(r => !r.success).length; + + debugLog('AVL Vendor Info 업데이트 결과', { + total: avlVendorInfoIds.length, + success: successCount, + failed: failCount + }); + + if (failCount > 0) { + debugWarn('일부 AVL Vendor Info 업데이트 실패', { + failedIds: updateResults.filter(r => !r.success).map(r => r.id) + }); + } + } + + // 4. 캐시 무효화 + revalidateTag('avl-list'); + revalidateTag('avl-vendor-info'); + + debugSuccess('프로젝트 AVL 최종 확정 완료', { + avlListId: newAvlList.id, + projectCode, + vendorInfoCount: avlVendorInfoIds.length + }); + + return { + success: true, + avlListId: newAvlList.id, + message: `프로젝트 AVL이 성공적으로 확정되었습니다. (AVL ID: ${newAvlList.id}, 벤더 정보: ${avlVendorInfoIds.length}개)` + }; + + } catch (err) { + debugError('프로젝트 AVL 최종 확정 실패', { + projectCode, + error: err + }); + + console.error("Error in finalizeProjectAvl:", err); + + return { + success: false, + message: err instanceof Error ? err.message : "프로젝트 AVL 최종 확정 중 오류가 발생했습니다." + }; + } +} + /** * 프로젝트 AVL Vendor Info 조회 (프로젝트별, isTemplate=false) * avl_list와 avlVendorInfo를 JOIN하여 프로젝트별 AVL 데이터를 조회합니다. */ -const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { +export const getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { try { const offset = (input.page - 1) * input.perPage; @@ -920,11 +1082,11 @@ const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { // 실제 쿼리는 아래에서 구성됨 // 검색 조건 구성 - const whereConditions: any[] = [eq(avlList.isTemplate, false)]; // 기본 조건 + const whereConditions: any[] = []; // 기본 조건 제거 - // 필수 필터: 프로젝트 코드 + // 필수 필터: 프로젝트 코드 (avlVendorInfo에서 직접 필터링) if (input.projectCode) { - whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`)); + whereConditions.push(ilike(avlVendorInfo.projectCode, `%${input.projectCode}%`)); } // 검색어 기반 필터링 @@ -1002,7 +1164,7 @@ const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { const totalCount = await db .select({ count: count() }) .from(avlVendorInfo) - .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) .where(and(...whereConditions)); // 데이터 조회 - JOIN 결과에서 필요한 필드들을 명시적으로 선택 @@ -1010,6 +1172,7 @@ const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { .select({ // avlVendorInfo의 모든 필드 id: avlVendorInfo.id, + projectCode: avlVendorInfo.projectCode, avlListId: avlVendorInfo.avlListId, ownerSuggestion: avlVendorInfo.ownerSuggestion, shiSuggestion: avlVendorInfo.shiSuggestion, @@ -1054,7 +1217,7 @@ const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { updatedAt: avlVendorInfo.updatedAt, }) .from(avlVendorInfo) - .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) .where(and(...whereConditions)) .orderBy(...orderByConditions) .limit(input.perPage) @@ -1103,21 +1266,12 @@ const _getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { } }; -// 캐시된 버전 export -export const getProjectAvlVendorInfo = unstable_cache( - _getProjectAvlVendorInfo, - ['project-avl-vendor-info'], - { - tags: ['project-avl-vendor-info'], - revalidate: 300, // 5분 캐시 - } -); /** * 표준 AVL Vendor Info 조회 (선종별 표준 AVL, isTemplate=true) * avl_list와 avlVendorInfo를 JOIN하여 표준 AVL 데이터를 조회합니다. */ -const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { +export const getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { try { const offset = (input.page - 1) * input.perPage; @@ -1127,20 +1281,20 @@ const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { // 실제 쿼리는 아래에서 구성됨 // 검색 조건 구성 - const whereConditions: any[] = [eq(avlList.isTemplate, true)]; // 기본 조건 + const whereConditions: any[] = [eq(avlVendorInfo.isTemplate, true)]; // 기본 조건: 표준 AVL - // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) + // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) - avlVendorInfo에서 직접 필터링 if (input.constructionSector) { - whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`)); + whereConditions.push(ilike(avlVendorInfo.constructionSector, `%${input.constructionSector}%`)); } if (input.shipType) { - whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`)); + whereConditions.push(ilike(avlVendorInfo.shipType, `%${input.shipType}%`)); } if (input.avlKind) { - whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`)); + whereConditions.push(ilike(avlVendorInfo.avlKind, `%${input.avlKind}%`)); } if (input.htDivision) { - whereConditions.push(eq(avlList.htDivision, input.htDivision)); + whereConditions.push(eq(avlVendorInfo.htDivision, input.htDivision)); } // 검색어 기반 필터링 @@ -1218,14 +1372,59 @@ const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { const totalCount = await db .select({ count: count() }) .from(avlVendorInfo) - .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) .where(and(...whereConditions)); - // 데이터 조회 + // 데이터 조회 - avlVendorInfo에서 직접 조회 const data = await db - .select() + .select({ + // avlVendorInfo의 모든 필드 + id: avlVendorInfo.id, + isTemplate: avlVendorInfo.isTemplate, + projectCode: avlVendorInfo.projectCode, + avlListId: avlVendorInfo.avlListId, + ownerSuggestion: avlVendorInfo.ownerSuggestion, + shiSuggestion: avlVendorInfo.shiSuggestion, + equipBulkDivision: avlVendorInfo.equipBulkDivision, + disciplineCode: avlVendorInfo.disciplineCode, + disciplineName: avlVendorInfo.disciplineName, + materialNameCustomerSide: avlVendorInfo.materialNameCustomerSide, + packageCode: avlVendorInfo.packageCode, + packageName: avlVendorInfo.packageName, + materialGroupCode: avlVendorInfo.materialGroupCode, + materialGroupName: avlVendorInfo.materialGroupName, + vendorId: avlVendorInfo.vendorId, + vendorName: avlVendorInfo.vendorName, + vendorCode: avlVendorInfo.vendorCode, + avlVendorName: avlVendorInfo.avlVendorName, + tier: avlVendorInfo.tier, + faTarget: avlVendorInfo.faTarget, + faStatus: avlVendorInfo.faStatus, + isAgent: avlVendorInfo.isAgent, + contractSignerId: avlVendorInfo.contractSignerId, + contractSignerName: avlVendorInfo.contractSignerName, + contractSignerCode: avlVendorInfo.contractSignerCode, + headquarterLocation: avlVendorInfo.headquarterLocation, + manufacturingLocation: avlVendorInfo.manufacturingLocation, + hasAvl: avlVendorInfo.hasAvl, + isBlacklist: avlVendorInfo.isBlacklist, + isBcc: avlVendorInfo.isBcc, + techQuoteNumber: avlVendorInfo.techQuoteNumber, + quoteCode: avlVendorInfo.quoteCode, + quoteVendorId: avlVendorInfo.quoteVendorId, + quoteVendorName: avlVendorInfo.quoteVendorName, + quoteVendorCode: avlVendorInfo.quoteVendorCode, + quoteCountry: avlVendorInfo.quoteCountry, + quoteTotalAmount: avlVendorInfo.quoteTotalAmount, + quoteReceivedDate: avlVendorInfo.quoteReceivedDate, + recentQuoteDate: avlVendorInfo.recentQuoteDate, + recentQuoteNumber: avlVendorInfo.recentQuoteNumber, + recentOrderDate: avlVendorInfo.recentOrderDate, + recentOrderNumber: avlVendorInfo.recentOrderNumber, + remark: avlVendorInfo.remark, + createdAt: avlVendorInfo.createdAt, + updatedAt: avlVendorInfo.updatedAt, + }) .from(avlVendorInfo) - .innerJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) .where(and(...whereConditions)) .orderBy(...orderByConditions) .limit(input.perPage) @@ -1233,30 +1432,30 @@ const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { // 데이터 변환 const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({ - ...(item.avl_vendor_info || item), + ...item, no: offset + index + 1, selected: false, - createdAt: ((item.avl_vendor_info || item).createdAt as Date)?.toISOString().split('T')[0] || '', - updatedAt: ((item.avl_vendor_info || item).updatedAt as Date)?.toISOString().split('T')[0] || '', + createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '', // UI 표시용 필드 변환 - equipBulkDivision: (item.avl_vendor_info || item).equipBulkDivision === "E" ? "EQUIP" : "BULK", - faTarget: (item.avl_vendor_info || item).faTarget ?? false, - faStatus: (item.avl_vendor_info || item).faStatus || '', - agentStatus: (item.avl_vendor_info || item).isAgent ? "예" : "아니오", - shiAvl: (item.avl_vendor_info || item).hasAvl ?? false, - shiBlacklist: (item.avl_vendor_info || item).isBlacklist ?? false, - shiBcc: (item.avl_vendor_info || item).isBcc ?? false, - salesQuoteNumber: (item.avl_vendor_info || item).techQuoteNumber || '', - quoteCode: (item.avl_vendor_info || item).quoteCode || '', - salesVendorInfo: (item.avl_vendor_info || item).quoteVendorName || '', - salesCountry: (item.avl_vendor_info || item).quoteCountry || '', - totalAmount: (item.avl_vendor_info || item).quoteTotalAmount ? (item.avl_vendor_info || item).quoteTotalAmount.toString() : '', - quoteReceivedDate: (item.avl_vendor_info || item).quoteReceivedDate || '', - recentQuoteDate: (item.avl_vendor_info || item).recentQuoteDate || '', - recentQuoteNumber: (item.avl_vendor_info || item).recentQuoteNumber || '', - recentOrderDate: (item.avl_vendor_info || item).recentOrderDate || '', - recentOrderNumber: (item.avl_vendor_info || item).recentOrderNumber || '', - remarks: (item.avl_vendor_info || item).remark || '', + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + faStatus: item.faStatus || '', + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', })); const pageCount = Math.ceil(totalCount[0].count / input.perPage); @@ -1274,12 +1473,891 @@ const _getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { } }; -// 캐시된 버전 export -export const getStandardAvlVendorInfo = unstable_cache( - _getStandardAvlVendorInfo, - ['standard-avl-vendor-info'], - { - tags: ['standard-avl-vendor-info'], - revalidate: 300, // 5분 캐시 +/** + * 선종별표준AVL → 프로젝트AVL로 복사 + */ +export const copyToProjectAvl = async ( + selectedIds: number[], + targetProjectCode: string, + targetAvlListId: number, + userName: string +): Promise => { + try { + debugLog('선종별표준AVL → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선택된 레코드들 조회 (선종별표준AVL에서) + const selectedRecords = await db + .select() + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, true), + inArray(avlVendorInfo.id, selectedIds) + ) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // 복사할 데이터 준비 + const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({ + ...record, + id: undefined, // 새 ID 생성 + isTemplate: false, // 프로젝트 AVL로 변경 + projectCode: targetProjectCode, // 대상 프로젝트 코드 + avlListId: targetAvlListId, // 대상 AVL 리스트 ID + // 표준 AVL 필드들은 null로 설정 (프로젝트 AVL에서는 사용하지 않음) + constructionSector: null, + shipType: null, + avlKind: null, + htDivision: null, + createdAt: undefined, + updatedAt: undefined, + })); + + // 벌크 인서트 + await db.insert(avlVendorInfo).values(recordsToInsert); + + debugSuccess('선종별표준AVL → 프로젝트AVL 복사 완료', { + copiedCount: recordsToInsert.length, + targetProjectCode, + userName + }); + + // 캐시 무효화 + revalidateTag('avl-lists'); + revalidateTag('avl-vendor-info'); + + return { + success: true, + message: `${recordsToInsert.length}개의 항목이 프로젝트 AVL로 복사되었습니다.` + }; + + } catch (error) { + debugError('선종별표준AVL → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode }); + return { + success: false, + message: "프로젝트 AVL로 복사 중 오류가 발생했습니다." + }; } -); +}; + +/** + * 프로젝트AVL → 선종별표준AVL로 복사 + */ +export const copyToStandardAvl = async ( + selectedIds: number[], + targetStandardInfo: { + constructionSector: string; + shipType: string; + avlKind: string; + htDivision: string; + }, + userName: string +): Promise => { + try { + debugLog('프로젝트AVL → 선종별표준AVL 복사 시작', { selectedIds, targetStandardInfo }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선종별 표준 AVL 검색 조건 검증 + if (!targetStandardInfo.constructionSector?.trim() || + !targetStandardInfo.shipType?.trim() || + !targetStandardInfo.avlKind?.trim() || + !targetStandardInfo.htDivision?.trim()) { + return { success: false, message: "선종별 표준 AVL 검색 조건을 모두 입력해주세요." }; + } + + // 선택된 레코드들 조회 (프로젝트AVL에서) + const selectedRecords = await db + .select() + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, false), + inArray(avlVendorInfo.id, selectedIds) + ) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // 복사할 데이터 준비 + const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({ + ...record, + id: undefined, // 새 ID 생성 + isTemplate: true, // 표준 AVL로 변경 + // 프로젝트 AVL 필드들은 null로 설정 + projectCode: null, + avlListId: null, + // 표준 AVL 필드들 설정 + constructionSector: targetStandardInfo.constructionSector, + shipType: targetStandardInfo.shipType, + avlKind: targetStandardInfo.avlKind, + htDivision: targetStandardInfo.htDivision, + createdAt: undefined, + updatedAt: undefined, + })); + + // 벌크 인서트 + await db.insert(avlVendorInfo).values(recordsToInsert); + + debugSuccess('프로젝트AVL → 선종별표준AVL 복사 완료', { + copiedCount: recordsToInsert.length, + targetStandardInfo, + userName + }); + + // 캐시 무효화 + revalidateTag('avl-lists'); + revalidateTag('avl-vendor-info'); + + return { + success: true, + message: `${recordsToInsert.length}개의 항목이 선종별표준AVL로 복사되었습니다.` + }; + + } catch (error) { + debugError('프로젝트AVL → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo }); + return { + success: false, + message: "선종별표준AVL로 복사 중 오류가 발생했습니다." + }; + } +}; + +/** + * 프로젝트AVL → 벤더풀로 복사 + */ +export const copyToVendorPool = async ( + selectedIds: number[], + userName: string +): Promise => { + try { + debugLog('프로젝트AVL → 벤더풀 복사 시작', { selectedIds }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선택된 레코드들 조회 (프로젝트AVL에서) + const selectedRecords = await db + .select() + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, false), + inArray(avlVendorInfo.id, selectedIds) + ) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // 벤더풀 테이블로 복사할 데이터 준비 (필드 매핑) + const recordsToInsert: NewVendorPool[] = selectedRecords.map(record => ({ + // 기본 정보 (프로젝트 AVL에서 추출 또는 기본값 설정) + constructionSector: record.constructionSector || "조선", // 기본값 설정 + htDivision: record.htDivision || "H", // 기본값 설정 + + // 설계 정보 + designCategoryCode: "XX", // 기본값 (실제로는 적절한 값으로 매핑 필요) + designCategory: record.disciplineName || "기타", + equipBulkDivision: record.equipBulkDivision || "E", + + // 패키지 정보 + packageCode: record.packageCode, + packageName: record.packageName, + + // 자재그룹 정보 + materialGroupCode: record.materialGroupCode, + materialGroupName: record.materialGroupName, + + // 자재 관련 정보 (빈 값으로 설정) + smCode: null, + similarMaterialNamePurchase: null, + similarMaterialNameOther: null, + + // 협력업체 정보 + vendorCode: record.vendorCode, + vendorName: record.vendorName, + + // 사업 및 인증 정보 + taxId: null, // 벤더풀에서 별도 관리 + faTarget: record.faTarget, + faStatus: record.faStatus, + faRemark: null, + tier: record.tier, + isAgent: record.isAgent, + + // 계약 정보 + contractSignerCode: record.contractSignerCode, + contractSignerName: record.contractSignerName, + + // 위치 정보 + headquarterLocation: null, // 별도 관리 필요 + manufacturingLocation: null, // 별도 관리 필요 + + // AVL 관련 정보 + avlVendorName: record.avlVendorName, + similarVendorName: null, + hasAvl: record.hasAvl, + + // 상태 정보 + isBlacklist: record.isBlacklist, + isBcc: record.isBcc, + purchaseOpinion: null, + + // AVL 적용 선종 (기본값으로 설정 - 실제로는 로직 필요) + shipTypeCommon: true, // 공통으로 설정 + shipTypeAmax: false, + shipTypeSmax: false, + shipTypeVlcc: false, + shipTypeLngc: false, + shipTypeCont: false, + + // AVL 적용 선종(해양) - 기본값 + offshoreTypeCommon: false, + offshoreTypeFpso: false, + offshoreTypeFlng: false, + offshoreTypeFpu: false, + offshoreTypePlatform: false, + offshoreTypeWtiv: false, + offshoreTypeGom: false, + + // eVCP 미등록 정보 - 빈 값 + picName: null, + picEmail: null, + picPhone: null, + agentName: null, + agentEmail: null, + agentPhone: null, + + // 업체 실적 현황 + recentQuoteDate: record.recentQuoteDate, + recentQuoteNumber: record.recentQuoteNumber, + recentOrderDate: record.recentOrderDate, + recentOrderNumber: record.recentOrderNumber, + + // 업데이트 히스토리 + registrationDate: undefined, // 현재 시간으로 자동 설정 + registrant: userName, + lastModifiedDate: undefined, + lastModifier: userName, + })); + + // 입력 데이터에서 중복 제거 (메모리에서 처리) + const seen = new Set(); + const uniqueRecords = recordsToInsert.filter(record => { + if (!record.vendorCode || !record.materialGroupCode) return true; // 필수 필드가 없는 경우는 추가 + const key = `${record.vendorCode}:${record.materialGroupCode}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // 중복 제거된 레코드 수 계산 + const duplicateCount = recordsToInsert.length - uniqueRecords.length; + + if (uniqueRecords.length === 0) { + return { success: false, message: "복사할 유효한 항목이 없습니다." }; + } + + // 벌크 인서트 + await db.insert(vendorPool).values(uniqueRecords); + + debugSuccess('프로젝트AVL → 벤더풀 복사 완료', { + copiedCount: uniqueRecords.length, + duplicateCount, + userName + }); + + // 캐시 무효화 + revalidateTag('vendor-pool'); + revalidateTag('vendor-pool-list'); + revalidateTag('vendor-pool-stats'); + + return { + success: true, + message: `${uniqueRecords.length}개의 항목이 벤더풀로 복사되었습니다.${duplicateCount > 0 ? ` (${duplicateCount}개 입력 데이터 중복 제외)` : ''}` + }; + + } catch (error) { + debugError('프로젝트AVL → 벤더풀 복사 실패', { error, selectedIds }); + return { + success: false, + message: "벤더풀로 복사 중 오류가 발생했습니다." + }; + } +}; + +/** + * 벤더풀 → 프로젝트AVL로 복사 + */ +export const copyFromVendorPoolToProjectAvl = async ( + selectedIds: number[], + targetProjectCode: string, + targetAvlListId: number, + userName: string +): Promise => { + try { + debugLog('벤더풀 → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선택된 레코드들 조회 (벤더풀에서) + const selectedRecords = await db + .select() + .from(vendorPool) + .where( + inArray(vendorPool.id, selectedIds) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // 벤더풀 데이터를 AVL Vendor Info로 변환하여 복사 + const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({ + // 프로젝트 AVL용 필드들 + projectCode: targetProjectCode, + avlListId: targetAvlListId, + isTemplate: false, + + // 벤더풀 데이터를 AVL Vendor Info로 매핑 + vendorId: null, // 벤더풀에서는 vendorId가 없을 수 있음 + vendorName: record.vendorName, + vendorCode: record.vendorCode, + + // 기본 정보 (벤더풀의 데이터 활용) + constructionSector: record.constructionSector, + htDivision: record.htDivision, + + // 자재그룹 정보 + materialGroupCode: record.materialGroupCode, + materialGroupName: record.materialGroupName, + + // 설계 정보 (벤더풀의 데이터 활용) + designCategory: record.designCategory, + equipBulkDivision: record.equipBulkDivision, + + // 패키지 정보 + packageCode: record.packageCode, + packageName: record.packageName, + + // 계약 정보 + contractSignerCode: record.contractSignerCode, + contractSignerName: record.contractSignerName, + + // 위치 정보 + headquarterLocation: record.headquarterLocation, + manufacturingLocation: record.manufacturingLocation, + + // AVL 관련 정보 + avlVendorName: record.avlVendorName, + hasAvl: record.hasAvl, + + // 상태 정보 + isBlacklist: record.isBlacklist, + isBcc: record.isBcc, + + // 기본값들 + ownerSuggestion: false, + shiSuggestion: false, + faTarget: record.faTarget, + faStatus: record.faStatus, + isAgent: record.isAgent, + + // 나머지 필드들은 null 또는 기본값 + disciplineCode: null, + disciplineName: null, + materialNameCustomerSide: null, + tier: record.tier, + filters: [], + joinOperator: "and", + search: "", + + createdAt: undefined, + updatedAt: undefined, + })); + + // 벌크 인서트 + await db.insert(avlVendorInfo).values(recordsToInsert); + + debugSuccess('벤더풀 → 프로젝트AVL 복사 완료', { + copiedCount: recordsToInsert.length, + targetProjectCode, + userName + }); + + // 캐시 무효화 + revalidateTag('avl-lists'); + revalidateTag('avl-vendor-info'); + + return { + success: true, + message: `${recordsToInsert.length}개의 항목이 프로젝트 AVL로 복사되었습니다.` + }; + + } catch (error) { + debugError('벤더풀 → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode }); + return { + success: false, + message: "프로젝트 AVL로 복사 중 오류가 발생했습니다." + }; + } +}; + +/** + * 벤더풀 → 선종별표준AVL로 복사 + */ +export const copyFromVendorPoolToStandardAvl = async ( + selectedIds: number[], + targetStandardInfo: { + constructionSector: string; + shipType: string; + avlKind: string; + htDivision: string; + }, + userName: string +): Promise => { + try { + debugLog('벤더풀 → 선종별표준AVL 복사 시작', { selectedIds, targetStandardInfo }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선택된 레코드들 조회 (벤더풀에서) + const selectedRecords = await db + .select() + .from(vendorPool) + .where( + inArray(vendorPool.id, selectedIds) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // 벤더풀 데이터를 AVL Vendor Info로 변환하여 복사 + const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({ + // 선종별 표준 AVL용 필드들 + isTemplate: true, + constructionSector: targetStandardInfo.constructionSector, + shipType: targetStandardInfo.shipType, + avlKind: targetStandardInfo.avlKind, + htDivision: targetStandardInfo.htDivision, + + // 벤더풀 데이터를 AVL Vendor Info로 매핑 + vendorId: null, + vendorName: record.vendorName, + vendorCode: record.vendorCode, + + // 자재그룹 정보 + materialGroupCode: record.materialGroupCode, + materialGroupName: record.materialGroupName, + + // 설계 정보 + disciplineName: record.designCategory, + equipBulkDivision: record.equipBulkDivision, + + // 패키지 정보 + packageCode: record.packageCode, + packageName: record.packageName, + + // 계약 정보 + contractSignerCode: record.contractSignerCode, + contractSignerName: record.contractSignerName, + + // 위치 정보 + headquarterLocation: record.headquarterLocation, + manufacturingLocation: record.manufacturingLocation, + + // AVL 관련 정보 + avlVendorName: record.avlVendorName, + hasAvl: record.hasAvl, + + // 상태 정보 + isBlacklist: record.isBlacklist, + isBcc: record.isBcc, + + // 기본값들 + ownerSuggestion: false, + shiSuggestion: false, + faTarget: record.faTarget, + faStatus: record.faStatus, + isAgent: record.isAgent, + + // 선종별 표준 AVL에서는 사용하지 않는 필드들 + projectCode: null, + avlListId: null, + + // 나머지 필드들은 null 또는 기본값 + disciplineCode: null, + materialNameCustomerSide: null, + tier: record.tier, + filters: [], + joinOperator: "and", + search: "", + + createdAt: undefined, + updatedAt: undefined, + })); + + // 벌크 인서트 + await db.insert(avlVendorInfo).values(recordsToInsert); + + debugSuccess('벤더풀 → 선종별표준AVL 복사 완료', { + copiedCount: recordsToInsert.length, + targetStandardInfo, + userName + }); + + // 캐시 무효화 + revalidateTag('avl-lists'); + revalidateTag('avl-vendor-info'); + + return { + success: true, + message: `${recordsToInsert.length}개의 항목이 선종별표준AVL로 복사되었습니다.` + }; + + } catch (error) { + debugError('벤더풀 → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo }); + return { + success: false, + message: "선종별표준AVL로 복사 중 오류가 발생했습니다." + }; + } +}; + +/** + * 선종별표준AVL → 벤더풀로 복사 + */ +export const copyFromStandardAvlToVendorPool = async ( + selectedIds: number[], + userName: string +): Promise => { + try { + debugLog('선종별표준AVL → 벤더풀 복사 시작', { selectedIds }); + + if (!selectedIds.length) { + return { success: false, message: "복사할 항목을 선택해주세요." }; + } + + // 선택된 레코드들 조회 (선종별표준AVL에서) + const selectedRecords = await db + .select() + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, true), + inArray(avlVendorInfo.id, selectedIds) + ) + ); + + if (!selectedRecords.length) { + return { success: false, message: "선택된 항목을 찾을 수 없습니다." }; + } + + // AVL Vendor Info 데이터를 벤더풀로 변환하여 복사 + const recordsToInsert: NewVendorPool[] = selectedRecords.map(record => ({ + // 기본 정보 + constructionSector: record.constructionSector || "조선", + htDivision: record.htDivision || "H", + + // 설계 정보 + designCategoryCode: "XX", // 기본값 + designCategory: record.disciplineName || "기타", + equipBulkDivision: record.equipBulkDivision || "E", + + // 패키지 정보 + packageCode: record.packageCode, + packageName: record.packageName, + + // 자재그룹 정보 + materialGroupCode: record.materialGroupCode, + materialGroupName: record.materialGroupName, + + // 협력업체 정보 + vendorCode: record.vendorCode, + vendorName: record.vendorName, + + // 사업 및 인증 정보 + taxId: null, + faTarget: record.faTarget, + faStatus: record.faStatus, + faRemark: null, + tier: record.tier, + isAgent: record.isAgent, + + // 계약 정보 + contractSignerCode: record.contractSignerCode, + contractSignerName: record.contractSignerName, + + // 위치 정보 + headquarterLocation: record.headquarterLocation, + manufacturingLocation: record.manufacturingLocation, + + // AVL 관련 정보 + avlVendorName: record.avlVendorName, + similarVendorName: null, + hasAvl: record.hasAvl, + + // 상태 정보 + isBlacklist: record.isBlacklist, + isBcc: record.isBcc, + purchaseOpinion: null, + + // AVL 적용 선종 (기본값) + shipTypeCommon: true, + shipTypeAmax: false, + shipTypeSmax: false, + shipTypeVlcc: false, + shipTypeLngc: false, + shipTypeCont: false, + + // AVL 적용 선종(해양) - 기본값 + offshoreTypeCommon: false, + offshoreTypeFpso: false, + offshoreTypeFlng: false, + offshoreTypeFpu: false, + offshoreTypePlatform: false, + offshoreTypeWtiv: false, + offshoreTypeGom: false, + + // eVCP 미등록 정보 + picName: null, + picEmail: null, + picPhone: null, + agentName: null, + agentEmail: null, + agentPhone: null, + + // 업체 실적 현황 + recentQuoteDate: record.recentQuoteDate, + recentQuoteNumber: record.recentQuoteNumber, + recentOrderDate: record.recentOrderDate, + recentOrderNumber: record.recentOrderNumber, + + // 업데이트 히스토리 + registrationDate: undefined, + registrant: userName, + lastModifiedDate: undefined, + lastModifier: userName, + })); + + // 중복 체크를 위한 고유한 vendorCode + materialGroupCode 조합 생성 + const uniquePairs = new Set(); + const validRecords = recordsToInsert.filter(record => { + if (!record.vendorCode || !record.materialGroupCode) return false; + const key = `${record.vendorCode}:${record.materialGroupCode}`; + if (uniquePairs.has(key)) return false; + uniquePairs.add(key); + return true; + }); + + if (validRecords.length === 0) { + return { success: false, message: "복사할 유효한 항목이 없습니다." }; + } + + // 벌크 인서트 + await db.insert(vendorPool).values(validRecords); + + const duplicateCount = recordsToInsert.length - validRecords.length; + + debugSuccess('선종별표준AVL → 벤더풀 복사 완료', { + copiedCount: validRecords.length, + duplicateCount, + userName + }); + + // 캐시 무효화 + revalidateTag('vendor-pool'); + + return { + success: true, + message: `${validRecords.length}개의 항목이 벤더풀로 복사되었습니다.${duplicateCount > 0 ? ` (${duplicateCount}개 입력 데이터 중복 제외)` : ''}` + }; + + } catch (error) { + debugError('선종별표준AVL → 벤더풀 복사 실패', { error, selectedIds }); + return { + success: false, + message: "벤더풀로 복사 중 오류가 발생했습니다." + }; + } +}; + +/** + * 표준 AVL 최종 확정 + * 표준 AVL을 최종 확정하여 AVL 리스트에 등록합니다. + */ +export async function finalizeStandardAvl( + standardAvlInfo: { + constructionSector: string; + shipType: string; + avlKind: string; + htDivision: string; + }, + avlVendorInfoIds: number[], + currentUser?: string +): Promise<{ success: boolean; avlListId?: number; message: string }> { + try { + debugLog('표준 AVL 최종 확정 시작', { + standardAvlInfo, + avlVendorInfoIds: avlVendorInfoIds.length, + currentUser + }); + + // 1. 기존 표준 AVL 리스트의 최고 revision 확인 + const existingAvlLists = await db + .select({ rev: avlList.rev }) + .from(avlList) + .where(and( + eq(avlList.constructionSector, standardAvlInfo.constructionSector), + eq(avlList.shipType, standardAvlInfo.shipType), + eq(avlList.avlKind, standardAvlInfo.avlKind), + eq(avlList.htDivision, standardAvlInfo.htDivision), + eq(avlList.isTemplate, true) + )) + .orderBy(desc(avlList.rev)) + .limit(1); + + const nextRevision = existingAvlLists.length > 0 ? (existingAvlLists[0].rev || 0) + 1 : 1; + + debugLog('표준 AVL 리스트 revision 계산', { + standardAvlInfo, + existingRevision: existingAvlLists.length > 0 ? existingAvlLists[0].rev : 0, + nextRevision + }); + + // 2. AVL 리스트 생성을 위한 데이터 준비 + const createAvlListData: CreateAvlListInput = { + isTemplate: true, // 표준 AVL이므로 true + constructionSector: standardAvlInfo.constructionSector, + projectCode: null, // 표준 AVL은 프로젝트 코드가 없음 + shipType: standardAvlInfo.shipType, + avlKind: standardAvlInfo.avlKind, + htDivision: standardAvlInfo.htDivision, + rev: nextRevision, // 계산된 다음 리비전 + createdBy: currentUser || 'system', + updatedBy: currentUser || 'system', + }; + + debugLog('표준 AVL 리스트 생성 데이터', { createAvlListData }); + + // 2-1. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) + debugLog('표준 AVL Vendor Info 스냅샷 생성 시작', { + vendorInfoIdsCount: avlVendorInfoIds.length, + vendorInfoIds: avlVendorInfoIds + }); + const vendorInfoSnapshot = await createVendorInfoSnapshot(avlVendorInfoIds); + debugSuccess('표준 AVL Vendor Info 스냅샷 생성 완료', { + snapshotCount: vendorInfoSnapshot.length, + snapshotSize: JSON.stringify(vendorInfoSnapshot).length, + sampleSnapshot: vendorInfoSnapshot.slice(0, 1) // 첫 번째 항목만 로깅 + }); + + // 스냅샷을 AVL 리스트 생성 데이터에 추가 + createAvlListData.vendorInfoSnapshot = vendorInfoSnapshot; + debugLog('표준 AVL 스냅샷이 createAvlListData에 추가됨', { + hasSnapshot: !!createAvlListData.vendorInfoSnapshot, + snapshotLength: createAvlListData.vendorInfoSnapshot?.length + }); + + // 3. AVL 리스트 생성 + const newAvlList = await createAvlList(createAvlListData); + + if (!newAvlList) { + throw new Error("표준 AVL 리스트 생성에 실패했습니다."); + } + + debugSuccess('표준 AVL 리스트 생성 완료', { avlListId: newAvlList.id }); + + // 4. avlVendorInfo 레코드들의 avlListId 업데이트 + if (avlVendorInfoIds.length > 0) { + debugLog('표준 AVL Vendor Info 업데이트 시작', { + count: avlVendorInfoIds.length, + newAvlListId: newAvlList.id + }); + + const updateResults = await Promise.all( + avlVendorInfoIds.map(async (vendorInfoId) => { + try { + const result = await db + .update(avlVendorInfo) + .set({ + avlListId: newAvlList.id, + updatedAt: new Date() + }) + .where(eq(avlVendorInfo.id, vendorInfoId)) + .returning({ id: avlVendorInfo.id }); + + return { id: vendorInfoId, success: true, result }; + } catch (error) { + debugError('표준 AVL Vendor Info 업데이트 실패', { vendorInfoId, error }); + return { id: vendorInfoId, success: false, error }; + } + }) + ); + + // 업데이트 결과 검증 + const successCount = updateResults.filter(r => r.success).length; + const failCount = updateResults.filter(r => !r.success).length; + + debugLog('표준 AVL Vendor Info 업데이트 결과', { + total: avlVendorInfoIds.length, + success: successCount, + failed: failCount + }); + + if (failCount > 0) { + debugWarn('일부 표준 AVL Vendor Info 업데이트 실패', { + failedIds: updateResults.filter(r => !r.success).map(r => r.id) + }); + } + } + + // 5. 캐시 무효화 + revalidateTag('avl-list'); + revalidateTag('avl-vendor-info'); + + debugSuccess('표준 AVL 최종 확정 완료', { + avlListId: newAvlList.id, + standardAvlInfo, + vendorInfoCount: avlVendorInfoIds.length + }); + + return { + success: true, + avlListId: newAvlList.id, + message: `표준 AVL이 성공적으로 확정되었습니다. (AVL ID: ${newAvlList.id}, 벤더 정보: ${avlVendorInfoIds.length}개)` + }; + + } catch (err) { + debugError('표준 AVL 최종 확정 실패', { + standardAvlInfo, + error: err + }); + + console.error("Error in finalizeStandardAvl:", err); + + return { + success: false, + message: err instanceof Error ? err.message : "표준 AVL 최종 확정 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/avl/snapshot-utils.ts b/lib/avl/snapshot-utils.ts new file mode 100644 index 00000000..0f5d9240 --- /dev/null +++ b/lib/avl/snapshot-utils.ts @@ -0,0 +1,190 @@ +/** + * AVL Vendor Info 스냅샷 관련 유틸리티 함수들 + */ + +import db from "@/db/db" +import { avlVendorInfo } from "@/db/schema/avl/avl" +import { inArray } from "drizzle-orm" + +/** + * AVL Vendor Info 스냅샷 데이터 타입 + */ +export interface VendorInfoSnapshot { + id: number + // 설계 정보 + equipBulkDivision: string | null + disciplineCode: string | null + disciplineName: string | null + // 자재 정보 + materialNameCustomerSide: string | null + packageCode: string | null + packageName: string | null + materialGroupCode: string | null + materialGroupName: string | null + // 협력업체 정보 + vendorId: number | null + vendorName: string | null + vendorCode: string | null + avlVendorName: string | null + tier: string | null + // FA 정보 + faTarget: boolean + faStatus: string | null + // Agent 정보 + isAgent: boolean + // 계약 서명주체 + contractSignerId: number | null + contractSignerName: string | null + contractSignerCode: string | null + // 위치 정보 + headquarterLocation: string | null + manufacturingLocation: string | null + // SHI Qualification + hasAvl: boolean + isBlacklist: boolean + isBcc: boolean + // 기술영업 견적결과 + techQuoteNumber: string | null + quoteCode: string | null + quoteVendorId: number | null + quoteVendorName: string | null + quoteVendorCode: string | null + quoteCountry: string | null + quoteTotalAmount: string | null // 숫자를 문자열로 변환 + quoteReceivedDate: string | null + // 업체 실적 현황 + recentQuoteDate: string | null + recentQuoteNumber: string | null + recentOrderDate: string | null + recentOrderNumber: string | null + // 기타 + remark: string | null + // 타임스탬프 + createdAt: string + updatedAt: string +} + +/** + * AVL Vendor Info ID 목록으로부터 스냅샷 데이터를 생성합니다. + * + * @param vendorInfoIds - 스냅샷을 생성할 AVL Vendor Info ID 목록 + * @returns 스냅샷 데이터 배열 + */ +export async function createVendorInfoSnapshot(vendorInfoIds: number[]): Promise { + if (vendorInfoIds.length === 0) { + console.log('[SNAPSHOT] 빈 vendorInfoIds 배열, 빈 스냅샷 반환') + return [] + } + + try { + console.log('[SNAPSHOT] 스냅샷 생성 시작', { vendorInfoIds, count: vendorInfoIds.length }) + + // AVL Vendor Info 데이터 조회 + const vendorInfoList = await db + .select() + .from(avlVendorInfo) + .where(inArray(avlVendorInfo.id, vendorInfoIds)) + + console.log('[SNAPSHOT] DB 조회 완료', { + requestedIds: vendorInfoIds, + foundCount: vendorInfoList.length, + foundIds: vendorInfoList.map(v => v.id) + }) + + // 스냅샷 데이터로 변환 + const snapshot: VendorInfoSnapshot[] = vendorInfoList.map(info => ({ + id: info.id, + // 설계 정보 + equipBulkDivision: info.equipBulkDivision, + disciplineCode: info.disciplineCode, + disciplineName: info.disciplineName, + // 자재 정보 + materialNameCustomerSide: info.materialNameCustomerSide, + packageCode: info.packageCode, + packageName: info.packageName, + materialGroupCode: info.materialGroupCode, + materialGroupName: info.materialGroupName, + // 협력업체 정보 + vendorId: info.vendorId, + vendorName: info.vendorName, + vendorCode: info.vendorCode, + avlVendorName: info.avlVendorName, + tier: info.tier, + // FA 정보 + faTarget: info.faTarget ?? false, + faStatus: info.faStatus, + // Agent 정보 + isAgent: info.isAgent ?? false, + // 계약 서명주체 + contractSignerId: info.contractSignerId, + contractSignerName: info.contractSignerName, + contractSignerCode: info.contractSignerCode, + // 위치 정보 + headquarterLocation: info.headquarterLocation, + manufacturingLocation: info.manufacturingLocation, + // SHI Qualification + hasAvl: info.hasAvl ?? false, + isBlacklist: info.isBlacklist ?? false, + isBcc: info.isBcc ?? false, + // 기술영업 견적결과 + techQuoteNumber: info.techQuoteNumber, + quoteCode: info.quoteCode, + quoteVendorId: info.quoteVendorId, + quoteVendorName: info.quoteVendorName, + quoteVendorCode: info.quoteVendorCode, + quoteCountry: info.quoteCountry, + quoteTotalAmount: info.quoteTotalAmount?.toString() || null, + quoteReceivedDate: info.quoteReceivedDate, + // 업체 실적 현황 + recentQuoteDate: info.recentQuoteDate, + recentQuoteNumber: info.recentQuoteNumber, + recentOrderDate: info.recentOrderDate, + recentOrderNumber: info.recentOrderNumber, + // 기타 + remark: info.remark, + // 타임스탬프 (ISO 문자열로 변환) + createdAt: info.createdAt?.toISOString() || new Date().toISOString(), + updatedAt: info.updatedAt?.toISOString() || new Date().toISOString(), + })) + + console.log('[SNAPSHOT] 스냅샷 변환 완료', { + snapshotCount: snapshot.length, + sampleData: snapshot.slice(0, 2) // 처음 2개 항목만 로깅 + }) + + return snapshot + + } catch (error) { + console.error('[SNAPSHOT] Vendor Info 스냅샷 생성 실패:', error) + throw new Error('Vendor Info 스냅샷 생성 중 오류가 발생했습니다.') + } +} + +/** + * 스냅샷 데이터의 통계 정보를 생성합니다. + * + * @param snapshot - 스냅샷 데이터 배열 + * @returns 통계 정보 객체 + */ +export function getSnapshotStatistics(snapshot: VendorInfoSnapshot[]) { + return { + totalCount: snapshot.length, + avlCount: snapshot.filter(item => item.hasAvl).length, + blacklistCount: snapshot.filter(item => item.isBlacklist).length, + bccCount: snapshot.filter(item => item.isBcc).length, + faTargetCount: snapshot.filter(item => item.faTarget).length, + agentCount: snapshot.filter(item => item.isAgent).length, + tierDistribution: { + 'Tier 1': snapshot.filter(item => item.tier === 'Tier 1').length, + 'Tier 2': snapshot.filter(item => item.tier === 'Tier 2').length, + 'Tier 3': snapshot.filter(item => item.tier === 'Tier 3').length, + 'Other': snapshot.filter(item => item.tier && !['Tier 1', 'Tier 2', 'Tier 3'].includes(item.tier)).length, + 'Unspecified': snapshot.filter(item => !item.tier).length, + }, + byDiscipline: snapshot.reduce((acc, item) => { + const discipline = item.disciplineName || 'Unknown' + acc[discipline] = (acc[discipline] || 0) + 1 + return acc + }, {} as Record), + } +} diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx index ba15c6ef..04384ec8 100644 --- a/lib/avl/table/avl-detail-table.tsx +++ b/lib/avl/table/avl-detail-table.tsx @@ -5,31 +5,13 @@ import * as React from "react" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { toast } from "sonner" import { columns, type AvlDetailItem } from "./columns-detail" -import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, handleAvlAction } from "../service" -import type { AvlDetailItem as AvlDetailType } from "../types" - -// 테이블 메타 타입 확장 -declare module "@tanstack/react-table" { - interface TableMeta { - onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise - onCellCancel?: (id: string, field: keyof TData) => void - onAction?: (action: string, data?: any) => void - onSaveEmptyRow?: (tempId: string) => Promise - onCancelEmptyRow?: (tempId: string) => void - isEmptyRow?: (id: string) => boolean - getPendingChanges?: () => Record> - } -} interface AvlDetailTableProps { data: AvlDetailItem[] pageCount?: number - avlListId: number // 상위 AVL 리스트 ID - onRefresh?: () => void // 데이터 새로고침 콜백 avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입 projectCode?: string // 프로젝트 코드 shipOwnerName?: string // 선주명 @@ -39,386 +21,61 @@ interface AvlDetailTableProps { export function AvlDetailTable({ data, pageCount, - avlListId, - onRefresh, avlType = '프로젝트AVL', projectCode, shipOwnerName, businessType = '조선' }: AvlDetailTableProps) { - // 수정사항 추적 (일괄 저장용) - const [pendingChanges, setPendingChanges] = React.useState>>({}) - const [isSaving, setIsSaving] = React.useState(false) - - // 빈 행 관리 (신규 등록용) - const [emptyRows, setEmptyRows] = React.useState>({}) - const [isCreating, setIsCreating] = React.useState(false) - - // 검색 상태 - const [searchValue, setSearchValue] = React.useState("") - - - // 인라인 편집 핸들러 (일괄 저장용) - const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlDetailItem, newValue: any) => { - const isEmptyRow = String(id).startsWith('temp-') - - if (isEmptyRow) { - // 빈 행의 경우 emptyRows 상태도 업데이트 - setEmptyRows(prev => ({ - ...prev, - [id]: { - ...prev[id], - [field]: newValue - } - })) - } - - // pendingChanges에 변경사항 저장 (실시간 표시용) - setPendingChanges(prev => ({ - ...prev, - [id]: { - ...prev[id], - [field]: newValue - } - })) - }, []) - - // 편집 취소 핸들러 - const handleCellCancel = React.useCallback((id: string, field: keyof AvlDetailItem) => { - const isEmptyRow = String(id).startsWith('temp-') - - if (isEmptyRow) { - // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소 - setEmptyRows(prev => ({ - ...prev, - [id]: { - ...prev[id], - [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용) - } - })) - - setPendingChanges(prev => { - const itemChanges = { ...prev[id] } - delete itemChanges[field] - - if (Object.keys(itemChanges).length === 0) { - const newChanges = { ...prev } - delete newChanges[id] - return newChanges - } - - return { - ...prev, - [id]: itemChanges - } - }) - } - }, []) - // 액션 핸들러 - const handleAction = React.useCallback(async (action: string, data?: any) => { - try { - switch (action) { - case 'new-vendor': - // 신규 협력업체 추가 - 빈 행 추가 - const tempId = `temp-${Date.now()}` - const newEmptyRow: AvlDetailItem = { - id: tempId, - no: 0, - selected: false, - avlListId: avlListId, - equipBulkDivision: "EQUIP", - disciplineCode: "", - disciplineName: "", - materialNameCustomerSide: "", - packageCode: "", - packageName: "", - materialGroupCode: "", - materialGroupName: "", - vendorId: undefined, - vendorName: "", - vendorCode: "", - avlVendorName: "", - tier: "", - faTarget: false, - faStatus: "", - isAgent: false, - agentStatus: "아니오", - contractSignerId: undefined, - contractSignerName: "", - contractSignerCode: "", - headquarterLocation: "", - manufacturingLocation: "", - shiAvl: false, - shiBlacklist: false, - shiBcc: false, - salesQuoteNumber: "", - quoteCode: "", - salesVendorInfo: "", - salesCountry: "", - totalAmount: "", - quoteReceivedDate: "", - recentQuoteDate: "", - recentQuoteNumber: "", - recentOrderDate: "", - recentOrderNumber: "", - remarks: "", - createdAt: new Date().toISOString().split('T')[0], - updatedAt: new Date().toISOString().split('T')[0], - } - - setEmptyRows(prev => ({ - ...prev, - [tempId]: newEmptyRow - })) - toast.success("신규 협력업체 행이 추가되었습니다.") - break - - case 'bulk-import': - // 일괄 입력 - const bulkResult = await handleAvlAction('bulk-import') - if (bulkResult.success) { - toast.success(bulkResult.message) - } else { - toast.error(bulkResult.message) - } - break - - case 'save': - // 변경사항 저장 - if (Object.keys(pendingChanges).length === 0) { - toast.info("저장할 변경사항이 없습니다.") - return - } - - setIsSaving(true) - try { - // 각 변경사항을 순차적으로 저장 - for (const [id, changes] of Object.entries(pendingChanges)) { - if (String(id).startsWith('temp-')) continue // 빈 행은 제외 - - const numericId = Number(id) - if (isNaN(numericId)) { - throw new Error(`유효하지 않은 ID: ${id}`) - } - - const result = await updateAvlVendorInfo(numericId, changes) - if (!result) { - throw new Error(`항목 ${id} 저장 실패`) - } - } - - setPendingChanges({}) - toast.success("변경사항이 저장되었습니다.") - onRefresh?.() - } catch (error) { - console.error('저장 실패:', error) - toast.error("저장 중 오류가 발생했습니다.") - } finally { - setIsSaving(false) - } - break - - case 'edit': - // 수정 모달 열기 (현재는 간단한 토스트로 처리) - toast.info(`${data?.id} 항목 수정`) - break - - case 'delete': - // 삭제 확인 및 실행 - if (!data?.id || String(data.id).startsWith('temp-')) return - - const numericId = Number(data.id) - if (isNaN(numericId)) { - toast.error("유효하지 않은 항목 ID입니다.") - return - } - - const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`) - if (!confirmed) return - - try { - const result = await deleteAvlVendorInfo(numericId) - if (result) { - toast.success("항목이 삭제되었습니다.") - onRefresh?.() - } else { - toast.error("삭제에 실패했습니다.") - } - } catch (error) { - console.error('삭제 실패:', error) - toast.error("삭제 중 오류가 발생했습니다.") - } - break - - case 'avl-form': - // AVL 양식 다운로드/보기 - toast.info("AVL 양식을 준비 중입니다.") - // TODO: AVL 양식 다운로드 로직 구현 - break - - case 'quote-request': - // 견적 요청 - toast.info("견적 요청을 처리 중입니다.") - // TODO: 견적 요청 로직 구현 - break - - case 'vendor-pool': - // Vendor Pool 관리 - toast.info("Vendor Pool을 열고 있습니다.") - // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현 - break - - case 'download': - // 데이터 다운로드 - toast.info("데이터를 다운로드 중입니다.") - // TODO: 데이터 다운로드 로직 구현 - break - - default: - toast.error(`알 수 없는 액션: ${action}`) - } - } catch (error) { - console.error('액션 처리 실패:', error) - toast.error("액션 처리 중 오류가 발생했습니다.") + const handleAction = React.useCallback(async (action: string) => { + switch (action) { + case 'avl-form': + toast.info("AVL 양식을 준비 중입니다.") + // TODO: AVL 양식 다운로드 로직 구현 + break + + case 'quote-request': + toast.info("견적 요청을 처리 중입니다.") + // TODO: 견적 요청 로직 구현 + break + + case 'vendor-pool': + toast.info("Vendor Pool을 열고 있습니다.") + // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현 + break + + case 'download': + toast.info("데이터를 다운로드 중입니다.") + // TODO: 데이터 다운로드 로직 구현 + break + + default: + toast.error(`알 수 없는 액션: ${action}`) } - }, [pendingChanges, onRefresh, avlListId]) - - // 빈 행 저장 핸들러 - const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { - const emptyRow = emptyRows[tempId] - if (!emptyRow) return - - try { - setIsCreating(true) - - // 필수 필드 검증 - if (!emptyRow.disciplineName || !emptyRow.vendorName) { - toast.error("설계공종과 협력업체명은 필수 입력 항목입니다.") - return - } - - // 빈 행 데이터를 생성 데이터로 변환 - const createData = { - avlListId: emptyRow.avlListId, - equipBulkDivision: emptyRow.equipBulkDivision, - disciplineCode: emptyRow.disciplineCode || undefined, - disciplineName: emptyRow.disciplineName, - materialNameCustomerSide: emptyRow.materialNameCustomerSide || undefined, - packageCode: emptyRow.packageCode || undefined, - packageName: emptyRow.packageName || undefined, - materialGroupCode: emptyRow.materialGroupCode || undefined, - materialGroupName: emptyRow.materialGroupName || undefined, - vendorId: emptyRow.vendorId, - vendorName: emptyRow.vendorName, - vendorCode: emptyRow.vendorCode || undefined, - avlVendorName: emptyRow.avlVendorName || undefined, - tier: emptyRow.tier || undefined, - faTarget: emptyRow.faTarget ?? false, - faStatus: emptyRow.faStatus || undefined, - isAgent: emptyRow.isAgent ?? false, - contractSignerId: emptyRow.contractSignerId, - contractSignerName: emptyRow.contractSignerName || undefined, - contractSignerCode: emptyRow.contractSignerCode || undefined, - headquarterLocation: emptyRow.headquarterLocation || undefined, - manufacturingLocation: emptyRow.manufacturingLocation || undefined, - hasAvl: emptyRow.shiAvl ?? false, - isBlacklist: emptyRow.shiBlacklist ?? false, - isBcc: emptyRow.shiBcc ?? false, - techQuoteNumber: emptyRow.salesQuoteNumber || undefined, - quoteCode: emptyRow.quoteCode || undefined, - quoteVendorId: emptyRow.vendorId, - quoteVendorName: emptyRow.salesVendorInfo || undefined, - quoteVendorCode: emptyRow.vendorCode, - quoteCountry: emptyRow.salesCountry || undefined, - quoteTotalAmount: emptyRow.totalAmount ? parseFloat(emptyRow.totalAmount.replace(/,/g, '')) : undefined, - quoteReceivedDate: emptyRow.quoteReceivedDate || undefined, - recentQuoteDate: emptyRow.recentQuoteDate || undefined, - recentQuoteNumber: emptyRow.recentQuoteNumber || undefined, - recentOrderDate: emptyRow.recentOrderDate || undefined, - recentOrderNumber: emptyRow.recentOrderNumber || undefined, - remark: emptyRow.remarks || undefined, - } - - const result = await createAvlVendorInfo(createData) - if (result) { - // 빈 행 제거 및 성공 메시지 - setEmptyRows(prev => { - const newRows = { ...prev } - delete newRows[tempId] - return newRows - }) - - // pendingChanges에서도 제거 - setPendingChanges(prev => { - const newChanges = { ...prev } - delete newChanges[tempId] - return newChanges - }) - - toast.success("새 협력업체가 등록되었습니다.") - onRefresh?.() - } else { - toast.error("등록에 실패했습니다.") - } - } catch (error) { - console.error('빈 행 저장 실패:', error) - toast.error("등록 중 오류가 발생했습니다.") - } finally { - setIsCreating(false) - } - }, [emptyRows, onRefresh]) - - // 빈 행 취소 핸들러 - const handleCancelEmptyRow = React.useCallback((tempId: string) => { - setEmptyRows(prev => { - const newRows = { ...prev } - delete newRows[tempId] - return newRows - }) - - setPendingChanges(prev => { - const newChanges = { ...prev } - delete newChanges[tempId] - return newChanges - }) - - toast.info("등록이 취소되었습니다.") }, []) - // 빈 행 포함한 전체 데이터 - const allData = React.useMemo(() => { - const emptyRowArray = Object.values(emptyRows) - return [...data, ...emptyRowArray] - }, [data, emptyRows]) - // 테이블 메타 설정 + // 테이블 메타 설정 (읽기 전용) const tableMeta = React.useMemo(() => ({ - onCellUpdate: handleCellUpdate, - onCellCancel: handleCellCancel, onAction: handleAction, - onSaveEmptyRow: handleSaveEmptyRow, - onCancelEmptyRow: handleCancelEmptyRow, - isEmptyRow: (id: string) => String(id).startsWith('temp-'), - getPendingChanges: () => pendingChanges, - }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges]) + }), [handleAction]) // 데이터 테이블 설정 const { table } = useDataTable({ - data: allData, + data, columns, - pageCount, + pageCount: pageCount ?? 1, initialState: { sorting: [{ id: "no", desc: false }], - columnPinning: { right: ["actions"] }, + pagination: { + pageIndex: 0, + pageSize: 10, + }, }, getRowId: (row) => String(row.id), meta: tableMeta, }) - // 변경사항이 있는지 확인 - const hasPendingChanges = Object.keys(pendingChanges).length > 0 - const hasEmptyRows = Object.keys(emptyRows).length > 0 return (
@@ -435,45 +92,25 @@ export function AvlDetailTable({
- {/* 상단 버튼 및 검색 영역 */} -
-
- - - - -
- -
-
- setSearchValue(e.target.value)} - /> -
-
+ {/* 상단 버튼 영역 */} +
+ + + +
{/* 데이터 테이블 */} - {/* 디버그 정보 (개발 환경에서만 표시) */} - {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && ( -
-
Pending Changes: {Object.keys(pendingChanges).length}
-
Empty Rows: {Object.keys(emptyRows).length}
-
- )}
) } diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx index def3d30a..52912a2c 100644 --- a/lib/avl/table/avl-registration-area.tsx +++ b/lib/avl/table/avl-registration-area.tsx @@ -5,15 +5,27 @@ import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" import { useAtom } from "jotai" -import { ProjectAvlTable } from "./project-avl-table" -import { StandardAvlTable } from "./standard-avl-table" -import { VendorPoolTable } from "./vendor-pool-table" +import { ProjectAvlTable, ProjectAvlTableRef } from "./project-avl-table" +import { StandardAvlTable, StandardAvlTableRef } from "./standard-avl-table" +import { VendorPoolTable, VendorPoolTableRef } from "./vendor-pool-table" import { selectedAvlRecordAtom } from "../avl-atoms" -import type { AvlListItem } from "../types" +import { copyToProjectAvl, copyToStandardAvl, copyToVendorPool, copyFromVendorPoolToProjectAvl, copyFromVendorPoolToStandardAvl, copyFromStandardAvlToVendorPool } from "../service" +import { useSession } from "next-auth/react" +import { toast } from "sonner" // 선택된 테이블 타입 type SelectedTable = 'project' | 'standard' | 'vendor' | null +// TODO: 나머지 테이블들도 ref 지원 추가 시 인터페이스 추가 필요 +// interface StandardAvlTableRef { +// getSelectedIds?: () => number[] +// } +// +// interface VendorPoolTableRef { +// getSelectedIds?: () => number[] +// } + + // 선택 상태 액션 타입 type SelectionAction = | { type: 'SELECT_PROJECT'; count: number } @@ -105,9 +117,13 @@ interface AvlRegistrationAreaProps { } export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) { + // 선택된 AVL 레코드 구독 const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom) + // 세션 정보 + const { data: session } = useSession() + // 단일 선택 상태 관리 (useReducer 사용) const [selectionState, dispatch] = React.useReducer(selectionReducer, { selectedTable: null, @@ -121,10 +137,12 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro // 선택 핸들러들 const handleProjectSelection = React.useCallback((count: number) => { + console.log('handleProjectSelection called with count:', count) dispatch({ type: 'SELECT_PROJECT', count }) }, []) const handleStandardSelection = React.useCallback((count: number) => { + console.log('handleStandardSelection called with count:', count) dispatch({ type: 'SELECT_STANDARD', count }) }, []) @@ -134,6 +152,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro const { selectedTable, selectedRowCount, resetCounters } = selectionState + console.log('selectedTable', selectedTable); + // 선택된 AVL에 따른 필터 값들 const [currentProjectCode, setCurrentProjectCode] = React.useState("") const constructionSector = selectedAvlRecord?.constructionSector || "" @@ -142,6 +162,38 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro const htDivision = selectedAvlRecord?.htDivision || "" const avlListId = selectedAvlRecord?.id ? String(selectedAvlRecord.id) : "" + // 선종별 표준 AVL 검색 조건 상태 (복사 버튼 활성화용) + const [standardSearchConditions, setStandardSearchConditions] = React.useState({ + constructionSector: "", + shipType: "", + avlKind: "", + htDivision: "" + }) + + // 검색 조건이 모두 입력되었는지 확인 + const isStandardSearchConditionsComplete = React.useMemo(() => { + return ( + standardSearchConditions.constructionSector.trim() !== "" && + standardSearchConditions.shipType.trim() !== "" && + standardSearchConditions.avlKind.trim() !== "" && + standardSearchConditions.htDivision.trim() !== "" + ) + }, [standardSearchConditions]) + + // 벤더 풀 리로드 트리거 + const [vendorPoolReloadTrigger, setVendorPoolReloadTrigger] = React.useState(0) + + // 선종별 표준 AVL 리로드 트리거 + const [standardAvlReloadTrigger, setStandardAvlReloadTrigger] = React.useState(0) + + // 프로젝트 AVL 리로드 트리거 + const [projectAvlReloadTrigger, setProjectAvlReloadTrigger] = React.useState(0) + + // 테이블 ref들 (선택된 행 정보 가져오기용) + const projectTableRef = React.useRef(null) + const standardTableRef = React.useRef(null) + const vendorTableRef = React.useRef(null) + // 선택된 AVL 레코드가 변경될 때 프로젝트 코드 초기화 React.useEffect(() => { setCurrentProjectCode(selectedAvlRecord?.projectCode || "") @@ -152,6 +204,231 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro setCurrentProjectCode(projectCode) }, []) + // 선택된 ID들을 가져오는 헬퍼 함수들 + const getSelectedIds = React.useCallback((tableType: 'project' | 'standard' | 'vendor') => { + // 각 테이블 컴포넌트에서 선택된 행들의 ID를 가져오는 로직 + switch (tableType) { + case 'project': + return projectTableRef.current?.getSelectedIds?.() || [] + case 'standard': + return standardTableRef.current?.getSelectedIds?.() || [] + case 'vendor': + return vendorTableRef.current?.getSelectedIds?.() || [] + default: + return [] + } + }, []) + + // 복사 버튼 핸들러들 + const handleCopyToProject = React.useCallback(async () => { + if (selectedTable !== 'standard' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('standard') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + if (!currentProjectCode) { + toast.error("프로젝트 코드가 설정되지 않았습니다.") + return + } + + try { + const result = await copyToProjectAvl( + selectedIds, + currentProjectCode, + parseInt(avlListId) || 1, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('프로젝트AVL로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session]) + + const handleCopyToStandard = React.useCallback(async () => { + if (selectedTable !== 'project' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('project') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + const targetStandardInfo = { + constructionSector: standardSearchConditions.constructionSector || "조선", + shipType: standardSearchConditions.shipType || "", + avlKind: standardSearchConditions.avlKind || "", + htDivision: standardSearchConditions.htDivision || "H" + } + + try { + const result = await copyToStandardAvl( + selectedIds, + targetStandardInfo, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선종별 표준 AVL 데이터 리로드 + setStandardAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('선종별표준AVL로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session]) + + const handleCopyToVendorPool = React.useCallback(async () => { + if (selectedTable !== 'project' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('project') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + try { + const result = await copyToVendorPool( + selectedIds, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 벤더 풀 데이터 리로드 + setVendorPoolReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, session]) + + // 추가 복사 버튼 핸들러들 + const handleCopyFromVendorToProject = React.useCallback(async () => { + if (selectedTable !== 'vendor' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('vendor') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + if (!currentProjectCode) { + toast.error("프로젝트 코드가 설정되지 않았습니다.") + return + } + + try { + const result = await copyFromVendorPoolToProjectAvl( + selectedIds, + currentProjectCode, + parseInt(avlListId) || 1, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 프로젝트 AVL 리로드 + setProjectAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀 → 프로젝트AVL 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session]) + + const handleCopyFromVendorToStandard = React.useCallback(async () => { + if (selectedTable !== 'vendor' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('vendor') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + const targetStandardInfo = { + constructionSector: standardSearchConditions.constructionSector || "조선", + shipType: standardSearchConditions.shipType || "", + avlKind: standardSearchConditions.avlKind || "", + htDivision: standardSearchConditions.htDivision || "H" + } + + try { + const result = await copyFromVendorPoolToStandardAvl( + selectedIds, + targetStandardInfo, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선종별 표준 AVL 리로드 + setStandardAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀 → 선종별표준AVL 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session]) + + const handleCopyFromStandardToVendor = React.useCallback(async () => { + if (selectedTable !== 'standard' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('standard') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + try { + const result = await copyFromStandardAvlToVendorPool( + selectedIds, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 벤더 풀 리로드 + setVendorPoolReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('선종별표준AVL → 벤더풀 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, session]) + return ( {/* 고정 헤더 영역 */} @@ -159,9 +436,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro

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

- + */}
@@ -172,11 +449,13 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* 프로젝트 AVL 테이블 - 9개 컬럼 */}
{/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */} @@ -188,7 +467,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro size="sm" className="w-8 h-8 p-0" title="프로젝트AVL로 복사" - disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0} + disabled={disabled || selectedTable === 'project' || selectedRowCount === 0} + onClick={handleCopyToProject} > @@ -198,7 +478,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro size="sm" className="w-8 h-8 p-0" title="선종별표준AVL로 복사" - disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0} + disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyToStandard} > @@ -209,6 +490,7 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro className="w-8 h-8 p-0" title="벤더풀로 복사" disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0} + onClick={handleCopyToVendorPool} > @@ -220,12 +502,15 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
{/* 이동 버튼들 - 두 번째 border 위에 오버레이 */} @@ -235,8 +520,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="프로젝트AVL로 복사" - disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0} + title="벤더풀의 항목을 프로젝트AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !currentProjectCode} + onClick={handleCopyFromVendorToProject} > @@ -245,8 +531,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="선종별표준AVL로 복사" - disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0} + title="벤더풀의 항목을 선종별표준AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyFromVendorToStandard} > @@ -254,8 +541,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="벤더풀로 복사" + title="선종별표준AVL의 항목을 벤더풀로 복사" disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0} + onClick={handleCopyFromStandardToVendor} > @@ -267,8 +555,10 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* Vendor Pool 테이블 - 10개 컬럼 */}
diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx index 77361f36..8caf012e 100644 --- a/lib/avl/table/avl-table-columns.tsx +++ b/lib/avl/table/avl-table-columns.tsx @@ -1,7 +1,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Eye, Edit, Trash2 } from "lucide-react" +import { Eye, Edit, Trash2, History } from "lucide-react" import { type ColumnDef, TableMeta } from "@tanstack/react-table" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { EditableCell } from "@/components/data-table/editable-cell" @@ -224,22 +224,24 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ), cell: ({ getValue, row, table }) => { const value = getValue() as number - const isModified = getIsModified(table, row.id, "rev") return ( - { - table.options.meta?.onCellUpdate?.(row.id, "rev", parseInt(newValue)) - }} - onCancel={() => { - table.options.meta?.onCellCancel?.(row.id, "rev") - }} - /> +
+ + {value || 1} + + +
) }, - size: 80, + size: 100, }, ], }, diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx index a6910ef5..eb9b2079 100644 --- a/lib/avl/table/avl-table.tsx +++ b/lib/avl/table/avl-table.tsx @@ -2,7 +2,6 @@ import * as React from "react" import type { - DataTableAdvancedFilterField, DataTableFilterField, } from "@/types/table" @@ -10,12 +9,12 @@ import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" import { toast } from "sonner" import { getColumns } from "./avl-table-columns" import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service" import type { AvlListItem } from "../types" +import { AvlHistoryModal, type AvlHistoryRecord } from "@/lib/avl/components/avl-history-modal" // 테이블 메타 타입 확장 declare module "@tanstack/react-table" { @@ -52,6 +51,50 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration const [emptyRows, setEmptyRows] = React.useState>({}) const [isCreating, setIsCreating] = React.useState(false) + // 히스토리 모달 관리 + const [historyModalOpen, setHistoryModalOpen] = React.useState(false) + const [selectedAvlItem, setSelectedAvlItem] = React.useState(null) + + // 히스토리 데이터 로드 함수 + const loadHistoryData = React.useCallback(async (avlItem: AvlListItem): Promise => { + 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 + } catch (error) { + console.error('히스토리 로드 실패:', error) + toast.error("히스토리를 불러오는데 실패했습니다.") + return [] + } + }, []) + // 필터 필드 정의 const filterFields: DataTableFilterField[] = [ { @@ -83,33 +126,6 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration }, ] - // 고급 필터 필드 정의 - const advancedFilterFields: DataTableAdvancedFilterField[] = [ - { - id: "projectCode", - label: "프로젝트 코드", - type: "text", - placeholder: "프로젝트 코드 입력...", - }, - { - id: "shipType", - label: "선종", - type: "text", - placeholder: "선종 입력...", - }, - { - id: "avlKind", - label: "AVL 종류", - type: "text", - placeholder: "AVL 종류 입력...", - }, - { - id: "createdBy", - label: "등재자", - type: "text", - placeholder: "등재자 입력...", - }, - ] // 인라인 편집 핸들러 (일괄 저장용) const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => { @@ -186,6 +202,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration avlKind: "", htDivision: "", rev: 1, + vendorInfoSnapshot: null, createdAt: new Date().toISOString().split('T')[0], updatedAt: new Date().toISOString().split('T')[0], createdBy: "system", @@ -296,6 +313,14 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration } break + case 'view-history': + // 리비전 히스토리 조회 + if (data?.id && !String(data.id).startsWith('temp-')) { + setSelectedAvlItem(data as AvlListItem) + setHistoryModalOpen(true) + } + break + default: toast.error(`알 수 없는 액션: ${action}`) } @@ -303,7 +328,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration console.error('액션 처리 실패:', error) toast.error("액션 처리 중 오류가 발생했습니다.") } - }, [pendingChanges, onRefresh]) + }, [pendingChanges, onRefresh, onRegistrationModeChange]) // 빈 행 저장 핸들러 const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { @@ -425,6 +450,10 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, + pagination: { + pageIndex: 0, + pageSize: 10, + }, }, getRowId: (row) => String(row.id), meta: tableMeta, @@ -502,6 +531,17 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration {/* 데이터 테이블 */} + {/* 히스토리 모달 */} + { + setHistoryModalOpen(false) + setSelectedAvlItem(null) + }} + avlItem={selectedAvlItem} + onLoadHistory={loadHistoryData} + /> + {/* 디버그 정보 (개발 환경에서만 표시) */} {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
diff --git a/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx new file mode 100644 index 00000000..174982e4 --- /dev/null +++ b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx @@ -0,0 +1,945 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { toast } from "sonner" +import type { AvlVendorInfoInput, AvlDetailItem } from "../types" + +interface AvlVendorAddAndModifyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onAddItem: (item: Omit) => Promise + editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) + onUpdateItem?: (id: number, item: Omit) => Promise + + // 모드 설정 + isTemplate?: boolean // false: 프로젝트 AVL, true: 표준 AVL + + // 표준 AVL용 초기값들 (선택적) + initialConstructionSector?: string + initialShipType?: string + initialAvlKind?: string + initialHtDivision?: string + + // 프로젝트 AVL용 초기값들 (선택적) + initialProjectCode?: string +} + +export function AvlVendorAddAndModifyDialog({ + open, + onOpenChange, + onAddItem, + editingItem, + onUpdateItem, + isTemplate = false, // 기본값: 프로젝트 AVL + initialConstructionSector, + initialShipType, + initialAvlKind, + initialHtDivision, + initialProjectCode +}: AvlVendorAddAndModifyDialogProps) { + const [formData, setFormData] = React.useState>({ + // 공통 기본 설정 + isTemplate: isTemplate, + + // 프로젝트 AVL용 필드들 + projectCode: initialProjectCode || "", + + // 표준 AVL용 필드들 + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + }) + + // 수정 모드일 때 폼 데이터 초기화 + React.useEffect(() => { + if (editingItem) { + setFormData({ + // 공통 기본 설정 + isTemplate: editingItem.isTemplate ?? isTemplate, + + // 프로젝트 AVL용 필드들 + projectCode: editingItem.projectCode || initialProjectCode || "", + + // 표준 AVL용 필드들 + constructionSector: editingItem.constructionSector || initialConstructionSector || "", + shipType: editingItem.shipType || initialShipType || "", + avlKind: editingItem.avlKind || initialAvlKind || "", + htDivision: editingItem.htDivision || initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", + disciplineCode: editingItem.disciplineCode || "", + disciplineName: editingItem.disciplineName || "", + + // 자재 정보 + materialNameCustomerSide: editingItem.materialNameCustomerSide || "", + + // 패키지 정보 + packageCode: editingItem.packageCode || "", + packageName: editingItem.packageName || "", + + // 자재그룹 정보 + materialGroupCode: editingItem.materialGroupCode || "", + materialGroupName: editingItem.materialGroupName || "", + + // 협력업체 정보 + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || "", + + // AVL 정보 + avlVendorName: editingItem.avlVendorName || "", + tier: editingItem.tier || "", + + // 제안방향 + ownerSuggestion: editingItem.ownerSuggestion || false, + shiSuggestion: editingItem.shiSuggestion || false, + + // 위치 정보 + headquarterLocation: editingItem.headquarterLocation || "", + manufacturingLocation: editingItem.manufacturingLocation || "", + + // FA 정보 + faTarget: editingItem.faTarget || false, + faStatus: editingItem.faStatus || "", + + // Agent 정보 + isAgent: editingItem.isAgent || false, + + // 계약 서명주체 + contractSignerName: editingItem.contractSignerName || "", + contractSignerCode: editingItem.contractSignerCode || "", + + // SHI Qualification + shiAvl: editingItem.shiAvl || false, + shiBlacklist: editingItem.shiBlacklist || false, + shiBcc: editingItem.shiBcc || false, + + // 기술영업 견적결과 + salesQuoteNumber: editingItem.salesQuoteNumber || "", + quoteCode: editingItem.quoteCode || "", + salesVendorInfo: editingItem.salesVendorInfo || "", + salesCountry: editingItem.salesCountry || "", + totalAmount: editingItem.totalAmount || "", + quoteReceivedDate: editingItem.quoteReceivedDate || "", + + // 업체 실적 현황 + recentQuoteDate: editingItem.recentQuoteDate || "", + recentQuoteNumber: editingItem.recentQuoteNumber || "", + recentOrderDate: editingItem.recentOrderDate || "", + recentOrderNumber: editingItem.recentOrderNumber || "", + + // 기타 + remarks: editingItem.remarks || "" + }) + } + }, [editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만) + React.useEffect(() => { + if (open && !editingItem) { + setFormData(prev => ({ + ...prev, + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + })) + } + }, [open, editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + const handleSubmit = async () => { + // 공통 필수 필드 검증 + if (!formData.disciplineName || !formData.materialNameCustomerSide) { + toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") + return + } + + // 모드별 필수 필드 검증 + if (isTemplate) { + // 표준 AVL 모드 + if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) { + toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.") + return + } + } else { + // 프로젝트 AVL 모드 + if (!formData.projectCode) { + toast.error("프로젝트 코드는 필수 입력 항목입니다.") + return + } + } + + try { + if (editingItem && onUpdateItem) { + // 수정 모드 + await onUpdateItem(editingItem.id, formData) + } else { + // 추가 모드 + await onAddItem(formData) + } + + // 폼 초기화 + setFormData({ + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + ownerSuggestion: false, + shiSuggestion: false, + headquarterLocation: "", + manufacturingLocation: "", + faTarget: false, + faStatus: "", + isAgent: false, + contractSignerName: "", + contractSignerCode: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "" + } as Omit) + + onOpenChange(false) + } catch (error) { + // 에러 처리는 호출하는 쪽에서 담당 + } + } + + const handleCancel = () => { + setFormData({ + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + ownerSuggestion: false, + shiSuggestion: false, + headquarterLocation: "", + manufacturingLocation: "", + faTarget: false, + faStatus: "", + isAgent: false, + contractSignerName: "", + contractSignerCode: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "" + } as Omit) + onOpenChange(false) + } + + // 선종 옵션들 (공사부문에 따라 다름) + const getShipTypeOptions = (constructionSector: string) => { + if (constructionSector === "조선") { + return [ + { value: "A-max", label: "A-max" }, + { value: "S-max", label: "S-max" }, + { value: "VLCC", label: "VLCC" }, + { value: "LNGC", label: "LNGC" }, + { value: "CONT", label: "CONT" }, + ] + } else if (constructionSector === "해양") { + return [ + { value: "FPSO", label: "FPSO" }, + { value: "FLNG", label: "FLNG" }, + { value: "FPU", label: "FPU" }, + { value: "Platform", label: "Platform" }, + { value: "WTIV", label: "WTIV" }, + { value: "GOM", label: "GOM" }, + ] + } else { + return [] + } + } + + const shipTypeOptions = getShipTypeOptions(formData.constructionSector) + + return ( + + + + + {isTemplate ? "표준 AVL" : "프로젝트 AVL"} {editingItem ? "항목 수정" : "항목 추가"} + + + {editingItem + ? `${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 수정합니다. 필수 항목을 입력해주세요.` + : `새로운 ${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 추가합니다. 필수 항목을 입력해주세요.` + } * 표시된 항목은 필수 입력사항입니다. + + +
+ {/* 모드별 필수 정보 */} + {!isTemplate ? ( + // 프로젝트 AVL 모드 +
+

프로젝트 정보 *

+
+
+ + setFormData(prev => ({ ...prev, projectCode: e.target.value }))} + placeholder="프로젝트 코드를 입력하세요" + /> +
+
+
+ ) : ( + // 표준 AVL 모드 +
+

표준 AVL 기본 정보 *

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {/* 공통 정보들 (나머지 폼 필드들은 동일하게 유지) */} + {/* 기본 정보 */} +
+

기본 정보

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

패키지 정보

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

자재그룹 정보

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

협력업체 정보

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

제안방향

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

위치 정보

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

FA 정보

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

Agent 정보

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

계약 서명주체

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

SHI Qualification

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

기술영업 견적결과

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

업체 실적 현황

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

기타

+
+ +