diff options
Diffstat (limited to 'lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx')
| -rw-r--r-- | lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx | 609 |
1 files changed, 609 insertions, 0 deletions
diff --git a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx new file mode 100644 index 00000000..cc80e29c --- /dev/null +++ b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx @@ -0,0 +1,609 @@ +"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, +} 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<typeof formSchema> + +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<GeneralEvaluationFormData | null>(null) + const [uploadedFiles, setUploadedFiles] = React.useState<Record<number, File[]>>({}) + const fileInputRefs = React.useRef<Record<number, HTMLInputElement | null>>({}) + + const form = useForm<FormData>({ + 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() + + if (isLoading) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + <div className="flex items-center justify-center h-full"> + <div className="text-center space-y-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div> + <p>평가 데이터를 불러오는 중...</p> + </div> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + {/* 📌 고정 헤더 영역 */} + <SheetHeader className="flex-shrink-0 pb-4"> + <SheetTitle>일반평가 작성</SheetTitle> + <SheetDescription> + {formData?.submission.vendorName}의 일반평가를 작성해주세요. + </SheetDescription> + </SheetHeader> + + {formData && ( + <> + {/* 📊 고정 진행률 표시 */} + <div className="flex-shrink-0 p-4 bg-gray-50 rounded-lg mb-4"> + <div className="flex items-center justify-between mb-2"> + <span className="text-sm font-medium">응답 진행률</span> + <span className="text-sm text-muted-foreground"> + {progress.completed}/{progress.total} 완료 + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all duration-300 ${ + progress.percentage === 100 + ? 'bg-green-500' + : progress.percentage >= 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${progress.percentage}%` }} + /> + </div> + <p className="text-xs text-muted-foreground mt-1"> + {progress.percentage}% 완료 + </p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 🔄 스크롤 가능한 중간 영역 */} + <div className="flex-1 overflow-y-auto min-h-0"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-6"> + {formData.evaluations.map((item, index) => ( + <Card key={item.evaluation.id}> + <CardHeader> + <CardTitle className="text-base flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {item.evaluation.serialNumber} + </Badge> + <span className="text-sm font-medium"> + {item.evaluation.category} + </span> + </div> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => handleSaveResponse(index)} + disabled={!item.response?.id} + > + <SaveIcon className="h-4 w-4 mr-1" /> + 저장 + {item.response?.id && uploadedFiles[item.response.id]?.length > 0 && ( + <Badge variant="secondary" className="ml-2 text-xs"> + +{uploadedFiles[item.response.id].length} + </Badge> + )} + </Button> + </CardTitle> + <p className="text-sm text-muted-foreground"> + {item.evaluation.inspectionItem} + </p> + {item.evaluation.remarks && ( + <p className="text-xs text-blue-600 bg-blue-50 p-2 rounded"> + 💡 {item.evaluation.remarks} + </p> + )} + </CardHeader> + <CardContent className="space-y-4"> + {/* 📝 응답 텍스트만 (점수 입력 제거) */} + <FormField + control={form.control} + name={`responses.${index}.responseText`} + render={({ field }) => ( + <FormItem> + <FormLabel>응답 내용 *</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="평가 항목에 대한 응답을 상세히 작성해주세요..." + className="min-h-[120px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 📎 첨부파일 영역 */} + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <FormLabel>첨부파일</FormLabel> + <div> + <Input + ref={(el) => item.response?.id && (fileInputRefs.current[item.response.id] = el)} + type="file" + multiple + className="hidden" + onChange={(e) => + item.response?.id && + handleFileUpload(item.response.id, e.target.files) + } + /> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + if (item.response?.id && fileInputRefs.current[item.response.id]) { + fileInputRefs.current[item.response.id]?.click() + } + }} + > + <PlusIcon className="h-4 w-4 mr-1" /> + 파일 추가 + </Button> + </div> + </div> + + {/* 기존 첨부파일 목록 */} + {item.attachments.length > 0 && ( + <div className="space-y-2"> + <p className="text-xs text-muted-foreground">기존 파일 (저장됨):</p> + {item.attachments.map((file) => ( + <div + key={file.id} + className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm" + > + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1"> + <FileIcon className="h-4 w-4 text-gray-600" /> + <span className="text-xs text-green-600">✓</span> + </div> + <span>{file.originalFileName}</span> + <Badge variant="secondary" className="text-xs"> + {(file.fileSize / 1024).toFixed(1)}KB + </Badge> + <Badge variant="outline" className="text-xs text-green-600 border-green-300"> + 저장됨 + </Badge> + </div> + <Button + type="button" + variant="ghost" + size="sm" + className="text-red-600 hover:text-red-800 hover:bg-red-50" + onClick={() => + item.response?.id && + handleExistingFileDelete(file.id, item.response.id) + } + > + <TrashIcon className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + + {/* 새로 업로드된 파일 목록 */} + {item.response?.id && uploadedFiles[item.response.id]?.length > 0 && ( + <div className="space-y-2"> + <p className="text-xs text-muted-foreground">새 파일 (저장 시 업로드됨):</p> + {uploadedFiles[item.response.id].map((file, fileIndex) => ( + <div + key={fileIndex} + className="flex items-center justify-between p-2 bg-blue-50 border border-blue-200 rounded text-sm" + > + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1"> + <FileIcon className="h-4 w-4 text-blue-600" /> + <span className="text-xs text-blue-600">📎</span> + </div> + <span className="text-blue-800">{file.name}</span> + <Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700"> + {(file.size / 1024).toFixed(1)}KB + </Badge> + <Badge variant="outline" className="text-xs text-blue-600 border-blue-300"> + 대기중 + </Badge> + </div> + <Button + type="button" + variant="ghost" + size="sm" + className="text-blue-600 hover:text-blue-800 hover:bg-blue-100" + onClick={() => + item.response?.id && + handleFileRemove(item.response.id, fileIndex) + } + > + <TrashIcon className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + </div> + </CardContent> + </Card> + ))} + </div> + </ScrollArea> + </div> + + <Separator className="my-4" /> + + {/* 📌 고정 하단 버튼 영역 */} + <div className="flex-shrink-0 flex items-center justify-between pt-4"> + <div className="text-sm text-muted-foreground"> + {progress.percentage === 100 ? ( + <div className="flex items-center gap-2 text-green-600"> + <CheckIcon className="h-4 w-4" /> + 모든 항목이 완료되었습니다 + </div> + ) : ( + <div className="flex items-center gap-2"> + <XIcon className="h-4 w-4" /> + {formData.evaluations.length - progress.completed}개 항목이 미완료입니다 + </div> + )} + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSaving || progress.completed === 0} + > + {isSaving ? "저장 중..." : "모두 저장"} + {progress.pendingFiles > 0 && ( + <Badge variant="secondary" className="ml-2 text-xs bg-blue-100 text-blue-700"> + 파일 {progress.pendingFiles}개 + </Badge> + )} + </Button> + </div> + </div> + </form> + </Form> + </> + )} + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
