summaryrefslogtreecommitdiff
path: root/lib/vendor-investigation
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:43:44 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:43:44 +0000
commit2eb717eb2bbfd97a5f149d13049aa336c26c393b (patch)
tree274283b7759bfba619e6d143edccf3845ba45ed6 /lib/vendor-investigation
parentbfc26491991997b5b109af6ea6bc75a8be138e9a (diff)
(최겸) 구매 실사 개발(진행중)
Diffstat (limited to 'lib/vendor-investigation')
-rw-r--r--lib/vendor-investigation/service.ts516
-rw-r--r--lib/vendor-investigation/table/investigation-progress-sheet.tsx324
-rw-r--r--lib/vendor-investigation/table/investigation-result-sheet.tsx808
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx110
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx51
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx144
-rw-r--r--lib/vendor-investigation/validations.ts53
7 files changed, 1902 insertions, 104 deletions
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index 9395a5de..f81f78f6 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -1,7 +1,7 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors } from "@/db/schema/"
-import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations"
+import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests } 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";
import { filterColumns } from "@/lib/filter-columns";
@@ -193,7 +193,213 @@ export async function requestInvestigateVendors({
}
-// 개선된 서버 액션 - 텍스트 데이터만 처리
+// 실사 진행 관리 업데이트 액션 (PLANNED -> IN_PROGRESS)
+export async function updateVendorInvestigationProgressAction(formData: FormData) {
+ try {
+ // 1) 텍스트 필드만 추출
+ const textEntries: Record<string, string> = {}
+ for (const [key, value] of formData.entries()) {
+ if (typeof value === "string") {
+ textEntries[key] = value
+ }
+ }
+
+ // 2) 적절한 타입으로 변환
+ const processedEntries: any = {}
+
+ // 필수 필드
+ if (textEntries.investigationId) {
+ processedEntries.investigationId = Number(textEntries.investigationId)
+ }
+
+ // 선택적 필드들
+ if (textEntries.investigationAddress) {
+ processedEntries.investigationAddress = textEntries.investigationAddress
+ }
+ if (textEntries.investigationMethod) {
+ processedEntries.investigationMethod = textEntries.investigationMethod
+ }
+
+ // 선택적 날짜 필드
+ if (textEntries.forecastedAt) {
+ processedEntries.forecastedAt = new Date(textEntries.forecastedAt)
+ }
+ if (textEntries.confirmedAt) {
+ processedEntries.confirmedAt = new Date(textEntries.confirmedAt)
+ }
+
+ // 3) Zod로 파싱/검증
+ const parsed = updateVendorInvestigationProgressSchema.parse(processedEntries)
+
+ // 4) 업데이트 데이터 준비
+ const updateData: any = {
+ updatedAt: new Date(),
+ }
+
+ // 선택적 필드들은 존재할 때만 추가
+ if (parsed.investigationAddress !== undefined) {
+ updateData.investigationAddress = parsed.investigationAddress
+ }
+ if (parsed.investigationMethod !== undefined) {
+ updateData.investigationMethod = parsed.investigationMethod
+ }
+ if (parsed.forecastedAt !== undefined) {
+ updateData.forecastedAt = parsed.forecastedAt
+ }
+ if (parsed.confirmedAt !== undefined) {
+ updateData.confirmedAt = parsed.confirmedAt
+ }
+
+ // 실사 방법이 설정되면 PLANNED -> IN_PROGRESS로 상태 변경
+ if (parsed.investigationMethod) {
+ updateData.investigationStatus = "IN_PROGRESS"
+ }
+
+ // 5) vendor_investigations 테이블 업데이트
+ await db
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, parsed.investigationId))
+
+ // 6) 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidatePath("/evcp/vendor-investigation")
+
+ return { success: true }
+ } catch (error) {
+ console.error("실사 진행 관리 업데이트 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 실사 결과 입력 액션 (IN_PROGRESS -> COMPLETED/CANCELED/SUPPLEMENT_REQUIRED)
+export async function updateVendorInvestigationResultAction(formData: FormData) {
+ try {
+ // 1) 텍스트 필드만 추출
+ const textEntries: Record<string, string> = {}
+ for (const [key, value] of formData.entries()) {
+ if (typeof value === "string") {
+ textEntries[key] = value
+ }
+ }
+
+ // 2) 적절한 타입으로 변환
+ const processedEntries: any = {}
+
+ // 필수 필드
+ if (textEntries.investigationId) {
+ processedEntries.investigationId = Number(textEntries.investigationId)
+ }
+
+ // 선택적 필드들
+ if (textEntries.completedAt) {
+ processedEntries.completedAt = new Date(textEntries.completedAt)
+ }
+ if (textEntries.evaluationScore) {
+ processedEntries.evaluationScore = Number(textEntries.evaluationScore)
+ }
+ if (textEntries.evaluationResult) {
+ processedEntries.evaluationResult = textEntries.evaluationResult
+ }
+ if (textEntries.investigationNotes) {
+ processedEntries.investigationNotes = textEntries.investigationNotes
+ }
+
+ // 3) Zod로 파싱/검증
+ const parsed = updateVendorInvestigationResultSchema.parse(processedEntries)
+
+ // 4) 업데이트 데이터 준비
+ const updateData: any = {
+ updatedAt: new Date(),
+ }
+
+ // 선택적 필드들은 존재할 때만 추가
+ if (parsed.completedAt !== undefined) {
+ updateData.completedAt = parsed.completedAt
+ }
+ if (parsed.evaluationScore !== undefined) {
+ updateData.evaluationScore = parsed.evaluationScore
+ }
+ if (parsed.evaluationResult !== undefined) {
+ updateData.evaluationResult = parsed.evaluationResult
+ }
+ if (parsed.investigationNotes !== undefined) {
+ updateData.investigationNotes = parsed.investigationNotes
+ }
+
+ // 평가 결과에 따라 상태 자동 변경
+ if (parsed.evaluationResult) {
+ if (parsed.evaluationResult === "REJECTED") {
+ updateData.investigationStatus = "CANCELED"
+ } else if (parsed.evaluationResult === "SUPPLEMENT" ||
+ parsed.evaluationResult === "SUPPLEMENT_REINSPECT" ||
+ parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ updateData.investigationStatus = "SUPPLEMENT_REQUIRED"
+ } else if (parsed.evaluationResult === "APPROVED") {
+ updateData.investigationStatus = "COMPLETED"
+ }
+ }
+
+ // 5) vendor_investigations 테이블 업데이트
+ await db
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, parsed.investigationId))
+ /*
+ 현재 보완 프로세스는 자동으로 처리됨. 만약 dialog 필요하면 아래 서버액션 분기 필요.(1029/최겸)
+ */
+ // 5-1) 보완 프로세스 자동 처리 (TO-BE)
+ if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ // 실사 방법 확인
+ const investigation = await db
+ .select({
+ investigationMethod: vendorInvestigations.investigationMethod,
+ })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, parsed.investigationId))
+ .then(rows => rows[0]);
+
+ if (investigation?.investigationMethod === "PRODUCT_INSPECTION" || investigation?.investigationMethod === "SITE_VISIT_EVAL") {
+ if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT") {
+ // 보완-재실사 요청 자동 생성
+ await requestSupplementReinspectionAction({
+ investigationId: parsed.investigationId,
+ siteVisitData: {
+ inspectionDuration: 1.0, // 기본 1일
+ additionalRequests: "보완을 위한 재실사 요청입니다.",
+ }
+ });
+ } else if (parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ // 보완-서류제출 요청 자동 생성
+ await requestSupplementDocumentAction({
+ investigationId: parsed.investigationId,
+ documentRequests: {
+ requiredDocuments: ["보완 서류"],
+ additionalRequests: "보완을 위한 서류 제출 요청입니다.",
+ }
+ });
+ }
+ }
+ }
+
+ // 6) 캐시 무효화
+ revalidateTag("vendor-investigations")
+ revalidatePath("/evcp/vendor-investigation")
+
+ return { success: true }
+ } catch (error) {
+ console.error("실사 결과 업데이트 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 기존 함수 (호환성을 위해 유지)
export async function updateVendorInvestigationAction(formData: FormData) {
try {
// 1) 텍스트 필드만 추출
@@ -300,12 +506,51 @@ export async function updateVendorInvestigationAction(formData: FormData) {
updateData.investigationStatus = "IN_PROGRESS";
}
+ // 보완 프로세스 분기 로직 (TO-BE)
+ if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ updateData.investigationStatus = "SUPPLEMENT_REQUIRED";
+ }
+
// 5) vendor_investigations 테이블 업데이트
await db
.update(vendorInvestigations)
.set(updateData)
.where(eq(vendorInvestigations.id, parsed.investigationId))
+ // 5-1) 보완 프로세스 자동 처리 (TO-BE)
+ if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ // 실사 방법 확인
+ const investigation = await db
+ .select({
+ investigationMethod: vendorInvestigations.investigationMethod,
+ })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, parsed.investigationId))
+ .then(rows => rows[0]);
+
+ if (investigation?.investigationMethod === "PRODUCT_INSPECTION" || investigation?.investigationMethod === "SITE_VISIT_EVAL") {
+ if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT") {
+ // 보완-재실사 요청 자동 생성
+ await requestSupplementReinspectionAction({
+ investigationId: parsed.investigationId,
+ siteVisitData: {
+ inspectionDuration: 1.0, // 기본 1일
+ additionalRequests: "보완을 위한 재실사 요청입니다.",
+ }
+ });
+ } else if (parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ // 보완-서류제출 요청 자동 생성
+ await requestSupplementDocumentAction({
+ investigationId: parsed.investigationId,
+ documentRequests: {
+ requiredDocuments: ["보완 서류"],
+ additionalRequests: "보완을 위한 서류 제출 요청입니다.",
+ }
+ });
+ }
+ }
+ }
+
// 6) 캐시 무효화
revalidateTag("vendor-investigations")
revalidateTag("pq-submissions")
@@ -693,4 +938,267 @@ export async function createVendorInvestigationAttachmentAction(input: {
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
-} \ No newline at end of file
+}
+
+// 보완-재실사 요청 액션
+export async function requestSupplementReinspectionAction({
+ investigationId,
+ siteVisitData
+}: {
+ investigationId: number;
+ siteVisitData: {
+ inspectionDuration?: number;
+ requestedStartDate?: Date;
+ requestedEndDate?: Date;
+ shiAttendees?: any;
+ vendorRequests?: any;
+ additionalRequests?: string;
+ };
+}) {
+ try {
+ // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "SUPPLEMENT_REQUIRED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 2. 새로운 방문실사 요청 생성
+ const [newSiteVisitRequest] = await db
+ .insert(siteVisitRequests)
+ .values({
+ investigationId: investigationId,
+ inspectionDuration: siteVisitData.inspectionDuration,
+ requestedStartDate: siteVisitData.requestedStartDate,
+ requestedEndDate: siteVisitData.requestedEndDate,
+ shiAttendees: siteVisitData.shiAttendees || {},
+ vendorRequests: siteVisitData.vendorRequests || {},
+ additionalRequests: siteVisitData.additionalRequests,
+ status: "REQUESTED",
+ })
+ .returning();
+
+ // 3. 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("site-visit-requests");
+
+ return { success: true, siteVisitRequestId: newSiteVisitRequest.id };
+ } catch (error) {
+ console.error("보완-재실사 요청 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
+// 보완-서류제출 요청 액션
+export async function requestSupplementDocumentAction({
+ investigationId,
+ documentRequests
+}: {
+ investigationId: number;
+ documentRequests: {
+ requiredDocuments: string[];
+ additionalRequests?: string;
+ };
+}) {
+ try {
+ // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "SUPPLEMENT_REQUIRED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 2. 서류제출 요청을 위한 방문실사 요청 생성 (서류제출용)
+ const [newSiteVisitRequest] = await db
+ .insert(siteVisitRequests)
+ .values({
+ investigationId: investigationId,
+ inspectionDuration: 0, // 서류제출은 방문 시간 0
+ shiAttendees: {}, // 서류제출은 참석자 없음
+ vendorRequests: {
+ requiredDocuments: documentRequests.requiredDocuments,
+ documentSubmissionOnly: true, // 서류제출 전용 플래그
+ },
+ additionalRequests: documentRequests.additionalRequests,
+ status: "REQUESTED",
+ })
+ .returning();
+
+ // 3. 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("site-visit-requests");
+
+ return { success: true, siteVisitRequestId: newSiteVisitRequest.id };
+ } catch (error) {
+ console.error("보완-서류제출 요청 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
+// 보완 서류 제출 완료 액션 (벤더가 서류 제출 완료)
+export async function completeSupplementDocumentAction({
+ investigationId,
+ siteVisitRequestId,
+ submittedBy
+}: {
+ investigationId: number;
+ siteVisitRequestId: number;
+ submittedBy: number;
+}) {
+ try {
+ // 1. 방문실사 요청 상태를 COMPLETED로 변경
+ await db
+ .update(siteVisitRequests)
+ .set({
+ status: "COMPLETED",
+ sentAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(siteVisitRequests.id, siteVisitRequestId));
+
+ // 2. 실사 상태를 IN_PROGRESS로 변경 (재검토 대기)
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "IN_PROGRESS",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 3. 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("site-visit-requests");
+
+ return { success: true };
+ } catch (error) {
+ console.error("보완 서류 제출 완료 처리 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
+// 보완 재실사 완료 액션 (재실사 완료 후)
+export async function completeSupplementReinspectionAction({
+ investigationId,
+ siteVisitRequestId,
+ evaluationResult,
+ evaluationScore,
+ investigationNotes
+}: {
+ investigationId: number;
+ siteVisitRequestId: number;
+ evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED";
+ evaluationScore?: number;
+ investigationNotes?: string;
+}) {
+ try {
+ // 1. 방문실사 요청 상태를 COMPLETED로 변경
+ await db
+ .update(siteVisitRequests)
+ .set({
+ status: "COMPLETED",
+ sentAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(siteVisitRequests.id, siteVisitRequestId));
+
+ // 2. 실사 상태 및 평가 결과 업데이트
+ const updateData: any = {
+ investigationStatus: evaluationResult === "APPROVED" ? "COMPLETED" : "SUPPLEMENT_REQUIRED",
+ evaluationResult: evaluationResult,
+ updatedAt: new Date(),
+ };
+
+ if (evaluationScore !== undefined) {
+ updateData.evaluationScore = evaluationScore;
+ }
+ if (investigationNotes) {
+ updateData.investigationNotes = investigationNotes;
+ }
+ if (evaluationResult === "COMPLETED") {
+ updateData.completedAt = new Date();
+ }
+
+ await db
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 3. 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("site-visit-requests");
+
+ return { success: true };
+ } catch (error) {
+ console.error("보완 재실사 완료 처리 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
+// 보완 서류제출 응답 제출 액션
+export async function submitSupplementDocumentResponseAction({
+ investigationId,
+ responseData
+}: {
+ investigationId: number
+ responseData: {
+ responseText: string
+ attachments: Array<{
+ fileName: string
+ url: string
+ size?: number
+ }>
+ }
+}) {
+ try {
+ // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "SUPPLEMENT_REQUIRED",
+ investigationNotes: responseData.responseText,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 2. 첨부 파일 저장
+ if (responseData.attachments.length > 0) {
+ const attachmentData = responseData.attachments.map(attachment => ({
+ investigationId,
+ fileName: attachment.fileName,
+ filePath: attachment.url,
+ fileSize: attachment.size || 0,
+ uploadedAt: new Date(),
+ }));
+
+ await db.insert(vendorInvestigationAttachments).values(attachmentData);
+ }
+
+ // 3. 캐시 무효화
+ revalidateTag("vendor-investigations");
+ revalidateTag("vendor-investigation-attachments");
+
+ return { success: true };
+ } catch (error) {
+ console.error("보완 서류제출 응답 처리 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
diff --git a/lib/vendor-investigation/table/investigation-progress-sheet.tsx b/lib/vendor-investigation/table/investigation-progress-sheet.tsx
new file mode 100644
index 00000000..c0357f5c
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-progress-sheet.tsx
@@ -0,0 +1,324 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { CalendarIcon, Loader } from "lucide-react"
+import { format } from "date-fns"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+import {
+ updateVendorInvestigationProgressSchema,
+ type UpdateVendorInvestigationProgressSchema,
+} from "../validations"
+import { updateVendorInvestigationProgressAction } from "../service"
+import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+
+interface InvestigationProgressSheetProps
+ extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ investigation: VendorInvestigationsViewWithContacts | null
+}
+
+/**
+ * 실사 진행 관리 시트
+ */
+export function InvestigationProgressSheet({
+ investigation,
+ ...props
+}: InvestigationProgressSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ // RHF + Zod
+ const form = useForm<UpdateVendorInvestigationProgressSchema>({
+ resolver: zodResolver(updateVendorInvestigationProgressSchema),
+ defaultValues: {
+ investigationId: investigation?.investigationId ?? 0,
+ investigationAddress: investigation?.investigationAddress ?? "",
+ investigationMethod: investigation?.investigationMethod ?? undefined,
+ forecastedAt: investigation?.forecastedAt ?? undefined,
+ confirmedAt: investigation?.confirmedAt ?? undefined,
+ },
+ })
+
+ // investigation이 변경될 때마다 폼 리셋
+ React.useEffect(() => {
+ if (investigation) {
+ form.reset({
+ investigationId: investigation.investigationId,
+ investigationAddress: investigation.investigationAddress ?? "",
+ investigationMethod: investigation.investigationMethod ?? undefined,
+ forecastedAt: investigation.forecastedAt ?? undefined,
+ confirmedAt: investigation.confirmedAt ?? undefined,
+ })
+ }
+ }, [investigation, form])
+
+ // Submit handler
+ async function onSubmit(values: UpdateVendorInvestigationProgressSchema) {
+ console.log("실사 진행 관리 onSubmit 호출됨:", values)
+
+ if (!values.investigationId) {
+ console.log("investigationId가 없음:", values.investigationId)
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ console.log("실사 진행 관리 startTransition 시작")
+
+ // FormData 생성
+ const formData = new FormData()
+
+ // 필수 필드
+ formData.append("investigationId", String(values.investigationId))
+
+ // 선택적 필드들
+ if (values.investigationAddress) {
+ formData.append("investigationAddress", values.investigationAddress)
+ }
+
+ if (values.investigationMethod) {
+ formData.append("investigationMethod", values.investigationMethod)
+ }
+
+ if (values.forecastedAt) {
+ formData.append("forecastedAt", values.forecastedAt.toISOString())
+ }
+
+ if (values.confirmedAt) {
+ formData.append("confirmedAt", values.confirmedAt.toISOString())
+ }
+
+ // 실사 진행 관리 업데이트 (PLANNED -> IN_PROGRESS)
+ const { error } = await updateVendorInvestigationProgressAction(formData)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ toast.success("실사 진행 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+
+ } catch (error) {
+ console.error("실사 진행 관리 업데이트 오류:", error)
+ toast.error("실사 진행 관리 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ // 디버깅을 위한 버튼 클릭 핸들러
+ const handleSaveClick = async () => {
+ console.log("실사 진행 관리 저장 버튼 클릭됨")
+ console.log("현재 폼 값:", form.getValues())
+ console.log("폼 에러:", form.formState.errors)
+
+ // 폼 검증 실행
+ const isValid = await form.trigger()
+ console.log("폼 검증 결과:", isValid)
+
+ if (isValid) {
+ form.handleSubmit(onSubmit)()
+ } else {
+ console.log("폼 검증 실패, 에러:", form.formState.errors)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col h-full sm:max-w-xl" >
+ <SheetHeader className="text-left flex-shrink-0">
+ <SheetTitle>실사 진행 관리</SheetTitle>
+ <SheetDescription>
+ {investigation?.vendorName && (
+ <span className="font-medium">{investigation.vendorName}</span>
+ )}의 실사 진행 정보를 관리합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ id="investigation-progress-form"
+ >
+ {/* 실사 주소 */}
+ <FormField
+ control={form.control}
+ name="investigationAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 주소</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사가 진행될 주소를 입력하세요..."
+ {...field}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 방법 */}
+ <FormField
+ control={form.control}
+ name="investigationMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 방법</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="실사 방법을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
+ <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem>
+ <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 수행 예정일 */}
+ <FormField
+ control={form.control}
+ name="forecastedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 수행 예정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 확정일 */}
+ <FormField
+ control={form.control}
+ name="confirmedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 계획 확정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </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}>
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ disabled={isPending}
+ onClick={handleSaveClick}
+ >
+ {isPending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx
new file mode 100644
index 00000000..b7577daa
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx
@@ -0,0 +1,808 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { CalendarIcon, Loader, X, Download } from "lucide-react"
+import { format } from "date-fns"
+import { toast } from "sonner"
+import { updateVendorInvestigationResultAction } from "../service"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list"
+
+import {
+ updateVendorInvestigationResultSchema,
+ type UpdateVendorInvestigationResultSchema,
+} from "../validations"
+import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment, createVendorInvestigationAttachmentAction } from "../service"
+import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+import prettyBytes from "pretty-bytes"
+import { downloadFile } from "@/lib/file-download"
+
+interface InvestigationResultSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ investigation: VendorInvestigationsViewWithContacts | null
+}
+
+// 첨부파일 정책 정의
+const getFileUploadConfig = (status: string) => {
+ // 취소된 상태에서만 파일 업로드 비활성화
+ if (status === "CANCELED") {
+ return {
+ enabled: false,
+ label: "",
+ description: "",
+ accept: undefined,
+ maxSize: 0,
+ maxSizeText: ""
+ }
+ }
+
+ // 모든 활성 상태에서 동일한 정책 적용
+ return {
+ enabled: true,
+ label: "실사 관련 첨부파일",
+ description: "실사와 관련된 모든 문서와 이미지를 첨부할 수 있습니다.",
+ accept: {
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
+ },
+ maxSize: 10 * 1024 * 1024, // 10MB
+ maxSizeText: "10MB"
+ }
+}
+
+/**
+ * 실사 결과 입력 시트
+ */
+export function InvestigationResultSheet({
+ investigation,
+ ...props
+}: InvestigationResultSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const [existingAttachments, setExistingAttachments] = React.useState<any[]>([])
+ const [loadingAttachments, setLoadingAttachments] = React.useState(false)
+ const [uploadingFiles, setUploadingFiles] = React.useState(false)
+
+ // RHF + Zod
+ const form = useForm<UpdateVendorInvestigationResultSchema>({
+ resolver: zodResolver(updateVendorInvestigationResultSchema),
+ defaultValues: {
+ investigationId: investigation?.investigationId ?? 0,
+ completedAt: investigation?.completedAt ?? undefined,
+ evaluationScore: investigation?.evaluationScore ?? undefined,
+ evaluationResult: investigation?.evaluationResult ?? undefined,
+ investigationNotes: investigation?.investigationNotes ?? "",
+ attachments: undefined,
+ },
+ })
+
+ // investigation이 변경될 때마다 폼 리셋
+ React.useEffect(() => {
+ if (investigation) {
+ form.reset({
+ investigationId: investigation.investigationId,
+ completedAt: investigation.completedAt ?? undefined,
+ evaluationScore: investigation.evaluationScore ?? undefined,
+ evaluationResult: investigation.evaluationResult ?? undefined,
+ investigationNotes: investigation.investigationNotes ?? "",
+ attachments: undefined,
+ })
+
+ // 기존 첨부파일 로드
+ loadExistingAttachments(investigation.investigationId)
+ }
+ }, [investigation, form])
+
+ // 기존 첨부파일 로드 함수
+ const loadExistingAttachments = async (investigationId: number) => {
+ setLoadingAttachments(true)
+ try {
+ const result = await getInvestigationAttachments(investigationId)
+ if (result.success) {
+ setExistingAttachments(result.attachments || [])
+ } else {
+ toast.error("첨부파일 목록을 불러오는데 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("첨부파일 로드 실패:", error)
+ toast.error("첨부파일 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setLoadingAttachments(false)
+ }
+ }
+
+ // 첨부파일 삭제 함수
+ const handleDeleteAttachment = async (attachmentId: number) => {
+ if (!investigation) return
+
+ try {
+ await deleteInvestigationAttachment(attachmentId)
+ toast.success("첨부파일이 삭제되었습니다.")
+ // 목록 새로고침
+ loadExistingAttachments(investigation.investigationId)
+
+ } catch (error) {
+ console.error("첨부파일 삭제 오류:", error)
+ toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 첨부파일 다운로드 함수
+ const handleDownloadAttachment = async (attachment: any) => {
+ if (!attachment.filePath || !attachment.fileName) {
+ toast.error("첨부파일 정보가 올바르지 않습니다.")
+ return
+ }
+
+ try {
+ await downloadFile(attachment.filePath, attachment.fileName, {
+ showToast: true,
+ action: 'download'
+ })
+ } catch (error) {
+ console.error("첨부파일 다운로드 오류:", error)
+ toast.error("첨부파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 선택된 파일에서 특정 파일 제거
+ const handleRemoveSelectedFile = (indexToRemove: number) => {
+ const currentFiles = form.getValues("attachments") || []
+ const updatedFiles = currentFiles.filter((_: File, index: number) => index !== indexToRemove)
+ form.setValue("attachments", updatedFiles.length > 0 ? updatedFiles : undefined)
+
+ if (updatedFiles.length === 0) {
+ toast.success("모든 선택된 파일이 제거되었습니다.")
+ } else {
+ toast.success("파일이 제거되었습니다.")
+ }
+ }
+
+ // 파일 업로드 섹션 렌더링
+ const renderFileUploadSection = () => {
+ const currentStatus = form.watch("investigationStatus")
+ const selectedFiles = form.watch("attachments") as File[] | undefined
+ const config = getFileUploadConfig(currentStatus)
+
+ if (!config.enabled) return null
+
+ return (
+ <>
+ {/* 기존 첨부파일 목록 */}
+ {(existingAttachments.length > 0 || loadingAttachments) && (
+ <div className="space-y-2">
+ <FormLabel>기존 첨부파일</FormLabel>
+ <div className="border rounded-md p-3 space-y-2 max-h-32 overflow-y-auto">
+ {loadingAttachments ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">
+ 첨부파일 로딩 중...
+ </span>
+ </div>
+ ) : existingAttachments.length > 0 ? (
+ existingAttachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between text-sm">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <span className="text-xs px-2 py-1 bg-muted rounded">
+ {attachment.attachmentType}
+ </span>
+ <span className="truncate">{attachment.fileName}</span>
+ <span className="text-muted-foreground">
+ ({Math.round(attachment.fileSize / 1024)}KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadAttachment(attachment)}
+ className="text-blue-600 hover:text-blue-700"
+ disabled={isPending}
+ title="파일 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(attachment.id)}
+ className="text-destructive hover:text-destructive"
+ disabled={isPending}
+ title="파일 삭제"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-2">
+ 첨부된 파일이 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* 새 파일 업로드 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field: { onChange, ...field } }) => (
+ <FormItem>
+ <FormLabel>{config.label}</FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={(acceptedFiles, rejectedFiles) => {
+ // 거부된 파일에 대한 상세 에러 메시지
+ if (rejectedFiles.length > 0) {
+ rejectedFiles.forEach((file) => {
+ const error = file.errors[0]
+ if (error.code === 'file-too-large') {
+ toast.error(`${file.file.name}: 파일 크기가 ${config.maxSizeText}를 초과합니다.`)
+ } else if (error.code === 'file-invalid-type') {
+ toast.error(`${file.file.name}: 지원하지 않는 파일 형식입니다.`)
+ } else {
+ toast.error(`${file.file.name}: 파일 업로드에 실패했습니다.`)
+ }
+ })
+ }
+
+ if (acceptedFiles.length > 0) {
+ // 기존 파일들과 새로 선택된 파일들을 합치기
+ const currentFiles = form.getValues("attachments") || []
+ const newFiles = [...currentFiles, ...acceptedFiles]
+ onChange(newFiles)
+ toast.success(`${acceptedFiles.length}개 파일이 추가되었습니다.`)
+ }
+ }}
+ accept={config.accept}
+ multiple
+ maxSize={config.maxSize}
+ disabled={isPending || uploadingFiles}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon />
+ <DropzoneTitle>
+ {isPending || uploadingFiles
+ ? "파일 업로드 중..."
+ : "파일을 드래그하거나 클릭하여 업로드"
+ }
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {config.description} (최대 {config.maxSizeText})
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles && selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ {/* <FormLabel>선택된 파일 ({selectedFiles.length}개)</FormLabel> */}
+ <FileList>
+ <FileListHeader>
+ <span className="text-sm font-medium">업로드 예정 파일 ({selectedFiles.length}개)</span>
+ </FileListHeader>
+ {selectedFiles.map((file, index) => (
+ <FileListItem
+ key={`${file.name}-${index}`}
+ className="flex items-center justify-between gap-2 px-2 py-2"
+ >
+ {/* 왼쪽 아이콘 */}
+ <FileListIcon className="shrink-0 h-4 w-4 text-muted-foreground" />
+
+ {/* 가운데 이름 + 사이즈 */}
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="truncate">{file.name}</FileListName>
+ <FileListSize className="text-xs text-muted-foreground shrink-0">
+ {file.size}
+ </FileListSize>
+ </FileListInfo>
+
+ {/* 오른쪽 삭제 버튼 */}
+ <FileListAction className="shrink-0">
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => handleRemoveSelectedFile(index)}
+ disabled={isPending || uploadingFiles}
+ className="h-5 w-5 text-destructive hover:text-destructive"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+
+ ))}
+ </FileList>
+ </div>
+ )}
+ </>
+ )
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async (files: File[], investigationId: number) => {
+ const uploadPromises = files.map(async (file) => {
+ try {
+ // 서버 액션을 호출하여 파일 저장 및 DB 레코드 생성
+ const result = await createVendorInvestigationAttachmentAction({
+ investigationId,
+ file,
+ userId: undefined // 필요시 사용자 ID 추가
+ });
+
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드 실패");
+ }
+
+ return result.attachment;
+ } catch (error) {
+ console.error(`파일 업로드 실패: ${file.name}`, error);
+ throw error;
+ }
+ });
+
+ return await Promise.all(uploadPromises);
+ }
+
+ // Submit handler
+ async function onSubmit(values: UpdateVendorInvestigationResultSchema) {
+ console.log("실사 결과 입력 onSubmit 호출됨:", values)
+
+ if (!values.investigationId) {
+ console.log("investigationId가 없음:", values.investigationId)
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ console.log("실사 결과 입력 startTransition 시작")
+
+ // 1) 먼저 텍스트 데이터 업데이트
+ const formData = new FormData()
+
+ // 필수 필드
+ formData.append("investigationId", String(values.investigationId))
+
+ // 선택적 필드들
+ if (values.completedAt) {
+ formData.append("completedAt", values.completedAt.toISOString())
+ }
+
+ if (values.evaluationScore !== undefined) {
+ formData.append("evaluationScore", String(values.evaluationScore))
+ }
+
+ if (values.evaluationResult) {
+ formData.append("evaluationResult", values.evaluationResult)
+ }
+
+ if (values.investigationNotes) {
+ formData.append("investigationNotes", values.investigationNotes)
+ }
+
+ // 텍스트 데이터 업데이트 (IN_PROGRESS -> COMPLETED/CANCELED/SUPPLEMENT_REQUIRED)
+ const { error } = await updateVendorInvestigationResultAction(formData)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 2) 파일이 있으면 업로드
+ if (values.attachments && values.attachments.length > 0) {
+ setUploadingFiles(true)
+
+ try {
+ await uploadFiles(values.attachments, values.investigationId)
+ toast.success(`실사 결과와 ${values.attachments.length}개 파일이 업데이트되었습니다!`)
+
+ // 첨부파일 목록 새로고침
+ loadExistingAttachments(values.investigationId)
+ } catch (fileError) {
+ console.error("파일 업로드 에러:", fileError)
+ toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`)
+ } finally {
+ setUploadingFiles(false)
+ }
+ } else {
+ toast.success("실사 결과가 업데이트되었습니다!")
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+
+ } catch (error) {
+ console.error("실사 결과 업데이트 오류:", error)
+ toast.error("실사 결과 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ // 디버깅을 위한 버튼 클릭 핸들러
+ const handleSaveClick = async () => {
+ console.log("저장 버튼 클릭됨")
+ console.log("현재 폼 값:", form.getValues())
+ console.log("폼 에러:", form.formState.errors)
+
+ // 폼 검증 실행
+ const isValid = await form.trigger()
+ console.log("폼 검증 결과:", isValid)
+
+ if (isValid) {
+ form.handleSubmit(onSubmit)()
+ } else {
+ console.log("폼 검증 실패, 에러:", form.formState.errors)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col h-full sm:max-w-xl" >
+ <SheetHeader className="text-left flex-shrink-0">
+ <SheetTitle>실사 결과 입력</SheetTitle>
+ <SheetDescription>
+ {investigation?.vendorName && (
+ <span className="font-medium">{investigation.vendorName}</span>
+ )}의 실사 결과를 입력합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ id="update-investigation-form"
+ >
+ {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */}
+ {/* <FormField
+ control={form.control}
+ name="investigationStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 상태</FormLabel>
+ <FormControl>
+ <Select value={field.value} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="상태를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PLANNED">계획됨</SelectItem>
+ <SelectItem value="IN_PROGRESS">진행 중</SelectItem>
+ <SelectItem value="COMPLETED">완료됨</SelectItem>
+ <SelectItem value="CANCELED">취소됨</SelectItem>
+ <SelectItem value="SUPPLEMENT_REQUIRED">보완 요구됨</SelectItem>
+ <SelectItem value="RESULT_SENT">실사결과발송</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+
+ {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
+ control={form.control}
+ name="investigationAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 주소</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사가 진행될 주소를 입력하세요..."
+ {...field}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+
+ {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
+ control={form.control}
+ name="investigationMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 방법</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="실사 방법을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
+ <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem>
+ <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+
+ {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
+ control={form.control}
+ name="forecastedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 수행 예정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+
+ {/* 실사 확정일 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
+ control={form.control}
+ name="confirmedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 계획 확정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+
+ {/* 실제 실사일 */}
+ <FormField
+ control={form.control}
+ name="completedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실제 실사일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 점수 */}
+ <FormField
+ control={form.control}
+ name="evaluationScore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 점수</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min={0}
+ max={100}
+ placeholder="0-100점"
+ {...field}
+ value={field.value || ""}
+ onChange={(e) => {
+ const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10)
+ field.onChange(value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 */}
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 결과를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="APPROVED">승인</SelectItem>
+ <SelectItem value="SUPPLEMENT">보완</SelectItem>
+ <SelectItem value="SUPPLEMENT_REINSPECT">보완-재실사</SelectItem>
+ <SelectItem value="SUPPLEMENT_DOCUMENT">보완-서류제출</SelectItem>
+ <SelectItem value="REJECTED">불가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* QM 의견 */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사에 대한 QM 의견을 입력하세요..."
+ {...field}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 섹션 */}
+ {renderFileUploadSection()}
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer Buttons */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline" disabled={isPending || uploadingFiles}>
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ disabled={isPending || uploadingFiles}
+ onClick={handleSaveClick}
+ >
+ {(isPending || uploadingFiles) && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx
index b5344a1e..28ecc2ec 100644
--- a/lib/vendor-investigation/table/investigation-table-columns.tsx
+++ b/lib/vendor-investigation/table/investigation-table-columns.tsx
@@ -5,7 +5,14 @@ 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 } from "lucide-react"
+import { Edit, Ellipsis, AlertTriangle } from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { formatDate } from "@/lib/utils"
@@ -24,6 +31,7 @@ interface GetVendorInvestigationsColumnsProps {
>
>
openVendorDetailsModal?: (vendorId: number) => void
+ openSupplementRequestDialog?: (investigationId: number, investigationMethod: string, vendorName: string) => void
}
// Helper function for investigation method variants
@@ -45,6 +53,7 @@ function getMethodVariant(method: string): "default" | "secondary" | "outline" |
export function getColumns({
setRowAction,
openVendorDetailsModal,
+ openSupplementRequestDialog,
}: GetVendorInvestigationsColumnsProps): ColumnDef<
VendorInvestigationsViewWithContacts
>[] {
@@ -86,20 +95,69 @@ export function getColumns({
cell: ({ row }) => {
const isCanceled = row.original.investigationStatus === "CANCELED"
const isCompleted = row.original.investigationStatus === "COMPLETED"
+ const canRequestSupplement = (row.original.investigationMethod === "PRODUCT_INSPECTION" ||
+ row.original.investigationMethod === "SITE_VISIT_EVAL") &&
+ row.original.investigationStatus === "COMPLETED" &&
+ (row.original.evaluationResult === "SUPPLEMENT" ||
+ row.original.evaluationResult === "SUPPLEMENT_REINSPECT" ||
+ row.original.evaluationResult === "SUPPLEMENT_DOCUMENT")
+
return (
- <Button
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- aria-label="실사 정보 수정"
- disabled={isCanceled}
- onClick={() => {
- if (!isCanceled || !isCompleted) {
- setRowAction?.({ type: "update", row })
- }
- }}
- >
- <Edit className="size-4" aria-hidden="true" />
- </Button>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem
+ onSelect={() => {
+ if (!isCanceled && row.original.investigationStatus === "PLANNED") {
+ setRowAction?.({ type: "update-progress", row })
+ }
+ }}
+ disabled={isCanceled || row.original.investigationStatus !== "PLANNED"}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 실사 진행 관리
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => {
+ if (!isCanceled && row.original.investigationStatus === "IN_PROGRESS") {
+ setRowAction?.({ type: "update-result", row })
+ }
+ }}
+ disabled={isCanceled || row.original.investigationStatus !== "IN_PROGRESS"}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 실사 결과 입력
+ </DropdownMenuItem>
+
+ {canRequestSupplement && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => {
+ openSupplementRequestDialog?.(
+ row.original.investigationId,
+ row.original.investigationMethod || "",
+ row.original.vendorName
+ )
+ }}
+ className="text-amber-600 focus:text-amber-600"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ 보완 요청
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
)
},
size: 40,
@@ -256,9 +314,9 @@ export function getColumns({
return (
<div className="flex flex-col">
<span>{value || "미배정"}</span>
- {row.original.requesterEmail && (
+ {row.original.requesterEmail ? (
<span className="text-xs text-muted-foreground">{row.original.requesterEmail}</span>
- )}
+ ) : null}
</div>
)
}
@@ -271,9 +329,9 @@ export function getColumns({
return (
<div className="flex flex-col">
<span>{value || "미배정"}</span>
- {row.original.qmManagerEmail && (
+ {row.original.qmManagerEmail ? (
<span className="text-xs text-muted-foreground">{row.original.qmManagerEmail}</span>
- )}
+ ) : null}
</div>
)
}
@@ -298,7 +356,7 @@ export function getColumns({
} else {
nestedColumns.push({
id: groupName,
- header: groupName,
+ header: groupName as any,
columns: colDefs,
})
}
@@ -325,6 +383,8 @@ function formatStatus(status: string): string {
return "완료됨"
case "CANCELED":
return "취소됨"
+ case "SUPPLEMENT_REQUIRED":
+ return "보완 요구됨"
case "RESULT_SENT":
return "실사결과발송"
default:
@@ -349,6 +409,10 @@ function formatEnumValue(value: string): string {
return "승인"
case "SUPPLEMENT":
return "보완"
+ case "SUPPLEMENT_REINSPECT":
+ return "보완-재실사"
+ case "SUPPLEMENT_DOCUMENT":
+ return "보완-서류제출"
case "REJECTED":
return "불가"
@@ -367,6 +431,10 @@ function getStatusVariant(status: string): "default" | "secondary" | "outline" |
return "outline"
case "CANCELED":
return "destructive"
+ case "SUPPLEMENT_REQUIRED":
+ return "secondary"
+ case "RESULT_SENT":
+ return "default"
default:
return "default"
}
@@ -380,6 +448,10 @@ function getResultVariant(result: string): "default" | "secondary" | "outline" |
return "default"
case "SUPPLEMENT":
return "secondary"
+ case "SUPPLEMENT_REINSPECT":
+ return "secondary"
+ case "SUPPLEMENT_DOCUMENT":
+ return "secondary"
case "REJECTED":
return "destructive"
default:
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
index b7663629..ee122f04 100644
--- a/lib/vendor-investigation/table/investigation-table.tsx
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -16,8 +16,10 @@ import { getColumns } from "./investigation-table-columns"
import { getVendorsInvestigation } from "../service"
import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions"
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
-import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet"
+import { InvestigationResultSheet } from "./investigation-result-sheet"
+import { InvestigationProgressSheet } from "./investigation-progress-sheet"
import { VendorDetailsDialog } from "./vendor-details-dialog"
+import { SupplementRequestDialog } from "@/components/investigation/supplement-request-dialog"
interface VendorsTableProps {
promises: Promise<
@@ -54,12 +56,34 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
const [vendorDetailsOpen, setVendorDetailsOpen] = React.useState(false)
const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ // Add state for supplement request dialog
+ const [supplementRequestOpen, setSupplementRequestOpen] = React.useState(false)
+ const [supplementRequestData, setSupplementRequestData] = React.useState<{
+ investigationId: number
+ investigationMethod: string
+ vendorName: string
+ } | null>(null)
+
// Create handler for opening vendor details modal
const openVendorDetailsModal = React.useCallback((vendorId: number) => {
setSelectedVendorId(vendorId)
setVendorDetailsOpen(true)
}, [])
+ // Create handler for opening supplement request dialog
+ const openSupplementRequestDialog = React.useCallback((
+ investigationId: number,
+ investigationMethod: string,
+ vendorName: string
+ ) => {
+ setSupplementRequestData({
+ investigationId,
+ investigationMethod,
+ vendorName
+ })
+ setSupplementRequestOpen(true)
+ }, [])
+
// Get router
const router = useRouter()
@@ -67,9 +91,10 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
const columns = React.useMemo(
() => getColumns({
setRowAction,
- openVendorDetailsModal
+ openVendorDetailsModal,
+ openSupplementRequestDialog
}),
- [setRowAction, openVendorDetailsModal]
+ [setRowAction, openVendorDetailsModal, openSupplementRequestDialog]
)
// 기본 필터 필드들
@@ -174,9 +199,15 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
</DataTableAdvancedToolbar>
</DataTable>
- {/* Update Investigation Sheet */}
- <UpdateVendorInvestigationSheet
- open={rowAction?.type === "update"}
+ {/* Update Investigation Sheets */}
+ <InvestigationProgressSheet
+ open={rowAction?.type === "update-progress"}
+ onOpenChange={() => setRowAction(null)}
+ investigation={rowAction?.row.original ?? null}
+ />
+
+ <InvestigationResultSheet
+ open={rowAction?.type === "update-result"}
onOpenChange={() => setRowAction(null)}
investigation={rowAction?.row.original ?? null}
/>
@@ -187,6 +218,14 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
onOpenChange={setVendorDetailsOpen}
vendorId={selectedVendorId}
/>
+
+ <SupplementRequestDialog
+ open={supplementRequestOpen}
+ onOpenChange={setSupplementRequestOpen}
+ investigationId={supplementRequestData?.investigationId || 0}
+ investigationMethod={supplementRequestData?.investigationMethod || ""}
+ vendorName={supplementRequestData?.vendorName || ""}
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx
index 9f7c8994..7daa9d44 100644
--- a/lib/vendor-investigation/table/update-investigation-sheet.tsx
+++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx
@@ -107,7 +107,7 @@ const getFileUploadConfig = (status: string) => {
}
/**
- * 실사 정보 수정 시트
+ * 실사 결과 입력 시트
*/
export function UpdateVendorInvestigationSheet({
investigation,
@@ -539,11 +539,11 @@ export function UpdateVendorInvestigationSheet({
<Sheet {...props}>
<SheetContent className="flex flex-col h-full sm:max-w-xl" >
<SheetHeader className="text-left flex-shrink-0">
- <SheetTitle>실사 업데이트</SheetTitle>
+ <SheetTitle>실사 결과 입력</SheetTitle>
<SheetDescription>
{investigation?.vendorName && (
<span className="font-medium">{investigation.vendorName}</span>
- )}의 실사 정보를 수정합니다.
+ )}의 실사 결과를 입력합니다.
</SheetDescription>
</SheetHeader>
@@ -554,8 +554,8 @@ export function UpdateVendorInvestigationSheet({
className="flex flex-col gap-4"
id="update-investigation-form"
>
- {/* 실사 상태 */}
- <FormField
+ {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */}
+ {/* <FormField
control={form.control}
name="investigationStatus"
render={({ field }) => (
@@ -572,6 +572,8 @@ export function UpdateVendorInvestigationSheet({
<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>
@@ -579,10 +581,10 @@ export function UpdateVendorInvestigationSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
- {/* 실사 주소 */}
- <FormField
+ {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
control={form.control}
name="investigationAddress"
render={({ field }) => (
@@ -598,10 +600,10 @@ export function UpdateVendorInvestigationSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
- {/* 실사 방법 */}
- <FormField
+ {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
control={form.control}
name="investigationMethod"
render={({ field }) => (
@@ -625,10 +627,10 @@ export function UpdateVendorInvestigationSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
- {/* 실사 수행 예정일 */}
- <FormField
+ {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
control={form.control}
name="forecastedAt"
render={({ field }) => (
@@ -662,10 +664,10 @@ export function UpdateVendorInvestigationSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
- {/* 실사 확정일 */}
- <FormField
+ {/* 실사 확정일 - 주석처리 (실사 진행 관리에서 처리) */}
+ {/* <FormField
control={form.control}
name="confirmedAt"
render={({ field }) => (
@@ -699,7 +701,7 @@ export function UpdateVendorInvestigationSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
{/* 실제 실사일 */}
<FormField
@@ -738,61 +740,59 @@ export function UpdateVendorInvestigationSheet({
)}
/>
- {/* 평가 점수 - 완료된 상태일 때만 표시 */}
- {form.watch("investigationStatus") === "COMPLETED" && (
- <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="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>
+ )}
+ />
- {/* 평가 결과 - 완료된 상태일 때만 표시 */}
- {form.watch("investigationStatus") === "COMPLETED" && (
- <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="REJECTED">불가</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </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
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index 0e84f13a..19412539 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -60,17 +60,64 @@ export const searchParamsInvestigationCache = createSearchParamsCache({
// Finally, export the type you can use in your server action:
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 type UpdateVendorInvestigationProgressSchema = z.infer<typeof updateVendorInvestigationProgressSchema>
+
+// 실사 결과 입력용 스키마
+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(),
+ investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+ attachments: z.any().optional(), // File 업로드를 위한 필드
+})
+
+export type UpdateVendorInvestigationResultSchema = z.infer<typeof updateVendorInvestigationResultSchema>
+
+// 기존 호환성을 위한 통합 스키마
export const updateVendorInvestigationSchema = z.object({
investigationId: z.number({
required_error: "Investigation ID is required",
}),
- investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"], {
+ investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "SUPPLEMENT_REQUIRED", "RESULT_SENT"], {
required_error: "실사 상태를 선택해주세요.",
}),
investigationAddress: z.string().optional(),
investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(),
- // 날짜 필드들을 string에서 Date로 변환하도록 수정
+ // 날짜 필드들
forecastedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
@@ -96,7 +143,7 @@ export const updateVendorInvestigationSchema = z.object({
.min(0, "평가 점수는 0점 이상이어야 합니다.")
.max(100, "평가 점수는 100점 이하여야 합니다.")
.optional(),
- evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED", "RESULT_SENT"]).optional(),
+ evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
attachments: z.any().optional(), // File 업로드를 위한 필드
})