diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-10 09:55:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-10 09:55:45 +0000 |
| commit | c657ef972feeafff16ab0e07cb4771f7dd141ba0 (patch) | |
| tree | befabd884b00d3cc632c628b3e3810f61cc9f38d /lib/evaluation-submit | |
| parent | b8a03c9d130435a71c5d6217d06ccb0beb9697e5 (diff) | |
(대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영
Diffstat (limited to 'lib/evaluation-submit')
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 453 | ||||
| -rw-r--r-- | lib/evaluation-submit/service.ts | 385 |
2 files changed, 711 insertions, 127 deletions
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx index 65da72b6..fbdcee69 100644 --- a/lib/evaluation-submit/evaluation-form.tsx +++ b/lib/evaluation-submit/evaluation-form.tsx @@ -27,20 +27,28 @@ import { Send, ArrowLeft, AlertCircle, - FileText + FileText, + Upload, + File, + X, + Download, + Paperclip } from "lucide-react" import { useRouter } from "next/navigation" import { useToast } from "@/hooks/use-toast" import { updateEvaluationResponse, + updateVariableEvaluationResponse, completeEvaluation } from "./service" import { - type EvaluationFormData, type EvaluationQuestionItem, EVALUATION_CATEGORIES } from "./validation" import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation" +import { EvaluationFormData } from "@/types/evaluation-form" +// 파일 다운로드 유틸리티 import +import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" interface EvaluationFormProps { formData: EvaluationFormData @@ -53,6 +61,15 @@ interface QuestionResponse { comment: string } +interface AttachmentInfo { + id: number + originalFileName: string + publicPath: string + fileSize: number + description?: string + createdAt: Date +} + /** * 평가 폼 메인 컴포넌트 (테이블 레이아웃) */ @@ -63,6 +80,7 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { const [isSaving, setIsSaving] = React.useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false) const [showCompleteDialog, setShowCompleteDialog] = React.useState(false) + const [uploadingFiles, setUploadingFiles] = React.useState<Set<number>>(new Set()) const { evaluationInfo, questions } = formData @@ -85,6 +103,44 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { return initial }) + // 첨부파일 상태 관리 (서버에서 받은 데이터로 초기화) + const [attachments, setAttachments] = React.useState<Record<number, AttachmentInfo[]>>(() => { + console.log('Initializing attachments from server data...') + const initial: Record<number, AttachmentInfo[]> = {} + questions.forEach(question => { + const questionAttachments = question.attachments || [] + initial[question.criteriaId] = questionAttachments + if (questionAttachments.length > 0) { + console.log(`Question ${question.criteriaId} has ${questionAttachments.length} attachments:`, questionAttachments) + } + }) + console.log('Initial attachments state:', initial) + return initial + }) + + // 첨부파일 다운로드 핸들러 - downloadFile 사용 + const handleDownloadAttachment = async (attachment: AttachmentInfo) => { + 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) + } + } + // 카테고리별 질문 그룹화 const questionsByCategory = React.useMemo(() => { const grouped = questions.reduce((acc, question) => { @@ -146,6 +202,130 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { setHasUnsavedChanges(true) } + // 파일 업로드 핸들러 + const handleFileUpload = async (questionId: number, file: File, description?: string) => { + try { + console.log('Starting file upload for question:', questionId, 'file:', file.name) + setUploadingFiles(prev => new Set(prev).add(questionId)) + + // 질문 정보 확인 + const question = questions.find(q => q.criteriaId === questionId) + if (!question) { + throw new Error('질문을 찾을 수 없습니다.') + } + + // 답변이 있는지 확인 + const response = responses[questionId] + const isVariable = question.scoreType === 'variable' + const isAnswered = isVariable ? + (response.score !== null) : + (response.detailId !== null && response.detailId > 0) + + if (!isAnswered) { + throw new Error('먼저 답변을 선택해주세요.') + } + + // FormData 생성 + const formData = new FormData() + formData.append('file', file) + formData.append('questionId', questionId.toString()) + formData.append('evaluationId', evaluationInfo.id.toString()) + + if (description) { + formData.append('description', description) + } + + if (isVariable) { + formData.append('isVariable', 'true') + } + + console.log('Sending upload request...') + + // 파일 업로드 API 호출 + const response_api = await fetch('/api/evaluation/attachments', { + method: 'POST', + body: formData, + }) + + console.log('Upload response status:', response_api.status) + + if (!response_api.ok) { + const errorText = await response_api.text() + console.error('Upload failed with status:', response_api.status, 'error:', errorText) + throw new Error(`파일 업로드에 실패했습니다. (${response_api.status})`) + } + + const result = await response_api.json() + console.log('Upload result:', result) + + if (result.success && result.attachment) { + // 첨부파일 목록 업데이트 + setAttachments(prev => ({ + ...prev, + [questionId]: [...(prev[questionId] || []), { + id: result.attachment.id, + originalFileName: result.attachment.originalFileName, + publicPath: result.attachment.publicPath, + fileSize: result.attachment.fileSize, + description: result.attachment.description, + createdAt: new Date(result.attachment.createdAt), + }] + })) + + toast({ + title: "파일 업로드 완료", + description: `${file.name}이 성공적으로 업로드되었습니다.`, + }) + } else { + throw new Error(result.error || '파일 업로드에 실패했습니다.') + } + } catch (error) { + console.error('File upload failed:', error) + toast({ + title: "업로드 실패", + description: error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setUploadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(questionId) + return newSet + }) + } + } + + // 첨부파일 삭제 핸들러 + const handleDeleteAttachment = async (questionId: number, attachmentId: number) => { + try { + const response = await fetch(`/api/evaluation/attachments/${attachmentId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error('파일 삭제에 실패했습니다.') + } + + // 첨부파일 목록에서 제거 + setAttachments(prev => ({ + ...prev, + [questionId]: prev[questionId]?.filter(att => att.id !== attachmentId) || [] + })) + + toast({ + title: "파일 삭제 완료", + description: "첨부파일이 삭제되었습니다.", + }) + } catch (error) { + console.error('File deletion failed:', error) + toast({ + title: "삭제 실패", + description: "파일 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + // 임시저장 const handleSave = async () => { try { @@ -166,12 +346,23 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { const question = questions.find(q => q.criteriaId === parseInt(questionId)) const isVariable = question?.scoreType === 'variable' - return updateEvaluationResponse( - evaluationInfo.id, - isVariable ? -1 : response.detailId!, - response.comment, - response.score || undefined - ) + if (isVariable) { + // Variable 타입은 별도 함수 사용 + return updateVariableEvaluationResponse( + evaluationInfo.id, + parseInt(questionId), // criteriaId + response.score!, + response.comment || undefined + ) + } else { + // 일반 타입 + return updateEvaluationResponse( + evaluationInfo.id, + response.detailId!, + response.comment || undefined, + response.score || undefined + ) + } }) await Promise.all(promises) @@ -285,35 +476,58 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { </CardTitle> </CardHeader> <CardContent className="pt-4 pb-4"> - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> - <div className="space-y-1"> - <Label className="text-sm text-muted-foreground">협력업체</Label> - <div className="font-medium text-sm">{evaluationInfo.vendorName} ({evaluationInfo.vendorCode})</div> - </div> - <div className="space-y-1"> - <Label className="text-sm text-muted-foreground">사업부</Label> - <div> - <Badge variant="outline"> - {divisionMap[evaluationInfo.division] || evaluationInfo.division} - </Badge> - </div> - </div> - <div className="space-y-1"> - <Label className="text-sm text-muted-foreground">자재유형</Label> - <div> - <Badge variant="outline"> - {vendortypeMap[evaluationInfo.materialType] || evaluationInfo.materialType} - </Badge> - </div> - </div> - <div className="space-y-1"> - <Label className="text-sm text-muted-foreground">담당부서</Label> - <div className="font-medium text-sm"> - {DEPARTMENT_CODE_LABELS[evaluationInfo.departmentCode] || evaluationInfo.departmentCode} - </div> - </div> - </div> - </CardContent> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">협력업체</Label> + <div className="font-medium text-sm">{evaluationInfo.vendorName} ({evaluationInfo.vendorCode})</div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">사업부</Label> + <div> + <Badge variant="outline"> + {divisionMap[evaluationInfo.division] || evaluationInfo.division} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">자재유형</Label> + <div> + <Badge variant="outline"> + {vendortypeMap[evaluationInfo.materialType] || evaluationInfo.materialType} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">담당부서</Label> + <div className="font-medium text-sm"> + {DEPARTMENT_CODE_LABELS[evaluationInfo.departmentCode] || evaluationInfo.departmentCode} + </div> + </div> + </div> + + {/* 📎 첨부파일 통계 정보 */} + {formData.attachmentStats && formData.attachmentStats.totalFiles > 0 && ( + <div className="border-t pt-4"> + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-2"> + <Paperclip className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">첨부파일 현황</span> + </div> + <div className="flex items-center gap-4"> + <div className="text-muted-foreground"> + 총 <span className="font-medium text-foreground">{formData.attachmentStats.totalFiles}</span>개 파일 + </div> + <div className="text-muted-foreground"> + 크기: <span className="font-medium text-foreground">{formatFileSize(formData.attachmentStats.totalSize)}</span> + </div> + <div className="text-muted-foreground"> + 첨부 질문: <span className="font-medium text-foreground">{formData.attachmentStats.questionsWithAttachments}</span>개 + </div> + </div> + </div> + </div> + )} + </CardContent> </Card> {/* 평가 테이블 - 카테고리별 */} @@ -370,12 +584,14 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { <TableHead className="w-[200px]">답변 선택</TableHead> <TableHead className="w-[80px]">점수</TableHead> <TableHead className="w-[250px]">추가 의견</TableHead> + <TableHead className="w-[200px]">첨부파일</TableHead> <TableHead className="w-[80px]">상태</TableHead> </TableRow> </TableHeader> <TableBody> {categoryQuestions.map((question) => { const response = responses[question.criteriaId] + const questionAttachments = attachments[question.criteriaId] || [] const isVariable = question.scoreType === 'variable' const isAnswered = isVariable ? @@ -480,6 +696,80 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { </TableCell> <TableCell> + <div className="space-y-2"> + {/* 파일 업로드 버튼 */} + <div className="flex items-center gap-2"> + <input + type="file" + id={`file-${question.criteriaId}`} + + className="hidden" + accept=".pdf,.doc,.docx,.hwp,.xls,.xlsx,.jpg,.jpeg,.png,.gif" + onChange={(e) => { + const file = e.target.files?.[0] + if (file && isAnswered) handleFileUpload(question.criteriaId, file) + }} + disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)} + /> + <Button + asChild /* shadcn/ui -> 내부에 <button> 대신 원하는 태그로 감싸 줌 */ + variant="outline" + size="sm" + disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)} + className="flex items-center gap-1" + > + <label htmlFor={`file-${question.criteriaId}`} className="cursor-pointer"> + <Upload className="h-3 w-3" /> + {uploadingFiles.has(question.criteriaId) ? "업로드 중..." : "파일 첨부"} + </label> + </Button> + </div> + + {/* 첨부된 파일 목록 - 개선된 버전 */} + {questionAttachments.length > 0 && ( + <div className="space-y-1"> + {questionAttachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + return ( + <div key={attachment.id} className="flex items-center justify-between p-2 bg-muted rounded-md"> + <div className="flex items-center gap-2 flex-1"> + <span className="text-sm">{fileInfo.icon}</span> + <div className="flex-1 min-w-0"> + <div className="text-xs font-medium truncate"> + {attachment.originalFileName} + </div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(attachment.fileSize)} + </div> + </div> + </div> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => handleDownloadAttachment(attachment)} + className="h-6 w-6 p-0" + > + <Download className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(question.criteriaId, attachment.id)} + className="h-6 w-6 p-0 text-destructive hover:text-destructive" + > + <X className="h-3 w-3" /> + </Button> + </div> + </div> + ) + })} + </div> + )} + </div> + </TableCell> + + <TableCell> {isAnswered ? ( <Badge variant="default" className="text-xs"> 완료 @@ -503,54 +793,51 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { {/* 하단 액션 버튼 */} <div className="sticky bottom-0 bg-background border-t p-4"> <div className="flex items-center justify-between max-w-7xl mx-auto"> - {!evaluationInfo.isCompleted && ( - <> - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - - {hasUnsavedChanges && ( - <div className="flex items-center gap-1"> - <AlertCircle className="h-4 w-4 text-amber-500" /> - <span>저장되지 않은 변경사항이 있습니다</span> + {!evaluationInfo.isCompleted && ( + <> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + {hasUnsavedChanges && ( + <div className="flex items-center gap-1"> + <AlertCircle className="h-4 w-4 text-amber-500" /> + <span>저장되지 않은 변경사항이 있습니다</span> + </div> + )} + <div className="flex items-center gap-1"> + <FileText className="h-4 w-4" /> + <span>진행률: {completedCount}/{totalCount}</span> + </div> </div> - )} - <div className="flex items-center gap-1"> - <FileText className="h-4 w-4" /> - <span>진행률: {completedCount}/{totalCount}</span> - </div> - </div> - <div className="flex items-center gap-2"> - <Button - variant="outline" - onClick={() => router.back()} - disabled={isLoading || isSaving} - > - 취소 - </Button> - - <Button - variant="secondary" - onClick={handleSave} - disabled={isLoading || isSaving || !hasUnsavedChanges} - className="flex items-center gap-2" - > - <Save className="h-4 w-4" /> - {isSaving ? "저장 중..." : "임시저장"} - </Button> - - - <Button - onClick={handleCompleteClick} - disabled={isLoading || isSaving || !allCompleted} - className="flex items-center gap-2" - > - <Send className="h-4 w-4" /> - 평가 완료 - </Button> - - </div> - </> - )} + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => router.back()} + disabled={isLoading || isSaving} + > + 취소 + </Button> + + <Button + variant="secondary" + onClick={handleSave} + disabled={isLoading || isSaving || !hasUnsavedChanges} + className="flex items-center gap-2" + > + <Save className="h-4 w-4" /> + {isSaving ? "저장 중..." : "임시저장"} + </Button> + + <Button + onClick={handleCompleteClick} + disabled={isLoading || isSaving || !allCompleted} + className="flex items-center gap-2" + > + <Send className="h-4 w-4" /> + 평가 완료 + </Button> + </div> + </> + )} </div> </div> diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts index 84d356e7..99c5cb5e 100644 --- a/lib/evaluation-submit/service.ts +++ b/lib/evaluation-submit/service.ts @@ -12,11 +12,14 @@ import { evaluationTargetReviewers, evaluationTargets, regEvalCriteria, - periodicEvaluations + periodicEvaluations, + reviewerEvaluationAttachments, + users } from "@/db/schema"; import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; -import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, EvaluationQuestionItem, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; +import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; +import { AttachmentInfo, EvaluationQuestionItem } from "@/types/evaluation-form"; // =============================================================================== @@ -128,7 +131,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType); // 2. 부서에 따른 카테고리 필터링 로직 - // const categoryFilter = getCategoryFilterByDepartment("admin"); const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode); // 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회 @@ -179,15 +181,90 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi updatedAt: reviewerEvaluationDetails.updatedAt, }) .from(reviewerEvaluationDetails) - .where( - and( - eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), - // ✅ null이 아닌 실제 응답만 조회 - isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId) - ) - ); + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)); + + // 📎 5. 첨부파일 정보 조회 + 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, + attachmentUpdatedAt: reviewerEvaluationAttachments.updatedAt, + + // 업로드한 사용자 정보 + uploadedByName: users.name, + + // 평가 세부사항 정보 (어떤 질문에 대한 첨부파일인지 확인) + evaluationDetailId: reviewerEvaluationDetails.id, + regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + + // 평가 기준 정보 (질문 식별용) + criteriaId: regEvalCriteriaDetails.criteriaId, + category: regEvalCriteria.category, + }) + .from(reviewerEvaluationAttachments) + .innerJoin( + reviewerEvaluationDetails, + eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id) + ) + .leftJoin( + regEvalCriteriaDetails, + eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id) + ) + .leftJoin( + regEvalCriteria, + eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id) + ) + .leftJoin( + users, + eq(reviewerEvaluationAttachments.uploadedBy, users.id) + ) + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)) + .orderBy(desc(reviewerEvaluationAttachments.createdAt)); + + // 📎 6. 첨부파일을 질문별로 그룹화 + const attachmentsByQuestion = new Map<number, AttachmentInfo[]>(); + const attachmentsByCategory = new Map<string, number>(); + + attachmentsData.forEach(attachment => { + // Variable 타입 질문의 경우 criteriaId가 null일 수 있으므로 처리 + const questionKey = attachment.criteriaId || attachment.evaluationDetailId; + + if (!attachmentsByQuestion.has(questionKey)) { + attachmentsByQuestion.set(questionKey, []); + } + + const attachmentInfo: AttachmentInfo = { + 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), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }; + + attachmentsByQuestion.get(questionKey)!.push(attachmentInfo); + + // 카테고리별 파일 수 집계 + const category = attachment.category || '기타'; + attachmentsByCategory.set(category, (attachmentsByCategory.get(category) || 0) + 1); + }); - // 5. 질문별로 그룹화하고 답변 옵션들 정리 + // 7. 질문별로 그룹화하고 답변 옵션들 정리 const questionsMap = new Map<number, EvaluationQuestionItem>(); criteriaWithDetails.forEach(record => { @@ -201,6 +278,8 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi // 질문이 이미 존재하는지 확인 if (!questionsMap.has(criteriaId)) { + const questionAttachments = attachmentsByQuestion.get(criteriaId) || []; + questionsMap.set(criteriaId, { criteriaId: record.criteriaId, category: record.category, @@ -215,6 +294,11 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음) currentScore: null, currentComment: null, + + // 📎 첨부파일 정보 추가 + attachments: questionAttachments, + attachmentCount: questionAttachments.length, + attachmentTotalSize: questionAttachments.reduce((sum, att) => sum + att.fileSize, 0), }); } @@ -228,14 +312,45 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi }); }); - // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성 + // 8. ✅ Variable 타입 질문 처리 (첨부파일은 있지만 criteriaId가 null인 경우) + const variableTypeAttachments = new Map<number, AttachmentInfo[]>(); + attachmentsData.forEach(attachment => { + if (!attachment.criteriaId && attachment.regEvalCriteriaDetailsId === null) { + // Variable 타입 질문의 첨부파일 + if (!variableTypeAttachments.has(attachment.evaluationDetailId)) { + variableTypeAttachments.set(attachment.evaluationDetailId, []); + } + variableTypeAttachments.get(attachment.evaluationDetailId)!.push({ + 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), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }); + } + }); - // 7. 기존 응답 데이터를 질문에 매핑 - const existingResponsesMap = new Map( - existingResponses.map(r => [r.regEvalCriteriaDetailsId, r]) - ); + // 9. 기존 응답 데이터를 질문에 매핑 + const existingResponsesMap = new Map<number | null, typeof existingResponses[0]>(); + const responseDetailMap = new Map<number, typeof existingResponses[0]>(); - // 8. 각 질문에 현재 응답 정보 매핑 + existingResponses.forEach(response => { + if (response.regEvalCriteriaDetailsId) { + existingResponsesMap.set(response.regEvalCriteriaDetailsId, response); + } else { + // Variable 타입 응답 (regEvalCriteriaDetailsId가 null) + responseDetailMap.set(response.id, response); + } + }); + + // 10. 각 질문에 현재 응답 정보 매핑 const questions: EvaluationQuestionItem[] = []; questionsMap.forEach(question => { // 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만) @@ -251,7 +366,7 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi if (selectedResponse) { question.responseId = selectedResponse.id; - question.currentScore = selectedResponse.score; + question.currentScore = selectedResponse.score ? Number(selectedResponse.score) : null; question.currentComment = selectedResponse.comment; } // ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null) @@ -259,12 +374,21 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi questions.push(question); }); + // 📎 11. 전체 첨부파일 통계 계산 + const attachmentStats = { + totalFiles: attachmentsData.length, + totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0), + questionsWithAttachments: attachmentsByQuestion.size + variableTypeAttachments.size, + filesByCategory: Object.fromEntries(attachmentsByCategory), + }; + return { evaluationInfo: { ...evaluation, reviewerType }, questions, + attachmentStats, }; } catch (err) { @@ -274,7 +398,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi } - /** * 평가 제출 목록을 조회합니다 */ @@ -395,22 +518,33 @@ export async function getSHIEvaluationSubmissionById(id: number) { /** * 평가 응답을 업데이트합니다 */ +// 기존 updateEvaluationResponse 함수를 확장하여 첨부파일 처리 지원 + export async function updateEvaluationResponse( reviewerEvaluationId: number, selectedDetailId: number, - comment?: string + comment?: string, + customScore?: number ) { try { - await db.transaction(async (tx) => { - // 1. 선택된 답변 옵션의 정보 조회 - const selectedDetail = await tx - .select() - .from(regEvalCriteriaDetails) - .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) - .limit(1); + let reviewerEvaluationDetailId: number | null = null; - if (selectedDetail.length === 0) { - throw new Error('Selected detail not found'); + await db.transaction(async (tx) => { + // 1. 선택된 답변 옵션의 정보 조회 (variable 타입이 아닌 경우) + let selectedDetail = null; + let score: number; + + if (selectedDetailId !== -1) { + const detailResult = await tx + .select() + .from(regEvalCriteriaDetails) + .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) + .limit(1); + + if (detailResult.length === 0) { + throw new Error('Selected detail not found'); + } + selectedDetail = detailResult[0]; } // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함) @@ -447,20 +581,37 @@ export async function updateEvaluationResponse( .where(eq(periodicEvaluations.id, periodicEvaluationId)); } - // 4. 리뷰어 타입 정보 조회 - const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); - if (!evaluationInfo) { - throw new Error('Evaluation not found'); + // 4. 점수 결정 + if (selectedDetailId === -1) { + // variable 타입인 경우 customScore 사용 + if (customScore === undefined) { + throw new Error('Custom score is required for variable type'); + } + score = customScore; + } else { + // 일반 타입인 경우 리뷰어 타입에 맞는 점수 가져오기 + const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); + if (!evaluationInfo) { + throw new Error('Evaluation not found'); + } + + const calculatedScore = getScoreByReviewerType(selectedDetail!, evaluationInfo.evaluationInfo.reviewerType); + if (calculatedScore === null) { + throw new Error('Score not found for this reviewer type'); + } + score = calculatedScore; } - // 5. 해당 리뷰어 타입에 맞는 점수 가져오기 - const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType); - if (score === null) { - throw new Error('Score not found for this reviewer type'); + // 5. 해당 평가 기준에 대한 기존 응답들 삭제 + let criteriaId: number; + if (selectedDetailId === -1) { + // variable 타입인 경우, criteriaId를 별도로 조회해야 함 + // 이 부분은 실제 데이터 구조에 따라 조정 필요 + throw new Error('Variable type criteria ID lookup not implemented'); + } else { + criteriaId = selectedDetail!.criteriaId; } - // 6. 같은 질문에 대한 기존 응답들 삭제 - const criteriaId = selectedDetail[0].criteriaId; await tx .delete(reviewerEvaluationDetails) .where( @@ -472,27 +623,173 @@ export async function updateEvaluationResponse( ) ); - // 7. 새로운 응답 생성 - await tx + // 6. 새로운 응답 생성 + const [newDetail] = await tx .insert(reviewerEvaluationDetails) .values({ reviewerEvaluationId, - regEvalCriteriaDetailsId: selectedDetailId, + regEvalCriteriaDetailsId: selectedDetailId === -1 ? null : selectedDetailId, score: score.toString(), comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, }); - // 8. 카테고리별 점수 계산 및 총점 업데이트 + reviewerEvaluationDetailId = newDetail.id; + + // 7. 카테고리별 점수 계산 및 총점 업데이트 await recalculateEvaluationScores(tx, reviewerEvaluationId); }); - return { success: true }; + return { + success: true, + reviewerEvaluationDetailId + }; } catch (err) { console.error('Error in updateEvaluationResponse:', err); throw err; } } +// variable 타입을 위한 별도 함수 +export async function updateVariableEvaluationResponse( + reviewerEvaluationId: number, + criteriaId: number, + score: number, + comment?: string +) { + try { + let reviewerEvaluationDetailId: number | null = null; + + await db.transaction(async (tx) => { + // 1. reviewerEvaluation 정보 조회 + const reviewerEvaluationInfo = await tx + .select({ + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + }) + .from(reviewerEvaluations) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)) + .limit(1); + + if (reviewerEvaluationInfo.length === 0) { + throw new Error('Reviewer evaluation not found'); + } + + const { periodicEvaluationId } = reviewerEvaluationInfo[0]; + + // 2. 상태 업데이트 + const currentStatus = await tx + .select({ + status: periodicEvaluations.status, + }) + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .limit(1); + + if (currentStatus.length > 0 && currentStatus[0].status !== "IN_REVIEW") { + await tx + .update(periodicEvaluations) + .set({ + status: "IN_REVIEW", + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, periodicEvaluationId)); + } + + // 3. 해당 평가 기준에 대한 기존 응답들 삭제 + await tx + .delete(reviewerEvaluationDetails) + .where( + and( + eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), + sql`${reviewerEvaluationDetails.regEvalCriteriaDetailsId} IN ( + SELECT id FROM reg_eval_criteria_details WHERE criteria_id = ${criteriaId} + )` + ) + ); + + // 4. 새로운 응답 생성 (variable 타입은 regEvalCriteriaDetailsId가 null) + const [newDetail] = await tx + .insert(reviewerEvaluationDetails) + .values({ + reviewerEvaluationId, + regEvalCriteriaDetailsId: null, // variable 타입은 null + score: score.toString(), + comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, + }); + + reviewerEvaluationDetailId = newDetail.id; + + // 5. 점수 재계산 + await recalculateEvaluationScores(tx, reviewerEvaluationId); + }); + + return { + success: true, + reviewerEvaluationDetailId + }; + } catch (err) { + console.error('Error in updateVariableEvaluationResponse:', err); + throw err; + } +} + +// 첨부파일과 함께 평가 응답 업데이트 +// export async function updateEvaluationResponseWithAttachment( +// reviewerEvaluationId: number, +// selectedDetailId: number, +// comment?: string, +// customScore?: number, +// attachmentFile?: File, +// attachmentDescription?: string +// ) { +// try { +// // 1. 먼저 평가 응답 업데이트 +// const updateResult = selectedDetailId === -1 && customScore !== undefined ? +// await updateVariableEvaluationResponse(reviewerEvaluationId, /* criteriaId 필요 */, customScore, comment) : +// await updateEvaluationResponse(reviewerEvaluationId, selectedDetailId, comment, customScore); + +// if (!updateResult.success || !updateResult.reviewerEvaluationDetailId) { +// throw new Error('Failed to update evaluation response'); +// } + +// // 2. 첨부파일이 있으면 저장 +// if (attachmentFile) { +// const fileResult = await saveFile({ +// file: attachmentFile, +// directory: "evaluation-attachments", +// originalName: attachmentFile.name, +// }); + +// if (!fileResult.success) { +// throw new Error(fileResult.error || "파일 저장에 실패했습니다."); +// } + +// // 3. DB에 첨부파일 정보 저장 +// await db.insert(reviewerEvaluationAttachments).values({ +// reviewerEvaluationDetailId: updateResult.reviewerEvaluationDetailId, +// originalFileName: attachmentFile.name, +// storedFileName: fileResult.fileName!, +// filePath: fileResult.filePath!, +// publicPath: fileResult.publicPath!, +// fileSize: attachmentFile.size, +// mimeType: attachmentFile.type, +// fileExtension: attachmentFile.name.split('.').pop()?.toLowerCase() || '', +// description: attachmentDescription || null, +// uploadedBy: /* session.user.id 필요 */, +// }); +// } + +// return { success: true }; +// } catch (err) { +// console.error('Error in updateEvaluationResponseWithAttachment:', err); +// throw err; +// } +// } /** * 평가 점수 재계산 |
