From 2b490956c9752c1b756780a3461bc1c37b6fe0a7 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 15 Sep 2025 18:58:07 +0900 Subject: (김준회) AVL 관리 및 상세 - 기능 구현 1차 + docker compose 내 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/avl/components/avl-history-modal.tsx | 297 ++++++++++++++++++++++++ lib/avl/components/project-field-components.tsx | 113 +++++++++ lib/avl/components/project-field-utils.ts | 45 ++++ 3 files changed, 455 insertions(+) create mode 100644 lib/avl/components/avl-history-modal.tsx create mode 100644 lib/avl/components/project-field-components.tsx create mode 100644 lib/avl/components/project-field-utils.ts (limited to 'lib/avl/components') diff --git a/lib/avl/components/avl-history-modal.tsx b/lib/avl/components/avl-history-modal.tsx new file mode 100644 index 00000000..4f0c354b --- /dev/null +++ b/lib/avl/components/avl-history-modal.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Calendar, Users, FileText, ChevronDown, ChevronRight } from "lucide-react" +import type { AvlListItem } from "@/lib/avl/types" + +interface AvlHistoryModalProps { + isOpen: boolean + onClose: () => void + avlItem: AvlListItem | null + historyData?: AvlHistoryRecord[] + onLoadHistory?: (avlItem: AvlListItem) => Promise +} + +export interface VendorSnapshot { + id: number + vendorName?: string + avlVendorName?: string + vendorCode?: string + disciplineName?: string + materialNameCustomerSide?: string + materialGroupCode?: string + materialGroupName?: string + tier?: string + hasAvl?: boolean + faTarget?: boolean + headquarterLocation?: string + ownerSuggestion?: boolean + shiSuggestion?: boolean + [key: string]: unknown // 다른 모든 속성들 +} + +export interface AvlHistoryRecord { + id: number + rev: number + createdAt: string + createdBy: string + vendorInfoSnapshot: VendorSnapshot[] // JSON 데이터 + changeDescription?: string +} + +// 스냅샷 테이블 컴포넌트 +interface SnapshotTableProps { + snapshot: VendorSnapshot[] + isOpen: boolean + onToggle: () => void +} + +function SnapshotTable({ snapshot, isOpen, onToggle }: SnapshotTableProps) { + if (!snapshot || snapshot.length === 0) { + return ( +
+ 스냅샷 데이터가 없습니다. +
+ ) + } + + return ( + + + + + +
+
+ + + + No. + 설계공종 + 고객사 AVL 자재명 + 자재그룹 코드 + 자재그룹 명 + AVL 등재업체명 + 협력업체 코드 + 협력업체 명 + 선주제안 + SHI 제안 + 본사 위치 + 등급 + AVL + FA대상 + + + + {snapshot.map((item, index) => ( + + {index + 1} + {item.disciplineName || '-'} + {item.materialNameCustomerSide || '-'} + {item.materialGroupCode || '-'} + {item.materialGroupName || '-'} + {item.avlVendorName || '-'} + {item.vendorCode || '-'} + {item.vendorName || '-'} + + + {item.ownerSuggestion ? "예" : "아니오"} + + + + + {item.shiSuggestion ? "예" : "아니오"} + + + {item.headquarterLocation || '-'} + + {item.tier ? ( + + {item.tier} + + ) : '-'} + + + + {item.hasAvl ? "Y" : "N"} + + + + + {item.faTarget ? "Y" : "N"} + + + + ))} + +
+
+
+
+
+ ) +} + +export function AvlHistoryModal({ + isOpen, + onClose, + avlItem, + historyData, + onLoadHistory +}: AvlHistoryModalProps) { + const [loading, setLoading] = React.useState(false) + const [history, setHistory] = React.useState([]) + const [openSnapshots, setOpenSnapshots] = React.useState>({}) + + // 히스토리 데이터 로드 + React.useEffect(() => { + if (isOpen && avlItem && onLoadHistory) { + setLoading(true) + onLoadHistory(avlItem) + .then(setHistory) + .catch(console.error) + .finally(() => setLoading(false)) + } else if (historyData) { + setHistory(historyData) + } + }, [isOpen, avlItem, onLoadHistory, historyData]) + + // 스냅샷 테이블 토글 함수 + const toggleSnapshot = (recordId: number) => { + setOpenSnapshots(prev => ({ + ...prev, + [recordId]: !prev[recordId] + })) + } + + if (!avlItem) return null + + return ( + + + + + + AVL 리비전 히스토리 + +
+ {avlItem.isTemplate ? "표준 AVL" : "프로젝트 AVL"} - {avlItem.avlKind} + {avlItem.projectCode && ` (${avlItem.projectCode})`} +
+
+ +
+
+ {loading ? ( +
+
히스토리를 불러오는 중...
+
+ ) : history.length === 0 ? ( +
+
히스토리 데이터가 없습니다.
+
+ ) : ( +
+ {history.map((record, index) => ( +
+ {/* 리비전 헤더 */} +
+
+ + Rev {record.rev} + + {index === 0 && ( + + 현재 + + )} +
+
+
+ + {new Date(record.createdAt).toLocaleDateString('ko-KR')} +
+
+
+ + {/* 변경 설명 */} + {record.changeDescription && ( +
+ {record.changeDescription} +
+ )} + + {/* Vendor Info 요약 */} +
+
+
+ {record.vendorInfoSnapshot?.length || 0} +
+
총 협력업체
+
+
+
+ {record.vendorInfoSnapshot?.filter(v => v.hasAvl).length || 0} +
+
AVL 등재
+
+
+
+ {record.vendorInfoSnapshot?.filter(v => v.faTarget).length || 0} +
+
FA 대상
+
+
+ + {/* 스냅샷 테이블 */} +
+ toggleSnapshot(record.id)} + /> +
+
+ ))} +
+ )} +
+
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/avl/components/project-field-components.tsx b/lib/avl/components/project-field-components.tsx new file mode 100644 index 00000000..95505d08 --- /dev/null +++ b/lib/avl/components/project-field-components.tsx @@ -0,0 +1,113 @@ +"use client" + +import * as React from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { + ProjectSearchStatus, + getLabelStatusClassName, + getDisplayElementStatusClassName, + getInputStatusClassName +} from "./project-field-utils" + +// 타입 재내보내기 +export type { ProjectSearchStatus } from "./project-field-utils" + +// 재사용 가능한 필드 컴포넌트들 +export interface ProjectInputFieldProps { + label: string + value: string + onChange: (value: string) => void + placeholder: string + status: ProjectSearchStatus + statusText?: string + minWidth?: string +} + +export const ProjectInputField: React.FC = ({ + label, + value, + onChange, + placeholder, + status, + statusText, + minWidth = "250px" +}) => ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={`h-8 text-sm ${getInputStatusClassName(status)}`} + /> +
+) + +export interface ProjectDisplayFieldProps { + label: string + value: string + status: ProjectSearchStatus + minWidth?: string + formatter?: (value: string) => string +} + +export const ProjectDisplayField: React.FC = ({ + label, + value, + status, + minWidth = "120px", + formatter +}) => { + const displayValue = status === 'searching' ? '조회 중...' : (formatter ? formatter(value) : (value || '-')) + + return ( +
+ +
+ {displayValue} +
+
+ ) +} + +export interface ProjectFileFieldProps { + label: string + originalFile: string + onFileUpload: (event: React.ChangeEvent) => void + minWidth?: string +} + +export const ProjectFileField: React.FC = ({ + label, + originalFile, + onFileUpload, + minWidth = "200px" +}) => ( +
+ +
+ {originalFile ? ( + {originalFile} + ) : ( +
+ + +
+ )} +
+
+) diff --git a/lib/avl/components/project-field-utils.ts b/lib/avl/components/project-field-utils.ts new file mode 100644 index 00000000..d3d84295 --- /dev/null +++ b/lib/avl/components/project-field-utils.ts @@ -0,0 +1,45 @@ +// 프로젝트 검색 상태 타입 +export type ProjectSearchStatus = 'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error' + +// 프로젝트 상태에 따른 스타일링 유틸리티 함수들 +export const getLabelStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'text-red-600' + case 'success-projects': + case 'success-bidding': + return 'text-green-600' + case 'searching': + return 'text-blue-600' + default: + return 'text-muted-foreground' + } +} + +export const getDisplayElementStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300' + case 'success-projects': + case 'success-bidding': + return 'border-green-300' + case 'searching': + return 'border-blue-300' + default: + return 'border-input' + } +} + +export const getInputStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300 focus:border-red-500 focus:ring-red-500/20' + case 'success-projects': + case 'success-bidding': + return 'border-green-300 focus:border-green-500 focus:ring-green-500/20' + case 'searching': + return 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' + default: + return '' + } +} -- cgit v1.2.3