From c657ef972feeafff16ab0e07cb4771f7dd141ba0 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 10 Jul 2025 09:55:45 +0000 Subject: (대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/evaluation/service.ts | 124 ++++- lib/evaluation/table/evaluation-columns.tsx | 71 +-- lib/evaluation/table/evaluation-details-dialog.tsx | 591 +++++++++++++-------- .../table/periodic-evaluation-finalize-dialogs.tsx | 16 +- .../table/periodic-evaluations-toolbar-actions.tsx | 2 +- 5 files changed, 512 insertions(+), 292 deletions(-) (limited to 'lib/evaluation') 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 { 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() + const attachmentsByReviewerId = new Map() + + 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() 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 }) => , + 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 ( +
+ + +
+ ); + }, + }, // ═══════════════════════════════════════════════════════════════ // 제출 현황 // ═══════════════════════════════════════════════════════════════ @@ -549,32 +583,5 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ] }, - - - - // ░░░ Actions ░░░ - { - id: "actions", - enableHiding: false, - size: 40, - minSize: 40, - cell: ({ row }) => { - return ( -
- - -
- ); - }, - }, ]; } \ 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(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 ( - - - - - - 평가 상세 - - - {/* 평가 기본 정보 */} - - - - - 평가 정보 - - - -
- {/* 협력업체 */} -
- 협력업체: - {evaluation.vendorName} - ({evaluation.vendorCode}) -
+ + + + {/* 고정 헤더 */} + + + + 평가 상세 + + + {/* 평가 기본 정보 */} + + + + + 평가 정보 + + + +
+ {/* 협력업체 */} +
+ 협력업체: + {evaluation.vendorName} + ({evaluation.vendorCode}) +
- {/* 평가년도 */} -
- 년도: - {evaluation.evaluationYear}년 -
+ {/* 평가년도 */} +
+ 년도: + {evaluation.evaluationYear}년 +
- {/* 구분 */} -
- 구분: - - {evaluation.division === "PLANT" ? "해양" : "조선"} - -
+ {/* 구분 */} +
+ 구분: + + {evaluation.division === "PLANT" ? "해양" : "조선"} + +
- {/* 진행상태 */} -
- 상태: - {evaluation.status} -
+ {/* 진행상태 */} +
+ 상태: + {evaluation.status} +
- {/* 평가점수/등급 */} -
- 평가점수/등급: - {evaluation.evaluationScore ? ( -
- - {Number(evaluation.evaluationScore).toFixed(1)}점 - - {evaluation.evaluationGrade && ( - - {evaluation.evaluationGrade} - - )} -
- ) : ( - - - )} -
+ {/* 평가점수/등급 */} +
+ 평가점수/등급: + {evaluation.evaluationScore ? ( +
+ + {Number(evaluation.evaluationScore).toFixed(1)}점 + + {evaluation.evaluationGrade && ( + + {evaluation.evaluationGrade} + + )} +
+ ) : ( + - + )} +
- {/* 확정점수/등급 */} -
- 확정점수/등급: - {evaluation.finalScore ? ( -
- - {Number(evaluation.finalScore).toFixed(1)}점 - - {evaluation.finalGrade && ( - - {evaluation.finalGrade} - - )} -
- ) : ( - 미확정 - )} + {/* 확정점수/등급 */} +
+ 확정점수/등급: + {evaluation.finalScore ? ( +
+ + {Number(evaluation.finalScore).toFixed(1)}점 + + {evaluation.finalGrade && ( + + {evaluation.finalGrade} + + )} +
+ ) : ( + 미확정 + )} +
-
-
-
-
- {isLoading ? ( -
- - - - - - - + -
- ) : evaluationDetails ? ( -
- {/* 통합 평가 테이블 */} - - - - - 평가 상세 내역 - - - - {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( - - - - 담당자 - {/* 상태 */} - 평가부문 - 항목 - 구분 - 범위 - 선택옵션 - 점수 - 의견 - - - - {evaluationDetails.reviewerDetails.map((reviewer) => - reviewer.evaluationItems.map((item, index) => ( - - -
-
{reviewer.departmentName}
-
- {reviewer.reviewerName} -
-
-
- {/* - {reviewer.isCompleted ? ( - - - 완료 - - ) : ( - - - 진행중 - - )} - */} - - - {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} - - - - {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} - - - {item.classification} - - - {item.range || "-"} - - - {item.scoreType === "variable" ? ( - 직접 입력 - ) : ( - item.selectedDetail || "-" - )} - - - {item.score !== null ? ( - - {item.score.toFixed(1)} + + + {/* 스크롤 가능한 컨텐츠 영역 */} +
+ {isLoading ? ( +
+ + + + + + + + +
+ ) : evaluationDetails ? ( +
+ {/* 통합 평가 테이블 */} + + + + + 평가 상세 내역 + + + + {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( +
+ + + 담당자 + 평가부문 + 항목 + 구분 + 범위 + 선택옵션 + 점수 + 첨부파일 + 의견 + + + + {evaluationDetails.reviewerDetails.map((reviewer) => + reviewer.evaluationItems.map((item, index) => ( + + +
+
{reviewer.departmentName}
+
+ {reviewer.reviewerName} +
+
+
+ + + {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} + + + + {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} + + + {item.classification} + + + {item.range || "-"} + + + {item.scoreType === "variable" ? ( + 직접 입력 + ) : ( + item.selectedDetail || "-" + )} + + + {item.score !== null ? ( + + {item.score.toFixed(1)} + + ) : ( + - + )} + + + {/* 📎 첨부파일 컬럼 - 개선된 버전 */} + + {item.attachments.length > 0 ? ( +
+ {item.attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + return ( +
+ {fileInfo.icon} + + + + {attachment.originalFileName} + + + +
+
{attachment.originalFileName}
+
크기: {formatFileSize(attachment.fileSize)}
+
타입: {fileInfo.type}
+ {attachment.description && ( +
설명: {attachment.description}
+ )} +
업로드: {attachment.uploadedByName}
+
+ {fileInfo.canPreview ? "미리보기 가능" : "다운로드만 가능"} +
+
+
+
+ +
+ ) + })} + {item.attachments.length > 1 && ( +
+ 총 {formatFileSize(item.attachmentTotalSize)} +
+ )} +
+ ) : ( +
+ 첨부파일 없음 +
+ )} +
+ + + {item.comment || ( + 의견 없음 + )} + +
+ )) + )} +
+
+ ) : ( +
+ +
평가 항목이 없습니다
+
+ )} +
+
+ + {/* 리뷰어별 종합 의견 (있는 경우만) */} + {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( + + + + + 종합 의견 + + + + {evaluationDetails.reviewerDetails + .filter(reviewer => reviewer.reviewerComment) + .map((reviewer) => ( +
+
+ {reviewer.departmentName} + {reviewer.reviewerName} + {reviewer.totalAttachments > 0 && ( + + + {reviewer.totalAttachments}개 파일 - ) : ( - - )} - - - {item.comment || ( - 의견 없음 - )} - - - )) - )} - - - ) : ( -
- -
평가 항목이 없습니다
-
+
+
+ {reviewer.reviewerComment} +
+
+ ))} +
+
)} - - - {/* 리뷰어별 종합 의견 (있는 경우만) */} - {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( - - - - - 종합 의견 - - - - {evaluationDetails.reviewerDetails - .filter(reviewer => reviewer.reviewerComment) - .map((reviewer) => ( -
-
- {reviewer.departmentName} - {reviewer.reviewerName} + {/* 📎 첨부파일 요약 (파일이 많은 경우) */} + {evaluationDetails.attachmentStats.totalFiles > 5 && ( + + + + + 첨부파일 요약 + + + +
+
+
전체 파일 수
+
{evaluationDetails.attachmentStats.totalFiles}개
-
- {reviewer.reviewerComment} +
+
전체 파일 크기
+
{formatFileSize(evaluationDetails.attachmentStats.totalSize)}
+
+
+
첨부 질문 수
+
{evaluationDetails.attachmentStats.questionsWithAttachments}개
+
+
+
첨부 담당자 수
+
{evaluationDetails.attachmentStats.reviewersWithAttachments}명
- ))} - - - )} + + + )} - {evaluationDetails.reviewerDetails.length === 0 && ( - + {evaluationDetails.reviewerDetails.length === 0 && ( + + +
+ +
배정된 리뷰어가 없습니다
+
+
+
+ )} +
+ ) : ( +
- -
배정된 리뷰어가 없습니다
+ 평가 상세 정보를 불러올 수 없습니다
)}
- ) : ( - - -
- 평가 상세 정보를 불러올 수 없습니다 -
-
-
- )} -
- -
- -
+ {/* 고정 푸터 */} +
+ +
+ +
+ ) } \ 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 -- cgit v1.2.3