diff options
Diffstat (limited to 'lib/vendor-investigation/table/investigation-result-sheet.tsx')
| -rw-r--r-- | lib/vendor-investigation/table/investigation-result-sheet.tsx | 580 |
1 files changed, 265 insertions, 315 deletions
diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx index b7577daa..36000333 100644 --- a/lib/vendor-investigation/table/investigation-result-sheet.tsx +++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx @@ -3,7 +3,7 @@ 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 { CalendarIcon, Loader, X, Download, AlertTriangle } from "lucide-react" import { format } from "date-fns" import { toast } from "sonner" import { updateVendorInvestigationResultAction } from "../service" @@ -68,6 +68,7 @@ import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInv 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<typeof Sheet> { investigation: VendorInvestigationsViewWithContacts | null @@ -117,6 +118,11 @@ export function InvestigationResultSheet({ const [loadingAttachments, setLoadingAttachments] = React.useState(false) const [uploadingFiles, setUploadingFiles] = React.useState(false) + // 불합격 안내 팝업 상태 + const [showRejectedDialog, setShowRejectedDialog] = React.useState(false) + // 보완 세부 항목 (재실사/자료제출) + const [supplementType, setSupplementType] = React.useState<string>("") + // RHF + Zod const form = useForm<UpdateVendorInvestigationResultSchema>({ resolver: zodResolver(updateVendorInvestigationResultSchema), @@ -130,6 +136,39 @@ export function InvestigationResultSheet({ }, }) + // 평가점수 변화 → 자동 평가 & 보완 타입 초기화 + 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) { @@ -214,9 +253,9 @@ export function InvestigationResultSheet({ // 파일 업로드 섹션 렌더링 const renderFileUploadSection = () => { - const currentStatus = form.watch("investigationStatus") + const currentStatus = form.watch("evaluationResult") as string | undefined const selectedFiles = form.watch("attachments") as File[] | undefined - const config = getFileUploadConfig(currentStatus) + const config = getFileUploadConfig(currentStatus ?? "") if (!config.enabled) return null @@ -454,6 +493,26 @@ export function InvestigationResultSheet({ 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) 파일이 있으면 업로드 if (values.attachments && values.attachments.length > 0) { setUploadingFiles(true) @@ -484,325 +543,216 @@ export function InvestigationResultSheet({ }) } - // 디버깅을 위한 버튼 클릭 핸들러 + // 저장버튼 커스텀(불합격시: 팝업 → 확인하면 제출 / 아니면 중단) 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) + 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 ( - <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 + <> + <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="completedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실제 실사일<span className="text-red-500 ml-1">*</span></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>평가 점수<span className="text-red-500 ml-1">*</span></FormLabel> + <FormControl> + <Input + type="number" + min={0} + max={100} + placeholder="0-100점" + maxLength={3} + {...field} + value={field.value || ""} + onChange={e => { + 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) + }} /> - </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> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 결과 VIEW (자동) */} + <div> + <FormLabel>평가 결과</FormLabel> + <div className="min-h-10 flex items-center gap-2 mt-1 font-bold"> + {(() => { + const result = form.watch("evaluationResult") + if (result === "APPROVED") return <span className="text-green-600">합격 (승인)</span> + if (result === "SUPPLEMENT") return <span className="text-yellow-600">보완 필요 (방법 선택)</span> + if (result === "SUPPLEMENT_REINSPECT") return <span className="text-yellow-600">보완 필요 - 재실사</span> + if (result === "SUPPLEMENT_DOCUMENT") return <span className="text-yellow-600">보완 필요 - 자료제출</span> + if (result === "REJECTED") return <span className="text-destructive">불합격</span> + return <span className="text-muted-foreground">-</span> + })()} + </div> + </div> + + {/* 보완 세부항목(70~79점) */} + {(() => { + const score = form.watch("evaluationScore") + return typeof score === "number" && score >= 70 && score < 80 + })() && ( + <div> + <FormLabel>보완 방법<span className="text-red-500 ml-1">*</span></FormLabel> + <Select value={supplementType} onValueChange={setSupplementType}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="REINSPECT">재실사</SelectItem> + <SelectItem value="DOCUMENT">자료제출</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </div> )} - /> - - {/* 파일 첨부 섹션 */} - {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}> - 취소 + + {/* 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> - </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> + </SheetFooter> + </SheetContent> + </Sheet> + + {/* 불합격 안내 팝업 */} + <SystemDialog open={showRejectedDialog} onOpenChange={setShowRejectedDialog}> + <SystemDialogContent> + <SystemDialogHeader> + <SystemDialogTitle><AlertTriangle className="mr-2 inline h-6 w-6 text-destructive" />불합격 확정 시 안내</SystemDialogTitle> + </SystemDialogHeader> + <div className="mt-2 mb-4 text-base leading-relaxed"> + 불합격 확정 시 <b>결과입력완료일부터 1년간 동일 건에 대한 실사는 불가합니다.</b><br/> + 정말 확정 처리하시겠습니까? + </div> + <SystemDialogFooter className="flex flex-row justify-end gap-2"> + <Button variant="outline" onClick={() => setShowRejectedDialog(false)}>취소</Button> + <Button variant="destructive" onClick={handleRejectedConfirm}>확인</Button> + </SystemDialogFooter> + </SystemDialogContent> + </SystemDialog> + </> ) }
\ No newline at end of file |
