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 | |
| parent | e87b7b06d92dc7e7235ecda24c212169f30e82ec (diff) | |
(최겸) 구매 협력업체 관리(PQ/실사관리, 정기평가 협력업체 제출 상세 dialog 개발,
Diffstat (limited to 'lib/evaluation')
| -rw-r--r-- | lib/evaluation/table/evaluation-columns.tsx | 46 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 11 | ||||
| -rw-r--r-- | lib/evaluation/table/vendor-submission-dialog.tsx | 623 | ||||
| -rw-r--r-- | lib/evaluation/vendor-submission-service.ts | 369 |
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 |
