summaryrefslogtreecommitdiff
path: root/lib/evaluation
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation')
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx46
-rw-r--r--lib/evaluation/table/evaluation-table.tsx11
-rw-r--r--lib/evaluation/table/vendor-submission-dialog.tsx623
-rw-r--r--lib/evaluation/vendor-submission-service.ts369
4 files changed, 1038 insertions, 11 deletions
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index e8b51b57..b68aa70d 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -6,13 +6,19 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3 } from "lucide-react";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3, ChevronDown } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
@@ -466,16 +472,34 @@ export function getPeriodicEvaluationsColumns({
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setRowAction({ row, type: "view" })}
- aria-label="상세보기"
- title="상세보기"
- >
- <Ellipsis className="size-4" />
- </Button>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ aria-label="평가 상세 메뉴"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "view" })}
+ className="flex items-center gap-2"
+ >
+ <Eye className="h-4 w-4" />
+ 평가 상세
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "vendor-submission" as any })}
+ className="flex items-center gap-2"
+ >
+ <FileText className="h-4 w-4" />
+ 협력업체 제출 상세
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
);
},
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 4404967a..1a5b450b 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -37,6 +37,7 @@ import {
} from "../service"
import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions"
import { EvaluationDetailsDialog } from "./evaluation-details-dialog"
+import { VendorSubmissionDialog } from "./vendor-submission-dialog"
import { searchParamsEvaluationsCache } from "../validation"
interface PeriodicEvaluationsTableProps {
@@ -745,6 +746,16 @@ export function PeriodicEvaluationsTable({
}}
evaluation={rowAction?.row.original || null}
/>
+
+ <VendorSubmissionDialog
+ open={rowAction?.type === "vendor-submission"}
+ onOpenChange={(open) => {
+ if (!open) {
+ setRowAction(null);
+ }
+ }}
+ evaluation={rowAction?.row.original || null}
+ />
</div>
</div>
</div>
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>
+ )
+}
diff --git a/lib/evaluation/vendor-submission-service.ts b/lib/evaluation/vendor-submission-service.ts
new file mode 100644
index 00000000..388f382a
--- /dev/null
+++ b/lib/evaluation/vendor-submission-service.ts
@@ -0,0 +1,369 @@
+'use server'
+
+import db from "@/db/db"
+import {
+ evaluationSubmissions,
+ generalEvaluations,
+ generalEvaluationResponses,
+ esgEvaluations,
+ esgEvaluationItems,
+ esgAnswerOptions,
+ esgEvaluationResponses,
+ vendorEvaluationAttachments,
+ periodicEvaluations,
+ evaluationTargets,
+ vendors,
+} from "@/db/schema"
+import { eq, and, asc, desc, sql } from "drizzle-orm"
+
+// 협력업체 제출 상세 정보 타입 정의
+export interface VendorSubmissionDetail {
+ // 제출 기본 정보
+ submissionId: string
+ evaluationYear: number
+ evaluationRound: string | null
+ submissionStatus: string
+ submittedAt: Date | null
+ reviewedAt: Date | null
+ reviewedBy: string | null
+ reviewComments: string | null
+ averageEsgScore: number | null
+
+ // 진행률 통계
+ totalGeneralItems: number
+ completedGeneralItems: number
+ totalEsgItems: number
+ completedEsgItems: number
+
+ // 협력업체 정보
+ vendor: {
+ id: number
+ vendorCode: string
+ vendorName: string
+ email: string | null
+ country: string | null
+ }
+
+ // 일반평가 응답
+ generalEvaluations: {
+ id: number
+ serialNumber: string
+ category: string
+ inspectionItem: string
+ remarks: string | null
+ response: {
+ responseText: string
+ hasAttachments: boolean
+ reviewComments: string | null
+ attachments: {
+ id: number
+ fileId: string
+ originalFileName: string
+ storedFileName: string
+ filePath: string
+ fileSize: number
+ mimeType: string | null
+ uploadedBy: string
+ createdAt: Date
+ }[]
+ } | null
+ }[]
+
+ // ESG 평가 응답
+ esgEvaluations: {
+ id: number
+ serialNumber: string
+ category: string
+ inspectionItem: string
+ evaluationItems: {
+ id: number
+ evaluationItem: string
+ evaluationItemDescription: string | null
+ orderIndex: number
+ response: {
+ selectedScore: number
+ additionalComments: string | null
+ answerOption: {
+ id: number
+ answerText: string
+ score: number
+ }
+ } | null
+ }[]
+ }[]
+
+ // 첨부파일 통계
+ attachmentStats: {
+ totalFiles: number
+ totalSize: number
+ generalEvaluationFiles: number
+ esgEvaluationFiles: number
+ }
+}
+
+/**
+ * 특정 정기평가에 대한 협력업체 제출 상세 정보를 조회합니다
+ */
+export async function getVendorSubmissionDetails(periodicEvaluationId: number): Promise<VendorSubmissionDetail | null> {
+ try {
+ // 1. 제출 정보 조회
+ const submissionResult = await db
+ .select({
+ // 제출 기본 정보
+ id: evaluationSubmissions.id,
+ submissionId: evaluationSubmissions.submissionId,
+ evaluationYear: evaluationSubmissions.evaluationYear,
+ evaluationRound: evaluationSubmissions.evaluationRound,
+ submissionStatus: evaluationSubmissions.submissionStatus,
+ submittedAt: evaluationSubmissions.submittedAt,
+ reviewedAt: evaluationSubmissions.reviewedAt,
+ reviewedBy: evaluationSubmissions.reviewedBy,
+ reviewComments: evaluationSubmissions.reviewComments,
+ averageEsgScore: evaluationSubmissions.averageEsgScore,
+
+ // 진행률 통계
+ totalGeneralItems: evaluationSubmissions.totalGeneralItems,
+ completedGeneralItems: evaluationSubmissions.completedGeneralItems,
+ totalEsgItems: evaluationSubmissions.totalEsgItems,
+ completedEsgItems: evaluationSubmissions.completedEsgItems,
+
+ // 협력업체 정보
+ vendorId: vendors.id,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+ vendorCountry: vendors.country,
+ })
+ .from(evaluationSubmissions)
+ .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id))
+ .where(
+ and(
+ eq(evaluationSubmissions.periodicEvaluationId, periodicEvaluationId),
+ eq(evaluationSubmissions.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (submissionResult.length === 0) {
+ return null // 제출 내용이 없음
+ }
+
+ const submission = submissionResult[0]
+ const submissionId = submission.id // evaluationSubmissions.id (integer)
+ const submissionUuid = submission.submissionId // evaluationSubmissions.submissionId (UUID)
+
+ console.log("=== 협력업체 제출 상세 조회 시작 ===")
+ console.log("submissionId (integer):", submissionId)
+ console.log("submissionUuid:", submissionUuid)
+ console.log("submission:", submission)
+
+ // 2. 일반평가 항목과 응답 조회
+ const generalEvaluationsResult = await db
+ .select({
+ // 일반평가 항목 정보
+ generalEvaluationId: generalEvaluations.id,
+ serialNumber: generalEvaluations.serialNumber,
+ category: generalEvaluations.category,
+ inspectionItem: generalEvaluations.inspectionItem,
+ remarks: generalEvaluations.remarks,
+
+ // 응답 정보
+ responseId: generalEvaluationResponses.id,
+ responseText: generalEvaluationResponses.responseText,
+ hasAttachments: generalEvaluationResponses.hasAttachments,
+ reviewComments: generalEvaluationResponses.reviewComments,
+
+ // 첨부파일 정보
+ attachmentId: vendorEvaluationAttachments.id,
+ fileId: vendorEvaluationAttachments.fileId,
+ originalFileName: vendorEvaluationAttachments.originalFileName,
+ storedFileName: vendorEvaluationAttachments.storedFileName,
+ filePath: vendorEvaluationAttachments.filePath,
+ fileSize: vendorEvaluationAttachments.fileSize,
+ mimeType: vendorEvaluationAttachments.mimeType,
+ uploadedBy: vendorEvaluationAttachments.uploadedBy,
+ attachmentCreatedAt: vendorEvaluationAttachments.createdAt,
+ })
+ .from(generalEvaluations)
+ .leftJoin(
+ generalEvaluationResponses,
+ and(
+ eq(generalEvaluationResponses.generalEvaluationId, generalEvaluations.id),
+ eq(generalEvaluationResponses.submissionId, submissionId),
+ eq(generalEvaluationResponses.isActive, true)
+ )
+ )
+ .leftJoin(
+ vendorEvaluationAttachments,
+ eq(vendorEvaluationAttachments.generalEvaluationResponseId, generalEvaluationResponses.id)
+ )
+ .where(eq(generalEvaluations.isActive, true))
+ .orderBy(asc(generalEvaluations.serialNumber))
+
+ // 3. ESG 평가 항목과 응답 조회
+ const esgEvaluationsResult = await db
+ .select({
+ // ESG 평가표 정보
+ esgEvaluationId: esgEvaluations.id,
+ esgSerialNumber: esgEvaluations.serialNumber,
+ esgCategory: esgEvaluations.category,
+ esgInspectionItem: esgEvaluations.inspectionItem,
+
+ // ESG 평가항목 정보
+ esgEvaluationItemId: esgEvaluationItems.id,
+ evaluationItem: esgEvaluationItems.evaluationItem,
+ evaluationItemDescription: esgEvaluationItems.evaluationItemDescription,
+ orderIndex: esgEvaluationItems.orderIndex,
+
+ // ESG 응답 정보
+ esgResponseId: esgEvaluationResponses.id,
+ selectedScore: esgEvaluationResponses.selectedScore,
+ additionalComments: esgEvaluationResponses.additionalComments,
+
+ // ESG 답변 옵션 정보
+ answerOptionId: esgAnswerOptions.id,
+ answerText: esgAnswerOptions.answerText,
+ answerScore: esgAnswerOptions.score,
+ })
+ .from(esgEvaluations)
+ .innerJoin(esgEvaluationItems, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id))
+ .leftJoin(
+ esgEvaluationResponses,
+ and(
+ eq(esgEvaluationResponses.esgEvaluationItemId, esgEvaluationItems.id),
+ eq(esgEvaluationResponses.submissionId, submissionId),
+ eq(esgEvaluationResponses.isActive, true)
+ )
+ )
+ .leftJoin(
+ esgAnswerOptions,
+ eq(esgAnswerOptions.id, esgEvaluationResponses.esgAnswerOptionId)
+ )
+ .where(
+ and(
+ eq(esgEvaluations.isActive, true),
+ eq(esgEvaluationItems.isActive, true)
+ )
+ )
+ .orderBy(
+ asc(esgEvaluations.serialNumber),
+ asc(esgEvaluationItems.orderIndex)
+ )
+
+ // 4. 데이터 가공
+ // 일반평가 데이터 그룹화
+ const generalEvaluationsMap = new Map<number, any>()
+ generalEvaluationsResult.forEach(row => {
+ if (!generalEvaluationsMap.has(row.generalEvaluationId)) {
+ generalEvaluationsMap.set(row.generalEvaluationId, {
+ id: row.generalEvaluationId,
+ serialNumber: row.serialNumber,
+ category: row.category,
+ inspectionItem: row.inspectionItem,
+ remarks: row.remarks,
+ response: row.responseId ? {
+ responseText: row.responseText,
+ hasAttachments: row.hasAttachments,
+ reviewComments: row.reviewComments,
+ attachments: []
+ } : null
+ })
+ }
+
+ // 첨부파일 추가
+ if (row.attachmentId && generalEvaluationsMap.get(row.generalEvaluationId)?.response) {
+ generalEvaluationsMap.get(row.generalEvaluationId).response.attachments.push({
+ id: row.attachmentId,
+ fileId: row.fileId,
+ originalFileName: row.originalFileName,
+ storedFileName: row.storedFileName,
+ filePath: row.filePath,
+ fileSize: row.fileSize,
+ mimeType: row.mimeType,
+ uploadedBy: row.uploadedBy,
+ createdAt: new Date(row.attachmentCreatedAt)
+ })
+ }
+ })
+
+ // ESG 평가 데이터 그룹화
+ const esgEvaluationsMap = new Map<number, any>()
+ esgEvaluationsResult.forEach(row => {
+ if (!esgEvaluationsMap.has(row.esgEvaluationId)) {
+ esgEvaluationsMap.set(row.esgEvaluationId, {
+ id: row.esgEvaluationId,
+ serialNumber: row.esgSerialNumber,
+ category: row.esgCategory,
+ inspectionItem: row.esgInspectionItem,
+ evaluationItems: []
+ })
+ }
+
+ const esgEvaluation = esgEvaluationsMap.get(row.esgEvaluationId)
+
+ // 평가항목 추가 (중복 방지)
+ const existingItem = esgEvaluation.evaluationItems.find((item: any) => item.id === row.esgEvaluationItemId)
+ if (!existingItem) {
+ esgEvaluation.evaluationItems.push({
+ id: row.esgEvaluationItemId,
+ evaluationItem: row.evaluationItem,
+ evaluationItemDescription: row.evaluationItemDescription,
+ orderIndex: row.orderIndex,
+ response: row.esgResponseId ? {
+ selectedScore: Number(row.selectedScore),
+ additionalComments: row.additionalComments,
+ answerOption: {
+ id: row.answerOptionId,
+ answerText: row.answerText,
+ score: Number(row.answerScore)
+ }
+ } : null
+ })
+ }
+ })
+
+ // 5. 첨부파일 통계 계산
+ const allAttachments = generalEvaluationsResult
+ .filter(row => row.attachmentId)
+ .map(row => ({
+ id: row.attachmentId,
+ fileSize: row.fileSize
+ }))
+
+ const attachmentStats = {
+ totalFiles: allAttachments.length,
+ totalSize: allAttachments.reduce((sum, att) => sum + att.fileSize, 0),
+ generalEvaluationFiles: allAttachments.length,
+ esgEvaluationFiles: 0 // ESG는 첨부파일 없음
+ }
+
+ return {
+ submissionId: submission.submissionId,
+ evaluationYear: submission.evaluationYear,
+ evaluationRound: submission.evaluationRound,
+ submissionStatus: submission.submissionStatus,
+ submittedAt: submission.submittedAt,
+ reviewedAt: submission.reviewedAt,
+ reviewedBy: submission.reviewedBy,
+ reviewComments: submission.reviewComments,
+ averageEsgScore: submission.averageEsgScore ? Number(submission.averageEsgScore) : null,
+
+
+ vendor: {
+ id: submission.vendorId,
+ vendorCode: submission.vendorCode || "",
+ vendorName: submission.vendorName,
+ email: submission.vendorEmail,
+ country: submission.vendorCountry,
+ },
+
+ generalEvaluations: Array.from(generalEvaluationsMap.values()),
+ esgEvaluations: Array.from(esgEvaluationsMap.values()),
+ attachmentStats
+ }
+
+ } catch (error) {
+ console.error("Error fetching vendor submission details:", error)
+ throw new Error("협력업체 제출 상세 정보 조회 중 오류가 발생했습니다")
+ }
+} \ No newline at end of file