diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
| commit | 90f79a7a691943a496f67f01c1e493256070e4de (patch) | |
| tree | 37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation/table | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation/table')
| -rw-r--r-- | lib/evaluation/table/evaluation-columns.tsx | 213 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-details-dialog.tsx | 366 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 11 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluation-action-dialogs.tsx | 231 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx | 305 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx | 179 |
6 files changed, 1097 insertions, 208 deletions
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index 10aa7704..e88c5764 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -8,10 +8,11 @@ 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 } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle } from "lucide-react"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { PeriodicEvaluationView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; +import { vendortypeMap } from "@/types/evaluation"; @@ -48,6 +49,63 @@ const getStatusLabel = (status: string) => { return statusMap[status] || status; }; +// 부서별 상태 배지 함수 +const getDepartmentStatusBadge = (status: string | null) => { + if (!status) return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} + <span className="text-xs text-gray-500">-</span> + </div> + ); + + switch (status) { + case "NOT_ASSIGNED": + return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-400 text-gray-400" /> */} + <span className="text-xs text-gray-600">미지정</span> + </div> + ); + case "NOT_STARTED": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-red-500 shadow-sm" /> + + {/* <span className="text-xs text-red-600">시작전</span> */} + </div> + ); + case "IN_PROGRESS": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-yellow-500 shadow-sm" /> + {/* <span className="text-xs text-yellow-600">진행중</span> */} + </div> + ); + case "COMPLETED": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm" /> + {/* <span className="text-xs text-green-600">완료</span> */} + </div> + ); + default: + return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} + <span className="text-xs text-gray-500">-</span> + </div> + ); + } +}; +// 부서명 라벨 +const DEPARTMENT_LABELS = { + ORDER_EVAL: "발주", + PROCUREMENT_EVAL: "조달", + QUALITY_EVAL: "품질", + DESIGN_EVAL: "설계", + CS_EVAL: "CS" +} as const; + // 등급별 색상 const getGradeBadgeVariant = (grade: string | null) => { if (!grade) return "outline"; @@ -78,19 +136,15 @@ const getDivisionBadge = (division: string) => { // 자재구분 배지 const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; + + return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; // 내외자 배지 const getDomesticForeignBadge = (domesticForeign: string) => { return ( <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> - {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + {domesticForeign === "DOMESTIC" ? "D" : "F"} </Badge> ); }; @@ -237,70 +291,41 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // 진행 현황 // ═══════════════════════════════════════════════════════════════ { - header: "평가자 진행 현황", + header: "부서별 평가 현황", columns: [ { - accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />, - cell: ({ row }) => { - const status = row.getValue<string>("status"); - return ( - <Badge variant={getStatusBadgeVariant(status)}> - {getStatusLabel(status)} - </Badge> - ); - }, - size: 100, + accessorKey: "orderEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발주" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")), + size: 60, }, - + { - id: "reviewProgress", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />, - cell: ({ row }) => { - const totalReviewers = row.original.totalReviewers || 0; - const completedReviewers = row.original.completedReviewers || 0; - - return getProgressBadge(completedReviewers, totalReviewers); - }, - size: 120, + accessorKey: "procurementEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조달" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")), + size: 70, }, - + { - accessorKey: "reviewCompletedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />, - cell: ({ row }) => { - const completedAt = row.getValue<Date>("reviewCompletedAt"); - return completedAt ? ( - <span className="text-sm"> - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(completedAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 100, + accessorKey: "qualityEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")), + size: 70, }, - + { - accessorKey: "finalizedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, - cell: ({ row }) => { - const finalizedAt = row.getValue<Date>("finalizedAt"); - return finalizedAt ? ( - <span className="text-sm font-medium"> - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(finalizedAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 80, + accessorKey: "designEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")), + size: 70, + }, + + { + accessorKey: "csEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="CS" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")), + size: 70, }, ] }, @@ -321,7 +346,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): </Badge> ); }, - size: 100, + size: 120, }, { @@ -519,7 +544,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): <span className="text-muted-foreground">-</span> ); }, - size: 80, + minSize: 100, }, ] @@ -528,38 +553,28 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // ░░░ Actions ░░░ - // { - // id: "actions", - // enableHiding: false, - // size: 40, - // minSize: 40, - // 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="상세보기" - // > - // <Eye className="size-4" /> - // </Button> - - // <Button - // variant="ghost" - // size="icon" - // className="size-8" - // onClick={() => setRowAction({ row, type: "update" })} - // aria-label="수정" - // title="수정" - // > - // <Pencil className="size-4" /> - // </Button> - // </div> - // ); - // }, - // }, + { + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + 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="상세보기" + > + <Eye className="size-4" /> + </Button> + + </div> + ); + }, + }, ]; }
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-details-dialog.tsx b/lib/evaluation/table/evaluation-details-dialog.tsx new file mode 100644 index 00000000..df4ef016 --- /dev/null +++ b/lib/evaluation/table/evaluation-details-dialog.tsx @@ -0,0 +1,366 @@ +"use client" + +import * as React from "react" +import { + Eye, + Building2, + User, + Calendar, + CheckCircle2, + Clock, + MessageSquare, + Award, + FileText +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +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 { PeriodicEvaluationView } from "@/db/schema" +import { getEvaluationDetails, type EvaluationDetailData } from "../service" + +interface EvaluationDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluation: PeriodicEvaluationView | null +} + +// 카테고리별 색상 매핑 +const getCategoryBadgeVariant = (category: string) => { + switch (category) { + case "quality": + return "default" + case "delivery": + return "secondary" + case "price": + return "outline" + case "cooperation": + return "destructive" + default: + return "outline" + } +} + +// 카테고리명 매핑 +const CATEGORY_LABELS = { + "customer-service": "CS", + administrator: "관리자", + procurement: "구매", + design: "설계", + sourcing: "조달", + quality: "품질" +} as const + +const CATEGORY_LABELS2 = { + bonus: "가점항목", + delivery: "납기", + management: "경영현황", + penalty: "감점항목", + procurement: "구매", + quality: "품질" + } as const + +export function EvaluationDetailsDialog({ + open, + onOpenChange, + evaluation, +}: EvaluationDetailsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [evaluationDetails, setEvaluationDetails] = React.useState<{ + evaluationInfo: any + reviewerDetails: EvaluationDetailData[] + } | null>(null) + + // 평가 상세 정보 로드 + React.useEffect(() => { + if (open && evaluation?.id) { + const loadEvaluationDetails = async () => { + try { + setIsLoading(true) + const details = await getEvaluationDetails(evaluation.id) + setEvaluationDetails(details) + } catch (error) { + console.error("Failed to load evaluation details:", error) + } finally { + setIsLoading(false) + } + } + + loadEvaluationDetails() + } + }, [open, evaluation?.id]) + + // 다이얼로그 닫을 때 데이터 리셋 + React.useEffect(() => { + if (!open) { + setEvaluationDetails(null) + } + }, [open]) + + if (!evaluation) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> + <DialogHeader className="space-y-4"> + <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"> + {/* 협력업체 */} + <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 className="flex items-center gap-2"> + <span className="text-muted-foreground">평가점수/등급:</span> + {evaluation.evaluationScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-blue-600"> + {Number(evaluation.evaluationScore).toFixed(1)}점 + </span> + {evaluation.evaluationGrade && ( + <Badge variant="default" className="text-xs h-5"> + {evaluation.evaluationGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + + {/* 확정점수/등급 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">확정점수/등급:</span> + {evaluation.finalScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-green-600"> + {Number(evaluation.finalScore).toFixed(1)}점 + </span> + {evaluation.finalGrade && ( + <Badge variant="default" className="bg-green-600 text-xs h-5"> + {evaluation.finalGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">미확정</span> + )} + </div> + </div> + </CardContent> + </Card> + </DialogHeader> + + {isLoading ? ( + <div className="space-y-4"> + <Card> + <CardHeader> + <Skeleton className="h-6 w-48" /> + </CardHeader> + <CardContent> + <Skeleton className="h-64 w-full" /> + </CardContent> + </Card> + </div> + ) : evaluationDetails ? ( + <div className="space-y-6"> + {/* 통합 평가 테이블 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 평가 상세 내역 + </CardTitle> + </CardHeader> + <CardContent> + {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">담당자</TableHead> + {/* <TableHead className="w-[80px]">상태</TableHead> */} + <TableHead className="w-[100px]">평가부문</TableHead> + <TableHead className="w-[100px]">항목</TableHead> + <TableHead className="w-[150px]">구분</TableHead> + <TableHead className="w-[200px]">범위</TableHead> + <TableHead className="w-[200px]">선택옵션</TableHead> + <TableHead className="w-[80px]">점수</TableHead> + <TableHead className="min-w-[200px]">의견</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {evaluationDetails.reviewerDetails.map((reviewer) => + reviewer.evaluationItems.map((item, index) => ( + <TableRow key={`${reviewer.reviewerEvaluationId}-${item.criteriaId}-${index}`}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium text-sm">{reviewer.departmentName}</div> + <div className="text-xs text-muted-foreground"> + {reviewer.reviewerName} + </div> + </div> + </TableCell> + {/* <TableCell> + {reviewer.isCompleted ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle2 className="h-3 w-3" /> + 완료 + </Badge> + ) : ( + <Badge variant="secondary" className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + 진행중 + </Badge> + )} + </TableCell> */} + <TableCell> + <Badge variant={getCategoryBadgeVariant(item.category)}> + {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} + </Badge> + </TableCell> + <TableCell> + {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} + </TableCell> + <TableCell className="font-medium"> + {item.classification} + </TableCell> + <TableCell className="text-sm"> + {item.range || "-"} + </TableCell> + <TableCell className="text-sm"> + {item.scoreType === "variable" ? ( + <Badge variant="outline">직접 입력</Badge> + ) : ( + item.selectedDetail || "-" + )} + </TableCell> + <TableCell> + {item.score !== null ? ( + <Badge variant="default" className="font-mono"> + {item.score.toFixed(1)} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> + <TableCell className="text-sm"> + {item.comment || ( + <span className="text-muted-foreground">의견 없음</span> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + ) : ( + <div className="text-center text-muted-foreground py-8"> + <FileText className="h-8 w-8 mx-auto mb-2" /> + <div>평가 항목이 없습니다</div> + </div> + )} + </CardContent> + </Card> + + {/* 리뷰어별 종합 의견 (있는 경우만) */} + {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 종합 의견 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {evaluationDetails.reviewerDetails + .filter(reviewer => reviewer.reviewerComment) + .map((reviewer) => ( + <div key={reviewer.reviewerEvaluationId} className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{reviewer.departmentName}</Badge> + <span className="text-sm font-medium">{reviewer.reviewerName}</span> + </div> + <div className="bg-muted p-3 rounded-md text-sm"> + {reviewer.reviewerComment} + </div> + </div> + ))} + </CardContent> + </Card> + )} + + {evaluationDetails.reviewerDetails.length === 0 && ( + <Card> + <CardContent className="py-8"> + <div className="text-center text-muted-foreground"> + <User className="h-8 w-8 mx-auto mb-2" /> + <div>배정된 리뷰어가 없습니다</div> + </div> + </CardContent> + </Card> + )} + </div> + ) : ( + <Card> + <CardContent className="py-8"> + <div className="text-center text-muted-foreground"> + 평가 상세 정보를 불러올 수 없습니다 + </div> + </CardContent> + </Card> + )} + + <div className="flex justify-end pt-4"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 9e32debb..cecaeeaa 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -25,6 +25,7 @@ import { getPeriodicEvaluationsColumns } from "./evaluation-columns" import { PeriodicEvaluationView } from "@/db/schema" import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" +import { EvaluationDetailsDialog } from "./evaluation-details-dialog" interface PeriodicEvaluationsTableProps { promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]> @@ -456,7 +457,15 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } </DataTableAdvancedToolbar> </DataTable> - {/* TODO: 수정/상세보기 모달 구현 */} + <EvaluationDetailsDialog + open={rowAction?.type === "view"} + onOpenChange={(open) => { + if (!open) { + setRowAction(null) + } + }} + evaluation={rowAction?.row.original || null} + /> </div> </div> diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx index 30ff9535..fc07aea1 100644 --- a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx +++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx @@ -14,11 +14,42 @@ import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { FileText, Users, Calendar, Send } from "lucide-react" +import { FileText, Users, Calendar, Send, Mail, Building } from "lucide-react" import { toast } from "sonner" import { PeriodicEvaluationView } from "@/db/schema" -import { checkExistingSubmissions, requestDocumentsFromVendors } from "../service" +import { + checkExistingSubmissions, + requestDocumentsFromVendors, + getReviewersForEvaluations, + createReviewerEvaluationsRequest +} from "../service" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" +// ================================================================ +// 부서 코드 매핑 +// ================================================================ + + +const getDepartmentLabel = (code: string): string => { + return DEPARTMENT_CODE_LABELS[code as keyof typeof DEPARTMENT_CODE_LABELS] || code +} + +// ================================================================ +// 타입 정의 +// ================================================================ +interface ReviewerInfo { + id: number + name: string + email: string + deptName: string | null + departmentCode: string + evaluationTargetId: number + evaluationTargetReviewerId: number +} + +interface EvaluationWithReviewers extends PeriodicEvaluationView { + reviewers: ReviewerInfo[] +} // ================================================================ // 2. 협력업체 자료 요청 다이얼로그 @@ -259,10 +290,8 @@ export function RequestDocumentsDialog({ ) } - - // ================================================================ -// 3. 평가자 평가 요청 다이얼로그 +// 3. 평가자 평가 요청 다이얼로그 (업데이트됨) // ================================================================ interface RequestEvaluationDialogProps { open: boolean @@ -278,10 +307,61 @@ export function RequestEvaluationDialog({ onSuccess, }: RequestEvaluationDialogProps) { const [isLoading, setIsLoading] = React.useState(false) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) const [message, setMessage] = React.useState("") + const [evaluationsWithReviewers, setEvaluationsWithReviewers] = React.useState<EvaluationWithReviewers[]>([]) // 제출완료 상태인 평가들만 필터링 - const submittedEvaluations = evaluations.filter(e => e.status === "SUBMITTED") + const submittedEvaluations = evaluations.filter(e => + e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION" + ) + + // 리뷰어 정보 로딩 + React.useEffect(() => { + if (!open || submittedEvaluations.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const loadReviewers = async () => { + setIsLoadingReviewers(true) + try { + const evaluationTargetIds = submittedEvaluations + .map(e => e.evaluationTargetId) + .filter(id => id !== null) + + if (evaluationTargetIds.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const reviewersData = await getReviewersForEvaluations(evaluationTargetIds) + + // 평가별로 리뷰어 그룹핑 + const evaluationsWithReviewersData = submittedEvaluations.map(evaluation => ({ + ...evaluation, + reviewers: reviewersData.filter(reviewer => + reviewer.evaluationTargetId === evaluation.evaluationTargetId + ) + })) + + setEvaluationsWithReviewers(evaluationsWithReviewersData) + } catch (error) { + console.error('Error loading reviewers:', error) + toast.error("평가자 정보를 불러오는데 실패했습니다.") + setEvaluationsWithReviewers([]) + } finally { + setIsLoadingReviewers(false) + } + } + + loadReviewers() + }, [open, submittedEvaluations.length]) + + // 총 리뷰어 수 계산 + const totalReviewers = evaluationsWithReviewers.reduce((sum, evaluation) => + sum + evaluation.reviewers.length, 0 + ) const handleSubmit = async () => { if (!message.trim()) { @@ -289,13 +369,34 @@ export function RequestEvaluationDialog({ return } + if (evaluationsWithReviewers.length === 0) { + toast.error("평가 요청할 대상이 없습니다.") + return + } + setIsLoading(true) try { - // TODO: 평가자들에게 평가 요청 API 호출 - toast.success(`${submittedEvaluations.length}개 평가에 대한 평가 요청이 발송되었습니다.`) - onSuccess() - onOpenChange(false) - setMessage("") + // 리뷰어 평가 레코드 생성 데이터 준비 + const reviewerEvaluationsData = evaluationsWithReviewers.flatMap(evaluation => + evaluation.reviewers.map(reviewer => ({ + periodicEvaluationId: evaluation.id, + evaluationTargetId: evaluation.evaluationTargetId, // 추가됨 + evaluationTargetReviewerId: reviewer.evaluationTargetReviewerId, + message: message.trim() + })) + ) + + // 서버 액션 호출 + const result = await createReviewerEvaluationsRequest(reviewerEvaluationsData) + + if (result.success) { + toast.success(result.message) + onSuccess() + onOpenChange(false) + setMessage("") + } else { + toast.error(result.message) + } } catch (error) { console.error('Error requesting evaluation:', error) toast.error("평가 요청 발송 중 오류가 발생했습니다.") @@ -306,7 +407,7 @@ export function RequestEvaluationDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-2xl"> + <DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Users className="size-4" /> @@ -318,28 +419,84 @@ export function RequestEvaluationDialog({ </DialogHeader> <div className="space-y-4"> - {/* 대상 평가 목록 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm"> - 평가 대상 ({submittedEvaluations.length}개 평가) - </CardTitle> - </CardHeader> - <CardContent className="space-y-2 max-h-32 overflow-y-auto"> - {submittedEvaluations.map((evaluation) => ( - <div - key={evaluation.id} - className="flex items-center justify-between text-sm" - > - <span className="font-medium">{evaluation.vendorName}</span> - <div className="flex gap-2"> - <Badge variant="outline">{evaluation.evaluationPeriod}</Badge> - <Badge variant="secondary">제출완료</Badge> + {isLoadingReviewers ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">평가자 정보를 불러오고 있습니다...</div> + </div> + ) : ( + <> + {/* 평가별 리뷰어 목록 */} + {evaluationsWithReviewers.length > 0 ? ( + <div className="space-y-4"> + <div className="text-sm font-medium text-green-600"> + 총 {evaluationsWithReviewers.length}개 평가, {totalReviewers}명의 평가자 </div> + + {evaluationsWithReviewers.map((evaluation) => ( + <Card key={evaluation.id}> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center justify-between"> + <span>{evaluation.vendorName}</span> + <div className="flex gap-2"> + <Badge variant="outline">{evaluation.vendorCode}</Badge> + <Badge variant={evaluation.submissionDate ? "default" : "secondary"}> + {evaluation.submissionDate ? "자료 제출완료" : "자료 미제출"} + </Badge> + </div> + </CardTitle> + </CardHeader> + <CardContent> + {evaluation.reviewers.length > 0 ? ( + <div className="space-y-2"> + <div className="text-xs text-muted-foreground mb-2"> + 평가자 {evaluation.reviewers.length}명 + </div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> + {evaluation.reviewers.map((reviewer) => ( + <div + key={reviewer.evaluationTargetReviewerId} + className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg" + > + <div className="flex-1"> + <div className="font-medium text-sm">{reviewer.name}</div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <Mail className="size-3" /> + {reviewer.email} + </div> + {reviewer.deptName && ( + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <Building className="size-3" /> + {reviewer.deptName} + </div> + )} + </div> + <Badge variant="outline" className="text-xs"> + {getDepartmentLabel(reviewer.departmentCode)} + </Badge> + </div> + ))} + </div> + </div> + ) : ( + <div className="text-sm text-muted-foreground text-center py-4"> + 지정된 평가자가 없습니다. + </div> + )} + </CardContent> + </Card> + ))} </div> - ))} - </CardContent> - </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 평가 요청할 대상이 없습니다. + </div> + </CardContent> + </Card> + )} + </> + )} {/* 요청 메시지 */} <div className="space-y-2"> @@ -350,6 +507,7 @@ export function RequestEvaluationDialog({ value={message} onChange={(e) => setMessage(e.target.value)} rows={4} + disabled={isLoadingReviewers} /> </div> </div> @@ -358,13 +516,16 @@ export function RequestEvaluationDialog({ <Button variant="outline" onClick={() => onOpenChange(false)} - disabled={isLoading} + disabled={isLoading || isLoadingReviewers} > 취소 </Button> - <Button onClick={handleSubmit} disabled={isLoading}> + <Button + onClick={handleSubmit} + disabled={isLoading || isLoadingReviewers || totalReviewers === 0} + > <Send className="size-4 mr-2" /> - {isLoading ? "발송 중..." : `${submittedEvaluations.length}개 평가 요청`} + {isLoading ? "발송 중..." : `${totalReviewers}명에게 평가 요청`} </Button> </DialogFooter> </DialogContent> diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx new file mode 100644 index 00000000..7d6ca45d --- /dev/null +++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx @@ -0,0 +1,305 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import * as z from "zod" +import { toast } from "sonner" +import { CheckCircle2, AlertCircle, Building2 } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { PeriodicEvaluationView } from "@/db/schema" +import { finalizeEvaluations } from "../service" + +// 등급 옵션 +const GRADE_OPTIONS = [ + { value: "S", label: "S등급 (90점 이상)" }, + { value: "A", label: "A등급 (80-89점)" }, + { value: "B", label: "B등급 (70-79점)" }, + { value: "C", label: "C등급 (60-69점)" }, + { value: "D", label: "D등급 (60점 미만)" }, +] as const + +// 점수에 따른 등급 계산 +const calculateGrade = (score: number): string => { + if (score >= 90) return "S" + if (score >= 80) return "A" + if (score >= 70) return "B" + if (score >= 60) return "C" + return "D" +} + +// 개별 평가 스키마 +const evaluationItemSchema = z.object({ + id: z.number(), + vendorName: z.string(), + vendorCode: z.string(), + evaluationScore: z.number().nullable(), + finalScore: z.number() + .min(0, "점수는 0 이상이어야 합니다"), + // .max(100, "점수는 100 이하여야 합니다"), + finalGrade: z.enum(["S", "A", "B", "C", "D"]), +}) + +// 전체 폼 스키마 +const finalizeEvaluationSchema = z.object({ + evaluations: z.array(evaluationItemSchema).min(1, "확정할 평가가 없습니다"), +}) + +type FinalizeEvaluationFormData = z.infer<typeof finalizeEvaluationSchema> + +interface FinalizeEvaluationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluations: PeriodicEvaluationView[] + onSuccess?: () => void +} + +export function FinalizeEvaluationDialog({ + open, + onOpenChange, + evaluations, + onSuccess, +}: FinalizeEvaluationDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<FinalizeEvaluationFormData>({ + resolver: zodResolver(finalizeEvaluationSchema), + defaultValues: { + evaluations: [], + }, + }) + + const { fields, update } = useFieldArray({ + control: form.control, + name: "evaluations", + }) + + // evaluations가 변경될 때 폼 초기화 + React.useEffect(() => { + if (evaluations.length > 0) { + const formData = evaluations.map(evaluation => ({ + id: evaluation.id, + vendorName: evaluation.vendorName || "", + vendorCode: evaluation.vendorCode || "", + evaluationScore: evaluation.evaluationScore || null, + finalScore: Number(evaluation.evaluationScore || 0), + finalGrade: calculateGrade(Number(evaluation.evaluationScore || 0)), + })) + + form.reset({ evaluations: formData }) + } + }, [evaluations, form]) + + // 점수 변경 시 등급 자동 계산 + const handleScoreChange = (index: number, score: number) => { + const currentEvaluation = form.getValues(`evaluations.${index}`) + const newGrade = calculateGrade(score) + + update(index, { + ...currentEvaluation, + finalScore: score, + finalGrade: newGrade, + }) + } + + // 폼 제출 + const onSubmit = async (data: FinalizeEvaluationFormData) => { + try { + setIsLoading(true) + + const finalizeData = data.evaluations.map(evaluation => ({ + id: evaluation.id, + finalScore: evaluation.finalScore, + finalGrade: evaluation.finalGrade, + })) + + await finalizeEvaluations(finalizeData) + + toast.success("평가가 확정되었습니다", { + description: `${data.evaluations.length}건의 평가가 최종 확정되었습니다.`, + }) + + onSuccess?.() + onOpenChange(false) + } catch (error) { + console.error("Failed to finalize evaluations:", error) + toast.error("평가 확정 실패", { + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CheckCircle2 className="h-5 w-5 text-purple-600" /> + 평가 확정 + </DialogTitle> + <DialogDescription> + 검토가 완료된 평가의 최종 점수와 등급을 확정합니다. + 확정 후에는 수정이 제한됩니다. + </DialogDescription> + </DialogHeader> + + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 확정할 평가: <strong>{evaluations.length}건</strong> + <br /> + 평가 점수는 리뷰어들의 평가를 바탕으로 계산된 값을 기본으로 하며, 필요시 조정 가능합니다. + </AlertDescription> + </Alert> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">협력업체</TableHead> + <TableHead className="w-[100px]">평가점수</TableHead> + <TableHead className="w-[120px]">최종점수</TableHead> + <TableHead className="w-[120px]">최종등급</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium"> + {form.watch(`evaluations.${index}.vendorName`)} + </div> + <div className="text-sm text-muted-foreground"> + {form.watch(`evaluations.${index}.vendorCode`)} + </div> + </div> + </TableCell> + + <TableCell> + <div className="text-center"> + {form.watch(`evaluations.${index}.evaluationScore`) !== null ? ( + <Badge variant="outline" className="font-mono"> + {Number(form.watch(`evaluations.${index}.evaluationScore`)).toFixed(1)}점 + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalScore`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + max="100" + step="0.1" + {...field} + onChange={(e) => { + const value = parseFloat(e.target.value) + field.onChange(value) + if (!isNaN(value)) { + handleScoreChange(index, value) + } + }} + className="text-center font-mono" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalGrade`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {GRADE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + className="bg-purple-600 hover:bg-purple-700" + > + {isLoading ? "확정 중..." : `평가 확정 (${fields.length}건)`} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx index 2d2bebc1..bb63a1fd 100644 --- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx +++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { type Table } from "@tanstack/react-table" import { @@ -9,7 +7,8 @@ import { Download, RefreshCw, FileText, - MessageSquare + MessageSquare, + CheckCircle2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" @@ -28,6 +27,7 @@ import { } from "./periodic-evaluation-action-dialogs" import { PeriodicEvaluationView } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" +import { FinalizeEvaluationDialog } from "./periodic-evaluation-finalize-dialogs" interface PeriodicEvaluationsTableToolbarActionsProps { table: Table<PeriodicEvaluationView> @@ -42,20 +42,66 @@ export function PeriodicEvaluationsTableToolbarActions({ const [createEvaluationDialogOpen, setCreateEvaluationDialogOpen] = React.useState(false) const [requestDocumentsDialogOpen, setRequestDocumentsDialogOpen] = React.useState(false) const [requestEvaluationDialogOpen, setRequestEvaluationDialogOpen] = React.useState(false) + const [finalizeEvaluationDialogOpen, setFinalizeEvaluationDialogOpen] = React.useState(false) const router = useRouter() // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedEvaluations = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedEvaluations를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedEvaluations = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 평가들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingSubmissionEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const submittedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const inReviewEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "IN_REVIEW"); + }, [table.getFilteredSelectedRowModel().rows]); + + const reviewCompletedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "REVIEW_COMPLETED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const finalizedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "FINALIZED"); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pendingSubmission = selectedEvaluations.filter(e => e.status === "PENDING_SUBMISSION").length - const submitted = selectedEvaluations.filter(e => e.status === "SUBMITTED").length - const inReview = selectedEvaluations.filter(e => e.status === "IN_REVIEW").length - const reviewCompleted = selectedEvaluations.filter(e => e.status === "REVIEW_COMPLETED").length - const finalized = selectedEvaluations.filter(e => e.status === "FINALIZED").length + const pendingSubmission = pendingSubmissionEvaluations.length + const submitted = submittedEvaluations.length + const inReview = inReviewEvaluations.length + const reviewCompleted = reviewCompletedEvaluations.length + const finalized = finalizedEvaluations.length // 협력업체에게 자료 요청 가능: PENDING_SUBMISSION 상태 const canRequestDocuments = pendingSubmission > 0 @@ -63,6 +109,9 @@ export function PeriodicEvaluationsTableToolbarActions({ // 평가자에게 평가 요청 가능: SUBMITTED 상태 (제출됐지만 아직 평가 시작 안됨) const canRequestEvaluation = submitted > 0 + // 평가 확정 가능: REVIEW_COMPLETED 상태 + const canFinalizeEvaluation = reviewCompleted > 0 + return { pendingSubmission, submitted, @@ -71,42 +120,37 @@ export function PeriodicEvaluationsTableToolbarActions({ finalized, canRequestDocuments, canRequestEvaluation, + canFinalizeEvaluation, total: selectedEvaluations.length } - }, [selectedEvaluations]) - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (자동) - // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { - setIsLoading(true) - try { - // TODO: 평가대상에서 자동 생성 API 호출 - toast.success("정기평가가 자동으로 생성되었습니다.") - router.refresh() - } catch (error) { - console.error('Error auto generating periodic evaluations:', error) - toast.error("자동 생성 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (수동) - // ---------------------------------------------------------------- - const handleManualCreate = () => { - setCreateEvaluationDialogOpen(true) - } - + }, [ + pendingSubmissionEvaluations.length, + submittedEvaluations.length, + inReviewEvaluations.length, + reviewCompletedEvaluations.length, + finalizedEvaluations.length, + selectedEvaluations.length + ]) + + // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "periodic-evaluations", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -117,12 +161,7 @@ export function PeriodicEvaluationsTableToolbarActions({ <Button variant="outline" size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "periodic-evaluations", - excludeColumns: ["select", "actions"], - }) - } + onClick={handleExport} className="gap-2" > <Download className="size-4" aria-hidden="true" /> @@ -165,27 +204,25 @@ export function PeriodicEvaluationsTableToolbarActions({ </Button> )} - {/* 알림 발송 버튼 (선택사항) */} - <Button - variant="outline" - size="sm" - className="gap-2" - onClick={() => { - // TODO: 선택된 평가에 대한 알림 발송 - toast.info("알림이 발송되었습니다.") - }} - disabled={isLoading} - > - <MessageSquare className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - 알림 발송 ({selectedStats.total}) - </span> - </Button> + {/* 평가 확정 버튼 */} + {selectedStats.canFinalizeEvaluation && ( + <Button + variant="outline" + size="sm" + className="gap-2 text-purple-600 border-purple-200 hover:bg-purple-50" + onClick={() => setFinalizeEvaluationDialogOpen(true)} + disabled={isLoading} + > + <CheckCircle2 className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 평가 확정 ({selectedStats.reviewCompleted}) + </span> + </Button> + )} </div> )} </div> - {/* 협력업체 자료 요청 다이얼로그 */} <RequestDocumentsDialog open={requestDocumentsDialogOpen} @@ -202,17 +239,13 @@ export function PeriodicEvaluationsTableToolbarActions({ onSuccess={handleActionSuccess} /> - {/* 선택 정보 표시 (디버깅용 - 필요시 주석 해제) */} - {/* {hasSelection && ( - <div className="text-xs text-muted-foreground mt-2"> - 선택된 {selectedRows.length}개 항목: - 제출대기 {selectedStats.pendingSubmission}개, - 제출완료 {selectedStats.submitted}개, - 검토중 {selectedStats.inReview}개, - 검토완료 {selectedStats.reviewCompleted}개, - 최종확정 {selectedStats.finalized}개 - </div> - )} */} + {/* 평가 확정 다이얼로그 */} + <FinalizeEvaluationDialog + open={finalizeEvaluationDialogOpen} + onOpenChange={setFinalizeEvaluationDialogOpen} + evaluations={reviewCompletedEvaluations} + onSuccess={handleActionSuccess} + /> </> ) -}
\ No newline at end of file +} |
