diff options
Diffstat (limited to 'lib/avl/components')
| -rw-r--r-- | lib/avl/components/avl-history-modal.tsx | 297 | ||||
| -rw-r--r-- | lib/avl/components/project-field-components.tsx | 113 | ||||
| -rw-r--r-- | lib/avl/components/project-field-utils.ts | 45 |
3 files changed, 455 insertions, 0 deletions
diff --git a/lib/avl/components/avl-history-modal.tsx b/lib/avl/components/avl-history-modal.tsx new file mode 100644 index 00000000..4f0c354b --- /dev/null +++ b/lib/avl/components/avl-history-modal.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Calendar, Users, FileText, ChevronDown, ChevronRight } from "lucide-react" +import type { AvlListItem } from "@/lib/avl/types" + +interface AvlHistoryModalProps { + isOpen: boolean + onClose: () => void + avlItem: AvlListItem | null + historyData?: AvlHistoryRecord[] + onLoadHistory?: (avlItem: AvlListItem) => Promise<AvlHistoryRecord[]> +} + +export interface VendorSnapshot { + id: number + vendorName?: string + avlVendorName?: string + vendorCode?: string + disciplineName?: string + materialNameCustomerSide?: string + materialGroupCode?: string + materialGroupName?: string + tier?: string + hasAvl?: boolean + faTarget?: boolean + headquarterLocation?: string + ownerSuggestion?: boolean + shiSuggestion?: boolean + [key: string]: unknown // 다른 모든 속성들 +} + +export interface AvlHistoryRecord { + id: number + rev: number + createdAt: string + createdBy: string + vendorInfoSnapshot: VendorSnapshot[] // JSON 데이터 + changeDescription?: string +} + +// 스냅샷 테이블 컴포넌트 +interface SnapshotTableProps { + snapshot: VendorSnapshot[] + isOpen: boolean + onToggle: () => void +} + +function SnapshotTable({ snapshot, isOpen, onToggle }: SnapshotTableProps) { + if (!snapshot || snapshot.length === 0) { + return ( + <div className="text-sm text-muted-foreground"> + 스냅샷 데이터가 없습니다. + </div> + ) + } + + return ( + <Collapsible open={isOpen} onOpenChange={onToggle}> + <CollapsibleTrigger asChild> + <Button variant="outline" size="sm" className="w-full justify-between"> + <span>벤더 상세 정보 ({snapshot.length}개)</span> + {isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} + </Button> + </CollapsibleTrigger> + <CollapsibleContent className="mt-3"> + <div className="border rounded-lg"> + <div className="overflow-auto max-h-[400px]"> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[60px]">No.</TableHead> + <TableHead className="w-[100px]">설계공종</TableHead> + <TableHead>고객사 AVL 자재명</TableHead> + <TableHead className="w-[120px]">자재그룹 코드</TableHead> + <TableHead className="w-[130px]">자재그룹 명</TableHead> + <TableHead>AVL 등재업체명</TableHead> + <TableHead className="w-[120px]">협력업체 코드</TableHead> + <TableHead className="w-[130px]">협력업체 명</TableHead> + <TableHead className="w-[80px]">선주제안</TableHead> + <TableHead className="w-[80px]">SHI 제안</TableHead> + <TableHead className="w-[100px]">본사 위치</TableHead> + <TableHead className="w-[80px]">등급</TableHead> + <TableHead className="w-[60px]">AVL</TableHead> + <TableHead className="w-[80px]">FA대상</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {snapshot.map((item, index) => ( + <TableRow key={item.id || index}> + <TableCell className="font-mono text-xs text-center">{index + 1}</TableCell> + <TableCell className="text-sm">{item.disciplineName || '-'}</TableCell> + <TableCell className="text-sm">{item.materialNameCustomerSide || '-'}</TableCell> + <TableCell className="font-mono text-xs">{item.materialGroupCode || '-'}</TableCell> + <TableCell className="text-sm">{item.materialGroupName || '-'}</TableCell> + <TableCell className="font-medium text-sm">{item.avlVendorName || '-'}</TableCell> + <TableCell className="font-mono text-xs">{item.vendorCode || '-'}</TableCell> + <TableCell className="font-medium text-sm">{item.vendorName || '-'}</TableCell> + <TableCell> + <Badge variant={item.ownerSuggestion ? "default" : "secondary"} className="text-xs"> + {item.ownerSuggestion ? "예" : "아니오"} + </Badge> + </TableCell> + <TableCell> + <Badge variant={item.shiSuggestion ? "default" : "secondary"} className="text-xs"> + {item.shiSuggestion ? "예" : "아니오"} + </Badge> + </TableCell> + <TableCell className="text-xs">{item.headquarterLocation || '-'}</TableCell> + <TableCell> + {item.tier ? ( + <Badge variant="outline" className="text-xs"> + {item.tier} + </Badge> + ) : '-'} + </TableCell> + <TableCell> + <Badge variant={item.hasAvl ? "default" : "secondary"} className="text-xs"> + {item.hasAvl ? "Y" : "N"} + </Badge> + </TableCell> + <TableCell> + <Badge variant={item.faTarget ? "default" : "secondary"} className="text-xs"> + {item.faTarget ? "Y" : "N"} + </Badge> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + </CollapsibleContent> + </Collapsible> + ) +} + +export function AvlHistoryModal({ + isOpen, + onClose, + avlItem, + historyData, + onLoadHistory +}: AvlHistoryModalProps) { + const [loading, setLoading] = React.useState(false) + const [history, setHistory] = React.useState<AvlHistoryRecord[]>([]) + const [openSnapshots, setOpenSnapshots] = React.useState<Record<number, boolean>>({}) + + // 히스토리 데이터 로드 + React.useEffect(() => { + if (isOpen && avlItem && onLoadHistory) { + setLoading(true) + onLoadHistory(avlItem) + .then(setHistory) + .catch(console.error) + .finally(() => setLoading(false)) + } else if (historyData) { + setHistory(historyData) + } + }, [isOpen, avlItem, onLoadHistory, historyData]) + + // 스냅샷 테이블 토글 함수 + const toggleSnapshot = (recordId: number) => { + setOpenSnapshots(prev => ({ + ...prev, + [recordId]: !prev[recordId] + })) + } + + if (!avlItem) return null + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + AVL 리비전 히스토리 + </DialogTitle> + <div className="text-sm text-muted-foreground"> + {avlItem.isTemplate ? "표준 AVL" : "프로젝트 AVL"} - {avlItem.avlKind} + {avlItem.projectCode && ` (${avlItem.projectCode})`} + </div> + </DialogHeader> + + <div className="flex-1 overflow-auto min-h-0"> + <div className="pr-4"> + {loading ? ( + <div className="flex items-center justify-center h-[300px]"> + <div className="text-muted-foreground">히스토리를 불러오는 중...</div> + </div> + ) : history.length === 0 ? ( + <div className="flex items-center justify-center h-[300px]"> + <div className="text-muted-foreground">히스토리 데이터가 없습니다.</div> + </div> + ) : ( + <div className="space-y-4 py-4"> + {history.map((record, index) => ( + <div + key={record.id} + className={`p-4 border rounded-lg ${ + index === 0 ? "border-primary bg-primary/5" : "border-border" + }`} + > + {/* 리비전 헤더 */} + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + <Badge + variant={index === 0 ? "default" : "outline"} + className="font-mono" + > + Rev {record.rev} + </Badge> + {index === 0 && ( + <Badge variant="secondary" className="text-xs"> + 현재 + </Badge> + )} + </div> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <div className="flex items-center gap-1"> + <Calendar className="h-4 w-4" /> + {new Date(record.createdAt).toLocaleDateString('ko-KR')} + </div> + </div> + </div> + + {/* 변경 설명 */} + {record.changeDescription && ( + <div className="mb-3 p-2 bg-muted/50 rounded text-sm"> + {record.changeDescription} + </div> + )} + + {/* Vendor Info 요약 */} + <div className="grid grid-cols-3 gap-4 text-sm"> + <div className="text-center"> + <div className="font-medium text-lg"> + {record.vendorInfoSnapshot?.length || 0} + </div> + <div className="text-muted-foreground">총 협력업체</div> + </div> + <div className="text-center"> + <div className="font-medium text-lg"> + {record.vendorInfoSnapshot?.filter(v => v.hasAvl).length || 0} + </div> + <div className="text-muted-foreground">AVL 등재</div> + </div> + <div className="text-center"> + <div className="font-medium text-lg"> + {record.vendorInfoSnapshot?.filter(v => v.faTarget).length || 0} + </div> + <div className="text-muted-foreground">FA 대상</div> + </div> + </div> + + {/* 스냅샷 테이블 */} + <div className="mt-3 pt-3 border-t"> + <SnapshotTable + snapshot={record.vendorInfoSnapshot || []} + isOpen={openSnapshots[record.id] || false} + onToggle={() => toggleSnapshot(record.id)} + /> + </div> + </div> + ))} + </div> + )} + </div> + </div> + + <div className="flex justify-end pt-4 border-t flex-shrink-0 mt-4"> + <Button variant="outline" onClick={onClose}> + 닫기 + </Button> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/avl/components/project-field-components.tsx b/lib/avl/components/project-field-components.tsx new file mode 100644 index 00000000..95505d08 --- /dev/null +++ b/lib/avl/components/project-field-components.tsx @@ -0,0 +1,113 @@ +"use client" + +import * as React from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { + ProjectSearchStatus, + getLabelStatusClassName, + getDisplayElementStatusClassName, + getInputStatusClassName +} from "./project-field-utils" + +// 타입 재내보내기 +export type { ProjectSearchStatus } from "./project-field-utils" + +// 재사용 가능한 필드 컴포넌트들 +export interface ProjectInputFieldProps { + label: string + value: string + onChange: (value: string) => void + placeholder: string + status: ProjectSearchStatus + statusText?: string + minWidth?: string +} + +export const ProjectInputField: React.FC<ProjectInputFieldProps> = ({ + label, + value, + onChange, + placeholder, + status, + statusText, + minWidth = "250px" +}) => ( + <div className="space-y-2 min-w-[250px] flex-shrink-0" style={{ minWidth }}> + <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}> + {label} + {statusText && <span className="ml-1 text-xs">{statusText}</span>} + </label> + <Input + value={value} + onChange={(e) => onChange(e.target.value)} + placeholder={placeholder} + className={`h-8 text-sm ${getInputStatusClassName(status)}`} + /> + </div> +) + +export interface ProjectDisplayFieldProps { + label: string + value: string + status: ProjectSearchStatus + minWidth?: string + formatter?: (value: string) => string +} + +export const ProjectDisplayField: React.FC<ProjectDisplayFieldProps> = ({ + label, + value, + status, + minWidth = "120px", + formatter +}) => { + const displayValue = status === 'searching' ? '조회 중...' : (formatter ? formatter(value) : (value || '-')) + + return ( + <div className="space-y-2 flex-shrink-0" style={{ minWidth }}> + <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}> + {label} + {status === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>} + </label> + <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${getDisplayElementStatusClassName(status)}`}> + {displayValue} + </div> + </div> + ) +} + +export interface ProjectFileFieldProps { + label: string + originalFile: string + onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void + minWidth?: string +} + +export const ProjectFileField: React.FC<ProjectFileFieldProps> = ({ + label, + originalFile, + onFileUpload, + minWidth = "200px" +}) => ( + <div className="space-y-2 flex-shrink-0" style={{ minWidth }}> + <label className="text-sm font-medium text-muted-foreground">{label}</label> + <div className="flex items-center gap-2 min-h-[32px]"> + {originalFile ? ( + <span className="text-sm text-blue-600">{originalFile}</span> + ) : ( + <div className="relative"> + <input + type="file" + onChange={onFileUpload} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + accept=".xlsx,.xls,.csv" + /> + <Button variant="outline" size="sm" className="text-xs"> + 파일 선택 + </Button> + </div> + )} + </div> + </div> +) diff --git a/lib/avl/components/project-field-utils.ts b/lib/avl/components/project-field-utils.ts new file mode 100644 index 00000000..d3d84295 --- /dev/null +++ b/lib/avl/components/project-field-utils.ts @@ -0,0 +1,45 @@ +// 프로젝트 검색 상태 타입 +export type ProjectSearchStatus = 'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error' + +// 프로젝트 상태에 따른 스타일링 유틸리티 함수들 +export const getLabelStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'text-red-600' + case 'success-projects': + case 'success-bidding': + return 'text-green-600' + case 'searching': + return 'text-blue-600' + default: + return 'text-muted-foreground' + } +} + +export const getDisplayElementStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300' + case 'success-projects': + case 'success-bidding': + return 'border-green-300' + case 'searching': + return 'border-blue-300' + default: + return 'border-input' + } +} + +export const getInputStatusClassName = (status: ProjectSearchStatus): string => { + switch (status) { + case 'error': + return 'border-red-300 focus:border-red-500 focus:ring-red-500/20' + case 'success-projects': + case 'success-bidding': + return 'border-green-300 focus:border-green-500 focus:ring-green-500/20' + case 'searching': + return 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' + default: + return '' + } +} |
