summaryrefslogtreecommitdiff
path: root/lib/evaluation/table/vendor-submission-dialog.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-27 10:03:06 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-27 10:03:06 +0000
commita3525f8bdfcf849cc1716fab81cb8facadbe9a8e (patch)
tree0b5b534e92bcfe188d4906db7d16c37044262c2f /lib/evaluation/table/vendor-submission-dialog.tsx
parente87b7b06d92dc7e7235ecda24c212169f30e82ec (diff)
(최겸) 구매 협력업체 관리(PQ/실사관리, 정기평가 협력업체 제출 상세 dialog 개발,
Diffstat (limited to 'lib/evaluation/table/vendor-submission-dialog.tsx')
-rw-r--r--lib/evaluation/table/vendor-submission-dialog.tsx623
1 files changed, 623 insertions, 0 deletions
diff --git a/lib/evaluation/table/vendor-submission-dialog.tsx b/lib/evaluation/table/vendor-submission-dialog.tsx
new file mode 100644
index 00000000..aff8dc56
--- /dev/null
+++ b/lib/evaluation/table/vendor-submission-dialog.tsx
@@ -0,0 +1,623 @@
+"use client"
+
+import * as React from "react"
+import {
+ Eye,
+ Building2,
+ User,
+ Calendar,
+ CheckCircle2,
+ Clock,
+ MessageSquare,
+ Award,
+ FileText,
+ Paperclip,
+ Download,
+ File,
+ BarChart3,
+ ChevronDown,
+ ChevronRight
+} from "lucide-react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { getVendorSubmissionDetails, type VendorSubmissionDetail } from "../vendor-submission-service"
+import { formatFileSize, getFileInfo } from "@/lib/file-download"
+
+interface VendorSubmissionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluation: PeriodicEvaluationView | null
+}
+
+// 상태별 배지 색상
+const getSubmissionStatusBadge = (status: string) => {
+ switch (status) {
+ case "submitted":
+ return <Badge variant="default" className="bg-green-600">제출완료</Badge>
+ case "draft":
+ return <Badge variant="secondary">임시저장</Badge>
+ case "reviewed":
+ return <Badge variant="outline">검토완료</Badge>
+ default:
+ return <Badge variant="outline">{status}</Badge>
+ }
+}
+
+// 진행률 계산
+const getProgressPercentage = (completed: number, total: number) => {
+ if (total === 0) return 0
+ return Math.round((completed / total) * 100)
+}
+
+export function VendorSubmissionDialog({
+ open,
+ onOpenChange,
+ evaluation,
+}: VendorSubmissionDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [submissionDetails, setSubmissionDetails] = React.useState<VendorSubmissionDetail | null>(null)
+ const [expandedGeneralItems, setExpandedGeneralItems] = React.useState<Set<number>>(new Set())
+ const [expandedEsgItems, setExpandedEsgItems] = React.useState<Set<number>>(new Set())
+
+ // 첨부파일 다운로드 핸들러
+ const handleDownloadAttachment = async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ })
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error)
+ }
+ }
+
+ // 일반평가 항목 토글
+ const toggleGeneralItem = (itemId: number) => {
+ const newExpanded = new Set(expandedGeneralItems)
+ if (newExpanded.has(itemId)) {
+ newExpanded.delete(itemId)
+ } else {
+ newExpanded.add(itemId)
+ }
+ setExpandedGeneralItems(newExpanded)
+ }
+
+ // ESG 평가 항목 토글
+ const toggleEsgItem = (itemId: number) => {
+ const newExpanded = new Set(expandedEsgItems)
+ if (newExpanded.has(itemId)) {
+ newExpanded.delete(itemId)
+ } else {
+ newExpanded.add(itemId)
+ }
+ setExpandedEsgItems(newExpanded)
+ }
+
+ // 제출 상세 정보 로드
+ React.useEffect(() => {
+ if (open && evaluation?.id) {
+ const loadSubmissionDetails = async () => {
+ try {
+ setIsLoading(true)
+ const details = await getVendorSubmissionDetails(evaluation.id)
+ setSubmissionDetails(details)
+ } catch (error) {
+ console.error("Failed to load vendor submission details:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSubmissionDetails()
+ }
+ }, [open, evaluation?.id])
+
+ // 다이얼로그 닫을 때 데이터 리셋
+ React.useEffect(() => {
+ if (!open) {
+ setSubmissionDetails(null)
+ setExpandedGeneralItems(new Set())
+ setExpandedEsgItems(new Set())
+ }
+ }, [open])
+
+ if (!evaluation) return null
+
+ return (
+ <TooltipProvider>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ {/* 고정 헤더 */}
+ <DialogHeader className="space-y-4 flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-blue-600" />
+ 협력업체 제출 상세
+ </DialogTitle>
+
+ {/* 평가 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <Building2 className="h-5 w-5" />
+ 평가 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex flex-wrap items-center gap-6 text-sm mb-4">
+ {/* 협력업체 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">협력업체:</span>
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <span className="text-muted-foreground">({evaluation.vendorCode})</span>
+ </div>
+
+ {/* 평가년도 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">년도:</span>
+ <span className="font-medium">{evaluation.evaluationYear}년</span>
+ </div>
+
+ {/* 구분 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">구분:</span>
+ <Badge variant="outline" className="text-xs">
+ {evaluation.division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ </div>
+
+ {/* 진행상태 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">상태:</span>
+ <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto min-h-0">
+ {isLoading ? (
+ <div className="space-y-4 p-1">
+ <Card>
+ <CardHeader>
+ <Skeleton className="h-6 w-48" />
+ </CardHeader>
+ <CardContent>
+ <Skeleton className="h-64 w-full" />
+ </CardContent>
+ </Card>
+ </div>
+ ) : submissionDetails ? (
+ <div className="space-y-6 p-1">
+ {/* 제출 정보 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 제출 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-muted-foreground">제출 상태</div>
+ <div>{getSubmissionStatusBadge(submissionDetails.submissionStatus)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">제출일</div>
+ <div className="font-medium">
+ {submissionDetails.submittedAt
+ ? new Date(submissionDetails.submittedAt).toLocaleDateString('ko-KR')
+ : "-"
+ }
+ </div>
+ </div>
+ {/* <div className="space-y-1">
+ <div className="text-muted-foreground">ESG 평균 점수</div>
+ <div className="font-medium">
+ {submissionDetails.averageEsgScore
+ ? `${submissionDetails.averageEsgScore.toFixed(1)}점`
+ : "-"
+ }
+ </div>
+ </div> */}
+ {/* <div className="space-y-1">
+ <div className="text-muted-foreground">검토자</div>
+ <div className="font-medium">
+ {submissionDetails.reviewedBy || "-"}
+ </div>
+ </div> */}
+ </div>
+
+ {/* 진행률 표시 */}
+ {/* <div className="mt-6 space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">일반평가 진행률</span>
+ <span className="font-medium">
+ {submissionDetails.completedGeneralItems}/{submissionDetails.totalGeneralItems}
+ ({getProgressPercentage(submissionDetails.completedGeneralItems, submissionDetails.totalGeneralItems)}%)
+ </span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300"
+ style={{
+ width: `${getProgressPercentage(submissionDetails.completedGeneralItems, submissionDetails.totalGeneralItems)}%`
+ }}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">ESG 평가 진행률</span>
+ <span className="font-medium">
+ {submissionDetails.completedEsgItems}/{submissionDetails.totalEsgItems}
+ ({getProgressPercentage(submissionDetails.completedEsgItems, submissionDetails.totalEsgItems)}%)
+ </span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-green-600 h-2 rounded-full transition-all duration-300"
+ style={{
+ width: `${getProgressPercentage(submissionDetails.completedEsgItems, submissionDetails.totalEsgItems)}%`
+ }}
+ />
+ </div>
+ </div>
+ </div> */}
+ </CardContent>
+ </Card>
+
+ {/* 탭으로 일반평가와 ESG 평가 구분 */}
+ <Tabs defaultValue="general" className="w-full">
+ <TabsList className={`grid w-full ${submissionDetails.vendor.country === "KR" ? "grid-cols-2" : "grid-cols-1"}`}>
+ <TabsTrigger value="general" className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 일반평가 ({submissionDetails.generalEvaluations.length}개)
+ </TabsTrigger>
+ {submissionDetails.vendor.country === "KR" && (
+ <TabsTrigger value="esg" className="flex items-center gap-2">
+ <Award className="h-4 w-4" />
+ ESG 평가 ({submissionDetails.esgEvaluations.length}개)
+ </TabsTrigger>
+ )}
+ </TabsList>
+
+ {/* 일반평가 탭 */}
+ <TabsContent value="general" className="space-y-4">
+ {submissionDetails.generalEvaluations.length > 0 ? (
+ <div className="space-y-4">
+ {submissionDetails.generalEvaluations.map((item) => (
+ <Card key={item.id}>
+ <Collapsible>
+ <CollapsibleTrigger asChild>
+ <CardHeader
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ onClick={() => toggleGeneralItem(item.id)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {expandedGeneralItems.has(item.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <CardTitle className="text-base">
+ {item.serialNumber}. {item.category}
+ </CardTitle>
+ </div>
+ <div className="flex items-center gap-2">
+ {item.response ? (
+ <Badge variant="default" className="bg-green-600">응답완료</Badge>
+ ) : (
+ <Badge variant="outline">미응답</Badge>
+ )}
+ {item.response?.hasAttachments && (
+ <Badge variant="secondary" className="text-xs">
+ <Paperclip className="h-3 w-3 mr-1" />
+ 첨부파일
+ </Badge>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">평가 항목</div>
+ <div className="text-sm text-muted-foreground">{item.inspectionItem}</div>
+ {item.remarks && (
+ <div className="mt-2">
+ <div className="text-sm font-medium mb-1">비고</div>
+ <div className="text-sm text-muted-foreground">{item.remarks}</div>
+ </div>
+ )}
+ </div>
+
+ {item.response ? (
+ <div className="space-y-4">
+ <Separator />
+ <div>
+ <div className="text-sm font-medium mb-2">협력업체 응답</div>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {item.response.responseText || "응답 내용이 없습니다."}
+ </div>
+ </div>
+
+ {/* 첨부파일 */}
+ {item.response.attachments.length > 0 && (
+ <div>
+ <div className="text-sm font-medium mb-2">첨부파일</div>
+ <div className="space-y-2">
+ {item.response.attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ return (
+ <div key={attachment.id} className="flex items-center gap-2 p-2 bg-muted rounded">
+ <span className="text-sm">{fileInfo.icon}</span>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-sm truncate flex-1 cursor-help">
+ {attachment.originalFileName}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs space-y-1">
+ <div className="font-medium">{attachment.originalFileName}</div>
+ <div>크기: {formatFileSize(attachment.fileSize)}</div>
+ <div>타입: {fileInfo.type}</div>
+ <div>업로드: {attachment.uploadedBy}</div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => handleDownloadAttachment(attachment.filePath, attachment.originalFileName)}
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 검토자 의견 */}
+ {item.response.reviewComments && (
+ <div>
+ <div className="text-sm font-medium mb-2">검토자 의견</div>
+ <div className="bg-blue-50 p-3 rounded-md text-sm">
+ {item.response.reviewComments}
+ </div>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center text-muted-foreground py-4">
+ <Clock className="h-8 w-8 mx-auto mb-2" />
+ <div>아직 응답하지 않았습니다</div>
+ </div>
+ )}
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-8 w-8 mx-auto mb-2" />
+ <div>일반평가 항목이 없습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* ESG 평가 탭 */}
+ <TabsContent value="esg" className="space-y-4">
+ {submissionDetails.esgEvaluations.length > 0 ? (
+ <div className="space-y-4">
+ {submissionDetails.esgEvaluations.map((esgEvaluation) => (
+ <Card key={esgEvaluation.id}>
+ <Collapsible>
+ <CollapsibleTrigger asChild>
+ <CardHeader
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ onClick={() => toggleEsgItem(esgEvaluation.id)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {expandedEsgItems.has(esgEvaluation.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <CardTitle className="text-base">
+ {esgEvaluation.serialNumber}. {esgEvaluation.category}
+ </CardTitle>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ {esgEvaluation.evaluationItems.length}개 항목
+ </Badge>
+ </div>
+ </div>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">평가 항목</div>
+ <div className="text-sm text-muted-foreground">{esgEvaluation.inspectionItem}</div>
+ </div>
+
+ <Separator />
+
+ {/* ESG 평가 세부 항목들 */}
+ <div className="space-y-3">
+ {esgEvaluation.evaluationItems.map((item) => (
+ <div key={item.id} className="border rounded-lg p-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium">{item.evaluationItem}</div>
+ {item.evaluationItemDescription && (
+ <div className="text-xs text-muted-foreground">
+ {item.evaluationItemDescription}
+ </div>
+ )}
+
+ {item.response ? (
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Badge variant="default" className="bg-green-600 text-xs">
+ 선택된 답변
+ </Badge>
+ <span className="text-sm font-medium">
+ {item.response.answerOption.answerText}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {item.response.selectedScore}점
+ </Badge>
+ </div>
+ {item.response.additionalComments && (
+ <div className="bg-muted p-2 rounded text-xs">
+ <div className="font-medium mb-1">추가 의견:</div>
+ <div>{item.response.additionalComments}</div>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center text-muted-foreground py-2">
+ <Clock className="h-4 w-4 mx-auto mb-1" />
+ <div className="text-xs">아직 응답하지 않았습니다</div>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <Award className="h-8 w-8 mx-auto mb-2" />
+ <div>ESG 평가 항목이 없습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ {/* 첨부파일 요약 */}
+ {submissionDetails.attachmentStats.totalFiles > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <BarChart3 className="h-5 w-5" />
+ 첨부파일 요약
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 수</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.totalFiles}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 크기</div>
+ <div className="font-bold text-lg">{formatFileSize(submissionDetails.attachmentStats.totalSize)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">일반평가 첨부파일</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.generalEvaluationFiles}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">ESG 평가 첨부파일</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.esgEvaluationFiles}개</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 검토자 의견 */}
+ {submissionDetails.reviewComments && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토자 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {submissionDetails.reviewComments}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ ) : (
+ <Card className="m-1">
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <User className="h-8 w-8 mx-auto mb-2" />
+ <div>제출 내용이 없습니다</div>
+ <div className="text-xs mt-1">협력업체가 아직 평가를 제출하지 않았습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="flex justify-end pt-4 border-t flex-shrink-0">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </TooltipProvider>
+ )
+}