diff options
Diffstat (limited to 'lib/evaluation-submit/evaluation-form.tsx')
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 453 |
1 files changed, 370 insertions, 83 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> |
