summaryrefslogtreecommitdiff
path: root/lib/evaluation/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
commit90f79a7a691943a496f67f01c1e493256070e4de (patch)
tree37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation/table
parentfbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff)
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation/table')
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx213
-rw-r--r--lib/evaluation/table/evaluation-details-dialog.tsx366
-rw-r--r--lib/evaluation/table/evaluation-table.tsx11
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx231
-rw-r--r--lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx305
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx179
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
+}