diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-27 10:03:06 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-27 10:03:06 +0000 |
| commit | a3525f8bdfcf849cc1716fab81cb8facadbe9a8e (patch) | |
| tree | 0b5b534e92bcfe188d4906db7d16c37044262c2f /lib/evaluation/table/vendor-submission-dialog.tsx | |
| parent | e87b7b06d92dc7e7235ecda24c212169f30e82ec (diff) | |
(최겸) 구매 협력업체 관리(PQ/실사관리, 정기평가 협력업체 제출 상세 dialog 개발,
Diffstat (limited to 'lib/evaluation/table/vendor-submission-dialog.tsx')
| -rw-r--r-- | lib/evaluation/table/vendor-submission-dialog.tsx | 623 |
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> + ) +} |
