summaryrefslogtreecommitdiff
path: root/lib/evaluation-submit/evaluation-form.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
commitc657ef972feeafff16ab0e07cb4771f7dd141ba0 (patch)
treebefabd884b00d3cc632c628b3e3810f61cc9f38d /lib/evaluation-submit/evaluation-form.tsx
parentb8a03c9d130435a71c5d6217d06ccb0beb9697e5 (diff)
(대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영
Diffstat (limited to 'lib/evaluation-submit/evaluation-form.tsx')
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx453
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>