From 2eb717eb2bbfd97a5f149d13049aa336c26c393b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 29 Oct 2025 07:43:44 +0000 Subject: (최겸) 구매 실사 개발(진행중) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pq-review-table-new/vendors-table-columns.tsx | 12 +- lib/pq/service.ts | 310 +++++++- lib/vendor-investigation/service.ts | 516 ++++++++++++- .../table/investigation-progress-sheet.tsx | 324 +++++++++ .../table/investigation-result-sheet.tsx | 808 +++++++++++++++++++++ .../table/investigation-table-columns.tsx | 110 ++- .../table/investigation-table.tsx | 51 +- .../table/update-investigation-sheet.tsx | 144 ++-- lib/vendor-investigation/validations.ts | 53 +- 9 files changed, 2213 insertions(+), 115 deletions(-) create mode 100644 lib/vendor-investigation/table/investigation-progress-sheet.tsx create mode 100644 lib/vendor-investigation/table/investigation-result-sheet.tsx (limited to 'lib') diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx index 30b1c83f..b4d7d038 100644 --- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx +++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx @@ -82,7 +82,7 @@ export interface PQSubmission { completedAt: Date | null forecastedAt: Date | null evaluationScore: number | null - evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | "RESULT_SENT" | null + evaluationResult: "APPROVED" | "SUPPLEMENT" | "SUPPLEMENT_REINSPECT" | "SUPPLEMENT_DOCUMENT" | "REJECTED" | "RESULT_SENT" | null investigationNotes: string | null } | null // 통합 상태를 위한 새 필드 @@ -327,6 +327,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const }; case "SUPPLEMENT": return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const }; + case "SUPPLEMENT_REINSPECT": + return { status: "INVESTIGATION_SUPPLEMENT_REINSPECT", label: "실사 보완-재실사", variant: "secondary" as const }; + case "SUPPLEMENT_DOCUMENT": + return { status: "INVESTIGATION_SUPPLEMENT_DOCUMENT", label: "실사 보완-서류제출", variant: "secondary" as const }; case "REJECTED": return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const }; default: @@ -336,6 +340,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const }; case "CANCELED": return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const }; + case "SUPPLEMENT_REQUIRED": + return { status: "INVESTIGATION_SUPPLEMENT_REQUIRED", label: "실사 보완 요구됨", variant: "secondary" as const }; case "RESULT_SENT": return { status: "INVESTIGATION_RESULT_SENT", label: "실사 결과 발송", variant: "success" as const }; default: @@ -398,6 +404,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return 승인; case "SUPPLEMENT": return 보완; + case "SUPPLEMENT_REINSPECT": + return 보완-재실사; + case "SUPPLEMENT_DOCUMENT": + return 보완-서류제출; case "REJECTED": return 불가; default: diff --git a/lib/pq/service.ts b/lib/pq/service.ts index b6640453..8b1986ce 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -1238,7 +1238,7 @@ export async function requestPqChangesAction({ await db .update(vendorPQSubmissions) .set({ - status: "IN_PROGRESS", // 변경 요청 상태로 설정 + status: "SUBMITTED", // 변경 요청 상태로 설정 updatedAt: new Date(), }) .where( @@ -2210,22 +2210,21 @@ export async function approvePQAction({ projectName = projectData?.name || 'Unknown Project'; } - // 5. PQ 상태 업데이트 + // 5. PQ 상태를 QM_REVIEWING으로 업데이트 (TO-BE: QM 검토 단계 추가) await db .update(vendorPQSubmissions) .set({ - status: "APPROVED", - approvedAt: currentDate, + status: "QM_REVIEWING", updatedAt: currentDate, }) .where(eq(vendorPQSubmissions.id, pqSubmissionId)); - // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항) + // 6. 일반 PQ인 경우 벤더 상태를 IN_PQ로 업데이트 (QM 검토 중) if (pqSubmission.type === "GENERAL") { await db .update(vendors) .set({ - status: "PQ_APPROVED", + status: "IN_PQ", updatedAt: currentDate, }) .where(eq(vendors.id, vendorId)); @@ -2235,21 +2234,21 @@ export async function approvePQAction({ if (vendor.email) { try { const emailSubject = pqSubmission.projectId - ? `[eVCP] Project PQ Approved for ${projectName}` - : "[eVCP] General PQ Approved"; + ? `[eVCP] Project PQ Under QM Review for ${projectName}` + : "[eVCP] General PQ Under QM Review"; const portalUrl = `${host}/partners/pq`; await sendEmail({ to: vendor.email, subject: emailSubject, - template: "pq-approved-vendor", + template: "pq-qm-review-vendor", context: { vendorName: vendor.vendorName, projectId: pqSubmission.projectId, projectName: projectName, isProjectPQ: !!pqSubmission.projectId, - approvedDate: currentDate.toLocaleString(), + reviewDate: currentDate.toLocaleString(), portalUrl, } }); @@ -2277,6 +2276,297 @@ export async function approvePQAction({ } } +// QM 검토 승인 액션 +export async function approveQMReviewAction({ + pqSubmissionId, + vendorId, +}: { + pqSubmissionId: number; + vendorId: number; +}) { + unstable_noStore(); + + try { + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const currentDate = new Date(); + + // 1. PQ 제출 정보 조회 + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + // 2. 상태 확인 (QM_REVIEWING 상태만 승인 가능) + if (pqSubmission.status !== "QM_REVIEWING") { + return { + ok: false, + error: `Cannot approve QM review in current status: ${pqSubmission.status}` + }; + } + + // 3. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 4. 프로젝트 정보 (프로젝트 PQ인 경우) + let projectName = ''; + if (pqSubmission.projectId) { + const projectData = await db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.id, pqSubmission.projectId)) + .then(rows => rows[0]); + + projectName = projectData?.name || 'Unknown Project'; + } + + // 5. PQ 상태를 QM_APPROVED로 업데이트 + await db + .update(vendorPQSubmissions) + .set({ + status: "QM_APPROVED", + approvedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 6. 일반 PQ인 경우 벤더 상태를 PQ_APPROVED로 업데이트 + if (pqSubmission.type === "GENERAL") { + await db + .update(vendors) + .set({ + status: "PQ_APPROVED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + // 7. 실사 요청 생성 (QM 승인 후 실사 프로세스 시작) + await db + .insert(vendorInvestigations) + .values({ + vendorId: vendorId, + pqSubmissionId: pqSubmissionId, + investigationStatus: "PLANNED", + investigationMethod: "DOCUMENT_EVAL", // 기본값, 나중에 변경 가능 + }); + + // 8. 벤더에게 이메일 알림 발송 + if (vendor.email) { + try { + const emailSubject = pqSubmission.projectId + ? `[eVCP] Project PQ Approved for ${projectName}` + : "[eVCP] General PQ Approved"; + + const portalUrl = `${host}/partners/pq`; + + await sendEmail({ + to: vendor.email, + subject: emailSubject, + template: "pq-approved-vendor", + context: { + vendorName: vendor.vendorName, + projectId: pqSubmission.projectId, + projectName: projectName, + isProjectPQ: !!pqSubmission.projectId, + approvedDate: currentDate.toLocaleString(), + portalUrl, + } + }); + } catch (emailError) { + console.error("Failed to send vendor notification:", emailError); + } + } + + // 9. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("pq-submissions"); + revalidateTag("vendor-pq-submissions"); + revalidateTag("vendor-investigations"); + revalidatePath("/evcp/pq_new"); + + return { ok: true }; + } catch (error) { + console.error("QM review approve error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + +// QM 검토 거절 액션 +export async function rejectQMReviewAction({ + pqSubmissionId, + vendorId, + rejectReason +}: { + pqSubmissionId: number; + vendorId: number; + rejectReason: string; +}) { + unstable_noStore(); + + try { + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const currentDate = new Date(); + + // 1. PQ 제출 정보 조회 + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + // 2. 상태 확인 (QM_REVIEWING 상태만 거절 가능) + if (pqSubmission.status !== "QM_REVIEWING") { + return { + ok: false, + error: `Cannot reject QM review in current status: ${pqSubmission.status}` + }; + } + + // 3. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 4. 프로젝트 정보 (프로젝트 PQ인 경우) + let projectName = ''; + if (pqSubmission.projectId) { + const projectData = await db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.id, pqSubmission.projectId)) + .then(rows => rows[0]); + + projectName = projectData?.name || 'Unknown Project'; + } + + // 5. PQ 상태를 QM_REJECTED로 업데이트 + await db + .update(vendorPQSubmissions) + .set({ + status: "QM_REJECTED", + rejectedAt: currentDate, + rejectReason: rejectReason, + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 6. 일반 PQ인 경우 벤더 상태를 PQ_FAILED로 업데이트 + if (pqSubmission.type === "GENERAL") { + await db + .update(vendors) + .set({ + status: "PQ_FAILED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + // 7. 벤더에게 이메일 알림 발송 + if (vendor.email) { + try { + const emailSubject = pqSubmission.projectId + ? `[eVCP] Project PQ Rejected for ${projectName}` + : "[eVCP] General PQ Rejected"; + + const portalUrl = `${host}/partners/pq`; + + await sendEmail({ + to: vendor.email, + subject: emailSubject, + template: "pq-rejected-vendor", + context: { + vendorName: vendor.vendorName, + projectId: pqSubmission.projectId, + projectName: projectName, + isProjectPQ: !!pqSubmission.projectId, + rejectedDate: currentDate.toLocaleString(), + rejectReason: rejectReason, + portalUrl, + } + }); + } catch (emailError) { + console.error("Failed to send vendor notification:", emailError); + } + } + + // 8. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("pq-submissions"); + revalidateTag("vendor-pq-submissions"); + revalidatePath("/evcp/pq_new"); + + return { ok: true }; + } catch (error) { + console.error("QM review reject error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + // PQ 거부 액션 export async function rejectPQAction({ pqSubmissionId, 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 = {} + 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 = {} + 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 { + investigation: VendorInvestigationsViewWithContacts | null +} + +/** + * 실사 진행 관리 시트 + */ +export function InvestigationProgressSheet({ + investigation, + ...props +}: InvestigationProgressSheetProps) { + const [isPending, startTransition] = React.useTransition() + + // RHF + Zod + const form = useForm({ + 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 ( + + + + 실사 진행 관리 + + {investigation?.vendorName && ( + {investigation.vendorName} + )}의 실사 진행 정보를 관리합니다. + + + +
+
+ + {/* 실사 주소 */} + ( + + 실사 주소 + +