summaryrefslogtreecommitdiff
path: root/lib/evaluation-submit
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation-submit')
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx453
-rw-r--r--lib/evaluation-submit/service.ts385
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;
+// }
+// }
/**
* 평가 점수 재계산