diff options
Diffstat (limited to 'lib/vendor-investigation/table/investigation-result-sheet.tsx')
| -rw-r--r-- | lib/vendor-investigation/table/investigation-result-sheet.tsx | 808 |
1 files changed, 808 insertions, 0 deletions
diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx new file mode 100644 index 00000000..b7577daa --- /dev/null +++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx @@ -0,0 +1,808 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { CalendarIcon, Loader, X, Download } 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" + +interface InvestigationResultSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { + 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<any[]>([]) + const [loadingAttachments, setLoadingAttachments] = React.useState(false) + const [uploadingFiles, setUploadingFiles] = React.useState(false) + + // RHF + Zod + const form = useForm<UpdateVendorInvestigationResultSchema>({ + resolver: zodResolver(updateVendorInvestigationResultSchema), + defaultValues: { + investigationId: investigation?.investigationId ?? 0, + completedAt: investigation?.completedAt ?? undefined, + evaluationScore: investigation?.evaluationScore ?? undefined, + evaluationResult: investigation?.evaluationResult ?? undefined, + investigationNotes: investigation?.investigationNotes ?? "", + attachments: undefined, + }, + }) + + // investigation이 변경될 때마다 폼 리셋 + React.useEffect(() => { + if (investigation) { + form.reset({ + investigationId: investigation.investigationId, + completedAt: investigation.completedAt ?? undefined, + evaluationScore: investigation.evaluationScore ?? undefined, + evaluationResult: investigation.evaluationResult ?? undefined, + investigationNotes: investigation.investigationNotes ?? "", + attachments: undefined, + }) + + // 기존 첨부파일 로드 + 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") || [] + const updatedFiles = currentFiles.filter((_: File, index: number) => index !== indexToRemove) + form.setValue("attachments", updatedFiles.length > 0 ? updatedFiles : undefined) + + if (updatedFiles.length === 0) { + toast.success("모든 선택된 파일이 제거되었습니다.") + } else { + toast.success("파일이 제거되었습니다.") + } + } + + // 파일 업로드 섹션 렌더링 + const renderFileUploadSection = () => { + const currentStatus = form.watch("investigationStatus") + const selectedFiles = form.watch("attachments") as File[] | undefined + const config = getFileUploadConfig(currentStatus) + + if (!config.enabled) return null + + return ( + <> + {/* 기존 첨부파일 목록 */} + {(existingAttachments.length > 0 || loadingAttachments) && ( + <div className="space-y-2"> + <FormLabel>기존 첨부파일</FormLabel> + <div className="border rounded-md p-3 space-y-2 max-h-32 overflow-y-auto"> + {loadingAttachments ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground"> + 첨부파일 로딩 중... + </span> + </div> + ) : existingAttachments.length > 0 ? ( + existingAttachments.map((attachment) => ( + <div key={attachment.id} className="flex items-center justify-between text-sm"> + <div className="flex items-center space-x-2 flex-1 min-w-0"> + <span className="text-xs px-2 py-1 bg-muted rounded"> + {attachment.attachmentType} + </span> + <span className="truncate">{attachment.fileName}</span> + <span className="text-muted-foreground"> + ({Math.round(attachment.fileSize / 1024)}KB) + </span> + </div> + <div className="flex items-center gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDownloadAttachment(attachment)} + className="text-blue-600 hover:text-blue-700" + disabled={isPending} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(attachment.id)} + className="text-destructive hover:text-destructive" + disabled={isPending} + title="파일 삭제" + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + )) + ) : ( + <div className="text-sm text-muted-foreground text-center py-2"> + 첨부된 파일이 없습니다. + </div> + )} + </div> + </div> + )} + + {/* 새 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field: { onChange, ...field } }) => ( + <FormItem> + <FormLabel>{config.label}</FormLabel> + <FormControl> + <Dropzone + onDrop={(acceptedFiles, rejectedFiles) => { + // 거부된 파일에 대한 상세 에러 메시지 + 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") || [] + const newFiles = [...currentFiles, ...acceptedFiles] + onChange(newFiles) + toast.success(`${acceptedFiles.length}개 파일이 추가되었습니다.`) + } + }} + accept={config.accept} + multiple + maxSize={config.maxSize} + disabled={isPending || uploadingFiles} + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle> + {isPending || uploadingFiles + ? "파일 업로드 중..." + : "파일을 드래그하거나 클릭하여 업로드" + } + </DropzoneTitle> + <DropzoneDescription> + {config.description} (최대 {config.maxSizeText}) + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles && selectedFiles.length > 0 && ( + <div className="space-y-2"> + {/* <FormLabel>선택된 파일 ({selectedFiles.length}개)</FormLabel> */} + <FileList> + <FileListHeader> + <span className="text-sm font-medium">업로드 예정 파일 ({selectedFiles.length}개)</span> + </FileListHeader> + {selectedFiles.map((file, index) => ( + <FileListItem + key={`${file.name}-${index}`} + className="flex items-center justify-between gap-2 px-2 py-2" + > + {/* 왼쪽 아이콘 */} + <FileListIcon className="shrink-0 h-4 w-4 text-muted-foreground" /> + + {/* 가운데 이름 + 사이즈 */} + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="truncate">{file.name}</FileListName> + <FileListSize className="text-xs text-muted-foreground shrink-0"> + {file.size} + </FileListSize> + </FileListInfo> + + {/* 오른쪽 삭제 버튼 */} + <FileListAction className="shrink-0"> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveSelectedFile(index)} + disabled={isPending || uploadingFiles} + className="h-5 w-5 text-destructive hover:text-destructive" + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + + ))} + </FileList> + </div> + )} + </> + ) + } + + // 파일 업로드 함수 + 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 + } + + // 2) 파일이 있으면 업로드 + if (values.attachments && values.attachments.length > 0) { + setUploadingFiles(true) + + try { + await uploadFiles(values.attachments, values.investigationId) + toast.success(`실사 결과와 ${values.attachments.length}개 파일이 업데이트되었습니다!`) + + // 첨부파일 목록 새로고침 + 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 () => { + console.log("저장 버튼 클릭됨") + console.log("현재 폼 값:", form.getValues()) + console.log("폼 에러:", form.formState.errors) + + // 폼 검증 실행 + const isValid = await form.trigger() + console.log("폼 검증 결과:", isValid) + + if (isValid) { + form.handleSubmit(onSubmit)() + } else { + console.log("폼 검증 실패, 에러:", form.formState.errors) + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col h-full sm:max-w-xl" > + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>실사 결과 입력</SheetTitle> + <SheetDescription> + {investigation?.vendorName && ( + <span className="font-medium">{investigation.vendorName}</span> + )}의 실사 결과를 입력합니다. + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + id="update-investigation-form" + > + {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */} + {/* <FormField + control={form.control} + name="investigationStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 상태</FormLabel> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="상태를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PLANNED">계획됨</SelectItem> + <SelectItem value="IN_PROGRESS">진행 중</SelectItem> + <SelectItem value="COMPLETED">완료됨</SelectItem> + <SelectItem value="CANCELED">취소됨</SelectItem> + <SelectItem value="SUPPLEMENT_REQUIRED">보완 요구됨</SelectItem> + <SelectItem value="RESULT_SENT">실사결과발송</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="investigationAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 주소</FormLabel> + <FormControl> + <Textarea + placeholder="실사가 진행될 주소를 입력하세요..." + {...field} + className="min-h-[60px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="investigationMethod" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 방법</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="실사 방법을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> + <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> + <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="forecastedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실사 수행 예정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 확정일 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="confirmedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실사 계획 확정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실제 실사일 */} + <FormField + control={form.control} + name="completedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실제 실사일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 점수 */} + <FormField + control={form.control} + name="evaluationScore" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 점수</FormLabel> + <FormControl> + <Input + type="number" + min={0} + max={100} + placeholder="0-100점" + {...field} + value={field.value || ""} + onChange={(e) => { + const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10) + field.onChange(value) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 결과 */} + <FormField + control={form.control} + name="evaluationResult" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 결과</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="평가 결과를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="APPROVED">승인</SelectItem> + <SelectItem value="SUPPLEMENT">보완</SelectItem> + <SelectItem value="SUPPLEMENT_REINSPECT">보완-재실사</SelectItem> + <SelectItem value="SUPPLEMENT_DOCUMENT">보완-서류제출</SelectItem> + <SelectItem value="REJECTED">불가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* QM 의견 */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>QM 의견</FormLabel> + <FormControl> + <Textarea + placeholder="실사에 대한 QM 의견을 입력하세요..." + {...field} + className="min-h-[80px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 섹션 */} + {renderFileUploadSection()} + </form> + </Form> + </div> + + {/* Footer Buttons */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0"> + <SheetClose asChild> + <Button type="button" variant="outline" disabled={isPending || uploadingFiles}> + 취소 + </Button> + </SheetClose> + <Button + disabled={isPending || uploadingFiles} + onClick={handleSaveClick} + > + {(isPending || uploadingFiles) && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
