summaryrefslogtreecommitdiff
path: root/lib/vendor-investigation
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-investigation')
-rw-r--r--lib/vendor-investigation/approval-actions.ts1
-rw-r--r--lib/vendor-investigation/handlers.ts3
-rw-r--r--lib/vendor-investigation/service.ts139
-rw-r--r--lib/vendor-investigation/table/investigation-cancel-plan-button.tsx91
-rw-r--r--lib/vendor-investigation/table/investigation-progress-sheet.tsx14
-rw-r--r--lib/vendor-investigation/table/investigation-result-sheet.tsx580
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx60
-rw-r--r--lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx4
-rw-r--r--lib/vendor-investigation/validations.ts71
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>