summaryrefslogtreecommitdiff
path: root/lib/avl
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-15 18:58:07 +0900
committerjoonhoekim <26rote@gmail.com>2025-09-15 18:58:07 +0900
commit2b490956c9752c1b756780a3461bc1c37b6fe0a7 (patch)
treeb0b8a03c8de5dfce4b6c7373a9d608306e9147c0 /lib/avl
parente7818a457371849e29519497ebf046f385f05ab6 (diff)
(김준회) AVL 관리 및 상세 - 기능 구현 1차
+ docker compose 내 오류 수정
Diffstat (limited to 'lib/avl')
-rw-r--r--lib/avl/components/avl-history-modal.tsx297
-rw-r--r--lib/avl/components/project-field-components.tsx113
-rw-r--r--lib/avl/components/project-field-utils.ts45
-rw-r--r--lib/avl/service.ts1244
-rw-r--r--lib/avl/snapshot-utils.ts190
-rw-r--r--lib/avl/table/avl-detail-table.tsx455
-rw-r--r--lib/avl/table/avl-registration-area.tsx316
-rw-r--r--lib/avl/table/avl-table-columns.tsx30
-rw-r--r--lib/avl/table/avl-table.tsx100
-rw-r--r--lib/avl/table/avl-vendor-add-and-modify-dialog.tsx945
-rw-r--r--lib/avl/table/columns-detail.tsx516
-rw-r--r--lib/avl/table/project-avl-table-columns.tsx167
-rw-r--r--lib/avl/table/project-avl-table.tsx720
-rw-r--r--lib/avl/table/standard-avl-add-dialog.tsx960
-rw-r--r--lib/avl/table/standard-avl-table-columns.tsx91
-rw-r--r--lib/avl/table/standard-avl-table.tsx603
-rw-r--r--lib/avl/table/vendor-pool-table-columns.tsx96
-rw-r--r--lib/avl/table/vendor-pool-table.tsx241
-rw-r--r--lib/avl/types.ts16
-rw-r--r--lib/avl/validations.ts4
20 files changed, 5466 insertions, 1683 deletions
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<AvlHistoryRecord[]>
+}
+
+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 (
+ <div className="text-sm text-muted-foreground">
+ 스냅샷 데이터가 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <Collapsible open={isOpen} onOpenChange={onToggle}>
+ <CollapsibleTrigger asChild>
+ <Button variant="outline" size="sm" className="w-full justify-between">
+ <span>벤더 상세 정보 ({snapshot.length}개)</span>
+ {isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
+ </Button>
+ </CollapsibleTrigger>
+ <CollapsibleContent className="mt-3">
+ <div className="border rounded-lg">
+ <div className="overflow-auto max-h-[400px]">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[60px]">No.</TableHead>
+ <TableHead className="w-[100px]">설계공종</TableHead>
+ <TableHead>고객사 AVL 자재명</TableHead>
+ <TableHead className="w-[120px]">자재그룹 코드</TableHead>
+ <TableHead className="w-[130px]">자재그룹 명</TableHead>
+ <TableHead>AVL 등재업체명</TableHead>
+ <TableHead className="w-[120px]">협력업체 코드</TableHead>
+ <TableHead className="w-[130px]">협력업체 명</TableHead>
+ <TableHead className="w-[80px]">선주제안</TableHead>
+ <TableHead className="w-[80px]">SHI 제안</TableHead>
+ <TableHead className="w-[100px]">본사 위치</TableHead>
+ <TableHead className="w-[80px]">등급</TableHead>
+ <TableHead className="w-[60px]">AVL</TableHead>
+ <TableHead className="w-[80px]">FA대상</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {snapshot.map((item, index) => (
+ <TableRow key={item.id || index}>
+ <TableCell className="font-mono text-xs text-center">{index + 1}</TableCell>
+ <TableCell className="text-sm">{item.disciplineName || '-'}</TableCell>
+ <TableCell className="text-sm">{item.materialNameCustomerSide || '-'}</TableCell>
+ <TableCell className="font-mono text-xs">{item.materialGroupCode || '-'}</TableCell>
+ <TableCell className="text-sm">{item.materialGroupName || '-'}</TableCell>
+ <TableCell className="font-medium text-sm">{item.avlVendorName || '-'}</TableCell>
+ <TableCell className="font-mono text-xs">{item.vendorCode || '-'}</TableCell>
+ <TableCell className="font-medium text-sm">{item.vendorName || '-'}</TableCell>
+ <TableCell>
+ <Badge variant={item.ownerSuggestion ? "default" : "secondary"} className="text-xs">
+ {item.ownerSuggestion ? "예" : "아니오"}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.shiSuggestion ? "default" : "secondary"} className="text-xs">
+ {item.shiSuggestion ? "예" : "아니오"}
+ </Badge>
+ </TableCell>
+ <TableCell className="text-xs">{item.headquarterLocation || '-'}</TableCell>
+ <TableCell>
+ {item.tier ? (
+ <Badge variant="outline" className="text-xs">
+ {item.tier}
+ </Badge>
+ ) : '-'}
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.hasAvl ? "default" : "secondary"} className="text-xs">
+ {item.hasAvl ? "Y" : "N"}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.faTarget ? "default" : "secondary"} className="text-xs">
+ {item.faTarget ? "Y" : "N"}
+ </Badge>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </Collapsible>
+ )
+}
+
+export function AvlHistoryModal({
+ isOpen,
+ onClose,
+ avlItem,
+ historyData,
+ onLoadHistory
+}: AvlHistoryModalProps) {
+ const [loading, setLoading] = React.useState(false)
+ const [history, setHistory] = React.useState<AvlHistoryRecord[]>([])
+ const [openSnapshots, setOpenSnapshots] = React.useState<Record<number, boolean>>({})
+
+ // 히스토리 데이터 로드
+ 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 (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ AVL 리비전 히스토리
+ </DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {avlItem.isTemplate ? "표준 AVL" : "프로젝트 AVL"} - {avlItem.avlKind}
+ {avlItem.projectCode && ` (${avlItem.projectCode})`}
+ </div>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-auto min-h-0">
+ <div className="pr-4">
+ {loading ? (
+ <div className="flex items-center justify-center h-[300px]">
+ <div className="text-muted-foreground">히스토리를 불러오는 중...</div>
+ </div>
+ ) : history.length === 0 ? (
+ <div className="flex items-center justify-center h-[300px]">
+ <div className="text-muted-foreground">히스토리 데이터가 없습니다.</div>
+ </div>
+ ) : (
+ <div className="space-y-4 py-4">
+ {history.map((record, index) => (
+ <div
+ key={record.id}
+ className={`p-4 border rounded-lg ${
+ index === 0 ? "border-primary bg-primary/5" : "border-border"
+ }`}
+ >
+ {/* 리비전 헤더 */}
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={index === 0 ? "default" : "outline"}
+ className="font-mono"
+ >
+ Rev {record.rev}
+ </Badge>
+ {index === 0 && (
+ <Badge variant="secondary" className="text-xs">
+ 현재
+ </Badge>
+ )}
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {new Date(record.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+ </div>
+
+ {/* 변경 설명 */}
+ {record.changeDescription && (
+ <div className="mb-3 p-2 bg-muted/50 rounded text-sm">
+ {record.changeDescription}
+ </div>
+ )}
+
+ {/* Vendor Info 요약 */}
+ <div className="grid grid-cols-3 gap-4 text-sm">
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.length || 0}
+ </div>
+ <div className="text-muted-foreground">총 협력업체</div>
+ </div>
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.filter(v => v.hasAvl).length || 0}
+ </div>
+ <div className="text-muted-foreground">AVL 등재</div>
+ </div>
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.filter(v => v.faTarget).length || 0}
+ </div>
+ <div className="text-muted-foreground">FA 대상</div>
+ </div>
+ </div>
+
+ {/* 스냅샷 테이블 */}
+ <div className="mt-3 pt-3 border-t">
+ <SnapshotTable
+ snapshot={record.vendorInfoSnapshot || []}
+ isOpen={openSnapshots[record.id] || false}
+ onToggle={() => toggleSnapshot(record.id)}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex justify-end pt-4 border-t flex-shrink-0 mt-4">
+ <Button variant="outline" onClick={onClose}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<ProjectInputFieldProps> = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+ status,
+ statusText,
+ minWidth = "250px"
+}) => (
+ <div className="space-y-2 min-w-[250px] flex-shrink-0" style={{ minWidth }}>
+ <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}>
+ {label}
+ {statusText && <span className="ml-1 text-xs">{statusText}</span>}
+ </label>
+ <Input
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ className={`h-8 text-sm ${getInputStatusClassName(status)}`}
+ />
+ </div>
+)
+
+export interface ProjectDisplayFieldProps {
+ label: string
+ value: string
+ status: ProjectSearchStatus
+ minWidth?: string
+ formatter?: (value: string) => string
+}
+
+export const ProjectDisplayField: React.FC<ProjectDisplayFieldProps> = ({
+ label,
+ value,
+ status,
+ minWidth = "120px",
+ formatter
+}) => {
+ const displayValue = status === 'searching' ? '조회 중...' : (formatter ? formatter(value) : (value || '-'))
+
+ return (
+ <div className="space-y-2 flex-shrink-0" style={{ minWidth }}>
+ <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}>
+ {label}
+ {status === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${getDisplayElementStatusClassName(status)}`}>
+ {displayValue}
+ </div>
+ </div>
+ )
+}
+
+export interface ProjectFileFieldProps {
+ label: string
+ originalFile: string
+ onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void
+ minWidth?: string
+}
+
+export const ProjectFileField: React.FC<ProjectFileFieldProps> = ({
+ label,
+ originalFile,
+ onFileUpload,
+ minWidth = "200px"
+}) => (
+ <div className="space-y-2 flex-shrink-0" style={{ minWidth }}>
+ <label className="text-sm font-medium text-muted-foreground">{label}</label>
+ <div className="flex items-center gap-2 min-h-[32px]">
+ {originalFile ? (
+ <span className="text-sm text-blue-600">{originalFile}</span>
+ ) : (
+ <div className="relative">
+ <input
+ type="file"
+ onChange={onFileUpload}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ accept=".xlsx,.xls,.csv"
+ />
+ <Button variant="outline" size="sm" className="text-xs">
+ 파일 선택
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+)
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<AvlListIt
avlKind: data.avlKind,
htDivision: data.htDivision,
rev: data.rev ?? 1,
+ vendorInfoSnapshot: data.vendorInfoSnapshot, // 스냅샷 데이터 추가
createdBy: data.createdBy || 'system',
updatedBy: data.updatedBy || 'system',
};
- debugLog('DB INSERT 시작', { table: 'avl_list', data: insertData });
+ debugLog('DB INSERT 시작', {
+ table: 'avl_list',
+ data: insertData,
+ hasVendorSnapshot: !!insertData.vendorInfoSnapshot,
+ snapshotLength: insertData.vendorInfoSnapshot?.length
+ });
// 데이터베이스에 삽입
const result = await db
@@ -539,7 +530,11 @@ export async function createAvlList(data: CreateAvlListInput): Promise<AvlListIt
throw new Error("Failed to create AVL list");
}
- debugSuccess('DB INSERT 완료', { table: 'avl_list', result: result[0] });
+ debugSuccess('DB INSERT 완료', {
+ table: 'avl_list',
+ result: result[0],
+ savedSnapshotLength: result[0].vendorInfoSnapshot?.length
+ });
const createdItem = result[0];
@@ -555,6 +550,7 @@ export async function createAvlList(data: CreateAvlListInput): Promise<AvlListIt
avlType: createdItem.avlKind || '',
htDivision: createdItem.htDivision || '',
rev: createdItem.rev || 1,
+ vendorInfoSnapshot: createdItem.vendorInfoSnapshot, // 스냅샷 데이터 포함
};
debugSuccess('AVL 리스트 생성 완료', { result: transformedData });
@@ -684,11 +680,15 @@ export async function createAvlVendorInfo(data: AvlVendorInfoInput): Promise<Avl
try {
debugLog('AVL Vendor Info 생성 시작', { inputData: data });
- const currentTimestamp = new Date();
-
// UI 필드를 DB 필드로 변환
const insertData: NewAvlVendorInfo = {
- avlListId: data.avlListId,
+ isTemplate: data.isTemplate ?? false, // AVL 타입 구분
+ constructionSector: data.constructionSector || null, // 표준 AVL용
+ shipType: data.shipType || null, // 표준 AVL용
+ avlKind: data.avlKind || null, // 표준 AVL용
+ htDivision: data.htDivision || null, // 표준 AVL용
+ projectCode: data.projectCode || null, // 프로젝트 코드 저장
+ avlListId: data.avlListId || null, // nullable - 나중에 프로젝트별로 묶어줄 때 설정
ownerSuggestion: data.ownerSuggestion ?? false,
shiSuggestion: data.shiSuggestion ?? false,
equipBulkDivision: data.equipBulkDivision === "EQUIP" ? "E" : "B",
@@ -907,10 +907,172 @@ export async function deleteAvlVendorInfo(id: number): Promise<boolean> {
}
/**
+ * 프로젝트 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<ActionResult> => {
+ 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<ActionResult> => {
+ 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<ActionResult> => {
+ 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<string>();
+ 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<ActionResult> => {
+ 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<ActionResult> => {
+ 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<ActionResult> => {
+ 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<string>();
+ 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<VendorInfoSnapshot[]> {
+ 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<string, number>),
+ }
+}
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<TData> {
- onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
- onCellCancel?: (id: string, field: keyof TData) => void
- onAction?: (action: string, data?: any) => void
- onSaveEmptyRow?: (tempId: string) => Promise<void>
- onCancelEmptyRow?: (tempId: string) => void
- isEmptyRow?: (id: string) => boolean
- getPendingChanges?: () => Record<string, Partial<AvlDetailItem>>
- }
-}
interface AvlDetailTableProps {
data: AvlDetailItem[]
pageCount?: number
- avlListId: number // 상위 AVL 리스트 ID
- onRefresh?: () => void // 데이터 새로고침 콜백
avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입
projectCode?: string // 프로젝트 코드
shipOwnerName?: string // 선주명
@@ -39,386 +21,61 @@ interface AvlDetailTableProps {
export function AvlDetailTable({
data,
pageCount,
- avlListId,
- onRefresh,
avlType = '프로젝트AVL',
projectCode,
shipOwnerName,
businessType = '조선'
}: AvlDetailTableProps) {
- // 수정사항 추적 (일괄 저장용)
- const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlDetailItem>>>({})
- const [isSaving, setIsSaving] = React.useState(false)
-
- // 빈 행 관리 (신규 등록용)
- const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlDetailItem>>({})
- const [isCreating, setIsCreating] = React.useState(false)
-
- // 검색 상태
- const [searchValue, setSearchValue] = React.useState("")
-
-
- // 인라인 편집 핸들러 (일괄 저장용)
- const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlDetailItem, newValue: any) => {
- const isEmptyRow = String(id).startsWith('temp-')
-
- if (isEmptyRow) {
- // 빈 행의 경우 emptyRows 상태도 업데이트
- setEmptyRows(prev => ({
- ...prev,
- [id]: {
- ...prev[id],
- [field]: newValue
- }
- }))
- }
-
- // pendingChanges에 변경사항 저장 (실시간 표시용)
- setPendingChanges(prev => ({
- ...prev,
- [id]: {
- ...prev[id],
- [field]: newValue
- }
- }))
- }, [])
-
- // 편집 취소 핸들러
- const handleCellCancel = React.useCallback((id: string, field: keyof AvlDetailItem) => {
- const isEmptyRow = String(id).startsWith('temp-')
-
- if (isEmptyRow) {
- // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소
- setEmptyRows(prev => ({
- ...prev,
- [id]: {
- ...prev[id],
- [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용)
- }
- }))
-
- setPendingChanges(prev => {
- const itemChanges = { ...prev[id] }
- delete itemChanges[field]
-
- if (Object.keys(itemChanges).length === 0) {
- const newChanges = { ...prev }
- delete newChanges[id]
- return newChanges
- }
-
- return {
- ...prev,
- [id]: itemChanges
- }
- })
- }
- }, [])
-
// 액션 핸들러
- const handleAction = React.useCallback(async (action: string, data?: any) => {
- try {
- switch (action) {
- case 'new-vendor':
- // 신규 협력업체 추가 - 빈 행 추가
- const tempId = `temp-${Date.now()}`
- const newEmptyRow: AvlDetailItem = {
- id: tempId,
- no: 0,
- selected: false,
- avlListId: avlListId,
- equipBulkDivision: "EQUIP",
- disciplineCode: "",
- disciplineName: "",
- materialNameCustomerSide: "",
- packageCode: "",
- packageName: "",
- materialGroupCode: "",
- materialGroupName: "",
- vendorId: undefined,
- vendorName: "",
- vendorCode: "",
- avlVendorName: "",
- tier: "",
- faTarget: false,
- faStatus: "",
- isAgent: false,
- agentStatus: "아니오",
- contractSignerId: undefined,
- contractSignerName: "",
- contractSignerCode: "",
- headquarterLocation: "",
- manufacturingLocation: "",
- shiAvl: false,
- shiBlacklist: false,
- shiBcc: false,
- salesQuoteNumber: "",
- quoteCode: "",
- salesVendorInfo: "",
- salesCountry: "",
- totalAmount: "",
- quoteReceivedDate: "",
- recentQuoteDate: "",
- recentQuoteNumber: "",
- recentOrderDate: "",
- recentOrderNumber: "",
- remarks: "",
- createdAt: new Date().toISOString().split('T')[0],
- updatedAt: new Date().toISOString().split('T')[0],
- }
-
- setEmptyRows(prev => ({
- ...prev,
- [tempId]: newEmptyRow
- }))
- toast.success("신규 협력업체 행이 추가되었습니다.")
- break
-
- case 'bulk-import':
- // 일괄 입력
- const bulkResult = await handleAvlAction('bulk-import')
- if (bulkResult.success) {
- toast.success(bulkResult.message)
- } else {
- toast.error(bulkResult.message)
- }
- break
-
- case 'save':
- // 변경사항 저장
- if (Object.keys(pendingChanges).length === 0) {
- toast.info("저장할 변경사항이 없습니다.")
- return
- }
-
- setIsSaving(true)
- try {
- // 각 변경사항을 순차적으로 저장
- for (const [id, changes] of Object.entries(pendingChanges)) {
- if (String(id).startsWith('temp-')) continue // 빈 행은 제외
-
- const numericId = Number(id)
- if (isNaN(numericId)) {
- throw new Error(`유효하지 않은 ID: ${id}`)
- }
-
- const result = await updateAvlVendorInfo(numericId, changes)
- if (!result) {
- throw new Error(`항목 ${id} 저장 실패`)
- }
- }
-
- setPendingChanges({})
- toast.success("변경사항이 저장되었습니다.")
- onRefresh?.()
- } catch (error) {
- console.error('저장 실패:', error)
- toast.error("저장 중 오류가 발생했습니다.")
- } finally {
- setIsSaving(false)
- }
- break
-
- case 'edit':
- // 수정 모달 열기 (현재는 간단한 토스트로 처리)
- toast.info(`${data?.id} 항목 수정`)
- break
-
- case 'delete':
- // 삭제 확인 및 실행
- if (!data?.id || String(data.id).startsWith('temp-')) return
-
- const numericId = Number(data.id)
- if (isNaN(numericId)) {
- toast.error("유효하지 않은 항목 ID입니다.")
- return
- }
-
- const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`)
- if (!confirmed) return
-
- try {
- const result = await deleteAvlVendorInfo(numericId)
- if (result) {
- toast.success("항목이 삭제되었습니다.")
- onRefresh?.()
- } else {
- toast.error("삭제에 실패했습니다.")
- }
- } catch (error) {
- console.error('삭제 실패:', error)
- toast.error("삭제 중 오류가 발생했습니다.")
- }
- break
-
- case 'avl-form':
- // AVL 양식 다운로드/보기
- toast.info("AVL 양식을 준비 중입니다.")
- // TODO: AVL 양식 다운로드 로직 구현
- break
-
- case 'quote-request':
- // 견적 요청
- toast.info("견적 요청을 처리 중입니다.")
- // TODO: 견적 요청 로직 구현
- break
-
- case 'vendor-pool':
- // Vendor Pool 관리
- toast.info("Vendor Pool을 열고 있습니다.")
- // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현
- break
-
- case 'download':
- // 데이터 다운로드
- toast.info("데이터를 다운로드 중입니다.")
- // TODO: 데이터 다운로드 로직 구현
- break
-
- default:
- toast.error(`알 수 없는 액션: ${action}`)
- }
- } catch (error) {
- console.error('액션 처리 실패:', error)
- toast.error("액션 처리 중 오류가 발생했습니다.")
+ 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 (
<div className="space-y-4">
@@ -435,45 +92,25 @@ export function AvlDetailTable({
</div>
</div>
- {/* 상단 버튼 및 검색 영역 */}
- <div className="flex items-center justify-between gap-4">
- <div className="flex items-center gap-2">
- <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}>
- AVL양식
- </Button>
- <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}>
- 견적요청
- </Button>
- <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}>
- Vendor Pool
- </Button>
- <Button variant="outline" size="sm" onClick={() => handleAction('download')}>
- 다운로드
- </Button>
- </div>
-
- <div className="flex items-center gap-2">
- <div className="relative">
- <Input
- placeholder="검색..."
- className="w-64"
- value={searchValue}
- onChange={(e) => setSearchValue(e.target.value)}
- />
- </div>
- </div>
+ {/* 상단 버튼 영역 */}
+ <div className="flex items-center gap-2">
+ <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}>
+ AVL양식
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}>
+ 견적요청
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}>
+ Vendor Pool
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('download')}>
+ 다운로드
+ </Button>
</div>
{/* 데이터 테이블 */}
<DataTable table={table} />
- {/* 디버그 정보 (개발 환경에서만 표시) */}
- {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
- <div className="text-xs text-muted-foreground p-2 bg-muted rounded">
- <div>Pending Changes: {Object.keys(pendingChanges).length}</div>
- <div>Empty Rows: {Object.keys(emptyRows).length}</div>
- </div>
- )}
</div>
)
}
diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx
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<string>("")
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<ProjectAvlTableRef>(null)
+ const standardTableRef = React.useRef<StandardAvlTableRef>(null)
+ const vendorTableRef = React.useRef<VendorPoolTableRef>(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 (
<Card className={`h-full min-w-full overflow-visible ${disabled ? 'opacity-50 pointer-events-none' : ''}`}>
{/* 고정 헤더 영역 */}
@@ -159,9 +436,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">AVL 등록 {disabled ? "(비활성화)" : ""}</h3>
<div className="flex gap-2">
- <Button variant="outline" size="sm" disabled={disabled}>
+ {/* <Button variant="outline" size="sm" disabled={disabled}>
AVL 불러오기
- </Button>
+ </Button> */}
</div>
</div>
</div>
@@ -172,11 +449,13 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro
{/* 프로젝트 AVL 테이블 - 9개 컬럼 */}
<div className="p-4 border-r relative">
<ProjectAvlTable
+ ref={projectTableRef}
onSelectionChange={handleProjectSelection}
resetCounter={resetCounters.project}
projectCode={currentProjectCode}
avlListId={parseInt(avlListId) || 1}
onProjectCodeChange={handleProjectCodeChange}
+ reloadTrigger={projectAvlReloadTrigger}
/>
{/* 이동 버튼들 - 첫 번째 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}
>
<ChevronLeft className="w-4 h-4" />
</Button>
@@ -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}
>
<ChevronRight className="w-4 h-4" />
</Button>
@@ -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}
>
<ChevronsRight className="w-4 h-4" />
</Button>
@@ -220,12 +502,15 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro
{/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
<div className="p-4 border-r relative">
<StandardAvlTable
+ ref={standardTableRef}
onSelectionChange={handleStandardSelection}
resetCounter={resetCounters.standard}
constructionSector={constructionSector}
shipType={shipType}
avlKind={avlKind}
htDivision={htDivision}
+ onSearchConditionsChange={setStandardSearchConditions}
+ reloadTrigger={standardAvlReloadTrigger}
/>
{/* 이동 버튼들 - 두 번째 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}
>
<ChevronsLeft className="w-4 h-4" />
</Button>
@@ -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}
>
<ChevronLeft className="w-4 h-4" />
</Button>
@@ -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}
>
<ChevronRight className="w-4 h-4" />
</Button>
@@ -267,8 +555,10 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro
{/* Vendor Pool 테이블 - 10개 컬럼 */}
<div className="p-4 relative">
<VendorPoolTable
+ ref={vendorTableRef}
onSelectionChange={handleVendorSelection}
resetCounter={resetCounters.vendor}
+ reloadTrigger={vendorPoolReloadTrigger}
/>
</div>
</div>
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 (
- <EditableCell
- value={value?.toString() || ""}
- isModified={isModified}
- type="number"
- onUpdate={(newValue) => {
- table.options.meta?.onCellUpdate?.(row.id, "rev", parseInt(newValue))
- }}
- onCancel={() => {
- table.options.meta?.onCellCancel?.(row.id, "rev")
- }}
- />
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="font-mono">
+ {value || 1}
+ </Badge>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => table.options.meta?.onAction?.('view-history', row.original)}
+ title="리비전 히스토리 보기"
+ >
+ <History className="h-3 w-3" />
+ </Button>
+ </div>
)
},
- 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<Record<string, AvlListItem>>({})
const [isCreating, setIsCreating] = React.useState(false)
+ // 히스토리 모달 관리
+ const [historyModalOpen, setHistoryModalOpen] = React.useState(false)
+ const [selectedAvlItem, setSelectedAvlItem] = React.useState<AvlListItem | null>(null)
+
+ // 히스토리 데이터 로드 함수
+ const loadHistoryData = React.useCallback(async (avlItem: AvlListItem): Promise<AvlHistoryRecord[]> => {
+ try {
+ // 현재 리비전의 스냅샷 데이터 (실제 저장된 데이터 사용)
+ const currentSnapshot = avlItem.vendorInfoSnapshot || []
+
+ const historyData: AvlHistoryRecord[] = [
+ {
+ id: avlItem.id,
+ rev: avlItem.rev || 1,
+ createdAt: avlItem.createdAt || new Date().toISOString(),
+ createdBy: avlItem.createdBy || "system",
+ vendorInfoSnapshot: currentSnapshot,
+ changeDescription: "최신 리비전 (확정완료)"
+ }
+ ]
+
+ // TODO: 실제 구현에서는 DB에서 이전 리비전들의 스냅샷 데이터를 조회해야 함
+ // 현재는 더미 데이터로 이전 리비전들을 시뮬레이션
+ if ((avlItem.rev || 1) > 1) {
+ for (let rev = (avlItem.rev || 1) - 1; rev >= 1; rev--) {
+ historyData.push({
+ id: avlItem.id + rev * 1000, // 임시 ID
+ rev,
+ createdAt: new Date(Date.now() - rev * 24 * 60 * 60 * 1000).toISOString(),
+ createdBy: "system",
+ vendorInfoSnapshot: [], // 이전 리비전의 스냅샷 데이터 (실제로는 DB에서 조회)
+ changeDescription: `리비전 ${rev} 변경사항`
+ })
+ }
+ }
+
+ return historyData
+ } catch (error) {
+ console.error('히스토리 로드 실패:', error)
+ toast.error("히스토리를 불러오는데 실패했습니다.")
+ return []
+ }
+ }, [])
+
// 필터 필드 정의
const filterFields: DataTableFilterField<AvlListItem>[] = [
{
@@ -83,33 +126,6 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration
},
]
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<AvlListItem>[] = [
- {
- id: "projectCode",
- label: "프로젝트 코드",
- type: "text",
- placeholder: "프로젝트 코드 입력...",
- },
- {
- id: "shipType",
- label: "선종",
- type: "text",
- placeholder: "선종 입력...",
- },
- {
- id: "avlKind",
- label: "AVL 종류",
- type: "text",
- placeholder: "AVL 종류 입력...",
- },
- {
- id: "createdBy",
- label: "등재자",
- type: "text",
- placeholder: "등재자 입력...",
- },
- ]
// 인라인 편집 핸들러 (일괄 저장용)
const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => {
@@ -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
{/* 데이터 테이블 */}
<DataTable table={table} />
+ {/* 히스토리 모달 */}
+ <AvlHistoryModal
+ isOpen={historyModalOpen}
+ onClose={() => {
+ setHistoryModalOpen(false)
+ setSelectedAvlItem(null)
+ }}
+ avlItem={selectedAvlItem}
+ onLoadHistory={loadHistoryData}
+ />
+
{/* 디버그 정보 (개발 환경에서만 표시) */}
{process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
<div className="text-xs text-muted-foreground p-2 bg-muted rounded">
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<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+
+ // 모드 설정
+ 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<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 공통 기본 설정
+ 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<AvlVendorInfoInput, 'avlListId'>)
+
+ 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<AvlVendorInfoInput, 'avlListId'>)
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {isTemplate ? "표준 AVL" : "프로젝트 AVL"} {editingItem ? "항목 수정" : "항목 추가"}
+ </DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? `${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 수정합니다. 필수 항목을 입력해주세요.`
+ : `새로운 ${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 추가합니다. 필수 항목을 입력해주세요.`
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 모드별 필수 정보 */}
+ {!isTemplate ? (
+ // 프로젝트 AVL 모드
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">프로젝트 정보 *</h4>
+ <div className="grid grid-cols-1 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="projectCode">프로젝트 코드 *</Label>
+ <Input
+ id="projectCode"
+ value={formData.projectCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, projectCode: e.target.value }))}
+ placeholder="프로젝트 코드를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+ ) : (
+ // 표준 AVL 모드
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="constructionSector">공사부문 *</Label>
+ <Select
+ value={formData.constructionSector}
+ onValueChange={(value) => {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: value,
+ shipType: "" // 공사부문 변경 시 선종 초기화
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="공사부문을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="shipType">선종 *</Label>
+ <Select
+ value={formData.shipType}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, shipType: value }))
+ }
+ disabled={!formData.constructionSector}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선종을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlKind">AVL종류 *</Label>
+ <Select
+ value={formData.avlKind}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, avlKind: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="AVL종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Nearshore">Nearshore</SelectItem>
+ <SelectItem value="Offshore">Offshore</SelectItem>
+ <SelectItem value="IOC">IOC</SelectItem>
+ <SelectItem value="NOC">NOC</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="htDivision">H/T 구분 *</Label>
+ <Select
+ value={formData.htDivision}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, htDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="H/T 구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="공통">공통</SelectItem>
+ <SelectItem value="H">Hull (H)</SelectItem>
+ <SelectItem value="T">Topside (T)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 공통 정보들 (나머지 폼 필드들은 동일하게 유지) */}
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/columns-detail.tsx b/lib/avl/table/columns-detail.tsx
index 204d34f5..84ad9d9a 100644
--- a/lib/avl/table/columns-detail.tsx
+++ b/lib/avl/table/columns-detail.tsx
@@ -1,28 +1,8 @@
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Edit, Trash2 } from "lucide-react"
-import { type ColumnDef, TableMeta } from "@tanstack/react-table"
+import { type ColumnDef } from "@tanstack/react-table"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { EditableCell } from "@/components/data-table/editable-cell"
-// 수정 여부 확인 헬퍼 함수
-const getIsModified = (table: any, rowId: string, fieldName: string) => {
- const pendingChanges = table.options.meta?.getPendingChanges?.() || {}
- return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)]
-}
-
-// 테이블 메타 타입 확장
-declare module "@tanstack/react-table" {
- interface TableMeta<TData> {
- onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
- onCellCancel?: (id: string, field: keyof TData) => void
- onAction?: (action: string, data?: any) => void
- onSaveEmptyRow?: (tempId: string) => Promise<void>
- onCancelEmptyRow?: (tempId: string) => void
- isEmptyRow?: (id: string) => boolean
- }
-}
// AVL 상세 아이템 타입
export type AvlDetailItem = {
@@ -90,29 +70,6 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: "기본 정보",
columns: [
{
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- size: 50,
- },
- {
accessorKey: "no",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="No." />
@@ -124,33 +81,12 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" />
),
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("equipBulkDivision") as string
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "equipBulkDivision", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "equipBulkDivision")
-
return (
- <EditableCell
- value={value}
- type="select"
- onSave={onSave}
- options={[
- { label: "EQUIP", value: "EQUIP" },
- { label: "BULK", value: "BULK" }
- ]}
- placeholder="구분 선택"
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
+ <Badge variant="outline">
+ {value || "-"}
+ </Badge>
)
},
size: 120,
@@ -160,31 +96,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="설계공종" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("disciplineName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "disciplineName", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "disciplineName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="설계공종 입력"
- maxLength={50}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("disciplineName") as string
+ return <span>{value || "-"}</span>
},
size: 120,
},
@@ -193,31 +107,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("materialNameCustomerSide")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "materialNameCustomerSide", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "materialNameCustomerSide")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="자재명 입력"
- maxLength={100}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("materialNameCustomerSide") as string
+ return <span>{value || "-"}</span>
},
size: 150,
},
@@ -226,31 +118,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="패키지 정보" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("packageName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "packageName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="패키지명 입력"
- maxLength={100}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("packageName") as string
+ return <span>{value || "-"}</span>
},
size: 130,
},
@@ -259,31 +129,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="자재그룹코드" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("materialGroupCode")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "materialGroupCode")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="자재그룹코드 입력"
- maxLength={50}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupCode") as string
+ return <span>{value || "-"}</span>
},
size: 120,
},
@@ -292,31 +140,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="자재그룹명" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("materialGroupName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "materialGroupName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="자재그룹명 입력"
- maxLength={100}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupName") as string
+ return <span>{value || "-"}</span>
},
size: 130,
},
@@ -325,31 +151,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="협력업체코드" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("vendorCode")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "vendorCode", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "vendorCode")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="협력업체코드 입력"
- maxLength={50}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("vendorCode") as string
+ return <span>{value || "-"}</span>
},
size: 120,
},
@@ -358,31 +162,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="협력업체명" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("vendorName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "vendorName", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "vendorName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="협력업체명 입력"
- maxLength={100}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("vendorName") as string
+ return <span className="font-medium">{value || "-"}</span>
},
size: 140,
},
@@ -391,31 +173,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("avlVendorName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "avlVendorName", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "avlVendorName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="AVL 등재업체명 입력"
- maxLength={100}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("avlVendorName") as string
+ return <span>{value || "-"}</span>
},
size: 140,
},
@@ -424,34 +184,20 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="등급 (Tier)" />
),
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("tier") as string
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "tier", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "tier")
-
+ if (!value) return <span>-</span>
+
+ const tierColor = {
+ "Tier 1": "bg-green-100 text-green-800",
+ "Tier 2": "bg-yellow-100 text-yellow-800",
+ "Tier 3": "bg-red-100 text-red-800"
+ }[value] || "bg-gray-100 text-gray-800"
+
return (
- <EditableCell
- value={value}
- type="select"
- onSave={onSave}
- options={[
- { label: "Tier 1", value: "Tier 1" },
- { label: "Tier 2", value: "Tier 2" },
- { label: "Tier 3", value: "Tier 3" }
- ]}
- placeholder="등급 선택"
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
+ <Badge className={tierColor}>
+ {value}
+ </Badge>
)
},
size: 100,
@@ -467,28 +213,12 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="FA 대상" />
),
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("faTarget") as boolean
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "faTarget", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "faTarget")
-
return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
+ <Badge variant={value ? "default" : "secondary"}>
+ {value ? "대상" : "비대상"}
+ </Badge>
)
},
size: 80,
@@ -498,31 +228,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="FA 현황" />
),
- cell: ({ row, table }) => {
- const value = row.getValue("faStatus")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "faStatus", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "faStatus")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="FA 현황 입력"
- maxLength={50}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ cell: ({ row }) => {
+ const value = row.getValue("faStatus") as string
+ return <span>{value || "-"}</span>
},
size: 100,
},
@@ -535,27 +243,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
{
accessorKey: "shiAvl",
header: "AVL",
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("shiAvl") as boolean
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shiAvl", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "shiAvl")
-
return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI AVL 등재 여부"
/>
)
},
@@ -564,27 +258,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
{
accessorKey: "shiBlacklist",
header: "Blacklist",
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("shiBlacklist") as boolean
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shiBlacklist", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "shiBlacklist")
-
return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI Blacklist 등재 여부"
/>
)
},
@@ -593,27 +273,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
{
accessorKey: "shiBcc",
header: "BCC",
- cell: ({ row, table }) => {
+ cell: ({ row }) => {
const value = row.getValue("shiBcc") as boolean
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shiBcc", newValue)
- }
- }
-
- const isModified = getIsModified(table, row.original.id, "shiBcc")
-
return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={true}
- disabled={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI BCC 등재 여부"
/>
)
},
@@ -621,60 +287,4 @@ export const columns: ColumnDef<AvlDetailItem>[] = [
},
],
},
- // 액션 컬럼
- {
- id: "actions",
- header: "액션",
- cell: ({ row, table }) => {
- const isEmptyRow = String(row.original.id).startsWith('temp-')
-
- return (
- <div className="flex items-center gap-2">
- {!isEmptyRow && (
- <>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => table.options.meta?.onAction?.('edit', row.original)}
- className="h-8 w-8 p-0"
- >
- <Edit className="h-4 w-4" />
- </Button>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => table.options.meta?.onAction?.('delete', row.original)}
- className="h-8 w-8 p-0 text-destructive hover:text-destructive"
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </>
- )}
- {isEmptyRow && (
- <>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => table.options.meta?.onSaveEmptyRow?.(row.original.id)}
- className="h-8 w-8 p-0"
- >
- 저장
- </Button>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => table.options.meta?.onCancelEmptyRow?.(row.original.id)}
- className="h-8 w-8 p-0"
- >
- 취소
- </Button>
- </>
- )}
- </div>
- )
- },
- size: 100,
- enableSorting: false,
- enableHiding: false,
- },
]
diff --git a/lib/avl/table/project-avl-table-columns.tsx b/lib/avl/table/project-avl-table-columns.tsx
new file mode 100644
index 00000000..c052e6f7
--- /dev/null
+++ b/lib/avl/table/project-avl-table-columns.tsx
@@ -0,0 +1,167 @@
+import { ColumnDef } from "@tanstack/react-table"
+import { ProjectAvlItem } from "./project-avl-table"
+import { Checkbox } from "@/components/ui/checkbox"
+
+
+// 프로젝트 AVL 테이블 컬럼
+export const getProjectAvlColumns = (): ColumnDef<ProjectAvlItem>[] => [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // 프로젝트 AVL 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.no}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.disciplineName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: "고객사 AVL 자재명",
+ size: 150,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialNameCustomerSide}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.avlVendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "ownerSuggestion",
+ header: "선주제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.ownerSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "shiSuggestion",
+ header: "SHI 제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.shiSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx
index c6dd8064..8664e32b 100644
--- a/lib/avl/table/project-avl-table.tsx
+++ b/lib/avl/table/project-avl-table.tsx
@@ -1,204 +1,57 @@
"use client"
import * as React from "react"
-import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from "react"
import { DataTable } from "@/components/data-table/data-table"
import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Input } from "@/components/ui/input"
-import { ProjectAvlAddDialog } from "./project-avl-add-dialog"
-import { getProjectAvlVendorInfo, getAvlListById, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo } from "../service"
+import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog"
+import { getProjectAvlVendorInfo, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeProjectAvl } from "../service"
import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service"
import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service"
import { GetProjectAvlSchema } from "../validations"
-import { AvlDetailItem, AvlListItem, AvlVendorInfoInput } from "../types"
+import { AvlDetailItem, AvlVendorInfoInput } from "../types"
import { toast } from "sonner"
+import { getProjectAvlColumns } from "./project-avl-table-columns"
+import {
+ ProjectDisplayField,
+ ProjectFileField
+} from "../components/project-field-components"
+import { ProjectSearchStatus } from "../components/project-field-utils"
+import { useSession } from "next-auth/react"
+
// 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용
export type ProjectAvlItem = AvlDetailItem
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface ProjectAvlTableRef {
+ getSelectedIds: () => number[]
+}
+
interface ProjectAvlTableProps {
onSelectionChange?: (count: number) => void
resetCounter?: number
projectCode?: string // 프로젝트 코드 필터
avlListId?: number // AVL 리스트 ID (관리 영역 표시용)
onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백
+ reloadTrigger?: number
}
-// 프로젝트 AVL 테이블 컬럼
-const getProjectAvlColumns = (): ColumnDef<ProjectAvlItem>[] => [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row, table }) => {
- // 프로젝트 AVL 테이블의 단일 선택 핸들러
- const handleRowSelection = (checked: boolean) => {
- if (checked) {
- // 다른 모든 행의 선택 해제
- table.getRowModel().rows.forEach(r => {
- if (r !== row && r.getIsSelected()) {
- r.toggleSelected(false)
- }
- })
- }
- // 현재 행 선택/해제
- row.toggleSelected(checked)
- }
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={handleRowSelection}
- aria-label="Select row"
- />
- )
- },
- enableSorting: false,
- enableHiding: false,
- size: 50,
- },
- {
- accessorKey: "no",
- header: "No.",
- size: 60,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.no}
- </span>
- )
- },
- },
- {
- accessorKey: "disciplineName",
- header: "설계공종",
- size: 120,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.disciplineName}
- </span>
- )
- },
- },
- {
- accessorKey: "materialNameCustomerSide",
- header: "고객사 AVL 자재명",
- size: 150,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.materialNameCustomerSide}
- </span>
- )
- },
- },
- {
- accessorKey: "materialGroupCode",
- header: "자재그룹 코드",
- size: 120,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.materialGroupCode}
- </span>
- )
- },
- },
- {
- accessorKey: "materialGroupName",
- header: "자재그룹 명",
- size: 130,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.materialGroupName}
- </span>
- )
- },
- },
- {
- accessorKey: "avlVendorName",
- header: "AVL 등재업체명",
- size: 140,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.avlVendorName}
- </span>
- )
- },
- },
- {
- accessorKey: "vendorCode",
- header: "협력업체 코드",
- size: 120,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.vendorCode}
- </span>
- )
- },
- },
- {
- accessorKey: "vendorName",
- header: "협력업체 명",
- size: 130,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.vendorName}
- </span>
- )
- },
- },
- {
- accessorKey: "ownerSuggestion",
- header: "선주제안",
- size: 100,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.ownerSuggestion ? "예" : "아니오"}
- </span>
- )
- },
- },
- {
- accessorKey: "shiSuggestion",
- header: "SHI 제안",
- size: 100,
- cell: ({ row }) => {
- return (
- <span>
- {row.original.shiSuggestion ? "예" : "아니오"}
- </span>
- )
- },
- },
-]
-export function ProjectAvlTable({
+export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTableProps>(({
onSelectionChange,
resetCounter,
projectCode,
avlListId,
- onProjectCodeChange
-}: ProjectAvlTableProps) {
+ onProjectCodeChange,
+ reloadTrigger
+}, ref) => {
+
+ const { data: sessionData } = useSession()
+
const [data, setData] = React.useState<ProjectAvlItem[]>([])
- const [loading, setLoading] = React.useState(false)
const [pageCount, setPageCount] = React.useState(0)
- const [avlListInfo, setAvlListInfo] = React.useState<AvlListItem | null>(null)
const [originalFile, setOriginalFile] = React.useState<string>("")
const [localProjectCode, setLocalProjectCode] = React.useState<string>(projectCode || "")
@@ -215,20 +68,28 @@ export function ProjectAvlTable({
} | null>(null)
// 프로젝트 검색 상태
- const [projectSearchStatus, setProjectSearchStatus] = React.useState<'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error'>('idle')
+ const [projectSearchStatus, setProjectSearchStatus] = React.useState<ProjectSearchStatus>('idle')
+
+ // 검색 버튼 클릭 여부 상태
+ const [isSearchClicked, setIsSearchClicked] = React.useState(false)
+
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
// 데이터 로드 함수
- const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema>) => {
+ const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema> = {}) => {
try {
- setLoading(true)
- const params: any = {
+ const params = {
page: searchParams.page ?? 1,
perPage: searchParams.perPage ?? 10,
sort: searchParams.sort ?? [{ id: "no", desc: false }],
flags: searchParams.flags ?? [],
projectCode: localProjectCode || "",
- equipBulkDivision: searchParams.equipBulkDivision ?? "",
+ equipBulkDivision: (searchParams.equipBulkDivision as "EQUIP" | "BULK") ?? "EQUIP",
disciplineCode: searchParams.disciplineCode ?? "",
disciplineName: searchParams.disciplineName ?? "",
materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "",
@@ -244,7 +105,13 @@ export function ProjectAvlTable({
joinOperator: searchParams.joinOperator ?? "and",
search: searchParams.search ?? "",
}
+ console.log('ProjectAvlTable - API call params:', params)
const result = await getProjectAvlVendorInfo(params)
+ console.log('ProjectAvlTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
setData(result.data)
setPageCount(result.pageCount)
} catch (error) {
@@ -252,32 +119,26 @@ export function ProjectAvlTable({
setData([])
setPageCount(0)
} finally {
- setLoading(false)
+ // 로딩 상태 처리 완료
}
}, [localProjectCode])
- // AVL 리스트 정보 로드
+
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
React.useEffect(() => {
- const loadAvlListInfo = async () => {
- if (avlListId) {
- try {
- const info = await getAvlListById(avlListId)
- setAvlListInfo(info)
- } catch (error) {
- console.error("AVL 리스트 정보 로드 실패:", error)
- }
- }
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('ProjectAvlTable - reloadTrigger changed, reloading data')
+ loadData({})
}
+ }, [reloadTrigger, loadData])
- loadAvlListInfo()
- }, [avlListId])
-
- // 초기 데이터 로드
+ // 초기 데이터 로드 (검색 버튼이 눌렸을 때만)
React.useEffect(() => {
- if (localProjectCode) {
+ if (localProjectCode && isSearchClicked) {
loadData({})
}
- }, [loadData, localProjectCode])
+ }, [loadData, localProjectCode, isSearchClicked])
// 파일 업로드 핸들러
const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
@@ -289,42 +150,41 @@ export function ProjectAvlTable({
}
}, [])
- // 프로젝트 코드 변경 핸들러
- const handleProjectCodeChange = React.useCallback(async (value: string) => {
- setLocalProjectCode(value)
- onProjectCodeChange?.(value)
+ // 프로젝트 검색 함수 (공통 로직)
+ const searchProject = React.useCallback(async (projectCode: string) => {
+ if (!projectCode.trim()) {
+ setProjectInfo(null)
+ setProjectSearchStatus('idle')
+ setData([])
+ setPageCount(0)
+ return
+ }
- // 프로젝트 코드가 입력된 경우 프로젝트 정보 조회
- if (value.trim()) {
- setProjectSearchStatus('searching') // 검색 시작 상태로 변경
+ setProjectSearchStatus('searching') // 검색 시작 상태로 변경
- try {
- // 1. projects 테이블에서 먼저 검색
- let projectData = null
- let searchSource = 'projects'
+ try {
+ // 1. projects 테이블에서 먼저 검색
+ let projectData: {
+ projectName?: string | null;
+ shipType?: string;
+ projectMsrm?: string | null;
+ projectHtDivision?: string | null;
+ } | null = null
+ let searchSource = 'projects'
+ try {
+ projectData = await getProjectInfoFromProjects(projectCode.trim())
+ // projects에서 찾았을 때만 즉시 성공 상태로 변경
+ setProjectSearchStatus('success-projects')
+ } catch {
+ // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색
try {
- projectData = await getProjectInfoFromProjects(value.trim())
- // projects에서 찾았을 때만 즉시 성공 상태로 변경
- setProjectSearchStatus('success-projects')
- } catch (projectsError) {
- // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색
- try {
- projectData = await getProjectInfoFromBiddingProjects(value.trim())
- if (projectData) {
- searchSource = 'bidding-projects'
- setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경
- } else {
- // 둘 다 실패한 경우에만 에러 상태로 변경
- setProjectInfo(null)
- setProjectSearchStatus('error')
- setData([])
- setPageCount(0)
- toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
- return
- }
- } catch (biddingError) {
- // biddingProjects에서도 에러가 발생한 경우
+ projectData = await getProjectInfoFromBiddingProjects(projectCode.trim())
+ if (projectData) {
+ searchSource = 'bidding-projects'
+ setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경
+ } else {
+ // 둘 다 실패한 경우에만 에러 상태로 변경
setProjectInfo(null)
setProjectSearchStatus('error')
setData([])
@@ -332,39 +192,67 @@ export function ProjectAvlTable({
toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
return
}
+ } catch {
+ // biddingProjects에서도 에러가 발생한 경우
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
}
+ }
- if (projectData) {
- setProjectInfo({
- projectName: projectData.projectName || "",
- constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경)
- shipType: projectData.shipType || projectData.projectMsrm || "",
- htDivision: projectData.projectHtDivision || ""
- })
+ if (projectData) {
+ setProjectInfo({
+ projectName: projectData.projectName || "",
+ constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경)
+ shipType: projectData.shipType || projectData.projectMsrm || "",
+ htDivision: projectData.projectHtDivision || ""
+ })
- const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트'
- toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`)
+ const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트'
+ toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`)
- // 프로젝트 검증 성공 시 해당 프로젝트의 AVL 데이터 로드
- loadData({})
- }
- } catch (error) {
- console.error("프로젝트 정보 조회 실패:", error)
- setProjectInfo(null)
- setProjectSearchStatus('error')
- setData([])
- setPageCount(0)
- toast.error("프로젝트 정보를 불러오는데 실패했습니다.")
+ // 검색 성공 시 AVL 데이터 로드 트리거
+ setIsSearchClicked(true)
}
- } else {
- // 프로젝트 코드가 비어있는 경우 프로젝트 정보 초기화 및 데이터 클리어
+ } catch (error) {
+ console.error("프로젝트 정보 조회 실패:", error)
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("프로젝트 정보를 불러오는데 실패했습니다.")
+ }
+ }, [setIsSearchClicked])
+
+ // 프로젝트 코드 변경 핸들러 (입력만 처리)
+ const handleProjectCodeChange = React.useCallback((value: string) => {
+ setLocalProjectCode(value)
+ onProjectCodeChange?.(value)
+
+ // 입력이 변경되면 검색 상태를 idle로 초기화하고 검색 클릭 상태를 리셋
+ if (!value.trim()) {
setProjectInfo(null)
setProjectSearchStatus('idle')
+ setIsSearchClicked(false)
setData([])
setPageCount(0)
+ } else {
+ // 새로운 프로젝트 코드가 입력되면 검색 클릭 상태를 리셋 (다시 검색 버튼을 눌러야 함)
+ setIsSearchClicked(false)
}
}, [onProjectCodeChange])
+ // 프로젝트 검색 버튼 핸들러
+ const handleProjectSearch = React.useCallback(async () => {
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ // 프로젝트 정보 검색 (성공 시 내부에서 AVL 데이터 로드 트리거)
+ await searchProject(localProjectCode)
+ }, [localProjectCode, searchProject])
+
// 행 추가 핸들러
const handleAddRow = React.useCallback(() => {
if (!localProjectCode.trim()) {
@@ -382,16 +270,11 @@ export function ProjectAvlTable({
// 다이얼로그에서 항목 추가 핸들러
const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
try {
- // AVL 리스트 ID 확인
- if (!avlListId) {
- toast.error("AVL 리스트가 선택되지 않았습니다.")
- return
- }
-
- // DB에 실제 저장할 데이터 준비
+ // DB에 실제 저장할 데이터 준비 (avlListId는 나중에 최종 확정 시 설정)
const saveData: AvlVendorInfoInput = {
...itemData,
- avlListId: avlListId // 현재 AVL 리스트 ID 설정
+ projectCode: localProjectCode, // 현재 프로젝트 코드 저장
+ avlListId: avlListId || undefined // 있으면 설정, 없으면 undefined (null로 저장됨)
}
// DB에 저장
@@ -409,7 +292,7 @@ export function ProjectAvlTable({
console.error("항목 추가 실패:", error)
toast.error("항목 추가 중 오류가 발생했습니다.")
}
- }, [avlListId, loadData])
+ }, [avlListId, loadData, localProjectCode])
// 다이얼로그에서 항목 수정 핸들러
const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
@@ -447,21 +330,43 @@ export function ProjectAvlTable({
getFilteredRowModel: getFilteredRowModel(),
manualPagination: true,
pageCount,
- initialState: {
- pagination: {
- pageSize: 10,
- },
+ state: {
+ pagination,
},
onPaginationChange: (updater) => {
- const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
- loadData({
- page: newState.pageIndex + 1,
- perPage: newState.pageSize,
+ // 페이지네이션 상태 업데이트
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('ProjectAvlTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ localProjectCode,
+ isSearchClicked,
+ willLoadData: localProjectCode && isSearchClicked
})
+
+ setPagination(newPaginationState)
+
+ if (localProjectCode && isSearchClicked) {
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('ProjectAvlTable - Loading data with params:', apiParams)
+ loadData(apiParams)
+ }
},
meta: tableMeta,
})
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
// 항목 수정 핸들러 (버튼 클릭)
const handleEditItem = React.useCallback(() => {
const selectedRows = table.getFilteredSelectedRowModel().rows
@@ -510,11 +415,79 @@ export function ProjectAvlTable({
}
}, [table, loadData])
+ // 최종 확정 핸들러
+ const handleFinalizeAvl = React.useCallback(async () => {
+ // 1. 필수 조건 검증
+ if (!localProjectCode.trim()) {
+ toast.error("프로젝트 코드를 먼저 입력해주세요.")
+ return
+ }
+
+ if (!projectInfo) {
+ toast.error("프로젝트 정보를 불러올 수 없습니다. 프로젝트 코드를 다시 확인해주세요.")
+ return
+ }
+
+ if (data.length === 0) {
+ toast.error("확정할 AVL 벤더 정보가 없습니다.")
+ return
+ }
+
+ // 2. 사용자 확인
+ const confirmed = window.confirm(
+ `현재 프로젝트(${localProjectCode})의 AVL을 최종 확정하시겠습니까?\n\n` +
+ `- 프로젝트명: ${projectInfo.projectName}\n` +
+ `- 벤더 정보: ${data.length}개\n` +
+ `- 공사부문: ${projectInfo.constructionSector}\n` +
+ `- 선종: ${projectInfo.shipType}\n` +
+ `- H/T 구분: ${projectInfo.htDivision}\n\n` +
+ `확정 후에는 수정이 어려울 수 있습니다.`
+ )
+
+ if (!confirmed) return
+
+ try {
+ // 3. 현재 데이터의 모든 ID 수집
+ const avlVendorInfoIds = data.map(item => item.id)
+
+ // 4. 최종 확정 실행
+ const result = await finalizeProjectAvl(
+ localProjectCode,
+ projectInfo,
+ avlVendorInfoIds,
+ sessionData?.user?.name || ""
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 5. 데이터 새로고침
+ loadData({})
+
+ // 6. 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("AVL 최종 확정 실패:", error)
+ toast.error("AVL 최종 확정 중 오류가 발생했습니다.")
+ }
+ }, [localProjectCode, projectInfo, data, table, loadData, sessionData?.user?.name])
+
+ // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedRowCount = useMemo(() => {
+ const count = selectedRows.length
+ console.log('ProjectAvlTable - selectedRowCount calculated:', count)
+ return count
+ }, [selectedRows])
+
// 선택 상태 변경 시 콜백 호출
- React.useEffect(() => {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- onSelectionChange?.(selectedRows.length)
- }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+ useLayoutEffect(() => {
+ console.log('ProjectAvlTable - onSelectionChange called with count:', selectedRowCount)
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
// 선택 해제 요청이 오면 모든 선택 해제
React.useEffect(() => {
@@ -540,7 +513,7 @@ export function ProjectAvlTable({
>
항목 수정
</Button>
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
파일 업로드
</Button>
<Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
@@ -548,13 +521,18 @@ export function ProjectAvlTable({
</Button>
<Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
강제 매핑
- </Button>
+ </Button> */}
<Button variant="outline" size="sm" onClick={handleDeleteItems}>
항목 삭제
</Button>
{/* 최종 확정 버튼 */}
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalizeAvl}
+ disabled={!localProjectCode.trim() || !projectInfo || data.length === 0}
+ >
최종 확정
</Button>
</div>
@@ -565,140 +543,84 @@ export function ProjectAvlTable({
<div className="mb-4 p-4 border rounded-lg bg-muted/50">
<div className="flex gap-4 overflow-x-auto pb-2">
{/* 프로젝트 코드 */}
- <div className="space-y-2 min-w-[250px] flex-shrink-0">
- <label className={`text-sm font-medium ${
- projectSearchStatus === 'error' ? 'text-red-600' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
- projectSearchStatus === 'searching' ? 'text-blue-600' :
- 'text-muted-foreground'
- }`}>
- 프로젝트 코드
- {projectSearchStatus === 'success-projects' && <span className="ml-1 text-xs">(프로젝트)</span>}
- {projectSearchStatus === 'success-bidding' && <span className="ml-1 text-xs">(견적프로젝트)</span>}
- {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(검색 중...)</span>}
- {projectSearchStatus === 'error' && <span className="ml-1 text-xs">(찾을 수 없음)</span>}
- </label>
- <Input
- value={localProjectCode}
- onChange={(e) => handleProjectCodeChange(e.target.value)}
- placeholder="프로젝트 코드를 입력하세요"
- // disabled={projectSearchStatus === 'searching'}
- className={`h-8 text-sm ${
- projectSearchStatus === 'error' ? 'border-red-300 focus:border-red-500 focus:ring-red-500/20' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300 focus:border-green-500 focus:ring-green-500/20' :
- projectSearchStatus === 'searching' ? 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' :
- ''
- }`}
- />
+ <div className="flex flex-col gap-1 min-w-[200px]">
+ <label className="text-sm font-medium">프로젝트 코드</label>
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <input
+ type="text"
+ value={localProjectCode}
+ onChange={(e) => handleProjectCodeChange(e.target.value)}
+ placeholder="프로젝트 코드를 입력하세요"
+ className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
+ projectSearchStatus === 'error' ? 'border-red-500' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-500' :
+ projectSearchStatus === 'searching' ? 'border-blue-500' : ''
+ }`}
+ disabled={projectSearchStatus === 'searching'}
+ />
+ {projectSearchStatus !== 'idle' && (
+ <div className="text-xs mt-1 text-muted-foreground">
+ {projectSearchStatus === 'success-projects' ? '(프로젝트)' :
+ projectSearchStatus === 'success-bidding' ? '(견적프로젝트)' :
+ projectSearchStatus === 'searching' ? '(검색 중...)' :
+ projectSearchStatus === 'error' ? '(찾을 수 없음)' :
+ undefined}
+ </div>
+ )}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleProjectSearch}
+ disabled={!localProjectCode.trim() || projectSearchStatus === 'searching'}
+ className="px-3 h-9"
+ >
+ {projectSearchStatus === 'searching' ? '검색 중...' : '검색'}
+ </Button>
+ </div>
</div>
{/* 프로젝트명 */}
- <div className="space-y-2 min-w-[250px] flex-shrink-0">
- <label className={`text-sm font-medium ${
- projectSearchStatus === 'error' ? 'text-red-600' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
- projectSearchStatus === 'searching' ? 'text-blue-600' :
- 'text-muted-foreground'
- }`}>
- 프로젝트명
- {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
- </label>
- <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
- projectSearchStatus === 'error' ? 'border-red-300' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
- projectSearchStatus === 'searching' ? 'border-blue-300' :
- 'border-input'
- }`}>
- {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.projectName || '-')}
- </div>
- </div>
+ <ProjectDisplayField
+ label="프로젝트명"
+ value={projectInfo?.projectName || ''}
+ status={projectSearchStatus}
+ minWidth="250px"
+ />
{/* 원본파일 */}
- <div className="space-y-2 min-w-[200px] flex-shrink-0">
- <label className="text-sm font-medium text-muted-foreground">원본파일</label>
- <div className="flex items-center gap-2 min-h-[32px]">
- {originalFile ? (
- <span className="text-sm text-blue-600">{originalFile}</span>
- ) : (
- <div className="relative">
- <input
- type="file"
- onChange={handleFileUpload}
- className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
- accept=".xlsx,.xls,.csv"
- />
- <Button variant="outline" size="sm" className="text-xs">
- 파일 선택
- </Button>
- </div>
- )}
- </div>
- </div>
+ <ProjectFileField
+ label="원본파일"
+ originalFile={originalFile}
+ onFileUpload={handleFileUpload}
+ />
{/* 공사부문 */}
- <div className="space-y-2 min-w-[120px] flex-shrink-0">
- <label className={`text-sm font-medium ${
- projectSearchStatus === 'error' ? 'text-red-600' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
- projectSearchStatus === 'searching' ? 'text-blue-600' :
- 'text-muted-foreground'
- }`}>
- 공사부문
- {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
- </label>
- <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
- projectSearchStatus === 'error' ? 'border-red-300' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
- projectSearchStatus === 'searching' ? 'border-blue-300' :
- 'border-input'
- }`}>
- {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.constructionSector || '-')}
- </div>
- </div>
+ <ProjectDisplayField
+ label="공사부문"
+ value={projectInfo?.constructionSector || ''}
+ status={projectSearchStatus}
+ />
{/* 선종 */}
- <div className="space-y-2 min-w-[120px] flex-shrink-0">
- <label className={`text-sm font-medium ${
- projectSearchStatus === 'error' ? 'text-red-600' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
- projectSearchStatus === 'searching' ? 'text-blue-600' :
- 'text-muted-foreground'
- }`}>
- 선종
- {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
- </label>
- <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
- projectSearchStatus === 'error' ? 'border-red-300' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
- projectSearchStatus === 'searching' ? 'border-blue-300' :
- 'border-input'
- }`}>
- {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.shipType || '-')}
- </div>
- </div>
+ <ProjectDisplayField
+ label="선종"
+ value={projectInfo?.shipType || ''}
+ status={projectSearchStatus}
+ />
{/* H/T 구분 */}
- <div className="space-y-2 min-w-[140px] flex-shrink-0">
- <label className={`text-sm font-medium ${
- projectSearchStatus === 'error' ? 'text-red-600' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
- projectSearchStatus === 'searching' ? 'text-blue-600' :
- 'text-muted-foreground'
- }`}>
- H/T 구분
- {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
- </label>
- <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
- projectSearchStatus === 'error' ? 'border-red-300' :
- projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
- projectSearchStatus === 'searching' ? 'border-blue-300' :
- 'border-input'
- }`}>
- {projectSearchStatus === 'searching' ? '조회 중...' :
- (projectInfo?.htDivision === 'H' ? 'Hull (H)' :
- projectInfo?.htDivision === 'T' ? 'Topside (T)' : '-')}
- </div>
- </div>
+ <ProjectDisplayField
+ label="H/T 구분"
+ value={projectInfo?.htDivision || ''}
+ status={projectSearchStatus}
+ minWidth="140px"
+ formatter={(value) =>
+ value === 'H' ? 'Hull (H)' :
+ value === 'T' ? 'Topside (T)' : '-'
+ }
+ />
</div>
</div>
@@ -707,7 +629,7 @@ export function ProjectAvlTable({
</div>
{/* 행 추가/수정 다이얼로그 */}
- <ProjectAvlAddDialog
+ <AvlVendorAddAndModifyDialog
open={isAddDialogOpen}
onOpenChange={(open) => {
setIsAddDialogOpen(open)
@@ -718,7 +640,11 @@ export function ProjectAvlTable({
onAddItem={handleAddItem}
editingItem={editingItem}
onUpdateItem={handleUpdateItem}
+ isTemplate={false} // 프로젝트 AVL 모드
+ initialProjectCode={localProjectCode}
/>
</div>
)
-}
+})
+
+ProjectAvlTable.displayName = "ProjectAvlTable"
diff --git a/lib/avl/table/standard-avl-add-dialog.tsx b/lib/avl/table/standard-avl-add-dialog.tsx
new file mode 100644
index 00000000..9e8b016c
--- /dev/null
+++ b/lib/avl/table/standard-avl-add-dialog.tsx
@@ -0,0 +1,960 @@
+"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 StandardAvlAddDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러
+ // 검색 조건에서 선택한 값들을 초기값으로 사용
+ initialConstructionSector?: string
+ initialShipType?: string
+ initialAvlKind?: string
+ initialHtDivision?: string
+}
+
+export function StandardAvlAddDialog({
+ open,
+ onOpenChange,
+ onAddItem,
+ editingItem,
+ onUpdateItem,
+ initialConstructionSector,
+ initialShipType,
+ initialAvlKind,
+ initialHtDivision
+}: StandardAvlAddDialogProps) {
+ const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 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({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 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, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만)
+ React.useEffect(() => {
+ if (open && !editingItem) {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+ }))
+ }
+ }, [open, editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ const handleSubmit = async () => {
+ // 필수 필드 검증 (표준 AVL용)
+ if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) {
+ toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.")
+ return
+ }
+
+ if (!formData.disciplineName || !formData.materialNameCustomerSide) {
+ toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ if (editingItem && onUpdateItem) {
+ // 수정 모드
+ await onUpdateItem(editingItem.id, formData)
+ } else {
+ // 추가 모드
+ await onAddItem(formData)
+ }
+
+ // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만)
+ setFormData({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들
+ constructionSector: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+
+ onOpenChange(false)
+ } catch (error) {
+ // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음
+ }
+ }
+
+ const handleCancel = () => {
+ setFormData({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들
+ constructionSector: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+ onOpenChange(false)
+ }
+
+ // 선종 옵션들 (공사부문에 따라 다름)
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>{editingItem ? "표준 AVL 항목 수정" : "표준 AVL 항목 추가"}</DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? "표준 AVL 항목을 수정합니다. 필수 항목을 입력해주세요."
+ : "새로운 표준 AVL 항목을 추가합니다. 필수 항목을 입력해주세요."
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 표준 AVL 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="constructionSector">공사부문 *</Label>
+ <Select
+ value={formData.constructionSector}
+ onValueChange={(value) => {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: value,
+ shipType: "" // 공사부문 변경 시 선종 초기화
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="공사부문을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="shipType">선종 *</Label>
+ <Select
+ value={formData.shipType}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, shipType: value }))
+ }
+ disabled={!formData.constructionSector}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선종을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlKind">AVL종류 *</Label>
+ <Select
+ value={formData.avlKind}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, avlKind: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="AVL종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Nearshore">Nearshore</SelectItem>
+ <SelectItem value="Offshore">Offshore</SelectItem>
+ <SelectItem value="IOC">IOC</SelectItem>
+ <SelectItem value="NOC">NOC</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="htDivision">H/T 구분 *</Label>
+ <Select
+ value={formData.htDivision}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, htDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="H/T 구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="공통">공통</SelectItem>
+ <SelectItem value="H">Hull (H)</SelectItem>
+ <SelectItem value="T">Topside (T)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/standard-avl-table-columns.tsx b/lib/avl/table/standard-avl-table-columns.tsx
new file mode 100644
index 00000000..903d2590
--- /dev/null
+++ b/lib/avl/table/standard-avl-table-columns.tsx
@@ -0,0 +1,91 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { ColumnDef } from "@tanstack/react-table"
+import { StandardAvlItem } from "./standard-avl-table"
+
+// 선종별 표준 AVL 테이블 컬럼
+export const standardAvlColumns: ColumnDef<StandardAvlItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // 선종별 표준 AVL 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ },
+ {
+ accessorKey: "headquarterLocation",
+ header: "본사 위치 (국가)",
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: "등급 (Tier)",
+ size: 120,
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx
index 924b972a..cc39540b 100644
--- a/lib/avl/table/standard-avl-table.tsx
+++ b/lib/avl/table/standard-avl-table.tsx
@@ -1,10 +1,10 @@
"use client"
import * as React from "react"
-import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { useLayoutEffect, useMemo, forwardRef, useImperativeHandle } from "react"
import { DataTable } from "@/components/data-table/data-table"
import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
import { getStandardAvlVendorInfo } from "../service"
import { GetStandardAvlSchema } from "../validations"
import { AvlDetailItem } from "../types"
@@ -17,40 +17,74 @@ import {
} from "@/components/ui/select"
import { Search } from "lucide-react"
import { toast } from "sonner"
+import { standardAvlColumns } from "./standard-avl-table-columns"
+import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog"
+import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeStandardAvl } from "../service"
+import { AvlVendorInfoInput } from "../types"
+import { useSession } from "next-auth/react"
+
+/**
+ * 조선인 경우, 선종:
+ * A-max, S-max, VLCC, LNGC, CONT
+ * 해양인 경우, 선종:
+ * FPSO, FLNG, FPU, Platform, WTIV, GOM
+ *
+ * AVL종류:
+ * Nearshore, Offshore, IOC, NOC
+ */
// 검색 옵션들
const constructionSectorOptions = [
- { value: "all", label: "전체" },
{ value: "조선", label: "조선" },
{ value: "해양", label: "해양" },
]
-const shipTypeOptions = [
- { value: "all", label: "전체" },
- { value: "컨테이너선", label: "컨테이너선" },
- { value: "유조선", label: "유조선" },
- { value: "LNG선", label: "LNG선" },
- { value: "LPG선", label: "LPG선" },
- { value: "벌크선", label: "벌크선" },
- { value: "여객선", label: "여객선" },
-]
+// 공사부문에 따른 선종 옵션들
+const 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 avlKindOptions = [
- { value: "all", label: "전체" },
- { value: "표준", label: "표준" },
- { value: "특별", label: "특별" },
- { value: "임시", label: "임시" },
+ { value: "Nearshore", label: "Nearshore" },
+ { value: "Offshore", label: "Offshore" },
+ { value: "IOC", label: "IOC" },
+ { value: "NOC", label: "NOC" },
]
const htDivisionOptions = [
- { value: "all", label: "전체" },
+ { value: "공통", label: "공통" },
{ value: "H", label: "Hull (H)" },
- { value: "T", label: "Topside (T)" },
+ { value: "T", label: "Top (T)" },
]
// 선종별 표준 AVL 테이블에서는 AvlDetailItem을 사용
export type StandardAvlItem = AvlDetailItem
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface StandardAvlTableRef {
+ getSelectedIds: () => number[]
+}
+
interface StandardAvlTableProps {
onSelectionChange?: (count: number) => void
resetCounter?: number
@@ -58,130 +92,154 @@ interface StandardAvlTableProps {
shipType?: string // 선종 필터
avlKind?: string // AVL 종류 필터
htDivision?: string // H/T 구분 필터
+ onSearchConditionsChange?: (conditions: {
+ constructionSector: string
+ shipType: string
+ avlKind: string
+ htDivision: string
+ }) => void
+ reloadTrigger?: number
}
-// 선종별 표준 AVL 테이블 컬럼
-const standardAvlColumns: ColumnDef<StandardAvlItem>[] = [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row, table }) => {
- // 선종별 표준 AVL 테이블의 단일 선택 핸들러
- const handleRowSelection = (checked: boolean) => {
- if (checked) {
- // 다른 모든 행의 선택 해제
- table.getRowModel().rows.forEach(r => {
- if (r !== row && r.getIsSelected()) {
- r.toggleSelected(false)
- }
- })
- }
- // 현재 행 선택/해제
- row.toggleSelected(checked)
- }
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={handleRowSelection}
- aria-label="Select row"
- />
- )
- },
- enableSorting: false,
- enableHiding: false,
- size: 50,
- },
- {
- accessorKey: "no",
- header: "No.",
- size: 60,
- },
- {
- accessorKey: "disciplineName",
- header: "설계공종",
- size: 120,
- },
- {
- accessorKey: "avlVendorName",
- header: "AVL 등재업체명",
- size: 140,
- },
- {
- accessorKey: "materialGroupCode",
- header: "자재그룹 코드",
- size: 120,
- },
- {
- accessorKey: "materialGroupName",
- header: "자재그룹 명",
- size: 130,
- },
- {
- accessorKey: "vendorCode",
- header: "협력업체 코드",
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: "협력업체 명",
- size: 130,
- },
- {
- accessorKey: "headquarterLocation",
- header: "본사 위치 (국가)",
- size: 140,
- },
- {
- accessorKey: "tier",
- header: "등급 (Tier)",
- size: 120,
- },
-]
-
-export function StandardAvlTable({
+export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTableProps>(({
onSelectionChange,
resetCounter,
constructionSector: initialConstructionSector,
shipType: initialShipType,
avlKind: initialAvlKind,
- htDivision: initialHtDivision
-}: StandardAvlTableProps) {
+ htDivision: initialHtDivision,
+ onSearchConditionsChange,
+ reloadTrigger
+}, ref) => {
+ const { data: sessionData } = useSession()
+
const [data, setData] = React.useState<StandardAvlItem[]>([])
const [loading, setLoading] = React.useState(false)
const [pageCount, setPageCount] = React.useState(0)
+ // 다이얼로그 상태
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [editingItem, setEditingItem] = React.useState<StandardAvlItem | undefined>(undefined)
+
// 검색 상태
- const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "all")
- const [searchShipType, setSearchShipType] = React.useState(initialShipType || "all")
- const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "all")
- const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "all")
+ const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "")
+ const [searchShipType, setSearchShipType] = React.useState(initialShipType || "")
+ const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "")
+ const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "")
+
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+ const table = useReactTable({
+ data,
+ columns: standardAvlColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ state: {
+ pagination,
+ },
+ onPaginationChange: (updater) => {
+ // 페이지네이션 상태 업데이트
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('StandardAvlTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ isAllSearchConditionsSelected,
+ willLoadData: isAllSearchConditionsSelected
+ })
+
+ setPagination(newPaginationState)
+
+ if (isAllSearchConditionsSelected) {
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('StandardAvlTable - Loading data with params:', apiParams)
+ loadData(apiParams)
+ }
+ },
+ })
+
+ // 공사부문 변경 시 선종 초기화
+ const handleConstructionSectorChange = React.useCallback((value: string) => {
+ setSearchConstructionSector(value)
+ // 공사부문이 변경되면 선종을 빈 값으로 초기화
+ setSearchShipType("")
+ }, [])
+
+ // 검색 상태 변경 시 부모 컴포넌트에 전달
+ React.useEffect(() => {
+ onSearchConditionsChange?.({
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision
+ })
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, onSearchConditionsChange])
+
+ // 현재 공사부문에 따른 선종 옵션들
+ const currentShipTypeOptions = React.useMemo(() =>
+ getShipTypeOptions(searchConstructionSector),
+ [searchConstructionSector]
+ )
+
+ // 모든 검색 조건이 선택되었는지 확인
+ const isAllSearchConditionsSelected = React.useMemo(() => {
+ return (
+ searchConstructionSector.trim() !== "" &&
+ searchShipType.trim() !== "" &&
+ searchAvlKind.trim() !== "" &&
+ searchHtDivision.trim() !== ""
+ )
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
// 데이터 로드 함수
- const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema>) => {
+ const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema> = {}) => {
try {
setLoading(true)
+
const params: GetStandardAvlSchema = {
page: searchParams.page ?? 1,
perPage: searchParams.perPage ?? 10,
sort: searchParams.sort ?? [{ id: "no", desc: false }],
- constructionSector: searchConstructionSector === "all" ? "" : searchConstructionSector || "",
- shipType: searchShipType === "all" ? "" : searchShipType || "",
- avlKind: searchAvlKind === "all" ? "" : searchAvlKind || "",
- htDivision: searchHtDivision === "all" ? "" : searchHtDivision || "",
+ flags: searchParams.flags ?? [],
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision as "공통" | "H" | "T" | "",
+ equipBulkDivision: searchParams.equipBulkDivision || "",
+ disciplineCode: searchParams.disciplineCode ?? "",
+ disciplineName: searchParams.disciplineName ?? "",
+ materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "",
+ packageCode: searchParams.packageCode ?? "",
+ packageName: searchParams.packageName ?? "",
+ materialGroupCode: searchParams.materialGroupCode ?? "",
+ materialGroupName: searchParams.materialGroupName ?? "",
+ vendorName: searchParams.vendorName ?? "",
+ vendorCode: searchParams.vendorCode ?? "",
+ avlVendorName: searchParams.avlVendorName ?? "",
+ tier: searchParams.tier ?? "",
+ filters: searchParams.filters ?? [],
+ joinOperator: searchParams.joinOperator ?? "and",
search: "",
...searchParams,
}
+ console.log('StandardAvlTable - API call params:', params)
const result = await getStandardAvlVendorInfo(params)
+ console.log('StandardAvlTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
setData(result.data)
setPageCount(result.pageCount)
} catch (error) {
@@ -193,52 +251,230 @@ export function StandardAvlTable({
}
}, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
- // 검색 핸들러
- const handleSearch = React.useCallback(() => {
- loadData({})
- }, [loadData])
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('StandardAvlTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
// 검색 초기화 핸들러
const handleResetSearch = React.useCallback(() => {
- setSearchConstructionSector("all")
- setSearchShipType("all")
- setSearchAvlKind("all")
- setSearchHtDivision("all")
- loadData({})
+ setSearchConstructionSector("")
+ setSearchShipType("")
+ setSearchAvlKind("")
+ setSearchHtDivision("")
+ // 초기화 시 빈 데이터로 설정
+ setData([])
+ setPageCount(0)
+ }, [])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ if (isAllSearchConditionsSelected) {
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ }
+ }, [loadData, isAllSearchConditionsSelected, pagination.pageSize])
+
+ // 항목 추가 핸들러
+ const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ const result = await createAvlVendorInfo(itemData)
+
+ if (result) {
+ toast.success("표준 AVL 항목이 성공적으로 추가되었습니다.")
+ // 데이터 새로고침
+ loadData({})
+ } else {
+ toast.error("항목 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 추가 실패:", error)
+ toast.error("항목 추가 중 오류가 발생했습니다.")
+ }
}, [loadData])
- // 초기 데이터 로드
- React.useEffect(() => {
- loadData({})
+ // 항목 수정 핸들러
+ const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ const result = await updateAvlVendorInfo(id, itemData)
+
+ if (result) {
+ toast.success("표준 AVL 항목이 성공적으로 수정되었습니다.")
+ // 데이터 새로고침
+ loadData({})
+ // 수정 모드 해제
+ setEditingItem(undefined)
+ } else {
+ toast.error("항목 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 수정 실패:", error)
+ toast.error("항목 수정 중 오류가 발생했습니다.")
+ }
}, [loadData])
- const table = useReactTable({
- data,
- columns: standardAvlColumns,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- manualPagination: true,
- pageCount,
- initialState: {
- pagination: {
- pageSize: 10,
- },
- },
- onPaginationChange: (updater) => {
- const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
- loadData({
- page: newState.pageIndex + 1,
- perPage: newState.pageSize,
+ // 항목 수정 핸들러 (버튼 클릭)
+ const handleEditItem = React.useCallback(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length !== 1) {
+ toast.error("수정할 항목을 하나만 선택해주세요.")
+ return
+ }
+
+ const selectedItem = selectedRows[0].original
+ setEditingItem(selectedItem)
+ setIsAddDialogOpen(true)
+ }, [table])
+
+ // 항목 삭제 핸들러
+ const handleDeleteItems = React.useCallback(async () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length === 0) {
+ toast.error("삭제할 항목을 선택해주세요.")
+ return
+ }
+
+ // 사용자 확인
+ const confirmed = window.confirm(`선택한 ${selectedRows.length}개 항목을 정말 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ // 선택된 항목들을 DB에서 삭제
+ const deletePromises = selectedRows.map(async (row) => {
+ await deleteAvlVendorInfo(row.original.id)
})
- },
- })
- // 선택 상태 변경 시 콜백 호출
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedRows.length}개 항목이 삭제되었습니다.`)
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } catch (error) {
+ console.error("항목 삭제 실패:", error)
+ toast.error("항목 삭제 중 오류가 발생했습니다.")
+ }
+ }, [table, loadData])
+
+ // 최종 확정 핸들러 (표준 AVL)
+ const handleFinalizeStandardAvl = React.useCallback(async () => {
+ // 1. 필수 조건 검증
+ if (!isAllSearchConditionsSelected) {
+ toast.error("검색 조건을 모두 선택해주세요.")
+ return
+ }
+
+ if (data.length === 0) {
+ toast.error("확정할 표준 AVL 벤더 정보가 없습니다.")
+ return
+ }
+
+ // 2. 사용자 확인
+ const confirmed = window.confirm(
+ `현재 표준 AVL을 최종 확정하시겠습니까?\n\n` +
+ `- 공사부문: ${searchConstructionSector}\n` +
+ `- 선종: ${searchShipType}\n` +
+ `- AVL종류: ${searchAvlKind}\n` +
+ `- H/T 구분: ${searchHtDivision}\n` +
+ `- 벤더 정보: ${data.length}개\n\n` +
+ `확정 후에는 수정이 어려울 수 있습니다.`
+ )
+
+ if (!confirmed) return
+
+ try {
+ // 3. 현재 데이터의 모든 ID 수집
+ const avlVendorInfoIds = data.map(item => item.id)
+
+ // 4. 최종 확정 실행
+ const standardAvlInfo = {
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision
+ }
+
+ const result = await finalizeStandardAvl(
+ standardAvlInfo,
+ avlVendorInfoIds,
+ sessionData?.user?.name || ""
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 5. 데이터 새로고침
+ loadData({})
+
+ // 6. 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("표준 AVL 최종 확정 실패:", error)
+ toast.error("표준 AVL 최종 확정 중 오류가 발생했습니다.")
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, isAllSearchConditionsSelected, data, table, loadData, sessionData?.user?.name])
+
+ // 초기 데이터 로드 (검색 조건이 모두 입력되었을 때만)
React.useEffect(() => {
- onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
- }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+ if (isAllSearchConditionsSelected) {
+ // 검색 조건이 모두 입력되면 페이지를 1페이지로 리셋하고 데이터 로드
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ } else {
+ // 검색 조건이 모두 입력되지 않은 경우 빈 데이터로 설정
+ setData([])
+ setPageCount(0)
+ }
+ }, [isAllSearchConditionsSelected, pagination.pageSize, searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+
+
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
+ // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedRowCount = useMemo(() => {
+ const count = selectedRows.length
+ console.log('StandardAvlTable - selectedRowCount calculated:', count)
+ return count
+ }, [selectedRows])
+
+ // 페이지네이션 상태 디버깅
+ React.useEffect(() => {
+ const paginationState = table.getState().pagination
+ console.log('StandardAvlTable - Current pagination state:', {
+ pageIndex: paginationState.pageIndex,
+ pageSize: paginationState.pageSize,
+ canPreviousPage: table.getCanPreviousPage(),
+ canNextPage: table.getCanNextPage(),
+ pageCount: table.getPageCount(),
+ currentDataLength: data.length
+ })
+ }, [table, data])
+
+ // 선택 상태 변경 시 콜백 호출
+ useLayoutEffect(() => {
+ console.log('StandardAvlTable - onSelectionChange called with count:', selectedRowCount)
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
// 선택 해제 요청이 오면 모든 선택 해제
React.useEffect(() => {
@@ -253,22 +489,35 @@ export function StandardAvlTable({
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">선종별 표준 AVL</h4>
<div className="flex gap-1">
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ <Button variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)}>
신규업체 추가
</Button>
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditItem}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ 항목 수정
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
파일 업로드
</Button>
<Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
일괄입력
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleDeleteItems}>
+ 항목 삭제
</Button>
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
- 항목삭제
- </Button>
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
저장
- </Button>
- <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ </Button> */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalizeStandardAvl}
+ disabled={!isAllSearchConditionsSelected || data.length === 0}
+ >
최종 확정
</Button>
</div>
@@ -281,7 +530,7 @@ export function StandardAvlTable({
{/* 공사부문 */}
<div className="space-y-2">
<label className="text-sm font-medium">공사부문</label>
- <Select value={searchConstructionSector} onValueChange={setSearchConstructionSector}>
+ <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -303,7 +552,7 @@ export function StandardAvlTable({
<SelectValue />
</SelectTrigger>
<SelectContent>
- {shipTypeOptions.map((option) => (
+ {currentShipTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@@ -352,7 +601,7 @@ export function StandardAvlTable({
<div className="flex gap-1">
<Button
onClick={handleSearch}
- disabled={loading}
+ disabled={loading || !isAllSearchConditionsSelected}
size="sm"
className="px-3"
>
@@ -375,6 +624,28 @@ export function StandardAvlTable({
<div className="flex-1">
<DataTable table={table} />
</div>
+
+ {/* 신규업체 추가 다이얼로그 */}
+ <AvlVendorAddAndModifyDialog
+ open={isAddDialogOpen}
+ onOpenChange={(open) => {
+ setIsAddDialogOpen(open)
+ if (!open) {
+ setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제
+ }
+ }}
+ onAddItem={handleAddItem}
+ editingItem={editingItem}
+ onUpdateItem={handleUpdateItem}
+ isTemplate={true} // 표준 AVL 모드
+ // 검색 조건에서 선택한 값들을 초기값으로 전달
+ initialConstructionSector={searchConstructionSector}
+ initialShipType={searchShipType}
+ initialAvlKind={searchAvlKind}
+ initialHtDivision={searchHtDivision}
+ />
</div>
)
-}
+})
+
+StandardAvlTable.displayName = "StandardAvlTable"
diff --git a/lib/avl/table/vendor-pool-table-columns.tsx b/lib/avl/table/vendor-pool-table-columns.tsx
new file mode 100644
index 00000000..53db1059
--- /dev/null
+++ b/lib/avl/table/vendor-pool-table-columns.tsx
@@ -0,0 +1,96 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { ColumnDef } from "@tanstack/react-table"
+import { VendorPoolItem } from "./vendor-pool-table"
+
+// Vendor Pool 테이블 컬럼
+export const vendorPoolColumns: ColumnDef<VendorPoolItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // Vendor Pool 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "designCategory",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹코드",
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 정보",
+ size: 130,
+ },
+ {
+ accessorKey: "tier",
+ header: "업체분류",
+ size: 100,
+ },
+ {
+ accessorKey: "faStatus",
+ header: "FA현황",
+ size: 100,
+ },
+ {
+ accessorKey: "recentQuoteNumber",
+ header: "최근견적번호",
+ size: 130,
+ },
+ {
+ accessorKey: "recentOrderNumber",
+ header: "최근발주번호",
+ size: 130,
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx
index 1a0a5fca..7ad9eb56 100644
--- a/lib/avl/table/vendor-pool-table.tsx
+++ b/lib/avl/table/vendor-pool-table.tsx
@@ -1,7 +1,8 @@
"use client"
import * as React from "react"
-import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { forwardRef, useImperativeHandle } from "react"
import { DataTable } from "@/components/data-table/data-table"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -10,114 +11,29 @@ import { Search } from "lucide-react"
import { getVendorPools } from "../../vendor-pool/service"
import { GetVendorPoolSchema } from "../../vendor-pool/validations"
import { VendorPool } from "../../vendor-pool/types"
+import { vendorPoolColumns } from "./vendor-pool-table-columns"
// Vendor Pool 데이터 타입 (실제 VendorPool 타입 사용)
export type VendorPoolItem = VendorPool
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface VendorPoolTableRef {
+ getSelectedIds: () => number[]
+}
+
interface VendorPoolTableProps {
onSelectionChange?: (count: number) => void
resetCounter?: number
+ reloadTrigger?: number
}
-// Vendor Pool 테이블 컬럼
-const vendorPoolColumns: ColumnDef<VendorPoolItem>[] = [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row, table }) => {
- // Vendor Pool 테이블의 단일 선택 핸들러
- const handleRowSelection = (checked: boolean) => {
- if (checked) {
- // 다른 모든 행의 선택 해제
- table.getRowModel().rows.forEach(r => {
- if (r !== row && r.getIsSelected()) {
- r.toggleSelected(false)
- }
- })
- }
- // 현재 행 선택/해제
- row.toggleSelected(checked)
- }
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={handleRowSelection}
- aria-label="Select row"
- />
- )
- },
- enableSorting: false,
- enableHiding: false,
- size: 50,
- },
- {
- accessorKey: "no",
- header: "No.",
- size: 60,
- },
- {
- accessorKey: "designCategory",
- header: "설계공종",
- size: 120,
- },
- {
- accessorKey: "avlVendorName",
- header: "AVL 등재업체명",
- size: 140,
- },
- {
- accessorKey: "materialGroupCode",
- header: "자재그룹코드",
- size: 130,
- },
- {
- accessorKey: "materialGroupName",
- header: "자재그룹명",
- size: 130,
- },
- {
- accessorKey: "vendorName",
- header: "협력업체 정보",
- size: 130,
- },
- {
- accessorKey: "tier",
- header: "업체분류",
- size: 100,
- },
- {
- accessorKey: "faStatus",
- header: "FA현황",
- size: 100,
- },
- {
- accessorKey: "recentQuoteNumber",
- header: "최근견적번호",
- size: 130,
- },
- {
- accessorKey: "recentOrderNumber",
- header: "최근발주번호",
- size: 130,
- },
-]
-
// 실제 데이터는 API에서 가져옴
-export function VendorPoolTable({
+export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTableProps>(({
onSelectionChange,
- resetCounter
-}: VendorPoolTableProps) {
+ resetCounter,
+ reloadTrigger
+}, ref) => {
const [data, setData] = React.useState<VendorPoolItem[]>([])
const [loading, setLoading] = React.useState(false)
const [pageCount, setPageCount] = React.useState(0)
@@ -126,18 +42,78 @@ export function VendorPoolTable({
const [searchText, setSearchText] = React.useState("")
const [showAll, setShowAll] = React.useState(false)
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
// 데이터 로드 함수
- const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema>) => {
+ const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema> = {}) => {
try {
setLoading(true)
- const params: GetVendorPoolSchema = {
+
+ const params = {
page: searchParams.page ?? 1,
perPage: searchParams.perPage ?? 10,
sort: searchParams.sort ?? [{ id: "registrationDate", desc: true }],
+ flags: [],
search: searchText || "",
+ constructionSector: undefined,
+ shipType: undefined,
+ htDivision: undefined,
+ designCategoryCode: undefined,
+ designCategory: undefined,
+ equipBulkDivision: undefined,
+ packageCode: undefined,
+ packageName: undefined,
+ materialGroupCode: undefined,
+ materialGroupName: undefined,
+ vendorCode: undefined,
+ vendorName: undefined,
+ faTarget: undefined,
+ faStatus: undefined,
+ tier: undefined,
+ isAgent: undefined,
+ isBlacklist: undefined,
+ isBcc: undefined,
+ purchaseOpinion: undefined,
+ shipTypeCommon: undefined,
+ shipTypeAmax: undefined,
+ shipTypeSmax: undefined,
+ shipTypeVlcc: undefined,
+ shipTypeLngc: undefined,
+ shipTypeCont: undefined,
+ offshoreTypeCommon: undefined,
+ offshoreTypeFpso: undefined,
+ offshoreTypeFlng: undefined,
+ offshoreTypeFpu: undefined,
+ offshoreTypePlatform: undefined,
+ offshoreTypeWtiv: undefined,
+ offshoreTypeGom: undefined,
+ picName: undefined,
+ picEmail: undefined,
+ picPhone: undefined,
+ agentName: undefined,
+ agentEmail: undefined,
+ agentPhone: undefined,
+ recentQuoteDate: undefined,
+ recentQuoteNumber: undefined,
+ recentOrderDate: undefined,
+ recentOrderNumber: undefined,
+ registrationDate: undefined,
+ registrant: undefined,
+ lastModifiedDate: undefined,
+ lastModifier: undefined,
...searchParams,
}
- const result = await getVendorPools(params)
+ console.log('VendorPoolTable - API call params:', params)
+ const result = await getVendorPools(params as GetVendorPoolSchema)
+ console.log('VendorPoolTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
setData(result.data)
setPageCount(result.pageCount)
} catch (error) {
@@ -155,9 +131,11 @@ export function VendorPoolTable({
// 전체보기 모드에서는 페이징 없이 전체 데이터 로드
loadData({ perPage: 1000 }) // 충분히 큰 숫자로 전체 데이터 가져오기
} else {
- loadData({})
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
}
- }, [loadData, showAll])
+ }, [loadData, showAll, pagination.pageSize])
// 전체보기 토글 핸들러
const handleShowAllToggle = React.useCallback((checked: boolean) => {
@@ -174,8 +152,18 @@ export function VendorPoolTable({
// 초기 데이터 로드
React.useEffect(() => {
- loadData({})
- }, [loadData])
+ // 초기 로드 시 페이지를 1페이지로 설정
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ }, [pagination.pageSize])
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('VendorPoolTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
const table = useReactTable({
data,
@@ -186,27 +174,48 @@ export function VendorPoolTable({
getFilteredRowModel: getFilteredRowModel(),
manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화
pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용
- initialState: {
- pagination: {
- pageSize: showAll ? data.length : 10, // 전체보기 시 모든 데이터 표시
- },
+ state: {
+ pagination: showAll ? { pageIndex: 0, pageSize: data.length } : pagination,
},
onPaginationChange: (updater) => {
if (!showAll) {
// 전체보기가 아닐 때만 페이징 변경 처리
- const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
- loadData({
- page: newState.pageIndex + 1,
- perPage: newState.pageSize,
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('VendorPoolTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ showAll,
+ willLoadData: !showAll
})
+
+ setPagination(newPaginationState)
+
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('VendorPoolTable - Loading data with params:', apiParams)
+ loadData(apiParams)
}
},
})
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
+ // 선택된 행 개수
+ const selectedRowCount = table.getFilteredSelectedRowModel().rows.length
+
// 선택 상태 변경 시 콜백 호출
React.useEffect(() => {
- onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
- }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
// 선택 해제 요청이 오면 모든 선택 해제
React.useEffect(() => {
@@ -287,4 +296,6 @@ export function VendorPoolTable({
</div>
</div>
)
-}
+})
+
+VendorPoolTable.displayName = "VendorPoolTable"
diff --git a/lib/avl/types.ts b/lib/avl/types.ts
index 640299ef..6a7b5143 100644
--- a/lib/avl/types.ts
+++ b/lib/avl/types.ts
@@ -7,6 +7,7 @@ export interface AvlListItem extends Omit<AvlList, 'createdAt' | 'updatedAt'> {
selected: boolean;
createdAt: string; // UI에서 사용하기 위해 string으로 변환
updatedAt: string; // UI에서 사용하기 위해 string으로 변환
+ vendorInfoSnapshot?: any; // JSON 데이터
// 추가 표시용 필드들 (실제로는 AvlVendorInfo에서 가져와야 함)
projectInfo?: string;
@@ -53,11 +54,12 @@ export interface AvlDetailItem extends Omit<AvlVendorInfo, 'createdAt' | 'update
}
// AVL 생성을 위한 입력 타입
-export interface CreateAvlListInput extends Omit<AvlList, 'id' | 'createdAt' | 'updatedAt'> {
+export interface CreateAvlListInput extends Omit<AvlList, 'id' | 'createdAt' | 'updatedAt' | 'vendorInfoSnapshot'> {
// UI에서 입력받을 추가 필드들
projectInfo?: string;
shipType?: string;
avlType?: string;
+ vendorInfoSnapshot?: any; // JSON 데이터, 선택적 속성
}
// AVL 업데이트를 위한 입력 타입
@@ -68,6 +70,18 @@ export interface UpdateAvlListInput extends Partial<CreateAvlListInput> {
// AVL Vendor Info UI 입력을 위한 인터페이스
export interface AvlVendorInfoInput {
+ // AVL 타입 구분
+ isTemplate?: boolean; // false: 프로젝트 AVL, true: 표준 AVL
+
+ // 표준 AVL용 필드들 (isTemplate=true일 경우)
+ constructionSector?: string; // 공사부문
+ shipType?: string; // 선종
+ avlKind?: string; // AVL 종류
+ htDivision?: string; // H/T 구분
+
+ // 프로젝트 코드 (나중에 AVL 리스트와 연결할 때 사용)
+ projectCode?: string;
+
// AVL 리스트 ID (생성 시 필수, UI에서는 선택적으로 사용)
avlListId?: number;
diff --git a/lib/avl/validations.ts b/lib/avl/validations.ts
index 367a5db1..6f09cdfd 100644
--- a/lib/avl/validations.ts
+++ b/lib/avl/validations.ts
@@ -141,10 +141,10 @@ export const standardAvlSearchParamsCache = createSearchParamsCache({
constructionSector: parseAsString.withDefault(""),
shipType: parseAsString.withDefault(""),
avlKind: parseAsString.withDefault(""),
- htDivision: parseAsStringEnum(["H", "T"]).withDefault(""),
+ htDivision: parseAsStringEnum(["공통", "H", "T", ""]).withDefault(""),
// 추가 필터들
- equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""),
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK", ""]).withDefault(""),
disciplineCode: parseAsString.withDefault(""),
disciplineName: parseAsString.withDefault(""),
materialNameCustomerSide: parseAsString.withDefault(""),