diff options
Diffstat (limited to 'lib/vendor-investigation')
9 files changed, 606 insertions, 357 deletions
diff --git a/lib/vendor-investigation/approval-actions.ts b/lib/vendor-investigation/approval-actions.ts index a75b9b70..607580d8 100644 --- a/lib/vendor-investigation/approval-actions.ts +++ b/lib/vendor-investigation/approval-actions.ts @@ -97,6 +97,7 @@ export async function requestPQInvestigationWithApproval(data: { investigationAddress: data.investigationAddress, investigationNotes: data.investigationNotes, vendorNames: data.vendorNames, + currentUser: data.currentUser, }, // approvalConfig: 결재 상신 정보 (템플릿 포함) diff --git a/lib/vendor-investigation/handlers.ts b/lib/vendor-investigation/handlers.ts index 6c0edbd7..24cad870 100644 --- a/lib/vendor-investigation/handlers.ts +++ b/lib/vendor-investigation/handlers.ts @@ -24,10 +24,12 @@ export async function requestPQInvestigationInternal(payload: { investigationAddress: string; investigationNotes?: string; vendorNames?: string; // 복수 업체 이름 (표시용) + currentUser: { id: number; epId: string | null; email?: string }; }) { debugLog('[PQInvestigationHandler] 실사 의뢰 핸들러 시작', { pqCount: payload.pqSubmissionIds.length, qmManagerId: payload.qmManagerId, + currentUser: payload.currentUser, vendorNames: payload.vendorNames, }); @@ -36,6 +38,7 @@ export async function requestPQInvestigationInternal(payload: { debugLog('[PQInvestigationHandler] requestInvestigationAction 호출'); const result = await requestInvestigationAction( payload.pqSubmissionIds, + payload.currentUser, { qmManagerId: payload.qmManagerId, forecastedAt: payload.forecastedAt, diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index f81f78f6..3ccbe880 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -1,6 +1,6 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests } from "@/db/schema/" +import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests, vendorPQSubmissions } from "@/db/schema/" import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema, updateVendorInvestigationProgressSchema, updateVendorInvestigationResultSchema } from "./validations" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore, revalidatePath } from "next/cache"; @@ -131,6 +131,46 @@ export async function getExistingInvestigationsForVendors(vendorIds: number[]) { } } +// PQ 제출 타입 조회 (investigation.pqSubmissionId → type) +export default async function getPQSubmissionTypeAction(pqSubmissionId: number) { + try { + const row = await db + .select({ type: vendorPQSubmissions.type }) + .from(vendorPQSubmissions) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)) + .limit(1) + .then(rows => rows[0]); + if (!row) return { success: false, error: "PQ submission not found" }; + return { success: true, type: row.type as "GENERAL" | "PROJECT" | "NON_INSPECTION" }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +// 실사 계획 취소 액션: 상태를 QM_REVIEW_CONFIRMED로 되돌림 +export async function cancelInvestigationPlanAction(investigationId: number) { + try { + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "QM_REVIEW_CONFIRMED", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)) + + revalidateTag("vendor-investigations") + revalidatePath("/evcp/vendor-investigation") + + return { success: true } + } catch (error) { + console.error("실사 계획 취소 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + } + } +} + interface RequestInvestigateVendorsInput { ids: number[] } @@ -228,7 +268,7 @@ export async function updateVendorInvestigationProgressAction(formData: FormData processedEntries.confirmedAt = new Date(textEntries.confirmedAt) } - // 3) Zod로 파싱/검증 + // 3) Zod로 파싱/검증 (4개 필수값 규칙 포함) const parsed = updateVendorInvestigationProgressSchema.parse(processedEntries) // 4) 업데이트 데이터 준비 @@ -250,7 +290,7 @@ export async function updateVendorInvestigationProgressAction(formData: FormData updateData.confirmedAt = parsed.confirmedAt } - // 실사 방법이 설정되면 PLANNED -> IN_PROGRESS로 상태 변경 + // 실사 방법이 설정되면 QM_REVIEW_CONFIRMED -> IN_PROGRESS로 상태 변경 if (parsed.investigationMethod) { updateData.investigationStatus = "IN_PROGRESS" } @@ -334,10 +374,12 @@ export async function updateVendorInvestigationResultAction(formData: FormData) if (parsed.evaluationResult) { if (parsed.evaluationResult === "REJECTED") { updateData.investigationStatus = "CANCELED" - } else if (parsed.evaluationResult === "SUPPLEMENT" || - parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || + } else if (parsed.evaluationResult === "SUPPLEMENT" || + parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { updateData.investigationStatus = "SUPPLEMENT_REQUIRED" + // 보완 요청이 있었음을 기록 + updateData.hasSupplementRequested = true } else if (parsed.evaluationResult === "APPROVED") { updateData.investigationStatus = "COMPLETED" } @@ -1150,6 +1192,93 @@ export async function completeSupplementReinspectionAction({ } } +// 실사 보완요청 메일 발송 액션 +export async function requestInvestigationSupplementAction({ + investigationId, + vendorId, + comment, +}: { + investigationId: number; + vendorId: number; + comment: string; +}) { + unstable_noStore(); + try { + const headersList = await import("next/headers").then(m => m.headers()); + const host = headersList.get('host') || 'localhost:3000'; + + // 실사/벤더 정보 조회 + const investigation = await db + .select({ + id: vendorInvestigations.id, + pqSubmissionId: vendorInvestigations.pqSubmissionId, + investigationAddress: vendorInvestigations.investigationAddress, + }) + .from(vendorInvestigations) + .where(eq(vendorInvestigations.id, investigationId)) + .then(rows => rows[0]); + + const vendor = await db + .select({ email: vendors.email, vendorName: vendors.vendorName }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor?.email) { + return { success: false, error: "벤더 이메일 정보가 없습니다." }; + } + + // PQ 번호 조회 + let pqNumber = "N/A"; + if (investigation?.pqSubmissionId) { + const pqRow = await db + .select({ pqNumber: vendorPQSubmissions.pqNumber }) + .from(vendorPQSubmissions) + .where(eq(vendorPQSubmissions.id, investigation.pqSubmissionId)) + .then(rows => rows[0]); + if (pqRow) pqNumber = pqRow.pqNumber; + } + + // 메일 발송 + const portalUrl = process.env.NEXTAUTH_URL || `http://${host}`; + const reviewUrl = `${portalUrl}/evcp/vendor-investigation`; + + await sendEmail({ + to: vendor.email, + subject: `[eVCP] 실사 보완요청 - ${vendor.vendorName}`, + template: "pq-investigation-supplement-request", + context: { + vendorName: vendor.vendorName, + investigationNumber: pqNumber, + supplementComment: comment, + requestedAt: new Date().toLocaleString('ko-KR'), + reviewUrl: reviewUrl, + year: new Date().getFullYear(), + } + }); + + // 실사 상태를 SUPPLEMENT_REQUIRED로 변경 (이미 되어있을 수 있음) + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "SUPPLEMENT_REQUIRED", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)); + + revalidateTag("vendor-investigations"); + revalidateTag("pq-submissions"); + + return { success: true }; + } catch (error) { + console.error("실사 보완요청 메일 발송 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + // 보완 서류제출 응답 제출 액션 export async function submitSupplementDocumentResponseAction({ investigationId, diff --git a/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx b/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx new file mode 100644 index 00000000..26016742 --- /dev/null +++ b/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx @@ -0,0 +1,91 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" +import { Button } from "@/components/ui/button" +import { RotateCcw } from "lucide-react" +import { toast } from "sonner" +import { cancelInvestigationPlanAction } from "../service" +import { getSiteVisitRequestAction } from "@/lib/site-visit/service" + +interface Props { + table: Table<VendorInvestigationsViewWithContacts> +} + +export function InvestigationCancelPlanButton({ table }: Props) { + const [loading, setLoading] = React.useState(false) + const selected = table.getSelectedRowModel().rows[0]?.original as VendorInvestigationsViewWithContacts | undefined + + const canCancel = React.useMemo(() => { + if (!selected) return false + // 이미 취소 상태로 되돌릴 필요가 없거나, QM_REVIEW_CONFIRMED이면 취소 불필요 + if (selected.investigationStatus === "QM_REVIEW_CONFIRMED") return false + if (!selected.investigationMethod) return false + + const method = selected.investigationMethod + // 1) 서류평가: 실사결과 입력 전까지 (평가 결과 없을 때) + if (method === "DOCUMENT_EVAL") { + return selected.evaluationResult == null + } + // 2) 구매자체평가: 자체평가 입력 전까지 (간주: investigationNotes가 비어있을 때) + if (method === "PURCHASE_SELF_EVAL") { + return !selected.investigationNotes && selected.evaluationResult == null + } + // 3) 방문/제품평가: 방문요청 전까지 (site visit request 없을 때) + if (method === "PRODUCT_INSPECTION" || method === "SITE_VISIT_EVAL") { + // 낙관적으로 UI에선 일단 true로 두고, 클릭 시 서버 확인 + return true + } + return false + }, [selected]) + + const onCancel = async () => { + if (!selected) return + try { + setLoading(true) + + // 방문/제품평가인 경우, 방문요청 존재 여부 서버 확인 + if (selected.investigationMethod === "PRODUCT_INSPECTION" || selected.investigationMethod === "SITE_VISIT_EVAL") { + try { + const req = await getSiteVisitRequestAction(selected.investigationId) + if (req.success && req.data) { + toast.error("방문요청 이후에는 실사계획을 취소할 수 없습니다.") + setLoading(false) + return + } + } catch {} + } + + const res = await cancelInvestigationPlanAction(selected.investigationId) + if (!res.success) { + toast.error(res.error || "실사계획 취소에 실패했습니다.") + setLoading(false) + return + } + toast.success("실사계획을 취소하고 상태를 'QM 검토 완료'로 되돌렸습니다.") + // 선택 해제 및 테이블 리프레시 유도 + table.resetRowSelection() + } catch (e) { + toast.error("실사계획 취소 중 오류가 발생했습니다.") + } finally { + setLoading(false) + } + } + + return ( + <Button + variant="outline" + size="sm" + onClick={onCancel} + disabled={loading || !canCancel} + className="gap-2" + title="실사계획 취소" + > + <RotateCcw className="size-4" /> + 취소 + </Button> + ) +} + + diff --git a/lib/vendor-investigation/table/investigation-progress-sheet.tsx b/lib/vendor-investigation/table/investigation-progress-sheet.tsx index c0357f5c..a9fbdfdb 100644 --- a/lib/vendor-investigation/table/investigation-progress-sheet.tsx +++ b/lib/vendor-investigation/table/investigation-progress-sheet.tsx @@ -45,7 +45,7 @@ import { updateVendorInvestigationProgressSchema, type UpdateVendorInvestigationProgressSchema, } from "../validations" -import { updateVendorInvestigationProgressAction } from "../service" +import getPQSubmissionTypeAction, { updateVendorInvestigationProgressAction } from "../service" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" interface InvestigationProgressSheetProps @@ -61,6 +61,7 @@ export function InvestigationProgressSheet({ ...props }: InvestigationProgressSheetProps) { const [isPending, startTransition] = React.useTransition() + const [isProjectPQ, setIsProjectPQ] = React.useState<boolean>(false) // RHF + Zod const form = useForm<UpdateVendorInvestigationProgressSchema>({ @@ -84,6 +85,14 @@ export function InvestigationProgressSheet({ forecastedAt: investigation.forecastedAt ?? undefined, confirmedAt: investigation.confirmedAt ?? undefined, }) + // PQ 타입 조회 (PROJECT면 구매자체평가 비활성화) + if (investigation.pqSubmissionId) { + getPQSubmissionTypeAction(investigation.pqSubmissionId).then((res) => { + if (res.success) setIsProjectPQ(res.type === "PROJECT") + }) + } else { + setIsProjectPQ(false) + } } }, [investigation, form]) @@ -211,7 +220,7 @@ export function InvestigationProgressSheet({ </SelectTrigger> <SelectContent> <SelectGroup> - <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="PURCHASE_SELF_EVAL" disabled={isProjectPQ}>구매자체평가</SelectItem> <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> @@ -237,6 +246,7 @@ export function InvestigationProgressSheet({ <Button variant="outline" className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + disabled={form.watch("investigationMethod") === "PRODUCT_INSPECTION" || form.watch("investigationMethod") === "SITE_VISIT_EVAL"} > {field.value ? ( format(field.value, "yyyy년 MM월 dd일") 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 diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index 28ecc2ec..9f4944c3 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -5,7 +5,7 @@ import { ColumnDef } from "@tanstack/react-table" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Edit, Ellipsis, AlertTriangle } from "lucide-react" +import { Edit, Ellipsis, AlertTriangle, FileEdit, Eye } from "lucide-react" import { DropdownMenu, DropdownMenuContent, @@ -22,7 +22,7 @@ import { vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" - +import { useRouter } from "next/navigation" // Props for the column generator function interface GetVendorInvestigationsColumnsProps { setRowAction?: React.Dispatch< @@ -93,8 +93,11 @@ export function getColumns({ id: "actions", enableHiding: false, cell: ({ row }) => { + const router = useRouter() const isCanceled = row.original.investigationStatus === "CANCELED" const isCompleted = row.original.investigationStatus === "COMPLETED" + const canReviewPQ = !isCanceled && row.original.investigationStatus === "PLANNED" && !!row.original.pqSubmissionId + const reviewUrl = `/evcp/pq_new/${row.original.vendorId}/${row.original.pqSubmissionId}` const canRequestSupplement = (row.original.investigationMethod === "PRODUCT_INSPECTION" || row.original.investigationMethod === "SITE_VISIT_EVAL") && row.original.investigationStatus === "COMPLETED" && @@ -116,23 +119,60 @@ export function getColumns({ <DropdownMenuContent align="end" className="w-48"> <DropdownMenuItem onSelect={() => { - if (!isCanceled && row.original.investigationStatus === "PLANNED") { - setRowAction?.({ type: "update-progress", row }) + if (!canReviewPQ) return + if (router) { + router.push(reviewUrl) + } else if (typeof window !== "undefined") { + window.location.href = reviewUrl } }} - disabled={isCanceled || row.original.investigationStatus !== "PLANNED"} + disabled={!canReviewPQ} + > + <FileEdit className="mr-2 h-4 w-4" /> + 검토 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + if (!isCanceled && row.original.investigationStatus === "QM_REVIEW_CONFIRMED") { + (setRowAction as any)?.({ type: "update-progress", row }) + } + }} + disabled={isCanceled || row.original.investigationStatus !== "QM_REVIEW_CONFIRMED"} > <Edit className="mr-2 h-4 w-4" /> 실사 진행 관리 </DropdownMenuItem> <DropdownMenuItem - onSelect={() => { - if (!isCanceled && row.original.investigationStatus === "IN_PROGRESS") { - setRowAction?.({ type: "update-result", row }) + onSelect={async () => { + if (isCanceled || row.original.investigationStatus !== "IN_PROGRESS") return + // 구매자체평가일 경우 결과입력 비활성화 + if (row.original.investigationMethod === "PURCHASE_SELF_EVAL") { + return + } + // 방문/제품 평가 시: 벤더 회신 여부 확인 후 열기 (없으면 토스트) + if ( + row.original.investigationMethod === "PRODUCT_INSPECTION" || + row.original.investigationMethod === "SITE_VISIT_EVAL" + ) { + try { + const { getSiteVisitRequestAction } = await import("@/lib/site-visit/service") + const req = await getSiteVisitRequestAction(row.original.investigationId) + const canProceed = req.success && req.data && req.data.status === "VENDOR_SUBMITTED" + if (!canProceed) { + const { toast } = await import("sonner") + toast.error("협력업체 방문실사 정보 회신 전에는 결과 입력이 불가합니다.") + return + } + } catch {} } + ;(setRowAction as any)?.({ type: "update-result", row }) }} - disabled={isCanceled || row.original.investigationStatus !== "IN_PROGRESS"} + disabled={ + isCanceled || + row.original.investigationStatus !== "IN_PROGRESS" || + row.original.investigationMethod === "PURCHASE_SELF_EVAL" + } > <Edit className="mr-2 h-4 w-4" /> 실사 결과 입력 @@ -377,6 +417,8 @@ function formatStatus(status: string): string { switch (status) { case "PLANNED": return "계획됨" + case "QM_REVIEW_CONFIRMED": + return "QM 검토 확정" case "IN_PROGRESS": return "진행 중" case "COMPLETED": diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx index 9f89a6ac..991c1ad6 100644 --- a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx +++ b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx @@ -2,12 +2,13 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check } from "lucide-react" +import { Download, RotateCcw } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" +import { InvestigationCancelPlanButton } from "./investigation-cancel-plan-button" interface VendorsTableToolbarActionsProps { @@ -20,6 +21,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions return ( <div className="flex items-center gap-2"> + <InvestigationCancelPlanButton table={table} /> {/** 4) Export 버튼 */} <Button diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index 19412539..891ef178 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -61,24 +61,44 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>> // 실사 진행 관리용 스키마 -export const updateVendorInvestigationProgressSchema = z.object({ - investigationId: z.number({ - required_error: "Investigation ID is required", - }), - investigationAddress: z.string().optional(), - investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), - - // 날짜 필드들 - forecastedAt: z.union([ - z.date(), - z.string().transform((str) => str ? new Date(str) : undefined) - ]).optional(), - - confirmedAt: z.union([ - z.date(), - z.string().transform((str) => str ? new Date(str) : undefined) - ]).optional(), -}) +export const updateVendorInvestigationProgressSchema = z + .object({ + investigationId: z.number({ + required_error: "Investigation ID is required", + }), + investigationAddress: z + .string({ required_error: "실사 주소는 필수입니다." }) + .min(1, "실사 주소는 필수입니다."), + investigationMethod: z.enum([ + "PURCHASE_SELF_EVAL", + "DOCUMENT_EVAL", + "PRODUCT_INSPECTION", + "SITE_VISIT_EVAL", + ], { required_error: "실사 방법은 필수입니다." }), + + // 날짜 필드들 + forecastedAt: z.union([ + z.date(), + z.string().transform((str) => (str ? new Date(str) : undefined)), + ]), + + confirmedAt: z.union([ + z.date(), + z.string().transform((str) => (str ? new Date(str) : undefined)), + ], { required_error: "실사 계획 확정일은 필수입니다." }), + }) + .superRefine((data, ctx) => { + // 방문/제품 평가일 경우 forecastedAt은 필수 아님, 그 외에는 필수 + const method = data.investigationMethod + const requiresForecast = method !== "PRODUCT_INSPECTION" && method !== "SITE_VISIT_EVAL" + if (requiresForecast && !data.forecastedAt) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["forecastedAt"], + message: "실사 수행 예정일은 필수입니다.", + }) + } + }) export type UpdateVendorInvestigationProgressSchema = z.infer<typeof updateVendorInvestigationProgressSchema> @@ -87,21 +107,22 @@ export const updateVendorInvestigationResultSchema = z.object({ investigationId: z.number({ required_error: "Investigation ID is required", }), - + // 날짜 필드들 completedAt: z.union([ z.date(), z.string().transform((str) => str ? new Date(str) : undefined) - ]).optional(), - + ]), + evaluationScore: z.number() .int("평가 점수는 정수여야 합니다.") .min(0, "평가 점수는 0점 이상이어야 합니다.") - .max(100, "평가 점수는 100점 이하여야 합니다.") - .optional(), - evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(), + .max(100, "평가 점수는 100점 이하여야 합니다."), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), - attachments: z.any().optional(), // File 업로드를 위한 필드 + attachments: z.any({ + required_error: "첨부파일은 필수입니다." + }), }) export type UpdateVendorInvestigationResultSchema = z.infer<typeof updateVendorInvestigationResultSchema> |
