"use client" import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { CalendarIcon, Loader, X, Download, AlertTriangle } from "lucide-react" import { format } from "date-fns" import { toast } from "sonner" import { updateVendorInvestigationResultAction } from "../service" import { Button } from "@/components/ui/button" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from "@/components/ui/dropzone" import { FileList, FileListAction, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, FileListSize, } from "@/components/ui/file-list" import { updateVendorInvestigationResultSchema, type UpdateVendorInvestigationResultSchema, } from "../validations" import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment, createVendorInvestigationAttachmentAction } from "../service" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" import prettyBytes from "pretty-bytes" import { downloadFile } from "@/lib/file-download" import { Dialog as SystemDialog, DialogContent as SystemDialogContent, DialogHeader as SystemDialogHeader, DialogTitle as SystemDialogTitle, DialogFooter as SystemDialogFooter } from "@/components/ui/dialog" interface InvestigationResultSheetProps extends React.ComponentPropsWithoutRef { investigation: VendorInvestigationsViewWithContacts | null } // 첨부파일 정책 정의 const getFileUploadConfig = (status: string) => { // 취소된 상태에서만 파일 업로드 비활성화 if (status === "CANCELED") { return { enabled: false, label: "", description: "", accept: undefined, maxSize: 0, maxSizeText: "" } } // 모든 활성 상태에서 동일한 정책 적용 return { enabled: true, label: "실사 관련 첨부파일", description: "실사와 관련된 모든 문서와 이미지를 첨부할 수 있습니다.", accept: { 'application/pdf': ['.pdf'], 'application/msword': ['.doc'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], 'application/vnd.ms-excel': ['.xls'], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], }, maxSize: 10 * 1024 * 1024, // 10MB maxSizeText: "10MB" } } /** * 실사 결과 입력 시트 */ export function InvestigationResultSheet({ investigation, ...props }: InvestigationResultSheetProps) { const [isPending, startTransition] = React.useTransition() const [existingAttachments, setExistingAttachments] = React.useState([]) const [loadingAttachments, setLoadingAttachments] = React.useState(false) const [uploadingFiles, setUploadingFiles] = React.useState(false) // 불합격 안내 팝업 상태 const [showRejectedDialog, setShowRejectedDialog] = React.useState(false) // 보완 세부 항목 (재실사/자료제출) const [supplementType, setSupplementType] = React.useState("") // RHF + Zod const form = useForm({ resolver: zodResolver(updateVendorInvestigationResultSchema), defaultValues: { investigationId: investigation?.investigationId ?? 0, completedAt: investigation?.completedAt ?? undefined, requestedAt: investigation?.requestedAt ?? undefined, // 날짜 검증을 위해 추가 evaluationScore: investigation?.evaluationScore ?? undefined, evaluationResult: investigation?.evaluationResult ?? undefined, investigationNotes: investigation?.investigationNotes ?? "", attachments: [], }, }) // 평가점수 변화 → 자동 평가 & 보완 타입 초기화 React.useEffect(() => { const score = form.watch("evaluationScore") let nextResult: string | undefined = undefined if (typeof score === "number") { if (score >= 80) nextResult = "APPROVED" else if (score >= 70) { // 70~79점일 때는 보완방법 선택을 기다리므로 바로 설정하지 않음 nextResult = undefined setSupplementType("") } else if (score < 70) nextResult = "REJECTED" } if (nextResult) { form.setValue("evaluationResult", nextResult as any) } else if (score >= 70 && score < 80) { // 70~79점 범위에서는 보완방법 선택이 필요하다는 표시 form.setValue("evaluationResult", "SUPPLEMENT" as any) } }, [form.watch("evaluationScore")]) // 보완방법 선택 변화 → 평가결과 변경 React.useEffect(() => { // 70~79점 범위에서만 보완방법 선택에 따라 결과 변경 const score = form.watch("evaluationScore") if (typeof score === "number" && score >= 70 && score < 80) { if (supplementType === "REINSPECT") form.setValue("evaluationResult", "SUPPLEMENT_REINSPECT" as any) else if (supplementType === "DOCUMENT") form.setValue("evaluationResult", "SUPPLEMENT_DOCUMENT" as any) } }, [supplementType, form.watch("evaluationScore")]) // investigation이 변경될 때마다 폼 리셋 React.useEffect(() => { if (investigation) { form.reset({ investigationId: investigation.investigationId, completedAt: investigation.completedAt ?? undefined, requestedAt: investigation.requestedAt ?? undefined, // 날짜 검증을 위해 추가 evaluationScore: investigation.evaluationScore ?? undefined, evaluationResult: investigation.evaluationResult ?? undefined, investigationNotes: investigation.investigationNotes ?? "", attachments: [], }) // 기존 첨부파일 로드 loadExistingAttachments(investigation.investigationId) } }, [investigation, form]) // 기존 첨부파일 로드 함수 const loadExistingAttachments = async (investigationId: number) => { setLoadingAttachments(true) try { const result = await getInvestigationAttachments(investigationId) if (result.success) { setExistingAttachments(result.attachments || []) } else { toast.error("첨부파일 목록을 불러오는데 실패했습니다.") } } catch (error) { console.error("첨부파일 로드 실패:", error) toast.error("첨부파일 목록을 불러오는 중 오류가 발생했습니다.") } finally { setLoadingAttachments(false) } } // 첨부파일 삭제 함수 const handleDeleteAttachment = async (attachmentId: number) => { if (!investigation) return try { await deleteInvestigationAttachment(attachmentId) toast.success("첨부파일이 삭제되었습니다.") // 목록 새로고침 loadExistingAttachments(investigation.investigationId) } catch (error) { console.error("첨부파일 삭제 오류:", error) toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.") } } // 첨부파일 다운로드 함수 const handleDownloadAttachment = async (attachment: any) => { if (!attachment.filePath || !attachment.fileName) { toast.error("첨부파일 정보가 올바르지 않습니다.") return } try { await downloadFile(attachment.filePath, attachment.fileName, { showToast: true, action: 'download' }) } catch (error) { console.error("첨부파일 다운로드 오류:", error) toast.error("첨부파일 다운로드 중 오류가 발생했습니다.") } } // 선택된 파일에서 특정 파일 제거 const handleRemoveSelectedFile = (indexToRemove: number) => { const currentFiles = (form.getValues("attachments") as File[]) || [] const updatedFiles = currentFiles.filter((_: File, index: number) => index !== indexToRemove) form.setValue("attachments", updatedFiles as any) if (updatedFiles.length === 0) { toast.success("모든 선택된 파일이 제거되었습니다.") } else { toast.success("파일이 제거되었습니다.") } } // 파일 업로드 섹션 렌더링 const renderFileUploadSection = () => { const currentStatus = form.watch("evaluationResult") as string | undefined const selectedFiles = form.watch("attachments") as File[] | undefined const config = getFileUploadConfig(currentStatus ?? "") if (!config.enabled) return null return ( <> {/* 기존 첨부파일 목록 */} {(existingAttachments.length > 0 || loadingAttachments) && (
기존 첨부파일
{loadingAttachments ? (
첨부파일 로딩 중...
) : existingAttachments.length > 0 ? ( existingAttachments.map((attachment) => (
{attachment.attachmentType} {attachment.fileName} ({Math.round(attachment.fileSize / 1024)}KB)
)) ) : (
첨부된 파일이 없습니다.
)}
)} {/* 새 파일 업로드 */} ( { // 거부된 파일에 대한 상세 에러 메시지 if (rejectedFiles.length > 0) { rejectedFiles.forEach((file) => { const error = file.errors[0] if (error.code === 'file-too-large') { toast.error(`${file.file.name}: 파일 크기가 ${config.maxSizeText}를 초과합니다.`) } else if (error.code === 'file-invalid-type') { toast.error(`${file.file.name}: 지원하지 않는 파일 형식입니다.`) } else { toast.error(`${file.file.name}: 파일 업로드에 실패했습니다.`) } }) } if (acceptedFiles.length > 0) { // 기존 파일들과 새로 선택된 파일들을 합치기 const currentFiles = (form.getValues("attachments") as File[]) || [] const newFiles = [...currentFiles, ...acceptedFiles] onChange(newFiles) toast.success(`${acceptedFiles.length}개 파일이 추가되었습니다.`) } }} accept={config.accept} multiple maxSize={config.maxSize} disabled={isPending || uploadingFiles} > {isPending || uploadingFiles ? "파일 업로드 중..." : "파일을 드래그하거나 클릭하여 업로드" } {config.description} (최대 {config.maxSizeText}) )} /> {/* 선택된 파일 목록 */} {selectedFiles && selectedFiles.length > 0 && (
{/* 선택된 파일 ({selectedFiles.length}개) */} 업로드 예정 파일 ({selectedFiles.length}개) {selectedFiles.map((file, index) => ( {/* 왼쪽 아이콘 */} {/* 가운데 이름 + 사이즈 */} {file.name} {file.size} {/* 오른쪽 삭제 버튼 */} ))}
)} ) } // 파일 업로드 함수 const uploadFiles = async (files: File[], investigationId: number) => { const uploadPromises = files.map(async (file) => { try { // 서버 액션을 호출하여 파일 저장 및 DB 레코드 생성 const result = await createVendorInvestigationAttachmentAction({ investigationId, file, userId: undefined // 필요시 사용자 ID 추가 }); if (!result.success) { throw new Error(result.error || "파일 업로드 실패"); } return result.attachment; } catch (error) { console.error(`파일 업로드 실패: ${file.name}`, error); throw error; } }); return await Promise.all(uploadPromises); } // Submit handler async function onSubmit(values: UpdateVendorInvestigationResultSchema) { console.log("실사 결과 입력 onSubmit 호출됨:", values) if (!values.investigationId) { console.log("investigationId가 없음:", values.investigationId) return } startTransition(async () => { try { console.log("실사 결과 입력 startTransition 시작") // 1) 먼저 텍스트 데이터 업데이트 const formData = new FormData() // 필수 필드 formData.append("investigationId", String(values.investigationId)) // 선택적 필드들 if (values.completedAt) { formData.append("completedAt", values.completedAt.toISOString()) } if (values.evaluationScore !== undefined) { formData.append("evaluationScore", String(values.evaluationScore)) } if (values.evaluationResult) { formData.append("evaluationResult", values.evaluationResult) } if (values.investigationNotes) { formData.append("investigationNotes", values.investigationNotes) } // 텍스트 데이터 업데이트 (IN_PROGRESS -> COMPLETED/CANCELED/SUPPLEMENT_REQUIRED) const { error } = await updateVendorInvestigationResultAction(formData) if (error) { toast.error(error) return } // 보완-서류제출 선택 시 메일 발송 if (values.evaluationResult === "SUPPLEMENT_DOCUMENT") { try { const { requestInvestigationSupplementAction } = await import('../service') const mailResult = await requestInvestigationSupplementAction({ investigationId: values.investigationId, vendorId: investigation?.vendorId || 0, comment: values.investigationNotes || "실사 보완이 필요합니다. 첨부된 내용을 확인하시고 필요한 자료를 제출해 주시기 바랍니다." }) if (!mailResult.success) { console.warn("보완 메일 발송 실패:", mailResult.error) toast.warning("실사 결과는 저장되었지만 보완 메일 발송에 실패했습니다.") } } catch (mailError) { console.warn("보완 메일 발송 중 오류:", mailError) toast.warning("실사 결과는 저장되었지만 보완 메일 발송에 실패했습니다.") } } // 2) 첨부파일 검증 (필수: 기존 첨부파일 + 새 파일 합계 최소 1개) const newFilesCount = values.attachments?.length || 0 const existingFilesCount = existingAttachments.length const totalFilesCount = newFilesCount + existingFilesCount if (totalFilesCount === 0) { toast.error("최소 1개의 첨부파일이 필요합니다.") return } // 새 파일이 있는 경우에만 업로드 진행 if (newFilesCount > 0) { setUploadingFiles(true) try { await uploadFiles(values.attachments, values.investigationId) toast.success(`실사 결과와 ${newFilesCount}개 파일이 업데이트되었습니다!`) // 첨부파일 목록 새로고침 loadExistingAttachments(values.investigationId) } catch (fileError) { console.error("파일 업로드 에러:", fileError) toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`) } finally { setUploadingFiles(false) } } else { // 기존 첨부파일만 있는 경우 toast.success("실사 결과가 업데이트되었습니다!") } form.reset() props.onOpenChange?.(false) } catch (error) { console.error("실사 결과 업데이트 오류:", error) toast.error("실사 결과 업데이트 중 오류가 발생했습니다.") } }) } // 저장버튼 커스텀(불합격시: 팝업 → 확인하면 제출 / 아니면 중단) const handleSaveClick = async () => { const score = form.getValues("evaluationScore") const result = form.getValues("evaluationResult") if (result === "REJECTED" && !showRejectedDialog) { setShowRejectedDialog(true) return } const isValid = await form.trigger() if (isValid) form.handleSubmit(onSubmit)() } // 불합격 안내(확정) 처리 const handleRejectedConfirm = () => { setShowRejectedDialog(false) form.handleSubmit(onSubmit)() } return ( <> 실사 결과 입력 {investigation?.vendorName && ( {investigation.vendorName} )}의 실사 결과를 입력합니다.
{/* 실제 실사일 */} ( 실제 실사일* )} /> {/* 평가 점수 */} ( 평가 점수* { const inputValue = e.target.value // 빈 값이거나 숫자가 아닌 경우 if (inputValue === "") { field.onChange(undefined) return } // 3자리 초과 입력 방지 if (inputValue.length > 3) { return } const numericValue = parseInt(inputValue, 10) // 100 이상 입력 시 alert if (numericValue > 100) { toast.error("평가 점수는 100점을 초과할 수 없습니다.") return } field.onChange(numericValue) }} /> )} /> {/* 평가 결과 VIEW (자동) */}
평가 결과
{(() => { const result = form.watch("evaluationResult") if (result === "APPROVED") return 합격 (승인) if (result === "SUPPLEMENT") return 보완 필요 (방법 선택) if (result === "SUPPLEMENT_REINSPECT") return 보완 필요 - 재실사 if (result === "SUPPLEMENT_DOCUMENT") return (
보완 필요 - 자료제출 💡 저장 시 협력업체에 자동으로 보완 요청 메일이 발송됩니다.
) if (result === "REJECTED") return 불합격 return - })()}
{/* 보완 세부항목(70~79점) */} {(() => { const score = form.watch("evaluationScore") return typeof score === "number" && score >= 70 && score < 80 })() && (
보완 방법*
)} {/* QM 의견 */} ( QM 의견