summaryrefslogtreecommitdiff
path: root/lib/avl/components
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl/components')
-rw-r--r--lib/avl/components/avl-history-modal.tsx297
-rw-r--r--lib/avl/components/project-field-components.tsx113
-rw-r--r--lib/avl/components/project-field-utils.ts45
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 ''
+ }
+}