"use client" import * as React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" import { FileIcon, SaveIcon, CheckIcon, XIcon, PlusIcon, TrashIcon } from "lucide-react" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { toast } from "sonner" import { Input } from "@/components/ui/input" import { getGeneralEvaluationFormData, saveGeneralEvaluationResponse, recalculateEvaluationProgress, // 진행률만 계산 GeneralEvaluationFormData, updateAttachmentStatus, } from "../service" import { EvaluationSubmissionWithVendor } from "../service" interface GeneralEvaluationFormSheetProps { open: boolean onOpenChange: (open: boolean) => void submission: EvaluationSubmissionWithVendor | null onSuccess: () => void } // 📝 간단한 폼 스키마 - 점수 필드 제거 const formSchema = z.object({ responses: z.array(z.object({ responseId: z.number(), responseText: z.string().min(1, "응답을 입력해주세요."), hasAttachments: z.boolean().default(false), })) }) type FormData = z.infer export function GeneralEvaluationFormSheet({ open, onOpenChange, submission, onSuccess, }: GeneralEvaluationFormSheetProps) { const [isLoading, setIsLoading] = React.useState(false) const [isSaving, setIsSaving] = React.useState(false) const [formData, setFormData] = React.useState(null) const [uploadedFiles, setUploadedFiles] = React.useState>({}) const fileInputRefs = React.useRef>({}) const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { responses: [] } }) // 데이터 로딩 React.useEffect(() => { if (open && submission?.id) { // 시트가 열릴 때마다 uploadedFiles 상태 초기화 setUploadedFiles({}) loadFormData() } }, [open, submission?.id]) const loadFormData = async () => { if (!submission?.id) return setIsLoading(true) try { const data = await getGeneralEvaluationFormData(submission.id) setFormData(data) // 📝 폼 초기값 설정 (점수 필드 제거) const responses = data.evaluations.map(item => ({ responseId: item.response?.id || 0, responseText: item.response?.responseText || '', hasAttachments: item.response?.hasAttachments || false, })) form.reset({ responses }) } catch (error) { console.error('Error loading form data:', error) toast.error('평가 데이터를 불러오는데 실패했습니다.') } finally { setIsLoading(false) } } // 개별 응답 저장 (파일 업로드 포함) const handleSaveResponse = async (index: number) => { if (!formData) return const responseData = form.getValues(`responses.${index}`) if (!responseData.responseId) return try { // 1. 새로 선택된 파일들이 있으면 먼저 업로드 const newFiles = uploadedFiles[responseData.responseId] || [] if (newFiles.length > 0) { const uploadFormData = new FormData() newFiles.forEach(file => { uploadFormData.append('files', file) }) uploadFormData.append('submissionId', submission?.id.toString() || '') uploadFormData.append('responseId', responseData.responseId.toString()) uploadFormData.append('uploadedBy', 'current-user') // 실제 사용자 정보로 교체 const uploadResponse = await fetch('/api/vendor-evaluation/upload-attachment', { method: 'POST', body: uploadFormData, }) if (!uploadResponse.ok) { const errorData = await uploadResponse.json() throw new Error(errorData.error || '파일 업로드에 실패했습니다.') } // 업로드 성공 시 UI 상태 초기화 setUploadedFiles(prev => ({ ...prev, [responseData.responseId]: [] })) } // 2. 응답 텍스트 저장 await saveGeneralEvaluationResponse({ responseId: responseData.responseId, responseText: responseData.responseText, hasAttachments: (uploadedFiles[responseData.responseId]?.length > 0) || formData.evaluations[index]?.attachments.length > 0, }) // 3. 진행률 재계산 if (submission?.id) { await recalculateEvaluationProgress(submission.id) } // 4. 폼 데이터 새로고침 (새로 업로드된 파일을 기존 파일 목록에 반영) await loadFormData() toast.success('응답이 저장되었습니다.') } catch (error) { console.error('Error saving response:', error) toast.error(error instanceof Error ? error.message : '응답 저장에 실패했습니다.') } } // 전체 저장 (파일 업로드 포함) const onSubmit = async (data: FormData) => { if (!formData) return setIsSaving(true) try { // 모든 응답을 순차적으로 저장 for (let i = 0; i < data.responses.length; i++) { const response = data.responses[i] if (response.responseId && response.responseText.trim()) { // 1. 새로 선택된 파일들이 있으면 먼저 업로드 const newFiles = uploadedFiles[response.responseId] || [] if (newFiles.length > 0) { const uploadFormData = new FormData() newFiles.forEach(file => { uploadFormData.append('files', file) }) uploadFormData.append('submissionId', submission?.id.toString() || '') uploadFormData.append('responseId', response.responseId.toString()) uploadFormData.append('uploadedBy', 'current-user') // 실제 사용자 정보로 교체 const uploadResponse = await fetch('/api/vendor-evaluation/upload-attachment', { method: 'POST', body: uploadFormData, }) if (!uploadResponse.ok) { const errorData = await uploadResponse.json() throw new Error(`파일 업로드 실패: ${errorData.error || '알 수 없는 오류'}`) } } // 2. 응답 텍스트 저장 await saveGeneralEvaluationResponse({ responseId: response.responseId, responseText: response.responseText, hasAttachments: (uploadedFiles[response.responseId]?.length > 0) || formData.evaluations[i]?.attachments.length > 0, }) } } // 모든 새 파일 상태 초기화 setUploadedFiles({}) // 진행률 재계산 if (submission?.id) { await recalculateEvaluationProgress(submission.id) } toast.success('모든 응답이 저장되었습니다.') onSuccess() } catch (error) { console.error('Error saving all responses:', error) toast.error(error instanceof Error ? error.message : '응답 저장에 실패했습니다.') } finally { setIsSaving(false) } } // 파일 업로드 핸들러 (UI만 업데이트) const handleFileUpload = (responseId: number, files: FileList | null) => { if (!files || files.length === 0) return const fileArray = Array.from(files) setUploadedFiles(prev => ({ ...prev, [responseId]: [...(prev[responseId] || []), ...fileArray] })) // hasAttachments 필드 업데이트 const responseIndex = formData?.evaluations.findIndex( item => item.response?.id === responseId ) ?? -1 if (responseIndex >= 0) { form.setValue(`responses.${responseIndex}.hasAttachments`, true) } // 파일 입력 초기화 (같은 파일 다시 선택 가능하도록) if (fileInputRefs.current[responseId]) { fileInputRefs.current[responseId]!.value = '' } } // 첨부파일 상태 업데이트 헬퍼 함수 const updateAttachmentStatusHelper = async (responseId: number) => { try { await updateAttachmentStatus(responseId) } catch (error) { console.error('Error updating attachment status:', error) } } // 파일 삭제 핸들러 (새로 업로드된 파일용) const handleFileRemove = (responseId: number, fileIndex: number) => { setUploadedFiles(prev => { const newFiles = [...(prev[responseId] || [])] newFiles.splice(fileIndex, 1) const responseIndex = formData?.evaluations.findIndex( item => item.response?.id === responseId ) ?? -1 if (responseIndex >= 0) { form.setValue(`responses.${responseIndex}.hasAttachments`, newFiles.length > 0) } return { ...prev, [responseId]: newFiles } }) } // 기존 첨부파일 삭제 핸들러 const handleExistingFileDelete = async (attachmentId: number, responseId: number) => { try { // 실제 구현에서는 deleteAttachment 서버 액션을 import해서 사용 // await deleteAttachment(attachmentId) // API 호출로 파일 삭제 const response = await fetch(`/api/delete-attachment/${attachmentId}`, { method: 'DELETE', }) if (!response.ok) { throw new Error('파일 삭제에 실패했습니다.') } toast.success('파일이 삭제되었습니다.') // 첨부파일 상태 업데이트 await updateAttachmentStatusHelper(responseId) // 폼 데이터 새로고침 loadFormData() } catch (error) { console.error('Error deleting file:', error) toast.error('파일 삭제에 실패했습니다.') } } // 📊 진행률 계산 (점수 계산 제거) const getProgress = () => { if (!formData) return { completed: 0, total: 0, percentage: 0, pendingFiles: 0 } const responses = form.getValues('responses') const completed = responses.filter(r => r.responseText.trim().length > 0).length const total = formData.evaluations.length const percentage = total > 0 ? Math.round((completed / total) * 100) : 0 // 대기 중인 파일 개수 계산 const pendingFiles = Object.values(uploadedFiles).reduce((sum, files) => sum + files.length, 0) return { completed, total, percentage, pendingFiles } } const progress = getProgress() // 응답 ID -> 전역 인덱스 매핑 (카테고리 그룹/정렬과 무관하게 안정적인 인덱스 사용) const responseIndexById = React.useMemo(() => { const map: Record = {} if (formData) { formData.evaluations.forEach((ev, idx) => { if (ev.response?.id) { map[ev.response.id] = idx } }) } return map }, [formData]) if (isLoading) { return (

평가 데이터를 불러오는 중...

) } return ( {/* 📌 고정 헤더 영역 */} 일반평가 작성 {formData?.submission.vendorName}의 일반평가를 작성해주세요. {formData && ( <> {/* 📊 고정 진행률 표시 */}
응답 진행률 {progress.completed}/{progress.total} 완료
= 50 ? 'bg-blue-500' : 'bg-yellow-500' }`} style={{ width: `${progress.percentage}%` }} />

{progress.percentage}% 완료

{/* 🔄 스크롤 가능한 중간 영역 */}
{Object.entries( formData.evaluations.reduce((groups, item) => { const category = item.evaluation.category || '기타' if (!groups[category]) { groups[category] = [] } groups[category].push(item) return groups }, {} as Record) ) .sort(([a], [b]) => a.localeCompare(b)) .map(([category, items]) => (

{category}

{[...items] .sort((a, b) => { const aNum = parseInt(String(a.evaluation.serialNumber).replace(/^\D+/g, '') || '0') const bNum = parseInt(String(b.evaluation.serialNumber).replace(/^\D+/g, '') || '0') return aNum - bNum }) .map((item, index) => (
{item.evaluation.serialNumber} {item.evaluation.category}

{item.evaluation.inspectionItem}

{item.evaluation.remarks && (

💡 {item.evaluation.remarks}

)}
{/* 📝 응답 텍스트만 (점수 입력 제거) */} {(() => { const gi = item.response?.id ? responseIndexById[item.response.id] : undefined if (gi === undefined) { return (
응답 내용 *