summaryrefslogtreecommitdiff
path: root/lib/evaluation
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
commitc657ef972feeafff16ab0e07cb4771f7dd141ba0 (patch)
treebefabd884b00d3cc632c628b3e3810f61cc9f38d /lib/evaluation
parentb8a03c9d130435a71c5d6217d06ccb0beb9697e5 (diff)
(대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영
Diffstat (limited to 'lib/evaluation')
-rw-r--r--lib/evaluation/service.ts124
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx71
-rw-r--r--lib/evaluation/table/evaluation-details-dialog.tsx591
-rw-r--r--lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx16
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx2
5 files changed, 512 insertions, 292 deletions
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 76811753..8e394f88 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -9,6 +9,7 @@ import {
periodicEvaluationsView,
regEvalCriteria,
regEvalCriteriaDetails,
+ reviewerEvaluationAttachments,
reviewerEvaluationDetails,
reviewerEvaluations,
roles,
@@ -32,6 +33,7 @@ import { revalidatePath } from "next/cache"
import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form"
export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
try {
@@ -989,17 +991,7 @@ export interface EvaluationDetailData {
/**
* 특정 정기평가의 상세 정보를 조회합니다
*/
-export async function getEvaluationDetails(periodicEvaluationId: number): Promise<{
- evaluationInfo: {
- id: number
- vendorName: string
- vendorCode: string
- evaluationYear: number
- division: string
- status: string
- }
- reviewerDetails: EvaluationDetailData[]
-}> {
+export async function getEvaluationDetails(periodicEvaluationId: number): Promise<EvaluationDetailResponse> {
try {
// 1. 평가 기본 정보 조회
const evaluationInfo = await db
@@ -1060,11 +1052,90 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
.where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId))
.orderBy(evaluationTargetReviewers.departmentCode, regEvalCriteria.category, regEvalCriteria.classification)
- // 3. 리뷰어별로 그룹화
+ // 📎 3. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ // 첨부파일 정보
+ attachmentId: reviewerEvaluationAttachments.id,
+ originalFileName: reviewerEvaluationAttachments.originalFileName,
+ storedFileName: reviewerEvaluationAttachments.storedFileName,
+ publicPath: reviewerEvaluationAttachments.publicPath,
+ fileSize: reviewerEvaluationAttachments.fileSize,
+ mimeType: reviewerEvaluationAttachments.mimeType,
+ fileExtension: reviewerEvaluationAttachments.fileExtension,
+ description: reviewerEvaluationAttachments.description,
+ uploadedBy: reviewerEvaluationAttachments.uploadedBy,
+ attachmentCreatedAt: reviewerEvaluationAttachments.createdAt,
+
+ // 업로드한 사용자 정보
+ uploadedByName: users.name,
+
+ // 평가 세부사항 정보
+ evaluationDetailId: reviewerEvaluationDetails.id,
+ reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId,
+
+ // 평가 기준 정보 (질문 식별용)
+ criteriaId: regEvalCriteriaDetails.criteriaId,
+ })
+ .from(reviewerEvaluationAttachments)
+ .innerJoin(
+ reviewerEvaluationDetails,
+ eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id)
+ )
+ .innerJoin(
+ reviewerEvaluations,
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluations.id)
+ )
+ .leftJoin(
+ regEvalCriteriaDetails,
+ eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id)
+ )
+ .leftJoin(
+ users,
+ eq(reviewerEvaluationAttachments.uploadedBy, users.id)
+ )
+ .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId))
+ .orderBy(desc(reviewerEvaluationAttachments.createdAt))
+
+ // 📎 4. 첨부파일을 평가 세부사항별로 그룹화
+ const attachmentsByDetailId = new Map<number, AttachmentDetail[]>()
+ const attachmentsByReviewerId = new Map<number, AttachmentDetail[]>()
+
+ attachmentsData.forEach(attachment => {
+ const attachmentInfo: AttachmentDetail = {
+ id: attachment.attachmentId,
+ originalFileName: attachment.originalFileName,
+ storedFileName: attachment.storedFileName,
+ publicPath: attachment.publicPath,
+ fileSize: attachment.fileSize,
+ mimeType: attachment.mimeType || undefined,
+ fileExtension: attachment.fileExtension || undefined,
+ description: attachment.description || undefined,
+ uploadedBy: attachment.uploadedBy,
+ uploadedByName: attachment.uploadedByName || undefined,
+ createdAt: new Date(attachment.attachmentCreatedAt),
+ }
+
+ // 평가 세부사항별 그룹화
+ if (!attachmentsByDetailId.has(attachment.evaluationDetailId)) {
+ attachmentsByDetailId.set(attachment.evaluationDetailId, [])
+ }
+ attachmentsByDetailId.get(attachment.evaluationDetailId)!.push(attachmentInfo)
+
+ // 리뷰어별 그룹화
+ if (!attachmentsByReviewerId.has(attachment.reviewerEvaluationId)) {
+ attachmentsByReviewerId.set(attachment.reviewerEvaluationId, [])
+ }
+ attachmentsByReviewerId.get(attachment.reviewerEvaluationId)!.push(attachmentInfo)
+ })
+
+ // 5. 리뷰어별로 그룹화하고 첨부파일 정보 포함
const reviewerDetailsMap = new Map<number, EvaluationDetailData>()
reviewerDetailsRaw.forEach(row => {
if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) {
+ const reviewerAttachments = attachmentsByReviewerId.get(row.reviewerEvaluationId) || []
+
reviewerDetailsMap.set(row.reviewerEvaluationId, {
reviewerEvaluationId: row.reviewerEvaluationId,
reviewerName: row.reviewerName || "",
@@ -1074,13 +1145,22 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
isCompleted: row.isCompleted || false,
completedAt: row.completedAt,
reviewerComment: row.reviewerComment,
- evaluationItems: []
+ evaluationItems: [],
+
+ // 📎 리뷰어별 첨부파일 통계
+ totalAttachments: reviewerAttachments.length,
+ totalAttachmentSize: reviewerAttachments.reduce((sum, att) => sum + att.fileSize, 0),
+ questionsWithAttachments: new Set(reviewerAttachments.map(att =>
+ attachmentsData.find(a => a.attachmentId === att.id)?.criteriaId
+ ).filter(Boolean)).size,
})
}
// 평가 항목이 있는 경우에만 추가
if (row.criteriaId && row.detailId) {
const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)!
+ const itemAttachments = attachmentsByDetailId.get(row.detailId) || []
+
reviewer.evaluationItems.push({
criteriaId: row.criteriaId,
category: row.category || "",
@@ -1093,14 +1173,28 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
selectedDetailId: row.selectedDetailId,
selectedDetail: row.selectedDetail,
score: row.score ? Number(row.score) : null,
- comment: row.comment
+ comment: row.comment,
+
+ // 📎 항목별 첨부파일 정보
+ attachments: itemAttachments,
+ attachmentCount: itemAttachments.length,
+ attachmentTotalSize: itemAttachments.reduce((sum, att) => sum + att.fileSize, 0),
})
}
})
+ // 📎 6. 전체 첨부파일 통계 계산
+ const attachmentStats = {
+ totalFiles: attachmentsData.length,
+ totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0),
+ reviewersWithAttachments: attachmentsByReviewerId.size,
+ questionsWithAttachments: new Set(attachmentsData.map(att => att.criteriaId).filter(Boolean)).size,
+ }
+
return {
evaluationInfo: evaluationInfo[0],
- reviewerDetails: Array.from(reviewerDetailsMap.values())
+ reviewerDetails: Array.from(reviewerDetailsMap.values()),
+ attachmentStats,
}
} catch (error) {
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 4b7d9a80..315ec66b 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -8,7 +8,7 @@ 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 } from "lucide-react";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
@@ -40,11 +40,11 @@ const getStatusBadgeVariant = (status: string) => {
const getStatusLabel = (status: string) => {
const statusMap = {
- PENDING_SUBMISSION: "제출대기",
+ PENDING_SUBMISSION: "자료접수중",
SUBMITTED: "제출완료",
- IN_REVIEW: "검토중",
- REVIEW_COMPLETED: "검토완료",
- FINALIZED: "최종확정"
+ IN_REVIEW: "평가중",
+ REVIEW_COMPLETED: "평가완료",
+ FINALIZED: "결과확정"
};
return statusMap[status] || status;
};
@@ -215,6 +215,14 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
size: 80,
},
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Status" />,
+ cell: ({ row }) => getStatusLabel(row.original.status || ""),
+ size: 80,
+ },
+
+
// ═══════════════════════════════════════════════════════════════
// 협력업체 정보
// ═══════════════════════════════════════════════════════════════
@@ -329,6 +337,32 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
]
},
+
+ {
+ // id: "평가상세",
+ // accessorKey: "평가상세",
+ header: "평가상세",
+ enableHiding: true,
+ size: 80,
+ minSize: 80,
+ 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>
+
+ </div>
+ );
+ },
+ },
// ═══════════════════════════════════════════════════════════════
// 제출 현황
// ═══════════════════════════════════════════════════════════════
@@ -549,32 +583,5 @@ 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>
-
- </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
index df4ef016..2f682402 100644
--- a/lib/evaluation/table/evaluation-details-dialog.tsx
+++ b/lib/evaluation/table/evaluation-details-dialog.tsx
@@ -10,7 +10,11 @@ import {
Clock,
MessageSquare,
Award,
- FileText
+ FileText,
+ Paperclip,
+ Download,
+ File,
+ BarChart3
} from "lucide-react"
import {
@@ -19,6 +23,12 @@ import {
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"
@@ -26,7 +36,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { PeriodicEvaluationView } from "@/db/schema"
-import { getEvaluationDetails, type EvaluationDetailData } from "../service"
+import { getEvaluationDetails} from "../service"
+import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form"
+// 파일 다운로드 유틸리티 import
+import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
interface EvaluationDetailsDialogProps {
open: boolean
@@ -75,10 +88,30 @@ export function EvaluationDetailsDialog({
evaluation,
}: EvaluationDetailsDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [evaluationDetails, setEvaluationDetails] = React.useState<{
- evaluationInfo: any
- reviewerDetails: EvaluationDetailData[]
- } | null>(null)
+ const [evaluationDetails, setEvaluationDetails] = React.useState<EvaluationDetailResponse | null>(null)
+
+ // 첨부파일 다운로드 핸들러 - downloadFile 사용
+ const handleDownloadAttachment = async (attachment: AttachmentDetail) => {
+ try {
+ await downloadFile(
+ attachment.publicPath,
+ attachment.originalFileName,
+ {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
+ }
+ }
+ )
+ } catch (error) {
+ console.error("다운로드 처리 중 오류:", error)
+ }
+ }
// 평가 상세 정보 로드
React.useEffect(() => {
@@ -109,258 +142,346 @@ export function EvaluationDetailsDialog({
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>
+ <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>
+ <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="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>
+ <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.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 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>
- </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>
+ </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)}
+ </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>
+ ) : evaluationDetails ? (
+ <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>
+ {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">담당자</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="w-[150px]">첨부파일</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>
+ <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>
+ {item.attachments.length > 0 ? (
+ <div className="space-y-1">
+ {item.attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ return (
+ <div key={attachment.id} className="flex items-center gap-1 p-1 bg-muted rounded">
+ <span className="text-sm">{fileInfo.icon}</span>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-xs truncate max-w-[80px] 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>
+ {attachment.description && (
+ <div>설명: {attachment.description}</div>
+ )}
+ <div>업로드: {attachment.uploadedByName}</div>
+ <div className="text-muted-foreground">
+ {fileInfo.canPreview ? "미리보기 가능" : "다운로드만 가능"}
+ </div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0"
+ onClick={() => handleDownloadAttachment(attachment)}
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ )
+ })}
+ {item.attachments.length > 1 && (
+ <div className="text-xs text-muted-foreground">
+ 총 {formatFileSize(item.attachmentTotalSize)}
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-xs text-muted-foreground">
+ 첨부파일 없음
+ </div>
+ )}
+ </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>
+ {reviewer.totalAttachments > 0 && (
+ <Badge variant="secondary" className="text-xs">
+ <Paperclip className="h-3 w-3 mr-1" />
+ {reviewer.totalAttachments}개 파일
</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>
+ </div>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {reviewer.reviewerComment}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
)}
- </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>
+ {/* 📎 첨부파일 요약 (파일이 많은 경우) */}
+ {evaluationDetails.attachmentStats.totalFiles > 5 && (
+ <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">{evaluationDetails.attachmentStats.totalFiles}개</div>
</div>
- <div className="bg-muted p-3 rounded-md text-sm">
- {reviewer.reviewerComment}
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 크기</div>
+ <div className="font-bold text-lg">{formatFileSize(evaluationDetails.attachmentStats.totalSize)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">첨부 질문 수</div>
+ <div className="font-bold text-lg">{evaluationDetails.attachmentStats.questionsWithAttachments}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">첨부 담당자 수</div>
+ <div className="font-bold text-lg">{evaluationDetails.attachmentStats.reviewersWithAttachments}명</div>
</div>
</div>
- ))}
- </CardContent>
- </Card>
- )}
+ </CardContent>
+ </Card>
+ )}
- {evaluationDetails.reviewerDetails.length === 0 && (
- <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 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>
</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>
+ {/* 고정 푸터 */}
+ <div className="flex justify-end pt-4 border-t flex-shrink-0">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </TooltipProvider>
)
} \ No newline at end of file
diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
index d6784754..84651350 100644
--- a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
+++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
@@ -40,18 +40,16 @@ 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: "A", label: "A등급 (95점 이상)" },
+ { value: "B", label: "B등급 (90-95점 미만)" },
+ { value: "C", label: "C등급 (60-90점 미만)" },
{ value: "D", label: "D등급 (60점 미만)" },
] as const
// 점수에 따른 등급 계산
-const calculateGrade = (score: number): "S" | "A" | "B" | "C" | "D" => {
- if (score >= 90) return "S"
- if (score >= 80) return "A"
- if (score >= 70) return "B"
+const calculateGrade = (score: number): "A" | "B" | "C" | "D" => {
+ if (score >= 95) return "A"
+ if (score >= 90) return "B"
if (score >= 60) return "C"
return "D"
}
@@ -65,7 +63,7 @@ const evaluationItemSchema = z.object({
finalScore: z.coerce.number()
.min(0, "점수는 0 이상이어야 합니다"),
// .max(100, "점수는 100 이하여야 합니다"),
- finalGrade: z.enum(["S", "A", "B", "C", "D"]),
+ finalGrade: z.enum(["A", "B", "C", "D"]),
})
// 전체 폼 스키마
diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
index 39a95cc7..d910f916 100644
--- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -49,7 +49,7 @@ export function PeriodicEvaluationsTableToolbarActions({
// 권한 체크 (방법 1 또는 방법 2 중 선택)
const { hasRole, isLoading: roleLoading } = useAuthRole()
- const canManageEvaluations = hasRole('정기평가')
+ const canManageEvaluations = hasRole('정기평가') || hasRole('admin')
// 선택된 행들
const selectedRows = table.getFilteredSelectedRowModel().rows