diff options
Diffstat (limited to 'lib/avl')
22 files changed, 9867 insertions, 0 deletions
diff --git a/lib/avl/avl-atoms.ts b/lib/avl/avl-atoms.ts new file mode 100644 index 00000000..26836413 --- /dev/null +++ b/lib/avl/avl-atoms.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; +import type { AvlListItem } from '@/lib/avl/types'; + +// AVL 페이지에서 선택된 AVL 레코드 +export const selectedAvlRecordAtom = atom<AvlListItem | null>(null); diff --git a/lib/avl/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 new file mode 100644 index 00000000..535a0169 --- /dev/null +++ b/lib/avl/service.ts @@ -0,0 +1,2363 @@ +"use server"; + +import { GetAvlListSchema, GetAvlDetailSchema, GetProjectAvlSchema, GetStandardAvlSchema } from "./validations"; +import { AvlListItem, AvlDetailItem, CreateAvlListInput, UpdateAvlListInput, ActionResult, AvlVendorInfoInput } from "./types"; +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 { 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 } from "next/cache"; +import { createVendorInfoSnapshot } from "./snapshot-utils"; + +/** + * AVL 리스트 조회 + * avl_list 테이블에서 실제 데이터를 조회합니다. + */ +export const getAvlLists = async (input: GetAvlListSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('AVL 리스트 조회 시작', { input, offset }); + + // 검색 조건 구성 + const whereConditions: any[] = []; + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlList.constructionSector, searchTerm), + ilike(avlList.projectCode, searchTerm), + ilike(avlList.shipType, searchTerm), + ilike(avlList.avlKind, searchTerm) + ) + ); + } + + // 필터 조건 추가 + if (input.isTemplate === "true") { + whereConditions.push(eq(avlList.isTemplate, true)); + } else if (input.isTemplate === "false") { + whereConditions.push(eq(avlList.isTemplate, false)); + } + + if (input.constructionSector) { + whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`)); + } + if (input.projectCode) { + whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`)); + } + if (input.shipType) { + whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`)); + } + if (input.avlKind) { + whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`)); + } + if (input.htDivision) { + whereConditions.push(eq(avlList.htDivision, input.htDivision)); + } + if (input.rev) { + whereConditions.push(eq(avlList.rev, parseInt(input.rev))); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlList; + + if (column && avlList[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlList[column]} desc`); + } else { + orderByConditions.push(sql`${avlList[column]} asc`); + } + } + }); + + // 기본 정렬 (등재일 내림차순) + if (orderByConditions.length === 0) { + orderByConditions.push(desc(avlList.createdAt)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlList) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(avlList) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 (timestamp -> string) + const transformedData: AvlListItem[] = data.map((item, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + // 추가 필드들 (실제로는 JOIN이나 별도 쿼리로 가져와야 함) + projectInfo: item.projectCode || '', + shipType: item.shipType || '', + avlType: item.avlKind || '', + htDivision: item.htDivision || '', + rev: item.rev || 1, + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('AVL 리스트 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('AVL 리스트 조회 실패', { error: err, input }); + console.error("Error in getAvlLists:", err); + return { data: [], pageCount: 0 }; + } +}; + + +/** + * AVL 상세 정보 조회 (특정 AVL ID의 모든 vendor info) + */ +export const getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('AVL 상세 조회 시작', { input, offset }); + + // 검색 조건 구성 + const whereConditions: any[] = []; + + // AVL 리스트 ID 필터 (필수) + whereConditions.push(eq(avlVendorInfo.avlListId, input.avlListId)); + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm) + ) + ); + } + + // 필터 조건 추가 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + if (input.faTarget === "true") { + whereConditions.push(eq(avlVendorInfo.faTarget, true)); + } else if (input.faTarget === "false") { + whereConditions.push(eq(avlVendorInfo.faTarget, false)); + } + if (input.faStatus) { + whereConditions.push(ilike(avlVendorInfo.faStatus, `%${input.faStatus}%`)); + } + if (input.isAgent === "true") { + whereConditions.push(eq(avlVendorInfo.isAgent, true)); + } else if (input.isAgent === "false") { + whereConditions.push(eq(avlVendorInfo.isAgent, false)); + } + if (input.contractSignerName) { + whereConditions.push(ilike(avlVendorInfo.contractSignerName, `%${input.contractSignerName}%`)); + } + if (input.headquarterLocation) { + whereConditions.push(ilike(avlVendorInfo.headquarterLocation, `%${input.headquarterLocation}%`)); + } + if (input.manufacturingLocation) { + whereConditions.push(ilike(avlVendorInfo.manufacturingLocation, `%${input.manufacturingLocation}%`)); + } + if (input.hasAvl === "true") { + whereConditions.push(eq(avlVendorInfo.hasAvl, true)); + } else if (input.hasAvl === "false") { + whereConditions.push(eq(avlVendorInfo.hasAvl, false)); + } + if (input.isBlacklist === "true") { + whereConditions.push(eq(avlVendorInfo.isBlacklist, true)); + } else if (input.isBlacklist === "false") { + whereConditions.push(eq(avlVendorInfo.isBlacklist, false)); + } + if (input.isBcc === "true") { + whereConditions.push(eq(avlVendorInfo.isBcc, true)); + } else if (input.isBcc === "false") { + whereConditions.push(eq(avlVendorInfo.isBcc, false)); + } + if (input.techQuoteNumber) { + whereConditions.push(ilike(avlVendorInfo.techQuoteNumber, `%${input.techQuoteNumber}%`)); + } + if (input.quoteCode) { + whereConditions.push(ilike(avlVendorInfo.quoteCode, `%${input.quoteCode}%`)); + } + if (input.quoteCountry) { + whereConditions.push(ilike(avlVendorInfo.quoteCountry, `%${input.quoteCountry}%`)); + } + if (input.remark) { + whereConditions.push(ilike(avlVendorInfo.remark, `%${input.remark}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .where(and(...whereConditions)); + + // 데이터 조회 + const data = await db + .select() + .from(avlVendorInfo) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 (timestamp -> string, DB 필드 -> UI 필드) + const transformedData: AvlDetailItem[] = data.map((item, index) => ({ + ...(item as any), + no: offset + index + 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('AVL 상세 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('AVL 상세 조회 실패', { error: err, input }); + console.error("Error in getAvlDetail:", err); + return { data: [], pageCount: 0 }; + } +}; + + +/** + * AVL 리스트 상세 정보 조회 (단일) + */ +export async function getAvlListById(id: number): Promise<AvlListItem | null> { + try { + const data = await db + .select() + .from(avlList) + .where(eq(avlList.id, id)) + .limit(1); + + if (data.length === 0) { + return null; + } + + const item = data[0]; + + // 데이터 변환 + const transformedData: AvlListItem = { + ...item, + no: 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + projectInfo: item.projectCode || '', + shipType: item.shipType || '', + avlType: item.avlKind || '', + htDivision: item.htDivision || '', + rev: item.rev || 1, + }; + + return transformedData; + } catch (err) { + console.error("Error in getAvlListById:", err); + return null; + } +} + +/** + * AVL Vendor Info 상세 정보 조회 (단일) + */ +export async function getAvlVendorInfoById(id: number): Promise<AvlDetailItem | null> { + try { + const data = await db + .select() + .from(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)) + .limit(1); + + if (data.length === 0) { + return null; + } + + const item = data[0]; + + // 데이터 변환 + const transformedData: AvlDetailItem = { + ...(item as any), + no: 1, + selected: false, + createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '', + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + }; + + return transformedData; + } catch (err) { + console.error("Error in getAvlVendorInfoById:", err); + return null; + } +} + +/** + * AVL 액션 처리 + * 신규등록, 일괄입력, 저장 등의 액션을 처리 + */ +export async function handleAvlAction( + action: string, + data?: any +): Promise<ActionResult> { + try { + switch (action) { + case "new-registration": + return { success: true, message: "신규 AVL 등록 모드" }; + + case "standard-registration": + return { success: true, message: "표준 AVL 등재 모드" }; + + case "project-registration": + return { success: true, message: "프로젝트 AVL 등재 모드" }; + + case "bulk-import": + if (!data?.file) { + return { success: false, message: "업로드할 파일이 없습니다." }; + } + console.log("일괄 입력 처리:", data.file); + return { success: true, message: "일괄 입력 처리가 시작되었습니다." }; + + case "save": + console.log("변경사항 저장:", data); + return { success: true, message: "변경사항이 저장되었습니다." }; + + case "edit": + if (!data?.id) { + return { success: false, message: "수정할 항목 ID가 없습니다." }; + } + return { success: true, message: "수정 모달이 열렸습니다.", data: { id: data.id } }; + + case "delete": + if (!data?.id) { + return { success: false, message: "삭제할 항목 ID가 없습니다." }; + } + // 실제 삭제 처리 + const deleteResult = await deleteAvlList(data.id); + if (deleteResult) { + return { success: true, message: "항목이 삭제되었습니다.", data: { id: data.id } }; + } else { + return { success: false, message: "항목 삭제에 실패했습니다." }; + } + + case "view-detail": + if (!data?.id) { + return { success: false, message: "조회할 항목 ID가 없습니다." }; + } + return { success: true, message: "상세 정보가 조회되었습니다.", data: { id: data.id } }; + + default: + return { success: false, message: `알 수 없는 액션입니다: ${action}` }; + } + } catch (err) { + console.error("Error in handleAvlAction:", err); + return { success: false, message: "액션 처리 중 오류가 발생했습니다." }; + } +} + +// 클라이언트에서 호출할 수 있는 서버 액션 래퍼들 +export async function createAvlListAction(data: CreateAvlListInput): Promise<AvlListItem | null> { + return await createAvlList(data); +} + +export async function updateAvlListAction(id: number, data: UpdateAvlListInput): Promise<AvlListItem | null> { + return await updateAvlList(id, data); +} + +export async function deleteAvlListAction(id: number): Promise<boolean> { + return await deleteAvlList(id); +} + +export async function handleAvlActionAction(action: string, data?: any): Promise<ActionResult> { + return await handleAvlAction(action, data); +} + +/** + * AVL 리스트 생성 + */ +export async function createAvlList(data: CreateAvlListInput): Promise<AvlListItem | null> { + try { + debugLog('AVL 리스트 생성 시작', { inputData: data }); + + const currentTimestamp = new Date(); + + // 데이터베이스에 삽입할 데이터 준비 + const insertData = { + isTemplate: data.isTemplate ?? false, + constructionSector: data.constructionSector, + projectCode: data.projectCode, + shipType: data.shipType, + avlKind: data.avlKind, + htDivision: data.htDivision, + rev: data.rev ?? 1, + vendorInfoSnapshot: data.vendorInfoSnapshot, // 스냅샷 데이터 추가 + createdBy: data.createdBy || 'system', + updatedBy: data.updatedBy || 'system', + }; + + debugLog('DB INSERT 시작', { + table: 'avl_list', + data: insertData, + hasVendorSnapshot: !!insertData.vendorInfoSnapshot, + snapshotLength: insertData.vendorInfoSnapshot?.length + }); + + // 데이터베이스에 삽입 + const result = await db + .insert(avlList) + .values(insertData) + .returning(); + + if (result.length === 0) { + debugError('DB 삽입 실패: 결과가 없음', { insertData }); + throw new Error("Failed to create AVL list"); + } + + debugSuccess('DB INSERT 완료', { + table: 'avl_list', + result: result[0], + savedSnapshotLength: result[0].vendorInfoSnapshot?.length + }); + + const createdItem = result[0]; + + // 생성된 데이터를 AvlListItem 타입으로 변환 + const transformedData: AvlListItem = { + ...createdItem, + no: 1, + selected: false, + createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '', + projectInfo: createdItem.projectCode || '', + shipType: createdItem.shipType || '', + avlType: createdItem.avlKind || '', + htDivision: createdItem.htDivision || '', + rev: createdItem.rev || 1, + vendorInfoSnapshot: createdItem.vendorInfoSnapshot, // 스냅샷 데이터 포함 + }; + + debugSuccess('AVL 리스트 생성 완료', { result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-list'); + + debugSuccess('AVL 캐시 무효화 완료', { tags: ['avl-list'] }); + + return transformedData; + } catch (err) { + debugError('AVL 리스트 생성 실패', { error: err, inputData: data }); + console.error("Error in createAvlList:", err); + return null; + } +} + +/** + * AVL 리스트 업데이트 + */ +export async function updateAvlList(id: number, data: UpdateAvlListInput): Promise<AvlListItem | null> { + try { + debugLog('AVL 리스트 업데이트 시작', { id, updateData: data }); + + const currentTimestamp = new Date(); + + // 업데이트할 데이터 준비 + const updateData: any = {}; + + if (data.isTemplate !== undefined) updateData.isTemplate = data.isTemplate; + if (data.constructionSector !== undefined) updateData.constructionSector = data.constructionSector; + if (data.projectCode !== undefined) updateData.projectCode = data.projectCode; + if (data.shipType !== undefined) updateData.shipType = data.shipType; + if (data.avlKind !== undefined) updateData.avlKind = data.avlKind; + if (data.htDivision !== undefined) updateData.htDivision = data.htDivision; + if (data.rev !== undefined) updateData.rev = data.rev; + if (data.createdBy !== undefined) updateData.createdBy = data.createdBy; + if (data.updatedBy !== undefined) updateData.updatedBy = data.updatedBy; + + updateData.updatedAt = currentTimestamp; + + // 업데이트할 데이터가 없는 경우 + if (Object.keys(updateData).length <= 1) { + return await getAvlListById(id); + } + + // 데이터베이스 업데이트 + const result = await db + .update(avlList) + .set(updateData) + .where(eq(avlList.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("AVL list not found or update failed"); + } + + const updatedItem = result[0]; + + // 업데이트된 데이터를 AvlListItem 타입으로 변환 + const transformedData: AvlListItem = { + ...updatedItem, + no: 1, + selected: false, + createdAt: updatedItem.createdAt ? updatedItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: updatedItem.updatedAt ? updatedItem.updatedAt.toISOString().split('T')[0] : '', + projectInfo: updatedItem.projectCode || '', + shipType: updatedItem.shipType || '', + avlType: updatedItem.avlKind || '', + htDivision: updatedItem.htDivision || '', + rev: updatedItem.rev || 1, + }; + + debugSuccess('AVL 리스트 업데이트 완료', { id, result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-list'); + + return transformedData; + } catch (err) { + debugError('AVL 리스트 업데이트 실패', { error: err, id, updateData: data }); + console.error("Error in updateAvlList:", err); + return null; + } +} + +/** + * AVL 리스트 삭제 + */ +export async function deleteAvlList(id: number): Promise<boolean> { + try { + debugLog('AVL 리스트 삭제 시작', { id }); + + // 데이터베이스에서 삭제 + const result = await db + .delete(avlList) + .where(eq(avlList.id, id)); + + // 삭제 확인을 위한 재조회 + const checkDeleted = await db + .select({ id: avlList.id }) + .from(avlList) + .where(eq(avlList.id, id)) + .limit(1); + + const isDeleted = checkDeleted.length === 0; + + if (isDeleted) { + debugSuccess('AVL 리스트 삭제 완료', { id }); + revalidateTag('avl-list'); + } else { + debugWarn('AVL 리스트 삭제 실패: 항목이 존재함', { id }); + } + + return isDeleted; + } catch (err) { + debugError('AVL 리스트 삭제 실패', { error: err, id }); + console.error("Error in deleteAvlList:", err); + return false; + } +} + +/** + * AVL Vendor Info 생성 + */ +export async function createAvlVendorInfo(data: AvlVendorInfoInput): Promise<AvlDetailItem | null> { + try { + debugLog('AVL Vendor Info 생성 시작', { inputData: data }); + + // UI 필드를 DB 필드로 변환 + const insertData: NewAvlVendorInfo = { + 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", + disciplineCode: data.disciplineCode || null, + disciplineName: data.disciplineName, + materialNameCustomerSide: data.materialNameCustomerSide, + packageCode: data.packageCode || null, + packageName: data.packageName || null, + materialGroupCode: data.materialGroupCode || null, + materialGroupName: data.materialGroupName || null, + vendorId: data.vendorId || null, + vendorName: data.vendorName || null, + vendorCode: data.vendorCode || null, + avlVendorName: data.avlVendorName || null, + tier: data.tier || null, + faTarget: data.faTarget ?? false, + faStatus: data.faStatus || null, + isAgent: data.isAgent ?? false, + contractSignerId: data.contractSignerId || null, + contractSignerName: data.contractSignerName || null, + contractSignerCode: data.contractSignerCode || null, + headquarterLocation: data.headquarterLocation || null, + manufacturingLocation: data.manufacturingLocation || null, + hasAvl: data.shiAvl ?? false, + isBlacklist: data.shiBlacklist ?? false, + isBcc: data.shiBcc ?? false, + techQuoteNumber: data.salesQuoteNumber || null, + quoteCode: data.quoteCode || null, + quoteVendorId: data.quoteVendorId || null, + quoteVendorName: data.salesVendorInfo || null, + quoteVendorCode: data.quoteVendorCode || null, + quoteCountry: data.salesCountry || null, + quoteTotalAmount: data.totalAmount ? data.totalAmount.replace(/,/g, '') as any : null, + quoteReceivedDate: data.quoteReceivedDate || null, + recentQuoteDate: data.recentQuoteDate || null, + recentQuoteNumber: data.recentQuoteNumber || null, + recentOrderDate: data.recentOrderDate || null, + recentOrderNumber: data.recentOrderNumber || null, + remark: data.remarks || null, + }; + + debugLog('DB INSERT 시작', { table: 'avl_vendor_info', data: insertData }); + + // 데이터베이스에 삽입 + const result = await db + .insert(avlVendorInfo) + .values(insertData as any) + .returning(); + + if (result.length === 0) { + debugError('DB 삽입 실패: 결과가 없음', { insertData }); + throw new Error("Failed to create AVL vendor info"); + } + + debugSuccess('DB INSERT 완료', { table: 'avl_vendor_info', result: result[0] }); + + const createdItem = result[0]; + + // 생성된 데이터를 AvlDetailItem 타입으로 변환 + const transformedData: AvlDetailItem = { + ...(createdItem as any), + no: 1, + selected: false, + createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '', + updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '', + equipBulkDivision: createdItem.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: createdItem.faTarget ?? false, + agentStatus: createdItem.isAgent ? "예" : "아니오", + shiAvl: createdItem.hasAvl ?? false, + shiBlacklist: createdItem.isBlacklist ?? false, + shiBcc: createdItem.isBcc ?? false, + salesQuoteNumber: createdItem.techQuoteNumber || '', + quoteCode: createdItem.quoteCode || '', + salesVendorInfo: createdItem.quoteVendorName || '', + salesCountry: createdItem.quoteCountry || '', + totalAmount: createdItem.quoteTotalAmount ? createdItem.quoteTotalAmount.toString() : '', + quoteReceivedDate: createdItem.quoteReceivedDate || '', + recentQuoteDate: createdItem.recentQuoteDate || '', + recentQuoteNumber: createdItem.recentQuoteNumber || '', + recentOrderDate: createdItem.recentOrderDate || '', + recentOrderNumber: createdItem.recentOrderNumber || '', + remarks: createdItem.remark || '', + }; + + debugSuccess('AVL Vendor Info 생성 완료', { result: transformedData }); + + // 캐시 무효화 + revalidateTag('avl-detail'); + + return transformedData; + } catch (err) { + debugError('AVL Vendor Info 생성 실패', { error: err, inputData: data }); + console.error("Error in createAvlVendorInfo:", err); + return null; + } +} + +/** + * AVL Vendor Info 업데이트 + */ +export async function updateAvlVendorInfo(id: number, data: Partial<AvlVendorInfoInput>): Promise<AvlDetailItem | null> { + try { + debugLog('AVL Vendor Info 업데이트 시작', { id, data }); + + // 간단한 필드 매핑 + const updateData: any = { updatedAt: new Date() }; + + // ownerSuggestion과 shiSuggestion 추가 + if (data.ownerSuggestion !== undefined) updateData.ownerSuggestion = data.ownerSuggestion; + if (data.shiSuggestion !== undefined) updateData.shiSuggestion = data.shiSuggestion; + + if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision === "EQUIP" ? "E" : "B"; + if (data.disciplineCode !== undefined) updateData.disciplineCode = data.disciplineCode; + if (data.disciplineName !== undefined) updateData.disciplineName = data.disciplineName; + if (data.materialNameCustomerSide !== undefined) updateData.materialNameCustomerSide = data.materialNameCustomerSide; + if (data.packageCode !== undefined) updateData.packageCode = data.packageCode; + if (data.packageName !== undefined) updateData.packageName = data.packageName; + if (data.materialGroupCode !== undefined) updateData.materialGroupCode = data.materialGroupCode; + if (data.materialGroupName !== undefined) updateData.materialGroupName = data.materialGroupName; + if (data.vendorId !== undefined) updateData.vendorId = data.vendorId; + if (data.vendorName !== undefined) updateData.vendorName = data.vendorName; + if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode; + if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName; + if (data.tier !== undefined) updateData.tier = data.tier; + if (data.faTarget !== undefined) updateData.faTarget = data.faTarget; + if (data.faStatus !== undefined) updateData.faStatus = data.faStatus; + if (data.isAgent !== undefined) updateData.isAgent = data.isAgent; + if (data.contractSignerId !== undefined) updateData.contractSignerId = data.contractSignerId; + if (data.contractSignerName !== undefined) updateData.contractSignerName = data.contractSignerName; + if (data.contractSignerCode !== undefined) updateData.contractSignerCode = data.contractSignerCode; + if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation; + if (data.manufacturingLocation !== undefined) updateData.manufacturingLocation = data.manufacturingLocation; + if (data.shiAvl !== undefined) updateData.hasAvl = data.shiAvl; + if (data.shiBlacklist !== undefined) updateData.isBlacklist = data.shiBlacklist; + if (data.shiBcc !== undefined) updateData.isBcc = data.shiBcc; + if (data.salesQuoteNumber !== undefined) updateData.techQuoteNumber = data.salesQuoteNumber; + if (data.quoteCode !== undefined) updateData.quoteCode = data.quoteCode; + if (data.quoteVendorId !== undefined) updateData.quoteVendorId = data.quoteVendorId; + if (data.quoteVendorCode !== undefined) updateData.quoteVendorCode = data.quoteVendorCode; + if (data.salesCountry !== undefined) updateData.quoteCountry = data.salesCountry; + if (data.quoteReceivedDate !== undefined) updateData.quoteReceivedDate = data.quoteReceivedDate; + if (data.recentQuoteDate !== undefined) updateData.recentQuoteDate = data.recentQuoteDate; + if (data.recentQuoteNumber !== undefined) updateData.recentQuoteNumber = data.recentQuoteNumber; + if (data.recentOrderDate !== undefined) updateData.recentOrderDate = data.recentOrderDate; + if (data.recentOrderNumber !== undefined) updateData.recentOrderNumber = data.recentOrderNumber; + if (data.remarks !== undefined) updateData.remark = data.remarks; + + // 숫자 변환 + if (data.totalAmount !== undefined) { + updateData.quoteTotalAmount = data.totalAmount ? parseFloat(data.totalAmount.replace(/,/g, '')) || null : null; + } + + // 문자열 필드 + if (data.salesVendorInfo !== undefined) updateData.quoteVendorName = data.salesVendorInfo; + + debugLog('업데이트할 데이터', { updateData }); + + // 데이터베이스 업데이트 + const result = await db + .update(avlVendorInfo) + .set(updateData) + .where(eq(avlVendorInfo.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("AVL vendor info not found"); + } + + debugSuccess('AVL Vendor Info 업데이트 성공', { id }); + + revalidateTag('avl-detail'); + + // 업데이트된 데이터 조회해서 반환 + return await getAvlVendorInfoById(id); + } catch (err) { + debugError('AVL Vendor Info 업데이트 실패', { id, error: err }); + console.error("Error in updateAvlVendorInfo:", err); + return null; + } +} + +/** + * AVL Vendor Info 삭제 + */ +export async function deleteAvlVendorInfo(id: number): Promise<boolean> { + try { + debugLog('AVL Vendor Info 삭제 시작', { id }); + + // 데이터베이스에서 삭제 + const result = await db + .delete(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)); + + // 삭제 확인을 위한 재조회 + const checkDeleted = await db + .select({ id: avlVendorInfo.id }) + .from(avlVendorInfo) + .where(eq(avlVendorInfo.id, id)) + .limit(1); + + const isDeleted = checkDeleted.length === 0; + + if (isDeleted) { + debugSuccess('AVL Vendor Info 삭제 완료', { id }); + revalidateTag('avl-detail'); + } else { + debugWarn('AVL Vendor Info 삭제 실패: 항목이 존재함', { id }); + } + + return isDeleted; + } catch (err) { + debugError('AVL Vendor Info 삭제 실패', { error: err, id }); + console.error("Error in deleteAvlVendorInfo:", err); + return false; + } +} + +/** + * 프로젝트 AVL 최종 확정 + * 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 데이터를 조회합니다. + */ +export const getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('프로젝트 AVL Vendor Info 조회 시작', { input, offset }); + + // 기본 JOIN 쿼리 구성 (프로젝트 AVL이므로 isTemplate=false) + // 실제 쿼리는 아래에서 구성됨 + + // 검색 조건 구성 + const whereConditions: any[] = []; // 기본 조건 제거 + + // 필수 필터: 프로젝트 코드 (avlVendorInfo에서 직접 필터링) + if (input.projectCode) { + whereConditions.push(ilike(avlVendorInfo.projectCode, `%${input.projectCode}%`)); + } + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm), + ilike(avlVendorInfo.packageName, searchTerm), + ilike(avlVendorInfo.materialGroupName, searchTerm) + ) + ); + } + + // 추가 필터 조건들 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)); + + // 데이터 조회 - JOIN 결과에서 필요한 필드들을 명시적으로 선택 + const data = await db + .select({ + // avlVendorInfo의 모든 필드 + id: avlVendorInfo.id, + 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) + .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id)) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 + const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + faStatus: item.faStatus || '', + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('프로젝트 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('프로젝트 AVL Vendor Info 조회 실패', { error: err, input }); + console.error("Error in getProjectAvlVendorInfo:", err); + return { data: [], pageCount: 0 }; + } +}; + + +/** + * 표준 AVL Vendor Info 조회 (선종별 표준 AVL, isTemplate=true) + * avl_list와 avlVendorInfo를 JOIN하여 표준 AVL 데이터를 조회합니다. + */ +export const getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { + try { + const offset = (input.page - 1) * input.perPage; + + debugLog('표준 AVL Vendor Info 조회 시작', { input, offset }); + + // 기본 JOIN 쿼리 구성 (표준 AVL이므로 isTemplate=true) + // 실제 쿼리는 아래에서 구성됨 + + // 검색 조건 구성 + const whereConditions: any[] = [eq(avlVendorInfo.isTemplate, true)]; // 기본 조건: 표준 AVL + + // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) - avlVendorInfo에서 직접 필터링 + if (input.constructionSector) { + whereConditions.push(ilike(avlVendorInfo.constructionSector, `%${input.constructionSector}%`)); + } + if (input.shipType) { + whereConditions.push(ilike(avlVendorInfo.shipType, `%${input.shipType}%`)); + } + if (input.avlKind) { + whereConditions.push(ilike(avlVendorInfo.avlKind, `%${input.avlKind}%`)); + } + if (input.htDivision) { + whereConditions.push(eq(avlVendorInfo.htDivision, input.htDivision)); + } + + // 검색어 기반 필터링 + if (input.search) { + const searchTerm = `%${input.search}%`; + whereConditions.push( + or( + ilike(avlVendorInfo.disciplineName, searchTerm), + ilike(avlVendorInfo.materialNameCustomerSide, searchTerm), + ilike(avlVendorInfo.vendorName, searchTerm), + ilike(avlVendorInfo.avlVendorName, searchTerm), + ilike(avlVendorInfo.packageName, searchTerm), + ilike(avlVendorInfo.materialGroupName, searchTerm) + ) + ); + } + + // 추가 필터 조건들 + if (input.equipBulkDivision) { + whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B")); + } + if (input.disciplineCode) { + whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`)); + } + if (input.disciplineName) { + whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`)); + } + if (input.materialNameCustomerSide) { + whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`)); + } + if (input.packageCode) { + whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`)); + } + if (input.packageName) { + whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`)); + } + if (input.materialGroupCode) { + whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`)); + } + if (input.materialGroupName) { + whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`)); + } + if (input.vendorName) { + whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`)); + } + if (input.vendorCode) { + whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`)); + } + if (input.avlVendorName) { + whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`)); + } + if (input.tier) { + whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`)); + } + + // 정렬 조건 구성 + const orderByConditions: any[] = []; + input.sort.forEach((sortItem) => { + const column = sortItem.id as keyof typeof avlVendorInfo; + if (column && avlVendorInfo[column]) { + if (sortItem.desc) { + orderByConditions.push(sql`${avlVendorInfo[column]} desc`); + } else { + orderByConditions.push(sql`${avlVendorInfo[column]} asc`); + } + } + }); + + // 기본 정렬 + if (orderByConditions.length === 0) { + orderByConditions.push(asc(avlVendorInfo.id)); + } + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(avlVendorInfo) + .where(and(...whereConditions)); + + // 데이터 조회 - avlVendorInfo에서 직접 조회 + const data = await db + .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) + .where(and(...whereConditions)) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 데이터 변환 + const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({ + ...item, + no: offset + index + 1, + selected: false, + createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '', + updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '', + // UI 표시용 필드 변환 + equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK", + faTarget: item.faTarget ?? false, + faStatus: item.faStatus || '', + agentStatus: item.isAgent ? "예" : "아니오", + shiAvl: item.hasAvl ?? false, + shiBlacklist: item.isBlacklist ?? false, + shiBcc: item.isBcc ?? false, + salesQuoteNumber: item.techQuoteNumber || '', + quoteCode: item.quoteCode || '', + salesVendorInfo: item.quoteVendorName || '', + salesCountry: item.quoteCountry || '', + totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '', + quoteReceivedDate: item.quoteReceivedDate || '', + recentQuoteDate: item.recentQuoteDate || '', + recentQuoteNumber: item.recentQuoteNumber || '', + recentOrderDate: item.recentOrderDate || '', + recentOrderNumber: item.recentOrderNumber || '', + remarks: item.remark || '', + })); + + const pageCount = Math.ceil(totalCount[0].count / input.perPage); + + debugSuccess('표준 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount }); + + return { + data: transformedData, + pageCount + }; + } catch (err) { + debugError('표준 AVL Vendor Info 조회 실패', { error: err, input }); + console.error("Error in getStandardAvlVendorInfo:", err); + return { data: [], pageCount: 0 }; + } +}; + +/** + * 선종별표준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 new file mode 100644 index 00000000..04384ec8 --- /dev/null +++ b/lib/avl/table/avl-detail-table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" + +import { columns, type AvlDetailItem } from "./columns-detail" + +interface AvlDetailTableProps { + data: AvlDetailItem[] + pageCount?: number + avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입 + projectCode?: string // 프로젝트 코드 + shipOwnerName?: string // 선주명 + businessType?: string // 사업 유형 (예: 조선/해양) +} + +export function AvlDetailTable({ + data, + pageCount, + avlType = '프로젝트AVL', + projectCode, + shipOwnerName, + businessType = '조선' +}: AvlDetailTableProps) { + // 액션 핸들러 + 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}`) + } + }, []) + + + // 테이블 메타 설정 (읽기 전용) + const tableMeta = React.useMemo(() => ({ + onAction: handleAction, + }), [handleAction]) + + // 데이터 테이블 설정 + const { table } = useDataTable({ + data, + columns, + pageCount: pageCount ?? 1, + initialState: { + sorting: [{ id: "no", desc: false }], + pagination: { + pageIndex: 0, + pageSize: 10, + }, + }, + getRowId: (row) => String(row.id), + meta: tableMeta, + }) + + + return ( + <div className="space-y-4"> + {/* 상단 정보 표시 영역 */} + <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-4"> + <h2 className="text-lg font-semibold">AVL 상세내역</h2> + <span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"> + {avlType} + </span> + <span className="text-sm text-muted-foreground"> + [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'}) + </span> + </div> + </div> + + {/* 상단 버튼 영역 */} + <div className="flex items-center 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} /> + + </div> + ) +} diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx new file mode 100644 index 00000000..52912a2c --- /dev/null +++ b/lib/avl/table/avl-registration-area.tsx @@ -0,0 +1,568 @@ +"use client" + +import * as React from "react" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" +import { useAtom } from "jotai" +import { ProjectAvlTable, 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 { 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 } + | { type: 'SELECT_STANDARD'; count: number } + | { type: 'SELECT_VENDOR'; count: number } + | { type: 'CLEAR_SELECTION' } + +// 선택 상태 +interface SelectionState { + selectedTable: SelectedTable + selectedRowCount: number + resetCounters: { + project: number + standard: number + vendor: number + } +} + +// 선택 상태 리듀서 +const selectionReducer = (state: SelectionState, action: SelectionAction): SelectionState => { + switch (action.type) { + case 'SELECT_PROJECT': + if (action.count > 0) { + return { + selectedTable: 'project', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + standard: state.selectedTable !== 'project' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + vendor: state.selectedTable !== 'project' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'project') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_STANDARD': + if (action.count > 0) { + return { + selectedTable: 'standard', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'standard' ? state.resetCounters.project + 1 : state.resetCounters.project, + vendor: state.selectedTable !== 'standard' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'standard') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_VENDOR': + if (action.count > 0) { + return { + selectedTable: 'vendor', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'vendor' ? state.resetCounters.project + 1 : state.resetCounters.project, + standard: state.selectedTable !== 'vendor' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + } + } + } else if (state.selectedTable === 'vendor') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + default: + return state + } +} + +interface AvlRegistrationAreaProps { + disabled?: boolean // 비활성화 상태 +} + +export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) { + + // 선택된 AVL 레코드 구독 + const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom) + + // 세션 정보 + const { data: session } = useSession() + + // 단일 선택 상태 관리 (useReducer 사용) + const [selectionState, dispatch] = React.useReducer(selectionReducer, { + selectedTable: null, + selectedRowCount: 0, + resetCounters: { + project: 0, + standard: 0, + vendor: 0, + }, + }) + + // 선택 핸들러들 + 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 }) + }, []) + + const handleVendorSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_VENDOR', count }) + }, []) + + const { selectedTable, selectedRowCount, resetCounters } = selectionState + + console.log('selectedTable', selectedTable); + + // 선택된 AVL에 따른 필터 값들 + const [currentProjectCode, setCurrentProjectCode] = React.useState<string>("") + const constructionSector = selectedAvlRecord?.constructionSector || "" + const shipType = selectedAvlRecord?.shipType || "" + const avlKind = selectedAvlRecord?.avlKind || "" + const htDivision = selectedAvlRecord?.htDivision || "" + const avlListId = selectedAvlRecord?.id ? String(selectedAvlRecord.id) : "" + + // 선종별 표준 AVL 검색 조건 상태 (복사 버튼 활성화용) + 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 || "") + }, [selectedAvlRecord?.projectCode]) + + // 프로젝트 코드 변경 핸들러 + const handleProjectCodeChange = React.useCallback((projectCode: string) => { + 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' : ''}`}> + {/* 고정 헤더 영역 */} + <div className="sticky top-0 z-10 p-4 border-b"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">AVL 등록 {disabled ? "(비활성화)" : ""}</h3> + <div className="flex gap-2"> + {/* <Button variant="outline" size="sm" disabled={disabled}> + AVL 불러오기 + </Button> */} + </div> + </div> + </div> + + {/* 스크롤되는 콘텐츠 영역 */} + <div className="overflow-x-auto overflow-y-hidden"> + <div className="grid grid-cols-[2.2fr_2fr_2.5fr] gap-0 min-w-[1200px] w-fit"> + {/* 프로젝트 AVL 테이블 - 9개 컬럼 */} + <div className="p-4 border-r relative"> + <ProjectAvlTable + ref={projectTableRef} + onSelectionChange={handleProjectSelection} + resetCounter={resetCounters.project} + projectCode={currentProjectCode} + avlListId={parseInt(avlListId) || 1} + onProjectCodeChange={handleProjectCodeChange} + reloadTrigger={projectAvlReloadTrigger} + /> + + {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */} + <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10"> + <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm"> + + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="프로젝트AVL로 복사" + disabled={disabled || selectedTable === 'project' || selectedRowCount === 0} + onClick={handleCopyToProject} + > + <ChevronLeft className="w-4 h-4" /> + </Button> + + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="선종별표준AVL로 복사" + disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyToStandard} + > + <ChevronRight className="w-4 h-4" /> + </Button> + + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="벤더풀로 복사" + disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0} + onClick={handleCopyToVendorPool} + > + <ChevronsRight className="w-4 h-4" /> + </Button> + + </div> + </div> + </div> + + {/* 선종별 표준 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 위에 오버레이 */} + <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10"> + <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm"> + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="벤더풀의 항목을 프로젝트AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !currentProjectCode} + onClick={handleCopyFromVendorToProject} + > + <ChevronsLeft className="w-4 h-4" /> + </Button> + + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="벤더풀의 항목을 선종별표준AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyFromVendorToStandard} + > + <ChevronLeft className="w-4 h-4" /> + </Button> + <Button + variant="outline" + size="sm" + className="w-8 h-8 p-0" + title="선종별표준AVL의 항목을 벤더풀로 복사" + disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0} + onClick={handleCopyFromStandardToVendor} + > + <ChevronRight className="w-4 h-4" /> + </Button> + + </div> + </div> + </div> + + {/* Vendor Pool 테이블 - 10개 컬럼 */} + <div className="p-4 relative"> + <VendorPoolTable + ref={vendorTableRef} + onSelectionChange={handleVendorSelection} + resetCounter={resetCounters.vendor} + reloadTrigger={vendorPoolReloadTrigger} + /> + </div> + </div> + </div> + </Card> + ) +} diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx new file mode 100644 index 00000000..8caf012e --- /dev/null +++ b/lib/avl/table/avl-table-columns.tsx @@ -0,0 +1,353 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Eye, Edit, Trash2, History } from "lucide-react" +import { type ColumnDef, TableMeta } from "@tanstack/react-table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { EditableCell } from "@/components/data-table/editable-cell" +import { AvlListItem } from "../types" + +interface GetColumnsProps { + selectedRows?: number[] + onRowSelect?: (id: number, selected: boolean) => void +} + +// 수정 여부 확인 헬퍼 함수 +const getIsModified = (table: any, rowId: string, fieldName: string) => { + const pendingChanges = table.options.meta?.getPendingChanges?.() || {} + return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)] +} + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta<TData> { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + } +} + +// 테이블 컬럼 정의 함수 +export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ColumnDef<AvlListItem>[] { + const columns: ColumnDef<AvlListItem>[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + id: "select", + header: () => <div className="text-center">선택</div>, + cell: ({ row }) => ( + <div className="flex justify-center"> + <Checkbox + checked={selectedRows.includes(row.original.id)} + onCheckedChange={(checked) => { + onRowSelect?.(row.original.id, !!checked) + }} + aria-label="행 선택" + className="translate-y-[2px]" + /> + </div> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "no", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="No" /> + ), + cell: ({ getValue }) => <div className="text-center">{getValue() as number}</div>, + size: 60, + }, + { + accessorKey: "isTemplate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="AVL 분류" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as boolean + const isModified = getIsModified(table, row.id, "isTemplate") + return ( + <EditableCell + value={value ? "표준 AVL" : "프로젝트 AVL"} + isModified={isModified} + type="select" + options={[ + { value: false, label: "프로젝트 AVL" }, + { value: true, label: "표준 AVL" }, + ]} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "isTemplate", newValue === "true") + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "isTemplate") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "constructionSector", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="공사부문" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "constructionSector") + return ( + <EditableCell + value={value} + isModified={isModified} + type="select" + options={[ + { value: "조선", label: "조선" }, + { value: "해양", label: "해양" }, + ]} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "constructionSector", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "constructionSector") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "projectCode") + return ( + <EditableCell + value={value} + isModified={isModified} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "projectCode", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "projectCode") + }} + /> + ) + }, + size: 140, + }, + { + accessorKey: "shipType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선종" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "shipType") + return ( + <EditableCell + value={value} + isModified={isModified} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "shipType", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "shipType") + }} + /> + ) + }, + size: 100, + }, + { + accessorKey: "avlKind", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="AVL 종류" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "avlKind") + return ( + <EditableCell + value={value} + isModified={isModified} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "avlKind", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "avlKind") + }} + /> + ) + }, + size: 120, + }, + { + accessorKey: "htDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="H/T 구분" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as string + const isModified = getIsModified(table, row.id, "htDivision") + return ( + <EditableCell + value={value} + isModified={isModified} + type="select" + options={[ + { value: "H", label: "H" }, + { value: "T", label: "T" }, + ]} + onUpdate={(newValue) => { + table.options.meta?.onCellUpdate?.(row.id, "htDivision", newValue) + }} + onCancel={() => { + table.options.meta?.onCellCancel?.(row.id, "htDivision") + }} + /> + ) + }, + size: 80, + }, + { + accessorKey: "rev", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Rev" /> + ), + cell: ({ getValue, row, table }) => { + const value = getValue() as number + return ( + <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: 100, + }, + ], + }, + + // 등록 정보 그룹 + { + header: "등록 정보", + columns: [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등재일" /> + ), + cell: ({ getValue }) => { + const date = getValue() as string + return <div className="text-center text-sm">{date}</div> + }, + size: 100, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ getValue }) => { + const date = getValue() as string + return <div className="text-center text-sm">{date}</div> + }, + size: 100, + }, + ], + }, + + // 액션 그룹 + { + id: "actions", + header: "액션", + columns: [ + { + id: "actions", + header: () => <div className="text-center">액션</div>, + cell: ({ row, table }) => { + const isEmptyRow = table.options.meta?.isEmptyRow?.(row.id) || false + + if (isEmptyRow) { + return ( + <div className="flex items-center justify-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => table.options.meta?.onSaveEmptyRow?.(row.id)} + className="h-8 w-8 p-0" + > + 저장 + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => table.options.meta?.onCancelEmptyRow?.(row.id)} + className="h-8 w-8 p-0 text-destructive hover:text-destructive" + > + 취소 + </Button> + </div> + ) + } + + return ( + <div className="flex items-center justify-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => table.options.meta?.onAction?.("view-detail", { id: row.original.id })} + className="h-8 w-8 p-0" + title="상세보기" + > + <Eye className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => table.options.meta?.onAction?.("edit", { id: row.original.id })} + className="h-8 w-8 p-0" + title="수정" + > + <Edit className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => table.options.meta?.onAction?.("delete", { id: row.original.id })} + className="h-8 w-8 p-0 text-destructive hover:text-destructive" + title="삭제" + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + ) + }, + enableSorting: false, + enableHiding: false, + size: 120, + }, + ], + }, + ] + + return columns +} diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx new file mode 100644 index 00000000..eb9b2079 --- /dev/null +++ b/lib/avl/table/avl-table.tsx @@ -0,0 +1,554 @@ +"use client" + +import * as React from "react" +import type { + DataTableFilterField, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Button } from "@/components/ui/button" +import { 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" { + interface TableMeta<TData> { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record<string, Partial<AvlListItem>> + } +} + +interface AvlTableProps { + data: AvlListItem[] + pageCount?: number + onRefresh?: () => void // 데이터 새로고침 콜백 + isLoading?: boolean // 로딩 상태 + onRegistrationModeChange?: (mode: 'standard' | 'project') => void // 등록 모드 변경 콜백 + onRowSelect?: (selectedRow: AvlListItem | null) => void // 행 선택 콜백 +} + +export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistrationModeChange, onRowSelect }: AvlTableProps) { + + // 단일 선택을 위한 상태 (shi-vendor-po 방식) + const [selectedRows, setSelectedRows] = React.useState<number[]>([]) + + // 수정사항 추적 (일괄 저장용) + const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlListItem>>>({}) + const [isSaving, setIsSaving] = React.useState(false) + + // 빈 행 관리 (신규 등록용) + const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlListItem>>({}) + const [isCreating, setIsCreating] = React.useState(false) + + // 히스토리 모달 관리 + const [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>[] = [ + { + id: "isTemplate", + label: "AVL 분류", + placeholder: "AVL 분류 선택...", + options: [ + { label: "프로젝트 AVL", value: "false" }, + { label: "표준 AVL", value: "true" }, + ], + }, + { + id: "constructionSector", + label: "공사부문", + placeholder: "공사부문 선택...", + options: [ + { label: "조선", value: "조선" }, + { label: "해양", value: "해양" }, + ], + }, + { + id: "htDivision", + label: "H/T 구분", + placeholder: "H/T 구분 선택...", + options: [ + { label: "H", value: "H" }, + { label: "T", value: "T" }, + ], + }, + ] + + + // 인라인 편집 핸들러 (일괄 저장용) + const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows 상태도 업데이트 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + } + + // pendingChanges에 변경사항 저장 (실시간 표시용) + setPendingChanges(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + }, []) + + // 편집 취소 핸들러 + const handleCellCancel = React.useCallback((id: string, field: keyof AvlListItem) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용) + } + })) + + setPendingChanges(prev => { + const itemChanges = { ...prev[id] } + delete itemChanges[field] + + if (Object.keys(itemChanges).length === 0) { + const newChanges = { ...prev } + delete newChanges[id] + return newChanges + } + + return { + ...prev, + [id]: itemChanges + } + }) + } + }, []) + + // 액션 핸들러 + const handleAction = React.useCallback(async (action: string, data?: any) => { + try { + switch (action) { + case 'new-registration': + // 신규 등록 - 빈 행 추가 + const tempId = `temp-${Date.now()}` + const newEmptyRow: AvlListItem = { + id: tempId as any, + no: 0, + selected: false, + isTemplate: false, + constructionSector: "", + projectCode: "", + shipType: "", + avlKind: "", + htDivision: "", + rev: 1, + vendorInfoSnapshot: null, + createdAt: new Date().toISOString().split('T')[0], + updatedAt: new Date().toISOString().split('T')[0], + createdBy: "system", + updatedBy: "system", + registrant: "system", + lastModifier: "system", + } + + setEmptyRows(prev => ({ + ...prev, + [tempId]: newEmptyRow + })) + toast.success("신규 등록 행이 추가되었습니다.") + break + + case 'standard-registration': + // 표준 AVL 등록 + const result = await handleAvlActionAction('standard-registration') + if (result.success) { + toast.success(result.message) + onRegistrationModeChange?.('standard') // 등록 모드 변경 콜백 호출 + } else { + toast.error(result.message) + } + break + + case 'project-registration': + // 프로젝트 AVL 등록 + const projectResult = await handleAvlActionAction('project-registration') + if (projectResult.success) { + toast.success(projectResult.message) + onRegistrationModeChange?.('project') // 등록 모드 변경 콜백 호출 + } else { + toast.error(projectResult.message) + } + break + + case 'bulk-import': + // 일괄 입력 + const bulkResult = await handleAvlActionAction('bulk-import') + if (bulkResult.success) { + toast.success(bulkResult.message) + } else { + toast.error(bulkResult.message) + } + break + + case 'save': + // 변경사항 저장 + if (Object.keys(pendingChanges).length === 0) { + toast.info("저장할 변경사항이 없습니다.") + return + } + + setIsSaving(true) + try { + // 각 변경사항을 순차적으로 저장 + for (const [id, changes] of Object.entries(pendingChanges)) { + if (String(id).startsWith('temp-')) continue // 빈 행은 제외 + + const result = await updateAvlListAction(Number(id), changes as any) + if (!result) { + throw new Error(`항목 ${id} 저장 실패`) + } + } + + setPendingChanges({}) + toast.success("변경사항이 저장되었습니다.") + onRefresh?.() + } catch (error) { + console.error('저장 실패:', error) + toast.error("저장 중 오류가 발생했습니다.") + } finally { + setIsSaving(false) + } + break + + case 'edit': + // 수정 모달 열기 (현재는 간단한 토스트로 처리) + toast.info(`${data?.id} 항목 수정`) + break + + case 'delete': + // 삭제 확인 및 실행 + if (!data?.id || String(data.id).startsWith('temp-')) return + + const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`) + if (!confirmed) return + + try { + const result = await deleteAvlListAction(Number(data.id)) + if (result) { + toast.success("항목이 삭제되었습니다.") + onRefresh?.() + } else { + toast.error("삭제에 실패했습니다.") + } + } catch (error) { + console.error('삭제 실패:', error) + toast.error("삭제 중 오류가 발생했습니다.") + } + break + + case 'view-detail': + // 상세 조회 (페이지 이동) + if (data?.id && !String(data.id).startsWith('temp-')) { + window.location.href = `/evcp/avl/${data.id}` + } + break + + case 'view-history': + // 리비전 히스토리 조회 + if (data?.id && !String(data.id).startsWith('temp-')) { + setSelectedAvlItem(data as AvlListItem) + setHistoryModalOpen(true) + } + break + + default: + toast.error(`알 수 없는 액션: ${action}`) + } + } catch (error) { + console.error('액션 처리 실패:', error) + toast.error("액션 처리 중 오류가 발생했습니다.") + } + }, [pendingChanges, onRefresh, onRegistrationModeChange]) + + // 빈 행 저장 핸들러 + const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { + const emptyRow = emptyRows[tempId] + if (!emptyRow) return + + try { + setIsCreating(true) + + // 필수 필드 검증 + if (!emptyRow.constructionSector || !emptyRow.avlKind) { + toast.error("공사부문과 AVL 종류는 필수 입력 항목입니다.") + return + } + + // 빈 행 데이터를 생성 데이터로 변환 + const createData = { + isTemplate: emptyRow.isTemplate, + constructionSector: emptyRow.constructionSector, + projectCode: emptyRow.projectCode || undefined, + shipType: emptyRow.shipType || undefined, + avlKind: emptyRow.avlKind, + htDivision: emptyRow.htDivision || undefined, + rev: emptyRow.rev, + createdBy: "system", + updatedBy: "system", + } + + const result = await createAvlListAction(createData as any) + if (result) { + // 빈 행 제거 및 성공 메시지 + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + // pendingChanges에서도 제거 + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.success("새 항목이 등록되었습니다.") + onRefresh?.() + } else { + toast.error("등록에 실패했습니다.") + } + } catch (error) { + console.error('빈 행 저장 실패:', error) + toast.error("등록 중 오류가 발생했습니다.") + } finally { + setIsCreating(false) + } + }, [emptyRows, onRefresh]) + + // 빈 행 취소 핸들러 + const handleCancelEmptyRow = React.useCallback((tempId: string) => { + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.info("등록이 취소되었습니다.") + }, []) + + // 빈 행 포함한 전체 데이터 + const allData = React.useMemo(() => { + // 로딩 중에는 빈 데이터를 표시 + if (isLoading) { + return [] + } + const emptyRowArray = Object.values(emptyRows) + return [...data, ...emptyRowArray] + }, [data, emptyRows, isLoading]) + + // 행 선택 처리 (1개만 선택 가능 - shi-vendor-po 방식) + const handleRowSelect = React.useCallback((id: number, selected: boolean) => { + if (selected) { + setSelectedRows([id]) // 1개만 선택 + // 선택된 레코드 찾아서 부모 콜백 호출 + const allData = isLoading ? [] : [...data, ...Object.values(emptyRows)] + const selectedRow = allData.find(row => row.id === id) + if (selectedRow) { + onRowSelect?.(selectedRow) + } + } else { + setSelectedRows([]) + onRowSelect?.(null) + } + }, [data, emptyRows, isLoading, onRowSelect]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({ + onCellUpdate: handleCellUpdate, + onCellCancel: handleCellCancel, + onAction: handleAction, + onSaveEmptyRow: handleSaveEmptyRow, + onCancelEmptyRow: handleCancelEmptyRow, + isEmptyRow: (id: string) => String(id).startsWith('temp-'), + getPendingChanges: () => pendingChanges, + }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges]) + + + // 데이터 테이블 설정 + const { table } = useDataTable({ + data: allData, + columns: getColumns({ selectedRows, onRowSelect: handleRowSelect }), + pageCount: pageCount || 1, + filterFields, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + 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"> + {/* 툴바 */} + <DataTableAdvancedToolbar + table={table} + filterFields={filterFields as any} + > + <div className="flex items-center gap-2"> + {/* 액션 버튼들 */} + <Button + variant="outline" + size="sm" + onClick={() => handleAction('new-registration')} + disabled={isCreating} + > + 신규등록 + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => handleAction('standard-registration')} + > + 표준AVL등록 + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => handleAction('project-registration')} + > + 프로젝트AVL등록 + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => handleAction('bulk-import')} + > + 파일 업로드 + </Button> + + {/* 저장 버튼 - 변경사항이 있을 때만 활성화 */} + {(hasPendingChanges || hasEmptyRows) && ( + <Button + variant="default" + size="sm" + onClick={() => handleAction('save')} + disabled={isSaving} + > + {isSaving ? "저장 중..." : "저장"} + </Button> + )} + + {/* 새로고침 버튼 */} + <Button + variant="outline" + size="sm" + onClick={onRefresh} + > + 새로고침 + </Button> + </div> + </DataTableAdvancedToolbar> + + {/* 데이터 테이블 */} + <DataTable table={table} /> + + {/* 히스토리 모달 */} + <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"> + <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-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 new file mode 100644 index 00000000..84ad9d9a --- /dev/null +++ b/lib/avl/table/columns-detail.tsx @@ -0,0 +1,290 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { type ColumnDef } from "@tanstack/react-table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + + +// AVL 상세 아이템 타입 +export type AvlDetailItem = { + id: string + no: number + selected: boolean + // AVL 리스트 ID (외래키) + avlListId: number + // 설계 정보 + equipBulkDivision: 'EQUIP' | 'BULK' + disciplineCode: string + disciplineName: string + // 자재 정보 + materialNameCustomerSide: string + packageCode: string + packageName: string + materialGroupCode: string + materialGroupName: string + // 협력업체 정보 + vendorId?: number + vendorName: string + vendorCode: string + avlVendorName: string + tier: string + // FA 정보 + faTarget: boolean + faStatus: string + // Agent 정보 + isAgent: boolean + agentStatus: string // UI 표시용 + // 계약 서명주체 + contractSignerId?: number + contractSignerName: string + contractSignerCode: string + // 위치 정보 + headquarterLocation: string + manufacturingLocation: string + // SHI Qualification + shiAvl: boolean + shiBlacklist: boolean + shiBcc: boolean + // 기술영업 견적결과 + salesQuoteNumber: string + quoteCode: string + salesVendorInfo: string + salesCountry: string + totalAmount: string + quoteReceivedDate: string + // 업체 실적 현황(구매) + recentQuoteDate: string + recentQuoteNumber: string + recentOrderDate: string + recentOrderNumber: string + // 기타 + remarks: string + // 타임스탬프 + createdAt: string + updatedAt: string +} + +// 테이블 컬럼 정의 +export const columns: ColumnDef<AvlDetailItem>[] = [ + // 기본 정보 그룹 + { + header: "기본 정보", + columns: [ + { + accessorKey: "no", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="No." /> + ), + size: 60, + }, + { + accessorKey: "equipBulkDivision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" /> + ), + cell: ({ row }) => { + const value = row.getValue("equipBulkDivision") as string + return ( + <Badge variant="outline"> + {value || "-"} + </Badge> + ) + }, + size: 120, + }, + { + accessorKey: "disciplineName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계공종" /> + ), + cell: ({ row }) => { + const value = row.getValue("disciplineName") as string + return <span>{value || "-"}</span> + }, + size: 120, + }, + { + accessorKey: "materialNameCustomerSide", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialNameCustomerSide") as string + return <span>{value || "-"}</span> + }, + size: 150, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지 정보" /> + ), + cell: ({ row }) => { + const value = row.getValue("packageName") as string + return <span>{value || "-"}</span> + }, + size: 130, + }, + { + accessorKey: "materialGroupCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹코드" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupCode") as string + return <span>{value || "-"}</span> + }, + size: 120, + }, + { + accessorKey: "materialGroupName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹명" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupName") as string + return <span>{value || "-"}</span> + }, + size: 130, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체코드" /> + ), + cell: ({ row }) => { + const value = row.getValue("vendorCode") as string + return <span>{value || "-"}</span> + }, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체명" /> + ), + cell: ({ row }) => { + const value = row.getValue("vendorName") as string + return <span className="font-medium">{value || "-"}</span> + }, + size: 140, + }, + { + accessorKey: "avlVendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" /> + ), + cell: ({ row }) => { + const value = row.getValue("avlVendorName") as string + return <span>{value || "-"}</span> + }, + size: 140, + }, + { + accessorKey: "tier", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" /> + ), + cell: ({ row }) => { + const value = row.getValue("tier") as string + 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 ( + <Badge className={tierColor}> + {value} + </Badge> + ) + }, + size: 100, + }, + ], + }, + // FA 정보 그룹 + { + header: "FA 정보", + columns: [ + { + accessorKey: "faTarget", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="FA 대상" /> + ), + cell: ({ row }) => { + const value = row.getValue("faTarget") as boolean + return ( + <Badge variant={value ? "default" : "secondary"}> + {value ? "대상" : "비대상"} + </Badge> + ) + }, + size: 80, + }, + { + accessorKey: "faStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="FA 현황" /> + ), + cell: ({ row }) => { + const value = row.getValue("faStatus") as string + return <span>{value || "-"}</span> + }, + size: 100, + }, + ], + }, + // SHI Qualification 그룹 + { + header: "SHI Qualification", + columns: [ + { + accessorKey: "shiAvl", + header: "AVL", + cell: ({ row }) => { + const value = row.getValue("shiAvl") as boolean + return ( + <Checkbox + checked={value} + disabled + aria-label="SHI AVL 등재 여부" + /> + ) + }, + size: 80, + }, + { + accessorKey: "shiBlacklist", + header: "Blacklist", + cell: ({ row }) => { + const value = row.getValue("shiBlacklist") as boolean + return ( + <Checkbox + checked={value} + disabled + aria-label="SHI Blacklist 등재 여부" + /> + ) + }, + size: 100, + }, + { + accessorKey: "shiBcc", + header: "BCC", + cell: ({ row }) => { + const value = row.getValue("shiBcc") as boolean + return ( + <Checkbox + checked={value} + disabled + aria-label="SHI BCC 등재 여부" + /> + ) + }, + size: 80, + }, + ], + }, +] diff --git a/lib/avl/table/project-avl-add-dialog.tsx b/lib/avl/table/project-avl-add-dialog.tsx new file mode 100644 index 00000000..509e4258 --- /dev/null +++ b/lib/avl/table/project-avl-add-dialog.tsx @@ -0,0 +1,779 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { toast } from "sonner" +import type { AvlVendorInfoInput, AvlDetailItem } from "../types" + +interface ProjectAvlAddDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> + editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) + onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러 +} + +export function ProjectAvlAddDialog({ open, onOpenChange, onAddItem, editingItem, onUpdateItem }: ProjectAvlAddDialogProps) { + const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + }) + + // 수정 모드일 때 폼 데이터 초기화 + React.useEffect(() => { + if (editingItem) { + setFormData({ + // 설계 정보 + equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", + disciplineCode: editingItem.disciplineCode || "", + disciplineName: editingItem.disciplineName || "", + + // 자재 정보 + materialNameCustomerSide: editingItem.materialNameCustomerSide || "", + + // 패키지 정보 + packageCode: editingItem.packageCode || "", + packageName: editingItem.packageName || "", + + // 자재그룹 정보 + materialGroupCode: editingItem.materialGroupCode || "", + materialGroupName: editingItem.materialGroupName || "", + + // 협력업체 정보 + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || "", + + // AVL 정보 + avlVendorName: editingItem.avlVendorName || "", + tier: editingItem.tier || "", + + // 제안방향 + ownerSuggestion: editingItem.ownerSuggestion || false, + shiSuggestion: editingItem.shiSuggestion || false, + + // 위치 정보 + headquarterLocation: editingItem.headquarterLocation || "", + manufacturingLocation: editingItem.manufacturingLocation || "", + + // FA 정보 + faTarget: editingItem.faTarget || false, + faStatus: editingItem.faStatus || "", + + // Agent 정보 + isAgent: editingItem.isAgent || false, + + // 계약 서명주체 + contractSignerName: editingItem.contractSignerName || "", + contractSignerCode: editingItem.contractSignerCode || "", + + // SHI Qualification + shiAvl: editingItem.shiAvl || false, + shiBlacklist: editingItem.shiBlacklist || false, + shiBcc: editingItem.shiBcc || false, + + // 기술영업 견적결과 + salesQuoteNumber: editingItem.salesQuoteNumber || "", + quoteCode: editingItem.quoteCode || "", + salesVendorInfo: editingItem.salesVendorInfo || "", + salesCountry: editingItem.salesCountry || "", + totalAmount: editingItem.totalAmount || "", + quoteReceivedDate: editingItem.quoteReceivedDate || "", + + // 업체 실적 현황 + recentQuoteDate: editingItem.recentQuoteDate || "", + recentQuoteNumber: editingItem.recentQuoteNumber || "", + recentOrderDate: editingItem.recentOrderDate || "", + recentOrderNumber: editingItem.recentOrderNumber || "", + + // 기타 + remarks: editingItem.remarks || "" + }) + } + }, [editingItem]) + + const handleSubmit = async () => { + // 필수 필드 검증 + if (!formData.disciplineName || !formData.materialNameCustomerSide) { + toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") + return + } + + try { + if (editingItem && onUpdateItem) { + // 수정 모드 + await onUpdateItem(editingItem.id, formData) + } else { + // 추가 모드 + await onAddItem(formData) + } + + // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만) + setFormData({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + + onOpenChange(false) + } catch (error) { + // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음 + } + } + + const handleCancel = () => { + setFormData({ + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>{editingItem ? "프로젝트 AVL 항목 수정" : "프로젝트 AVL 항목 추가"}</DialogTitle> + <DialogDescription> + {editingItem + ? "AVL 항목을 수정합니다. 필수 항목을 입력해주세요." + : "새로운 AVL 항목을 추가합니다. 필수 항목을 입력해주세요." + } * 표시된 항목은 필수 입력사항입니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-6 py-4"> + {/* 기본 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label> + <Select + value={formData.equipBulkDivision} + onValueChange={(value: "EQUIP" | "BULK") => + setFormData(prev => ({ ...prev, equipBulkDivision: value })) + } + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EQUIP">EQUIP</SelectItem> + <SelectItem value="BULK">BULK</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="disciplineCode">설계공종코드</Label> + <Input + id="disciplineCode" + value={formData.disciplineCode} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} + placeholder="설계공종코드를 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="disciplineName">설계공종명 *</Label> + <Input + id="disciplineName" + value={formData.disciplineName} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} + placeholder="설계공종명을 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> + <Input + id="materialNameCustomerSide" + value={formData.materialNameCustomerSide} + onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} + placeholder="고객사 AVL 자재명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 패키지 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="packageCode">패키지 코드</Label> + <Input + id="packageCode" + value={formData.packageCode} + onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))} + placeholder="패키지 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="packageName">패키지 명</Label> + <Input + id="packageName" + value={formData.packageName} + onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))} + placeholder="패키지 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 자재그룹 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="materialGroupCode">자재그룹 코드</Label> + <Input + id="materialGroupCode" + value={formData.materialGroupCode} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} + placeholder="자재그룹 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="materialGroupName">자재그룹 명</Label> + <Input + id="materialGroupName" + value={formData.materialGroupName} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} + placeholder="자재그룹 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 협력업체 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorCode">협력업체 코드</Label> + <Input + id="vendorCode" + value={formData.vendorCode} + onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} + placeholder="협력업체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorName">협력업체 명</Label> + <Input + id="vendorName" + value={formData.vendorName} + onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} + placeholder="협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="avlVendorName">AVL 등재업체명</Label> + <Input + id="avlVendorName" + value={formData.avlVendorName} + onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} + placeholder="AVL 등재업체명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="tier">등급 (Tier)</Label> + <Input + id="tier" + value={formData.tier} + onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} + placeholder="등급을 입력하세요" + /> + </div> + </div> + </div> + + {/* 제안방향 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="ownerSuggestion" + checked={formData.ownerSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) + } + /> + <Label htmlFor="ownerSuggestion">선주제안</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiSuggestion" + checked={formData.shiSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) + } + /> + <Label htmlFor="shiSuggestion">SHI 제안</Label> + </div> + </div> + </div> + + {/* 위치 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label> + <Input + id="headquarterLocation" + value={formData.headquarterLocation} + onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} + placeholder="본사 위치를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> + <Input + id="manufacturingLocation" + value={formData.manufacturingLocation} + onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} + placeholder="제작/선적지를 입력하세요" + /> + </div> + </div> + </div> + + {/* FA 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="faTarget" + checked={formData.faTarget} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, faTarget: !!checked })) + } + /> + <Label htmlFor="faTarget">FA 대상</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="faStatus">FA 현황</Label> + <Input + id="faStatus" + value={formData.faStatus} + onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))} + placeholder="FA 현황을 입력하세요" + /> + </div> + </div> + </div> + + {/* Agent 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4> + <div className="flex items-center space-x-2"> + <Checkbox + id="isAgent" + checked={formData.isAgent} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, isAgent: !!checked })) + } + /> + <Label htmlFor="isAgent">Agent 여부</Label> + </div> + </div> + + {/* 계약 서명주체 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="contractSignerCode">계약서명주체 코드</Label> + <Input + id="contractSignerCode" + value={formData.contractSignerCode} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} + placeholder="계약서명주체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="contractSignerName">계약서명주체 명</Label> + <Input + id="contractSignerName" + value={formData.contractSignerName} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} + placeholder="계약서명주체 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* SHI Qualification */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiAvl" + checked={formData.shiAvl} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiAvl: !!checked })) + } + /> + <Label htmlFor="shiAvl">AVL</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBlacklist" + checked={formData.shiBlacklist} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) + } + /> + <Label htmlFor="shiBlacklist">Blacklist</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBcc" + checked={formData.shiBcc} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBcc: !!checked })) + } + /> + <Label htmlFor="shiBcc">BCC</Label> + </div> + </div> + </div> + + {/* 기술영업 견적결과 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label> + <Input + id="salesQuoteNumber" + value={formData.salesQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} + placeholder="기술영업 견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteCode">견적서 Code</Label> + <Input + id="quoteCode" + value={formData.quoteCode} + onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} + placeholder="견적서 Code를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label> + <Input + id="salesVendorInfo" + value={formData.salesVendorInfo} + onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} + placeholder="견적 협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesCountry">국가</Label> + <Input + id="salesCountry" + value={formData.salesCountry} + onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} + placeholder="국가를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="totalAmount">총 금액</Label> + <Input + id="totalAmount" + type="number" + value={formData.totalAmount} + onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} + placeholder="총 금액을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> + <Input + id="quoteReceivedDate" + value={formData.quoteReceivedDate} + onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 업체 실적 현황 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="recentQuoteNumber">최근견적번호</Label> + <Input + id="recentQuoteNumber" + value={formData.recentQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} + placeholder="최근견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> + <Input + id="recentQuoteDate" + value={formData.recentQuoteDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderNumber">최근발주번호</Label> + <Input + id="recentOrderNumber" + value={formData.recentOrderNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} + placeholder="최근발주번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> + <Input + id="recentOrderDate" + value={formData.recentOrderDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 기타 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4> + <div className="space-y-2"> + <Label htmlFor="remarks">비고</Label> + <Textarea + id="remarks" + value={formData.remarks} + onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))} + placeholder="비고를 입력하세요" + rows={3} + /> + </div> + </div> + </div> + <DialogFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="button" onClick={handleSubmit}> + {editingItem ? "수정" : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/avl/table/project-avl-table-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 new file mode 100644 index 00000000..8664e32b --- /dev/null +++ b/lib/avl/table/project-avl-table.tsx @@ -0,0 +1,650 @@ +"use client" + +import * as React from "react" +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 { 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, 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 +} + + +export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTableProps>(({ + onSelectionChange, + resetCounter, + projectCode, + avlListId, + onProjectCodeChange, + reloadTrigger +}, ref) => { + + const { data: sessionData } = useSession() + + const [data, setData] = React.useState<ProjectAvlItem[]>([]) + const [pageCount, setPageCount] = React.useState(0) + const [originalFile, setOriginalFile] = React.useState<string>("") + const [localProjectCode, setLocalProjectCode] = React.useState<string>(projectCode || "") + + // 행 추가/수정 다이얼로그 상태 + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [editingItem, setEditingItem] = React.useState<AvlDetailItem | undefined>(undefined) + + // 프로젝트 정보 상태 + const [projectInfo, setProjectInfo] = React.useState<{ + projectName: string + constructionSector: string + shipType: string + htDivision: string + } | null>(null) + + // 프로젝트 검색 상태 + const [projectSearchStatus, setProjectSearchStatus] = React.useState<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> = {}) => { + try { + 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 as "EQUIP" | "BULK") ?? "EQUIP", + disciplineCode: searchParams.disciplineCode ?? "", + disciplineName: searchParams.disciplineName ?? "", + materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "", + packageCode: searchParams.packageCode ?? "", + packageName: searchParams.packageName ?? "", + materialGroupCode: searchParams.materialGroupCode ?? "", + materialGroupName: searchParams.materialGroupName ?? "", + vendorName: searchParams.vendorName ?? "", + vendorCode: searchParams.vendorCode ?? "", + avlVendorName: searchParams.avlVendorName ?? "", + tier: searchParams.tier ?? "", + filters: searchParams.filters ?? [], + joinOperator: searchParams.joinOperator ?? "and", + search: searchParams.search ?? "", + } + 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) { + console.error("프로젝트 AVL 데이터 로드 실패:", error) + setData([]) + setPageCount(0) + } finally { + // 로딩 상태 처리 완료 + } + }, [localProjectCode]) + + + + // reloadTrigger가 변경될 때마다 데이터 리로드 + React.useEffect(() => { + if (reloadTrigger && reloadTrigger > 0) { + console.log('ProjectAvlTable - reloadTrigger changed, reloading data') + loadData({}) + } + }, [reloadTrigger, loadData]) + + // 초기 데이터 로드 (검색 버튼이 눌렸을 때만) + React.useEffect(() => { + if (localProjectCode && isSearchClicked) { + loadData({}) + } + }, [loadData, localProjectCode, isSearchClicked]) + + // 파일 업로드 핸들러 + const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + setOriginalFile(file.name) + // TODO: 실제 파일 업로드 로직 구현 + console.log("파일 업로드:", file.name) + } + }, []) + + // 프로젝트 검색 함수 (공통 로직) + const searchProject = React.useCallback(async (projectCode: string) => { + if (!projectCode.trim()) { + setProjectInfo(null) + setProjectSearchStatus('idle') + setData([]) + setPageCount(0) + return + } + + setProjectSearchStatus('searching') // 검색 시작 상태로 변경 + + 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 getProjectInfoFromBiddingProjects(projectCode.trim()) + if (projectData) { + searchSource = 'bidding-projects' + setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경 + } else { + // 둘 다 실패한 경우에만 에러 상태로 변경 + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + 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 || "" + }) + + const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트' + toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`) + + // 검색 성공 시 AVL 데이터 로드 트리거 + setIsSearchClicked(true) + } + } 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()) { + toast.error("프로젝트 코드를 먼저 입력해주세요.") + return + } + if (!projectInfo) { + toast.error("프로젝트 정보를 불러올 수 없습니다.") + return + } + setIsAddDialogOpen(true) + }, [localProjectCode, projectInfo]) + + + // 다이얼로그에서 항목 추가 핸들러 + const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + // DB에 실제 저장할 데이터 준비 (avlListId는 나중에 최종 확정 시 설정) + const saveData: AvlVendorInfoInput = { + ...itemData, + projectCode: localProjectCode, // 현재 프로젝트 코드 저장 + avlListId: avlListId || undefined // 있으면 설정, 없으면 undefined (null로 저장됨) + } + + // DB에 저장 + const result = await createAvlVendorInfo(saveData) + + if (result) { + toast.success("새 항목이 성공적으로 추가되었습니다.") + + // 데이터 새로고침 + loadData({}) + } else { + toast.error("항목 추가에 실패했습니다.") + } + } catch (error) { + console.error("항목 추가 실패:", error) + toast.error("항목 추가 중 오류가 발생했습니다.") + } + }, [avlListId, loadData, localProjectCode]) + + // 다이얼로그에서 항목 수정 핸들러 + const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + // DB에 실제 수정 + const result = await updateAvlVendorInfo(id, itemData) + + if (result) { + toast.success("항목이 성공적으로 수정되었습니다.") + + // 데이터 새로고침 + loadData({}) + + // 다이얼로그 닫기 및 수정 모드 해제 + setIsAddDialogOpen(false) + setEditingItem(undefined) + } else { + toast.error("항목 수정에 실패했습니다.") + } + } catch (error) { + console.error("항목 수정 실패:", error) + toast.error("항목 수정 중 오류가 발생했습니다.") + } + }, [loadData]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({}), []) + + const table = useReactTable({ + data, + columns: getProjectAvlColumns(), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + manualPagination: true, + pageCount, + state: { + pagination, + }, + onPaginationChange: (updater) => { + // 페이지네이션 상태 업데이트 + 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 + + 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]) + + // 최종 확정 핸들러 + 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]) + + // 선택 상태 변경 시 콜백 호출 + useLayoutEffect(() => { + console.log('ProjectAvlTable - onSelectionChange called with count:', selectedRowCount) + onSelectionChange?.(selectedRowCount) + }, [selectedRowCount, onSelectionChange]) + + // 선택 해제 요청이 오면 모든 선택 해제 + React.useEffect(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( + <div className="h-full flex flex-col"> + <div className="mb-2"> + <div className="flex items-center justify-between mb-2"> + <h4 className="font-medium">프로젝트 AVL</h4> + <div className="flex gap-1"> + <Button variant="outline" size="sm" onClick={handleAddRow}> + 행 추가 + </Button> + <Button + variant="outline" + size="sm" + onClick={handleEditItem} + disabled={table.getFilteredSelectedRowModel().rows.length !== 1} + > + 항목 수정 + </Button> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 파일 업로드 + </Button> + <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 자동 매핑 + </Button> + <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 강제 매핑 + </Button> */} + <Button variant="outline" size="sm" onClick={handleDeleteItems}> + 항목 삭제 + </Button> + + {/* 최종 확정 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleFinalizeAvl} + disabled={!localProjectCode.trim() || !projectInfo || data.length === 0} + > + 최종 확정 + </Button> + </div> + </div> + </div> + + {/* 조회대상 관리영역 */} + <div className="mb-4 p-4 border rounded-lg bg-muted/50"> + <div className="flex gap-4 overflow-x-auto pb-2"> + {/* 프로젝트 코드 */} + <div className="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> + + {/* 프로젝트명 */} + <ProjectDisplayField + label="프로젝트명" + value={projectInfo?.projectName || ''} + status={projectSearchStatus} + minWidth="250px" + /> + + {/* 원본파일 */} + <ProjectFileField + label="원본파일" + originalFile={originalFile} + onFileUpload={handleFileUpload} + /> + + {/* 공사부문 */} + <ProjectDisplayField + label="공사부문" + value={projectInfo?.constructionSector || ''} + status={projectSearchStatus} + /> + + {/* 선종 */} + <ProjectDisplayField + label="선종" + value={projectInfo?.shipType || ''} + status={projectSearchStatus} + /> + + {/* H/T 구분 */} + <ProjectDisplayField + label="H/T 구분" + value={projectInfo?.htDivision || ''} + status={projectSearchStatus} + minWidth="140px" + formatter={(value) => + value === 'H' ? 'Hull (H)' : + value === 'T' ? 'Topside (T)' : '-' + } + /> + </div> + </div> + + <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={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 new file mode 100644 index 00000000..cc39540b --- /dev/null +++ b/lib/avl/table/standard-avl-table.tsx @@ -0,0 +1,651 @@ +"use client" + +import * as React from "react" +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 { getStandardAvlVendorInfo } from "../service" +import { GetStandardAvlSchema } from "../validations" +import { AvlDetailItem } from "../types" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Search } from "lucide-react" +import { toast } from "sonner" +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: "조선", 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: "Nearshore", label: "Nearshore" }, + { value: "Offshore", label: "Offshore" }, + { value: "IOC", label: "IOC" }, + { value: "NOC", label: "NOC" }, +] + +const htDivisionOptions = [ + { value: "공통", label: "공통" }, + { value: "H", label: "Hull (H)" }, + { 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 + constructionSector?: string // 공사부문 필터 + shipType?: string // 선종 필터 + avlKind?: string // AVL 종류 필터 + htDivision?: string // H/T 구분 필터 + onSearchConditionsChange?: (conditions: { + constructionSector: string + shipType: string + avlKind: string + htDivision: string + }) => void + reloadTrigger?: number +} + +export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTableProps>(({ + onSelectionChange, + resetCounter, + constructionSector: initialConstructionSector, + shipType: initialShipType, + avlKind: initialAvlKind, + 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 || "") + 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> = {}) => { + try { + setLoading(true) + + const params: GetStandardAvlSchema = { + page: searchParams.page ?? 1, + perPage: searchParams.perPage ?? 10, + sort: searchParams.sort ?? [{ id: "no", desc: false }], + 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) { + console.error("선종별 표준 AVL 데이터 로드 실패:", error) + setData([]) + setPageCount(0) + } finally { + setLoading(false) + } + }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) + + // reloadTrigger가 변경될 때마다 데이터 리로드 + React.useEffect(() => { + if (reloadTrigger && reloadTrigger > 0) { + console.log('StandardAvlTable - reloadTrigger changed, reloading data') + loadData({}) + } + }, [reloadTrigger, loadData]) + + // 검색 초기화 핸들러 + const handleResetSearch = React.useCallback(() => { + 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]) + + // 항목 수정 핸들러 + 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 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(() => { + 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(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( + <div className="h-full flex flex-col"> + <div className="mb-2"> + <div className="flex items-center justify-between mb-2"> + <h4 className="font-medium">선종별 표준 AVL</h4> + <div className="flex gap-1"> + <Button variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)}> + 신규업체 추가 + </Button> + <Button + variant="outline" + size="sm" + onClick={handleEditItem} + disabled={table.getFilteredSelectedRowModel().rows.length !== 1} + > + 항목 수정 + </Button> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 파일 업로드 + </Button> + <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 일괄입력 + </Button> */} + <Button variant="outline" size="sm" onClick={handleDeleteItems}> + 항목 삭제 + </Button> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + 저장 + </Button> */} + <Button + variant="outline" + size="sm" + onClick={handleFinalizeStandardAvl} + disabled={!isAllSearchConditionsSelected || data.length === 0} + > + 최종 확정 + </Button> + </div> + </div> + </div> + + {/* 검색 UI */} + <div className="mb-4 p-4 border rounded-lg bg-muted/50"> + <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"> + {/* 공사부문 */} + <div className="space-y-2"> + <label className="text-sm font-medium">공사부문</label> + <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {constructionSectorOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* 선종 */} + <div className="space-y-2"> + <label className="text-sm font-medium">선종</label> + <Select value={searchShipType} onValueChange={setSearchShipType}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {currentShipTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* AVL종류 */} + <div className="space-y-2"> + <label className="text-sm font-medium">AVL종류</label> + <Select value={searchAvlKind} onValueChange={setSearchAvlKind}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {avlKindOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* H/T */} + <div className="space-y-2"> + <label className="text-sm font-medium">H/T 구분</label> + <Select value={searchHtDivision} onValueChange={setSearchHtDivision}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {htDivisionOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* 검색 버튼들 */} + <div className="space-y-2"> + <label className="text-sm font-medium opacity-0">버튼</label> + <div className="flex gap-1"> + <Button + onClick={handleSearch} + disabled={loading || !isAllSearchConditionsSelected} + size="sm" + className="px-3" + > + <Search className="w-4 h-4 mr-1" /> + 조회 + </Button> + <Button + onClick={handleResetSearch} + variant="outline" + size="sm" + className="px-3" + > + 초기화 + </Button> + </div> + </div> + </div> + </div> + + <div className="flex-1"> + <DataTable table={table} /> + </div> + + {/* 신규업체 추가 다이얼로그 */} + <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 new file mode 100644 index 00000000..7ad9eb56 --- /dev/null +++ b/lib/avl/table/vendor-pool-table.tsx @@ -0,0 +1,301 @@ +"use client" + +import * as React from "react" +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" +import { Input } from "@/components/ui/input" +import { Search } from "lucide-react" +import { getVendorPools } from "../../vendor-pool/service" +import { GetVendorPoolSchema } from "../../vendor-pool/validations" +import { VendorPool } from "../../vendor-pool/types" +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 +} + +// 실제 데이터는 API에서 가져옴 + +export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTableProps>(({ + onSelectionChange, + resetCounter, + reloadTrigger +}, ref) => { + const [data, setData] = React.useState<VendorPoolItem[]>([]) + const [loading, setLoading] = React.useState(false) + const [pageCount, setPageCount] = React.useState(0) + + // 검색 상태 + const [searchText, setSearchText] = React.useState("") + const [showAll, setShowAll] = React.useState(false) + + // 페이지네이션 상태 + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + + // 데이터 로드 함수 + const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema> = {}) => { + try { + setLoading(true) + + 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, + } + 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) { + console.error("Vendor Pool 데이터 로드 실패:", error) + setData([]) + setPageCount(0) + } finally { + setLoading(false) + } + }, [searchText]) + + // 검색 핸들러 + const handleSearch = React.useCallback(() => { + if (showAll) { + // 전체보기 모드에서는 페이징 없이 전체 데이터 로드 + loadData({ perPage: 1000 }) // 충분히 큰 숫자로 전체 데이터 가져오기 + } else { + // 검색 시 페이지를 1페이지로 리셋 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + loadData({ page: 1, perPage: pagination.pageSize }) + } + }, [loadData, showAll, pagination.pageSize]) + + // 전체보기 토글 핸들러 + const handleShowAllToggle = React.useCallback((checked: boolean) => { + setShowAll(checked) + if (checked) { + // 전체보기 활성화 시 전체 데이터 로드 + loadData({ perPage: 1000 }) + setSearchText("") + } else { + // 전체보기 비활성화 시 일반 페이징으로 전환 + loadData({}) + } + }, [loadData]) + + // 초기 데이터 로드 + React.useEffect(() => { + // 초기 로드 시 페이지를 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, + columns: vendorPoolColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화 + pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용 + state: { + pagination: showAll ? { pageIndex: 0, pageSize: data.length } : pagination, + }, + onPaginationChange: (updater) => { + if (!showAll) { + // 전체보기가 아닐 때만 페이징 변경 처리 + 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?.(selectedRowCount) + }, [selectedRowCount, onSelectionChange]) + + // 선택 해제 요청이 오면 모든 선택 해제 + React.useEffect(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( + <div className="h-full flex flex-col"> + <div className="mb-2"> + <div className="flex items-center justify-between mb-2"> + <h4 className="font-medium">Vendor Pool</h4> + <div className="flex gap-1"> + {/* <Button variant="outline" size="sm"> + 신규업체 추가 + </Button> */} + </div> + </div> + </div> + + {/* 검색 UI */} + <div className="mb-4 p-4 border rounded-lg bg-muted/50"> + <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center"> + {/* 전체보기 체크박스 */} + <div className="flex items-center space-x-2"> + <Checkbox + id="showAll" + checked={showAll} + onCheckedChange={handleShowAllToggle} + /> + <label + htmlFor="showAll" + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + 전체보기 + </label> + </div> + + {/* 검색어 입력 */} + {!showAll && ( + <div className="flex gap-2 flex-1 max-w-md"> + <Input + placeholder="설계공종, 업체명, 자재그룹 등으로 검색..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + className="flex-1" + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSearch() + } + }} + /> + <Button + onClick={handleSearch} + disabled={loading} + size="sm" + className="px-3" + > + <Search className="w-4 h-4" /> + </Button> + </div> + )} + + {/* 검색 결과 정보 */} + <div className="text-sm text-muted-foreground"> + {showAll ? ( + `전체 ${data.length}개 항목 표시 중` + ) : ( + `${data.length}개 항목${searchText ? ` (검색어: "${searchText}")` : ""}` + )} + </div> + </div> + </div> + + <div className="flex-1"> + <DataTable table={table} /> + </div> + </div> + ) +}) + +VendorPoolTable.displayName = "VendorPoolTable" diff --git a/lib/avl/types.ts b/lib/avl/types.ts new file mode 100644 index 00000000..6a7b5143 --- /dev/null +++ b/lib/avl/types.ts @@ -0,0 +1,163 @@ +// AVL 관련 타입 정의 +import { AvlList, AvlVendorInfo } from "@/db/schema/avl/avl"; + +// AVL 리스트 아이템 (UI에서 사용하는 타입) +export interface AvlListItem extends Omit<AvlList, 'createdAt' | 'updatedAt'> { + no: number; + selected: boolean; + createdAt: string; // UI에서 사용하기 위해 string으로 변환 + updatedAt: string; // UI에서 사용하기 위해 string으로 변환 + vendorInfoSnapshot?: any; // JSON 데이터 + + // 추가 표시용 필드들 (실제로는 AvlVendorInfo에서 가져와야 함) + projectInfo?: string; + shipType?: string; + avlType?: string; + htDivision?: string; + rev?: number; + pkg?: string; + materialGroup?: string; + vendor?: string; + tier?: string; + ownerSuggestion?: string; + shiSuggestion?: string; + registrant?: string; + lastModifier?: string; +} + +// AVL 상세 아이템 (UI에서 사용하는 타입) +export interface AvlDetailItem extends Omit<AvlVendorInfo, 'createdAt' | 'updatedAt'> { + no: number; + selected: boolean; + createdAt: string; + updatedAt: string; + + // UI 표시용 추가 필드들 + equipBulkDivision: 'EQUIP' | 'BULK'; // UI에서 표시하기 위한 변환 + faTarget: boolean; // UI에서 표시하기 위한 변환 + faStatus: string; + agentStatus: string; // UI에서 표시하기 위한 변환 + shiAvl: boolean; // hasAvl로 매핑 + shiBlacklist: boolean; // isBlacklist로 매핑 + shiBcc: boolean; // isBcc로 매핑 + salesQuoteNumber: string; // techQuoteNumber로 매핑 + quoteCode: string; // quoteCode로 매핑 + salesVendorInfo: string; // quoteVendorName으로 매핑 + salesCountry: string; // quoteCountry로 매핑 + totalAmount: string; // quoteTotalAmount로 매핑 (string으로 변환) + quoteReceivedDate: string; // quoteReceivedDate로 매핑 + recentQuoteDate: string; // recentQuoteDate로 매핑 + recentQuoteNumber: string; // recentQuoteNumber로 매핑 + recentOrderDate: string; // recentOrderDate로 매핑 + recentOrderNumber: string; // recentOrderNumber로 매핑 + remarks: string; // remark으로 매핑 +} + +// AVL 생성을 위한 입력 타입 +export interface CreateAvlListInput extends Omit<AvlList, 'id' | 'createdAt' | 'updatedAt' | 'vendorInfoSnapshot'> { + // UI에서 입력받을 추가 필드들 + projectInfo?: string; + shipType?: string; + avlType?: string; + vendorInfoSnapshot?: any; // JSON 데이터, 선택적 속성 +} + +// AVL 업데이트를 위한 입력 타입 +export interface UpdateAvlListInput extends Partial<CreateAvlListInput> { + id: number; +} + + +// 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; + + // 설계 정보 + equipBulkDivision: 'EQUIP' | 'BULK'; + disciplineCode?: string; + disciplineName: string; + + // 자재 정보 + materialNameCustomerSide: string; + + // 패키지 정보 + packageCode?: string; + packageName?: string; + + // 자재그룹 정보 + materialGroupCode?: string; + materialGroupName?: string; + + // 협력업체 정보 + vendorId?: number; + vendorName?: string; + vendorCode?: string; + + // AVL 정보 + avlVendorName?: string; + tier?: string; + + // 제안방향 + ownerSuggestion?: boolean; + shiSuggestion?: boolean; + + // 위치 정보 + headquarterLocation?: string; + manufacturingLocation?: string; + + // FA 정보 + faTarget?: boolean; + faStatus?: string; + + // Agent 정보 + isAgent?: boolean; + + // 계약 서명주체 + contractSignerId?: number; + contractSignerName?: string; + contractSignerCode?: string; + + // SHI Qualification + shiAvl?: boolean; + shiBlacklist?: boolean; + shiBcc?: boolean; + + // 기술영업 견적결과 + salesQuoteNumber?: string; + quoteCode?: string; + quoteVendorId?: number; + salesVendorInfo?: string; + quoteVendorCode?: string; + salesCountry?: string; + totalAmount?: string; + quoteReceivedDate?: string; + + // 업체 실적 현황 + recentQuoteDate?: string; + recentQuoteNumber?: string; + recentOrderDate?: string; + recentOrderNumber?: string; + + // 기타 + remarks?: string; +} + +// 액션 처리 결과 타입 +export interface ActionResult { + success: boolean; + message: string; + data?: any; +} diff --git a/lib/avl/validations.ts b/lib/avl/validations.ts new file mode 100644 index 00000000..6f09cdfd --- /dev/null +++ b/lib/avl/validations.ts @@ -0,0 +1,170 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { AvlListItem, AvlDetailItem } from "./types" + +// AVL 리스트 검색 파라미터 캐시 +export const avlListSearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (등재일 기준 내림차순) + sort: getSortingStateParser<AvlListItem>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // AVL 기본 정보 필드 + isTemplate: parseAsStringEnum(["true", "false"]).withDefault(""), // 표준 AVL 여부 + constructionSector: parseAsString.withDefault(""), // 공사부문 + projectCode: parseAsString.withDefault(""), // 프로젝트코드 + shipType: parseAsString.withDefault(""), // 선종 + avlKind: parseAsString.withDefault(""), // AVL 종류 + htDivision: parseAsStringEnum(["H", "T"]).withDefault(""), // H/T구분 + rev: parseAsString.withDefault(""), // 리비전 + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// AVL 상세 검색 파라미터 캐시 +export const avlDetailSearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<AvlDetailItem>().withDefault([ + { id: "no", desc: false }, + ]), + + // AVL Vendor Info 관련 필드들 + equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""), // Equip/Bulk 구분 + disciplineCode: parseAsString.withDefault(""), // 설계공종코드 + disciplineName: parseAsString.withDefault(""), // 설계공종명 + materialNameCustomerSide: parseAsString.withDefault(""), // 고객사 AVL 자재명 + packageCode: parseAsString.withDefault(""), // 패키지 코드 + packageName: parseAsString.withDefault(""), // 패키지 명 + materialGroupCode: parseAsString.withDefault(""), // 자재그룹 코드 + materialGroupName: parseAsString.withDefault(""), // 자재그룹 명 + vendorName: parseAsString.withDefault(""), // 협력업체 명 + vendorCode: parseAsString.withDefault(""), // 협력업체 코드 + avlVendorName: parseAsString.withDefault(""), // AVL 등재업체명 + tier: parseAsString.withDefault(""), // 등급 + faTarget: parseAsStringEnum(["true", "false"]).withDefault(""), // FA 대상 + faStatus: parseAsString.withDefault(""), // FA 현황 + isAgent: parseAsStringEnum(["true", "false"]).withDefault(""), // Agent 여부 + contractSignerName: parseAsString.withDefault(""), // 계약 서명주체 + headquarterLocation: parseAsString.withDefault(""), // 본사 위치 + manufacturingLocation: parseAsString.withDefault(""), // 제작/선적지 + hasAvl: parseAsStringEnum(["true", "false"]).withDefault(""), // AVL 존재 + isBlacklist: parseAsStringEnum(["true", "false"]).withDefault(""), // Blacklist + isBcc: parseAsStringEnum(["true", "false"]).withDefault(""), // BCC + techQuoteNumber: parseAsString.withDefault(""), // 기술영업 견적번호 + quoteCode: parseAsString.withDefault(""), // 견적서 Code + quoteCountry: parseAsString.withDefault(""), // 국가 + remark: parseAsString.withDefault(""), // 비고 + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 프로젝트 AVL 검색 파라미터 캐시 (프로젝트별 AVL Vendor Info) +export const projectAvlSearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<AvlDetailItem>().withDefault([ + { id: "no", desc: false }, + ]), + + // 필수 필터: 프로젝트 코드 + projectCode: parseAsString.withDefault(""), + + // 추가 필터들 + equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""), + disciplineCode: parseAsString.withDefault(""), + disciplineName: parseAsString.withDefault(""), + materialNameCustomerSide: parseAsString.withDefault(""), + packageCode: parseAsString.withDefault(""), + packageName: parseAsString.withDefault(""), + materialGroupCode: parseAsString.withDefault(""), + materialGroupName: parseAsString.withDefault(""), + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + avlVendorName: parseAsString.withDefault(""), + tier: parseAsString.withDefault(""), + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 표준 AVL 검색 파라미터 캐시 (선종별 표준 AVL Vendor Info) +export const standardAvlSearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<AvlDetailItem>().withDefault([ + { id: "no", desc: false }, + ]), + + // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) + constructionSector: parseAsString.withDefault(""), + shipType: parseAsString.withDefault(""), + avlKind: parseAsString.withDefault(""), + htDivision: parseAsStringEnum(["공통", "H", "T", ""]).withDefault(""), + + // 추가 필터들 + equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK", ""]).withDefault(""), + disciplineCode: parseAsString.withDefault(""), + disciplineName: parseAsString.withDefault(""), + materialNameCustomerSide: parseAsString.withDefault(""), + packageCode: parseAsString.withDefault(""), + packageName: parseAsString.withDefault(""), + materialGroupCode: parseAsString.withDefault(""), + materialGroupName: parseAsString.withDefault(""), + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + avlVendorName: parseAsString.withDefault(""), + tier: parseAsString.withDefault(""), + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 최종 타입 추론 +export type GetAvlListSchema = Awaited<ReturnType<typeof avlListSearchParamsCache.parse>> +export type GetAvlDetailSchema = Awaited<ReturnType<typeof avlDetailSearchParamsCache.parse>> +export type GetProjectAvlSchema = Awaited<ReturnType<typeof projectAvlSearchParamsCache.parse>> +export type GetStandardAvlSchema = Awaited<ReturnType<typeof standardAvlSearchParamsCache.parse>> |
