summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-30 10:44:47 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-30 10:44:47 +0000
commit871a6d46a769cbe9e87146434f4bcb2d6792ab81 (patch)
treeacc25b3645e2253625e68f3721a203131ff4f3c4
parent17b9d2016be7c0ab6571de6aba36b3f4ea37bdb1 (diff)
(최겸) 구매 PQ/실사 재개발(테스트 필요), 정규업체등록 결재 개발, 실사 의뢰 결재 후처리 등
-rw-r--r--components/additional-info/join-form.tsx8
-rw-r--r--components/pq-input/pq-review-wrapper.tsx92
-rw-r--r--config/vendorInvestigationsColumnsConfig.ts2
-rw-r--r--config/vendorRegularRegistrationsColumnsConfig.ts23
-rw-r--r--db/schema/pq.ts19
-rw-r--r--db/schema/vendorRegistrations.ts2
-rw-r--r--lib/approval/handlers-registry.ts7
-rw-r--r--lib/mail/templates/pq-approved-vendor.hbs7
-rw-r--r--lib/mail/templates/pq-investigation-supplement-request.hbs54
-rw-r--r--lib/mail/templates/pq-rejected-vendor.hbs7
-rw-r--r--lib/mail/templates/pq-submitted-vendor.hbs9
-rw-r--r--lib/mail/templates/pq-supplement-request.hbs56
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx6
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx135
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx43
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx91
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx5
-rw-r--r--lib/pq/service.ts266
-rw-r--r--lib/site-visit/service.ts131
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx110
-rw-r--r--lib/vendor-investigation/approval-actions.ts1
-rw-r--r--lib/vendor-investigation/handlers.ts3
-rw-r--r--lib/vendor-investigation/service.ts139
-rw-r--r--lib/vendor-investigation/table/investigation-cancel-plan-button.tsx91
-rw-r--r--lib/vendor-investigation/table/investigation-progress-sheet.tsx14
-rw-r--r--lib/vendor-investigation/table/investigation-result-sheet.tsx580
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx60
-rw-r--r--lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx4
-rw-r--r--lib/vendor-investigation/validations.ts71
-rw-r--r--lib/vendor-regular-registrations/approval-actions.ts14
-rw-r--r--lib/vendor-regular-registrations/handlers.ts56
-rw-r--r--lib/vendor-regular-registrations/repository.ts5
-rw-r--r--lib/vendor-regular-registrations/service.ts487
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx19
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx5
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx1
36 files changed, 1629 insertions, 994 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx
index 8dca4b61..afe38841 100644
--- a/components/additional-info/join-form.tsx
+++ b/components/additional-info/join-form.tsx
@@ -1129,11 +1129,11 @@ export function InfoForm() {
{registrationData.registration && (
<div className="flex items-center gap-2 p-3 bg-muted/30 rounded-lg">
<Badge variant="secondary">
- {registrationData.registration.status === 'under_review' && '검토중'}
+ {registrationData.registration.status === 'audit_pass' && '검토중'}
{registrationData.registration.status === 'approval_ready' && '조건충족'}
- {registrationData.registration.status === 'in_review' && '정규등록검토'}
- {registrationData.registration.status === 'completed' && '등록완료'}
- {registrationData.registration.status === 'pending_approval' && '장기미등록'}
+ {registrationData.registration.status === 'pending_approval' && '결재진행중'}
+ {registrationData.registration.status === 'registration_completed' && '등록완료'}
+ {registrationData.registration.status === 'registration_failed' && '등록실패'}
</Badge>
</div>
)}
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
index 9b719644..bcb384d9 100644
--- a/components/pq-input/pq-review-wrapper.tsx
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -23,7 +23,7 @@ import {
import { useToast } from "@/hooks/use-toast"
import { CheckCircle, AlertCircle, Paperclip, Square } from "lucide-react"
import { PQGroupData } from "@/lib/pq/service"
-import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction } from "@/lib/pq/service"
+import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction, requestPqSupplementAction } from "@/lib/pq/service"
// import * as ExcelJS from 'exceljs';
// import { saveAs } from "file-saver";
@@ -67,10 +67,13 @@ export function PQReviewWrapper({
const [showRejectDialog, setShowRejectDialog] = React.useState(false)
const [showQMApproveDialog, setShowQMApproveDialog] = React.useState(false)
const [showQMRejectDialog, setShowQMRejectDialog] = React.useState(false)
+ const [showSupplementDialog, setShowSupplementDialog] = React.useState(false)
const [rejectReason, setRejectReason] = React.useState("")
const [qmRejectReason, setQmRejectReason] = React.useState("")
+ const [supplementComment, setSupplementComment] = React.useState("")
const [shiComments, setShiComments] = React.useState<Record<number, string>>({})
const [isUpdatingComment, setIsUpdatingComment] = React.useState<number | null>(null)
+ const [isSendingSupplement, setIsSendingSupplement] = React.useState(false)
// 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서)
@@ -108,7 +111,7 @@ export function PQReviewWrapper({
setShiComments(initialComments)
}, [pqData])
- // PQ 승인 처리 (구매 담당자)
+ // PQ 승인 처리
const handleApprove = async () => {
try {
setIsApproving(true)
@@ -121,10 +124,10 @@ export function PQReviewWrapper({
if (result.ok) {
toast({
title: "PQ 승인 완료",
- description: "PQ가 QM 검토 단계로 전환되었습니다.",
+ description: "PQ가 성공적으로 승인되었습니다.",
})
// 페이지 새로고침
- router.refresh()
+ router.push(`/evcp/pq_new/`)
} else {
toast({
title: "승인 실패",
@@ -182,6 +185,35 @@ export function PQReviewWrapper({
}
}
+ // 보완요청 처리
+ const handleRequestSupplement = async () => {
+ if (!supplementComment.trim()) {
+ toast({ title: "보완요청 내용 필요", description: "보완요청 사유를 입력해주세요.", variant: "destructive" })
+ return
+ }
+ try {
+ setIsSendingSupplement(true)
+ const result = await requestPqSupplementAction({
+ pqSubmissionId: pqSubmission.id,
+ vendorId,
+ comment: supplementComment,
+ })
+ if (result.ok) {
+ toast({ title: "보완요청 전송", description: "보완요청 메일을 발송했습니다." })
+ setShowSupplementDialog(false)
+ setSupplementComment("")
+ router.refresh()
+ } else {
+ toast({ title: "전송 실패", description: result.error || "보완요청 전송 중 오류가 발생했습니다.", variant: "destructive" })
+ }
+ } catch (e) {
+ console.error(e)
+ toast({ title: "전송 실패", description: "보완요청 전송 중 오류가 발생했습니다.", variant: "destructive" })
+ } finally {
+ setIsSendingSupplement(false)
+ }
+ }
+
// QM 거절 처리
const handleQMReject = async () => {
if (!qmRejectReason.trim()) {
@@ -426,8 +458,8 @@ export function PQReviewWrapper({
title: "PQ 거부 완료",
description: "PQ가 거부되었습니다.",
})
- // 페이지 새로고침
- router.refresh()
+ // 페이지 리다이렉트
+ router.push(`/evcp/pq_new/`)
} else {
toast({
title: "거부 실패",
@@ -746,6 +778,13 @@ export function PQReviewWrapper({
{isRejecting ? "거부 중..." : "거부"}
</Button>
<Button
+ variant="secondary"
+ onClick={() => setShowSupplementDialog(true)}
+ disabled={isSendingSupplement}
+ >
+ 보완요청
+ </Button>
+ <Button
variant="default"
onClick={() => setShowApproveDialog(true)}
disabled={isApproving}
@@ -756,7 +795,7 @@ export function PQReviewWrapper({
)}
{/* QM_REVIEWING 상태: QM 승인/거절 */}
- {pqSubmission.status === "QM_REVIEWING" && (
+ {pqSubmission.status === "APPROVED" && (
<>
<Button
variant="outline"
@@ -766,6 +805,13 @@ export function PQReviewWrapper({
{isQMRejecting ? "QM 거절 중..." : "QM 거절"}
</Button>
<Button
+ variant="secondary"
+ onClick={() => setShowSupplementDialog(true)}
+ disabled={isSendingSupplement}
+ >
+ 보완요청
+ </Button>
+ <Button
variant="default"
onClick={() => setShowQMApproveDialog(true)}
disabled={isQMApproving}
@@ -802,17 +848,17 @@ export function PQReviewWrapper({
</div>
- {/* 구매 승인 확인 다이얼로그 */}
+ {/* 승인 확인 다이얼로그 */}
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
<DialogContent>
<DialogHeader>
- <DialogTitle>PQ 구매 승인 확인</DialogTitle>
+ <DialogTitle>PQ 승인 확인</DialogTitle>
<DialogDescription>
{pqSubmission.vendorName || "알 수 없는 업체"}의 {
pqSubmission.type === "GENERAL" ? "일반" :
pqSubmission.type === "PROJECT" ? "프로젝트" :
pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반"
- } PQ를 구매 승인하여 QM 검토 단계로 전환하시겠습니까?
+ } PQ를 승인하시겠습니까?
{pqSubmission.projectId && (
<span> 프로젝트: {pqSubmission.projectName}</span>
)}
@@ -829,6 +875,32 @@ export function PQReviewWrapper({
</DialogContent>
</Dialog>
+ {/* 보완요청 다이얼로그 */}
+ <Dialog open={showSupplementDialog} onOpenChange={setShowSupplementDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>PQ 보완요청</DialogTitle>
+ <DialogDescription>
+ {pqSubmission.vendorName || "알 수 없는 업체"}에 보완요청을 전송합니다. 내용(사유)을 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <Textarea
+ value={supplementComment}
+ onChange={(e) => setSupplementComment(e.target.value)}
+ placeholder="보완요청 사유를 입력하세요"
+ className="min-h-24"
+ />
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowSupplementDialog(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleRequestSupplement} disabled={isSendingSupplement || !supplementComment.trim()}>
+ {isSendingSupplement ? "전송 중..." : "보완요청 전송"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
{/* QM 승인 확인 다이얼로그 */}
<Dialog open={showQMApproveDialog} onOpenChange={setShowQMApproveDialog}>
<DialogContent>
diff --git a/config/vendorInvestigationsColumnsConfig.ts b/config/vendorInvestigationsColumnsConfig.ts
index 44a0cf09..1fab1de6 100644
--- a/config/vendorInvestigationsColumnsConfig.ts
+++ b/config/vendorInvestigationsColumnsConfig.ts
@@ -8,7 +8,7 @@ export type VendorInvestigationsViewRaw = {
pqSubmissionId: number | null
requesterId: number | null
qmManagerId: number | null
- investigationStatus: "PLANNED" | "IN_PROGRESS" | "COMPLETED" | "CANCELED" | "SUPPLEMENT_REQUIRED" | "RESULT_SENT"
+ investigationStatus: "PLANNED" | "QM_REVIEW_CONFIRMED" | "IN_PROGRESS" | "COMPLETED" | "CANCELED" | "SUPPLEMENT_REQUIRED" | "RESULT_SENT"
investigationAddress: string | null
investigationMethod: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" | null
scheduledStartAt: Date | null
diff --git a/config/vendorRegularRegistrationsColumnsConfig.ts b/config/vendorRegularRegistrationsColumnsConfig.ts
index c69b4378..5cee82e5 100644
--- a/config/vendorRegularRegistrationsColumnsConfig.ts
+++ b/config/vendorRegularRegistrationsColumnsConfig.ts
@@ -137,27 +137,22 @@ export const vendorRegularRegistrationsColumnsConfig: VendorRegularRegistrationC
];
export const statusLabels: Record<string, string> = {
- audit_pass: "실사통과",
- cp_submitted: "CP등록",
- cp_review: "CP검토",
- cp_finished: "CP완료",
+ under_review: "검토중",
approval_ready: "조건충족",
- registration_requested: "등록요청됨",
- in_review: "정규등록검토",
- pending_approval: "장기미등록",
+ pending_approval: "결재진행중",
+ registration_completed: "등록완료",
+ registration_failed: "등록실패",
};
export const statusColors: Record<string, string> = {
- audit_pass: "bg-blue-100 text-blue-800",
- cp_submitted: "bg-green-100 text-green-800",
- cp_review: "bg-yellow-100 text-yellow-800",
- cp_finished: "bg-purple-100 text-purple-800",
+ under_review: "bg-blue-100 text-blue-800",
approval_ready: "bg-emerald-100 text-emerald-800",
- registration_requested: "bg-indigo-100 text-indigo-800",
- in_review: "bg-orange-100 text-orange-800",
- pending_approval: "bg-red-100 text-red-800",
+ pending_approval: "bg-yellow-100 text-yellow-800",
+ registration_completed: "bg-green-100 text-green-800",
+ registration_failed: "bg-red-100 text-red-800",
};
+
export const documentStatusColumns: { key: keyof VendorRegularRegistration["documentSubmissions"]; label: string }[] = [
{ key: "businessRegistration", label: "사업자등록증" },
{ key: "creditEvaluation", label: "신용평가서" },
diff --git a/db/schema/pq.ts b/db/schema/pq.ts
index b233119f..11d55473 100644
--- a/db/schema/pq.ts
+++ b/db/schema/pq.ts
@@ -132,13 +132,13 @@ export const vendorPQSubmissions = pgTable("vendor_pq_submissions", {
status: varchar("status", {
length: 20,
enum: [
- "REQUESTED",
- "SUBMITTED",
- "APPROVED",
- "REJECTED",
+ "REQUESTED", // PQ 요청됨
+ "SUBMITTED", // PQ 제출됨
+ "APPROVED", // PQ 승인됨
+ "REJECTED", // PQ 거절됨
"QM_REVIEWING", // QM 검토중
- "QM_APPROVED", // QM 승인
- "QM_REJECTED" // QM 거절
+ "QM_APPROVED", // QM 승인됨
+ "QM_REJECTED" // QM 거절됨
]
}).notNull().default("REQUESTED"),
dueDate: timestamp("due_date"),
@@ -255,6 +255,7 @@ export const vendorInvestigations = pgTable("vendor_investigations", {
length: 50,
enum: [
"PLANNED", // 계획됨
+ "QM_REVIEW_CONFIRMED", // QM 검토 확정
"IN_PROGRESS", // 진행 중
"COMPLETED", // 완료됨
"CANCELED", // 취소됨
@@ -265,6 +266,11 @@ export const vendorInvestigations = pgTable("vendor_investigations", {
.notNull()
.default("PLANNED"),
+ // 보완요청 여부 (최초 보완 요청이 있었는지 기록)
+ hasSupplementRequested: boolean("has_supplement_requested")
+ .notNull()
+ .default(false),
+
// 실사 주소
investigationAddress: text("investigation_address"),
@@ -403,6 +409,7 @@ export const vendorInvestigationsView = pgView(
evaluationScore: vendorInvestigations.evaluationScore,
evaluationResult: vendorInvestigations.evaluationResult,
investigationNotes: vendorInvestigations.investigationNotes,
+ hasSupplementRequested: vendorInvestigations.hasSupplementRequested,
createdAt: vendorInvestigations.createdAt,
updatedAt: vendorInvestigations.updatedAt,
diff --git a/db/schema/vendorRegistrations.ts b/db/schema/vendorRegistrations.ts
index d6030a75..b5f13ca3 100644
--- a/db/schema/vendorRegistrations.ts
+++ b/db/schema/vendorRegistrations.ts
@@ -16,7 +16,7 @@ import { vendors } from "./vendors";
export const vendorRegularRegistrations = pgTable("vendor_regular_registrations", {
id: serial("id").primaryKey(),
vendorId: integer("vendor_id").notNull().references(() => vendors.id),
- status: varchar("status", { length: 50 }).notNull().default("audit_pass"), // audit_pass, cp_submitted, cp_review, cp_finished, approval_ready, in_review, pending_approval
+ status: varchar("status", { length: 50 }).notNull().default("audit_pass"), // audit_pass, approval_ready, pending_approval, registration_completed, registration_failed
potentialCode: varchar("potential_code", { length: 20 }), // 잠재코드
majorItems: text("major_items"), // 주요품목 (JSON 형태로 저장)
registrationRequestDate: date("registration_request_date"), // 등록요청일
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index 1db79974..6306f811 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -1,5 +1,5 @@
/**
- * 결재 액션 핸들러 중앙 등록소
+ * 결재 후 실행될 액션 핸들러 중앙 등록소
*
* 모든 결재 가능한 액션의 핸들러를 한 곳에서 등록
* instrumentation.ts 또는 Next.js middleware에서 import하여 초기화
@@ -20,10 +20,10 @@ export async function initializeApprovalHandlers() {
reRequestPQInvestigationInternal
} = await import('@/lib/vendor-investigation/handlers');
- // PQ 실사의뢰 핸들러 등록
+ // PQ 실사의뢰 핸들러 등록 (결재 승인 후 실행될 함수 requestPQInvestigationInternal )
registerActionHandler('pq_investigation_request', requestPQInvestigationInternal);
- // PQ 실사 재의뢰 핸들러 등록
+ // PQ 실사 재의뢰 핸들러 등록 (결재 승인 후 실행될 함수 reRequestPQInvestigationInternal )
registerActionHandler('pq_investigation_rerequest', reRequestPQInvestigationInternal);
// 2. 발주 요청 핸들러
@@ -32,6 +32,7 @@ export async function initializeApprovalHandlers() {
// 3. 정규업체 등록 핸들러
const { registerVendorInternal } = await import('@/lib/vendor-regular-registrations/handlers');
+ // 정규업체 등록 핸들러 등록 (결재 승인 후 실행될 함수 registerVendorInternal )
registerActionHandler('vendor_regular_registration', registerVendorInternal);
// 4. 계약 승인 핸들러
diff --git a/lib/mail/templates/pq-approved-vendor.hbs b/lib/mail/templates/pq-approved-vendor.hbs
index 1ded76ef..e357958a 100644
--- a/lib/mail/templates/pq-approved-vendor.hbs
+++ b/lib/mail/templates/pq-approved-vendor.hbs
@@ -97,11 +97,12 @@
</div>
<!-- Action Button -->
- <div style="text-align: center; margin: 24px 0;">
- <a href="{{portalUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin: 8px 0;">
+ <p style="margin: 24px 0; text-align: center;">
+ <a class="btn" href="{{portalUrl}}" target="_blank" rel="noopener"
+ style="display: inline-block; background: #163CC4; color: #fff !important; text-decoration: none; padding: 12px 20px; border-radius: 8px; font-weight: 600; width: 250px; text-align: center; line-height: 28px;">
협력업체 포털로 이동
</a>
- </div>
+ </p>
<!-- Support Message -->
<p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;">
diff --git a/lib/mail/templates/pq-investigation-supplement-request.hbs b/lib/mail/templates/pq-investigation-supplement-request.hbs
new file mode 100644
index 00000000..c869b2bd
--- /dev/null
+++ b/lib/mail/templates/pq-investigation-supplement-request.hbs
@@ -0,0 +1,54 @@
+<!doctype html>
+<html lang="ko">
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="x-ua-compatible" content="ie=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>실사 보완요청</title>
+ <style>
+ body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Noto Sans KR, Apple SD Gothic Neo, Helvetica, Arial, sans-serif; color: #111827; }
+ .container { max-width: 640px; margin: 0 auto; padding: 24px; background: #ffffff; }
+ .header { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
+ .muted { color: #6b7280; font-size: 13px; }
+ .section { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 16px 0; background: #fafafa; }
+ .label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
+ .value { margin-bottom: 12px; }
+ .button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; }
+ .footer { border-top: 1px solid #e5e7eb; padding-top: 16px; margin-top: 32px; text-align: center; font-size: 12px; color: #6b7280; }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1 class="header">실사 보완요청 안내</h1>
+ <p class="muted">eVCP 실사 시스템에서 실사 보완요청이 접수되었습니다.</p>
+
+ <div class="section">
+ <div class="label">협력업체</div>
+ <div class="value"><strong>{{vendorName}}</strong></div>
+
+ <div class="label">실사 번호</div>
+ <div class="value">{{investigationNumber}}</div>
+
+ <div class="label">보완 내용</div>
+ <div class="value">{{supplementComment}}</div>
+
+ <div class="label">요청일</div>
+ <div class="value">{{requestedAt}}</div>
+ </div>
+
+ <div style="text-align: center; margin: 32px 0;">
+ <a href="{{reviewUrl}}" class="button">실사 확인 및 보완하기</a>
+ </div>
+
+ <p style="color: #374151; line-height: 1.5;">
+ 위 보완 사항을 확인하시고, 시스템에서 필요한 자료를 제출해 주시기 바랍니다.<br/>
+ 추가 문의사항이 있으시면 담당자에게 연락 부탁드립니다.
+ </p>
+
+ <div class="footer">
+ <p>이 메일은 자동으로 발송된 것입니다.<br/>
+ © {{year}} eVCP. All rights reserved.</p>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/lib/mail/templates/pq-rejected-vendor.hbs b/lib/mail/templates/pq-rejected-vendor.hbs
index 3cb8aea6..f75e9037 100644
--- a/lib/mail/templates/pq-rejected-vendor.hbs
+++ b/lib/mail/templates/pq-rejected-vendor.hbs
@@ -114,11 +114,12 @@
</div>
<!-- Action Button -->
- <div style="text-align: center; margin: 24px 0;">
- <a href="{{portalUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin: 8px 0;">
+ <p style="margin: 24px 0; text-align: center;">
+ <a class="btn" href="{{portalUrl}}" target="_blank" rel="noopener"
+ style="display: inline-block; background: #163CC4; color: #fff !important; text-decoration: none; padding: 12px 20px; border-radius: 8px; font-weight: 600; width: 250px; text-align: center; line-height: 28px;">
협력업체 포털로 이동
</a>
- </div>
+ </p>
<!-- Support Message -->
<p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;">
diff --git a/lib/mail/templates/pq-submitted-vendor.hbs b/lib/mail/templates/pq-submitted-vendor.hbs
index 0d35c33b..8e40334a 100644
--- a/lib/mail/templates/pq-submitted-vendor.hbs
+++ b/lib/mail/templates/pq-submitted-vendor.hbs
@@ -57,10 +57,11 @@
대시보드에 접속하여 제출 상태를 확인하고 업체 프로필을 관리하실 수 있습니다.
</p>
-<p>
- <a href="{{portalUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;">
- 협력업체 포털로 이동
- </a>
+<p style="margin: 24px 0; text-align: center;">
+ <a class="btn" href="{{portalUrl}}" target="_blank" rel="noopener"
+ style="display: inline-block; background: #163CC4; color: #fff !important; text-decoration: none; padding: 12px 20px; border-radius: 8px; font-weight: 600; width: 250px; text-align: center; line-height: 28px;">
+ 협력업체 포털로 이동
+ </a>
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
diff --git a/lib/mail/templates/pq-supplement-request.hbs b/lib/mail/templates/pq-supplement-request.hbs
new file mode 100644
index 00000000..68ea64f6
--- /dev/null
+++ b/lib/mail/templates/pq-supplement-request.hbs
@@ -0,0 +1,56 @@
+<!doctype html>
+<html lang="ko">
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="x-ua-compatible" content="ie=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>PQ 보완요청</title>
+ <style>
+ body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Noto Sans KR, Apple SD Gothic Neo, Helvetica, Arial, sans-serif; color: #111827; }
+ .container { max-width: 640px; margin: 0 auto; padding: 24px; background: #ffffff; }
+ .header { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
+ .muted { color: #6b7280; font-size: 13px; }
+ .section { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 16px 0; background: #fafafa; }
+ .label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
+ .value { font-size: 14px; color: #111827; white-space: pre-wrap; }
+ .btn { display: inline-block; background: #0ea5e9; color: #fff; text-decoration: none; padding: 10px 14px; border-radius: 6px; font-weight: 600; }
+ .footer { margin-top: 24px; font-size: 12px; color: #6b7280; }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1 class="header">[eVCP] PQ 보완요청 안내</h1>
+
+ <p>
+ 안녕하세요, <strong>{{vendorName}}</strong> 담당자님.<br/>
+ 아래 PQ에 대해 보완 요청 사항이 접수되었습니다.
+ </p>
+
+ <div class="section">
+ <div class="label">PQ 번호</div>
+ <div class="value">{{pqNumber}}</div>
+ </div>
+
+ <div class="section">
+ <div class="label">보완 요청 내용</div>
+ <div class="value">{{comment}}</div>
+ </div>
+
+ {{#if reviewUrl}}
+ <p style="margin: 20px 0;">
+ <a class="btn" href="{{reviewUrl}}" target="_blank" rel="noopener">PQ 확인 및 수정하기</a>
+ </p>
+ {{/if}}
+
+ <p class="muted">
+ 본 메일은 시스템에서 자동 발송되었습니다. 문의가 필요하신 경우 회신 또는 담당자에게 연락해 주세요.
+ </p>
+
+ <div class="footer">
+ © {{year}} Samsung Heavy Industries. All rights reserved.
+ </div>
+ </div>
+ </body>
+ </html>
+
+
diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
index c4057798..8e139b79 100644
--- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -51,7 +51,7 @@ const editInvestigationSchema = z.object({
z.string().transform((str) => str ? new Date(str) : undefined)
]).optional(),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(),
- investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+ investigationNotes: z.string().max(1000, "구매 의견은 1000자 이내로 입력해주세요.").optional(),
attachments: z.array(z.instanceof(File)).optional(),
})
@@ -210,9 +210,9 @@ export function EditInvestigationDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>실사 정보 수정</DialogTitle>
+ <DialogTitle>구매자체평가 실사 결과 수정</DialogTitle>
<DialogDescription>
- 구매자체평가 실사 정보를 수정합니다.
+ 구매자체평가 실사 결과를 수정합니다.
</DialogDescription>
</DialogHeader>
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
index 2b65d03e..b1474150 100644
--- a/lib/pq/pq-review-table-new/site-visit-dialog.tsx
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -140,6 +140,7 @@ interface SiteVisitDialogProps {
projectCode?: string
pqItems?: Array<{itemCode: string, itemName: string}> | null
}
+ isReinspection?: boolean // 재실사 모드 플래그
}
export function SiteVisitDialog({
@@ -147,6 +148,7 @@ export function SiteVisitDialog({
onClose,
onSubmit,
investigation,
+ isReinspection = false,
}: SiteVisitDialogProps) {
const [isPending, setIsPending] = React.useState(false)
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
@@ -184,58 +186,88 @@ export function SiteVisitDialog({
},
})
- // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 확인
+ // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 로딩
React.useEffect(() => {
if (isOpen) {
- // 기존 방문실사 요청이 있는지 확인
- const checkExistingRequest = async () => {
+ const loadExistingRequest = async () => {
try {
+ // 기존 방문실사 요청이 있는지 확인하고 최신 것을 로드
const existingRequest = await getSiteVisitRequestAction(investigation.id)
-
+
if (existingRequest.success && existingRequest.data) {
- toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
- onClose()
+ // 기존 데이터를 form에 로드
+ const data = existingRequest.data
+ form.reset({
+ inspectionDuration: data.inspectionDuration || 1.0,
+ requestedStartDate: data.requestedStartDate ? new Date(data.requestedStartDate) : undefined,
+ requestedEndDate: data.requestedEndDate ? new Date(data.requestedEndDate) : undefined,
+ shiAttendees: data.shiAttendees || {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: data.shiAttendeeDetails || "",
+ vendorRequests: data.vendorRequests || {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: data.otherVendorRequests || "",
+ additionalRequests: data.additionalRequests || "",
+ })
return
}
+
+ // 기본값으로 폼 초기화 (기존 요청이 없는 경우)
+ form.reset({
+ inspectionDuration: 1.0,
+ requestedStartDate: undefined,
+ requestedEndDate: undefined,
+ shiAttendees: {
+ technicalSales: { checked: false, count: 0, details: "" },
+ design: { checked: false, count: 0, details: "" },
+ procurement: { checked: false, count: 0, details: "" },
+ quality: { checked: false, count: 0, details: "" },
+ production: { checked: false, count: 0, details: "" },
+ commissioning: { checked: false, count: 0, details: "" },
+ other: { checked: false, count: 0, details: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ })
} catch (error) {
- console.error("방문실사 요청 상태 확인 중 오류:", error)
- toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ console.error("방문실사 요청 데이터 로드 중 오류:", error)
+ toast.error("방문실사 요청 데이터 로드 중 오류가 발생했습니다.")
onClose()
return
}
}
-
- checkExistingRequest()
-
- form.reset({
- inspectionDuration: 1.0,
- requestedStartDate: undefined,
- requestedEndDate: undefined,
- shiAttendees: {
- technicalSales: { checked: false, count: 0, details: "" },
- design: { checked: false, count: 0, details: "" },
- procurement: { checked: false, count: 0, details: "" },
- quality: { checked: false, count: 0, details: "" },
- production: { checked: false, count: 0, details: "" },
- commissioning: { checked: false, count: 0, details: "" },
- other: { checked: false, count: 0, details: "" },
- },
- shiAttendeeDetails: "",
- vendorRequests: {
- availableDates: false,
- factoryName: false,
- factoryLocation: false,
- factoryAddress: false,
- factoryPicName: false,
- factoryPicPhone: false,
- factoryPicEmail: false,
- factoryDirections: false,
- accessProcedure: false,
- other: false,
- },
- otherVendorRequests: "",
- additionalRequests: "",
- })
+
+ loadExistingRequest()
setSelectedFiles([])
}
}, [isOpen, form, investigation.id, onClose])
@@ -243,19 +275,11 @@ export function SiteVisitDialog({
async function handleSubmit(data: SiteVisitRequestFormValues) {
setIsPending(true)
try {
- // 제출 전에 한 번 더 기존 요청이 있는지 확인
- const existingRequest = await getSiteVisitRequestAction(investigation.id)
-
- if (existingRequest.success && existingRequest.data) {
- toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
- onClose()
- return
- }
-
+
await onSubmit(data, selectedFiles)
- toast.success("방문실사 요청이 성공적으로 발송되었습니다.")
+ toast.success(isReinspection ? "재실사 요청이 성공적으로 발송되었습니다." : "방문실사 요청이 성공적으로 발송되었습니다.")
} catch (error) {
- toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ toast.error(isReinspection ? "재실사 요청 발송 중 오류가 발생했습니다." : "방문실사 요청 발송 중 오류가 발생했습니다.")
console.error("방문실사 요청 오류:", error)
} finally {
setIsPending(false)
@@ -294,9 +318,12 @@ export function SiteVisitDialog({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>방문실사 요청 생성</DialogTitle>
+ <DialogTitle>{isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"}</DialogTitle>
<DialogDescription>
- 협력업체에 방문실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다.
+ {isReinspection
+ ? "협력업체에 재실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다."
+ : "협력업체에 방문실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다."
+ }
</DialogDescription>
</DialogHeader>
@@ -710,7 +737,7 @@ export function SiteVisitDialog({
취소
</Button>
<Button type="submit" disabled={isPending}>
- {isPending ? "처리 중..." : "방문실사 요청 생성"}
+ {isPending ? "처리 중..." : (isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성")}
</Button>
</DialogFooter>
</form>
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 b4d7d038..3e10177d 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -75,6 +75,7 @@ export interface PQSubmission {
qmManagerEmail: string | null // QM 담당자 이메일
investigationAddress: string | null
investigationMethod: string | null
+ hasSupplementRequested: boolean
scheduledStartAt: Date | null
scheduledEndAt: Date | null
requestedAt: Date | null
@@ -100,24 +101,6 @@ interface GetColumnsProps {
router: NextRouter;
}
-// 상태에 따른 Badge 변형 결정 함수
-function getStatusBadge(status: string) {
- switch (status) {
- case "REQUESTED":
- return <Badge variant="outline">요청됨</Badge>
- case "IN_PROGRESS":
- return <Badge variant="secondary">진행 중</Badge>
- case "SUBMITTED":
- return <Badge>제출됨</Badge>
- case "APPROVED":
- return <Badge variant="success">승인됨</Badge>
- case "REJECTED":
- return <Badge variant="destructive">거부됨</Badge>
- default:
- return <Badge variant="outline">{status}</Badge>
- }
-}
-
/**
* tanstack table 컬럼 정의
*/
@@ -285,15 +268,15 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
const combinedStatus = getCombinedStatus(row.original);
return value.includes(combinedStatus.status);
},
- enableSorting: true,
+ enableSorting: false,
enableHiding: true,
excelHeader: "진행현황",
};
// PQ 상태와 실사 상태를 결합하는 헬퍼 함수
function getCombinedStatus(submission: PQSubmission) {
- // PQ가 승인되지 않은 경우, PQ 상태를 우선 표시
- if (submission.status !== "APPROVED") {
+ // PQ가 QM 승인되지 않은 경우, PQ 상태를 우선 표시
+ if (submission.status !== "QM_APPROVED") {
switch (submission.status) {
case "REQUESTED":
return { status: "PQ_REQUESTED", label: "PQ 요청됨", variant: "outline" as const };
@@ -301,22 +284,30 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
case "SUBMITTED":
return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
+ case "APPROVED":
+ return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
case "REJECTED":
return { status: "PQ_REJECTED", label: "PQ 거부됨", variant: "destructive" as const };
+ case "QM_REVIEWING":
+ return { status: "PQ_QM_REVIEWING", label: "QM 검토 중", variant: "secondary" as const };
+ case "QM_REJECTED":
+ return { status: "PQ_QM_REJECTED", label: "QM 거부됨", variant: "destructive" as const };
default:
return { status: submission.status, label: submission.status, variant: "outline" as const };
}
}
- // PQ가 승인되었지만 실사가 없는 경우
+ // PQ가 QM 승인되었지만 실사가 없는 경우
if (!submission.investigation) {
- return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
+ return { status: "PQ_QM_APPROVED", label: "PQ 승인됨", variant: "success" as const };
}
// PQ가 승인되고 실사가 있는 경우
switch (submission.investigation.investigationStatus) {
case "PLANNED":
return { status: "INVESTIGATION_PLANNED", label: "실사 계획됨", variant: "outline" as const };
+ case "QM_REVIEW_CONFIRMED":
+ return { status: "INVESTIGATION_QM_REVIEW_CONFIRMED", label: "QM 검토 완료", variant: "outline" as const };
case "IN_PROGRESS":
return { status: "INVESTIGATION_IN_PROGRESS", label: "실사 진행 중", variant: "secondary" as const };
case "COMPLETED":
@@ -343,6 +334,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
case "SUPPLEMENT_REQUIRED":
return { status: "INVESTIGATION_SUPPLEMENT_REQUIRED", label: "실사 보완 요구됨", variant: "secondary" as const };
case "RESULT_SENT":
+ // 보완을 통해 최종 합격/불합격한 경우
+ if (submission.investigation.hasSupplementRequested) {
+ return { status: "INVESTIGATION_RESULT_SENT_SUPPLEMENT", label: "실사 결과 발송(보완)", variant: "success" as const };
+ }
return { status: "INVESTIGATION_RESULT_SENT", label: "실사 결과 발송", variant: "success" as const };
default:
return {
@@ -761,7 +756,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
}}
>
<Edit className="mr-2 h-4 w-4" />
- 실사 정보 수정
+ 구매 자체 평가
</DropdownMenuItem>
)}
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index ea6b6189..98b1cc76 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -15,6 +15,7 @@ import {
getFactoryLocationAnswer,
getQMManagers
} from "@/lib/pq/service"
+import { SiteVisitDialog } from "./site-visit-dialog"
import { RequestInvestigationDialog } from "./request-investigation-dialog"
import { CancelInvestigationDialog, ReRequestInvestigationDialog } from "./cancel-investigation-dialog"
import { SendResultsDialog } from "./send-results-dialog"
@@ -49,6 +50,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false)
const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false)
const [isReRequestDialogOpen, setIsReRequestDialogOpen] = React.useState(false)
+ const [isReinspectionDialogOpen, setIsReinspectionDialogOpen] = React.useState(false)
const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false)
const [isReRequestApprovalDialogOpen, setIsReRequestApprovalDialogOpen] = React.useState(false)
@@ -441,6 +443,53 @@ const handleOpenRequestDialog = async () => {
}
}
+ // 재실사 요청 처리
+ const handleRequestReinspection = async (data: {
+ qmManagerId: number,
+ forecastedAt: Date,
+ investigationAddress: string,
+ investigationNotes?: string
+ }) => {
+ try {
+ // 보완-재실사 대상 실사만 필터링
+ const supplementReinspectInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT"
+ );
+
+ if (supplementReinspectInvestigations.length === 0) {
+ toast.error("보완-재실사 대상 실사가 없습니다.");
+ return;
+ }
+
+ // 첫 번째 대상 실사로 재실사 요청 생성
+ const targetInvestigation = supplementReinspectInvestigations[0].original.investigation!;
+ const { requestSupplementReinspectionAction } = await import('@/lib/vendor-investigation/service');
+
+ const result = await requestSupplementReinspectionAction({
+ investigationId: targetInvestigation.id,
+ siteVisitData: {
+ inspectionDuration: 1.0, // 기본 1일
+ requestedStartDate: data.forecastedAt,
+ requestedEndDate: new Date(data.forecastedAt.getTime() + 24 * 60 * 60 * 1000), // 1일 후
+ shiAttendees: {},
+ vendorRequests: {},
+ additionalRequests: data.investigationNotes || "보완을 위한 재실사 요청입니다.",
+ }
+ });
+
+ if (result.success) {
+ toast.success("재실사 요청이 생성되었습니다.");
+ window.location.reload();
+ } else {
+ toast.error(result.error || "재실사 요청 생성 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ console.error("재실사 요청 오류:", error);
+ toast.error("재실사 요청 중 오류가 발생했습니다.");
+ }
+ };
+
// 실사 결과 발송 처리
const handleSendInvestigationResults = async (data: { purchaseComment?: string }) => {
try {
@@ -505,8 +554,14 @@ const handleOpenRequestDialog = async () => {
row.original.investigation.investigationStatus === "CANCELED"
).length
+ // 재실사 요청 대상 수 확인 (보완-재실사 결과만)
+ const reinspectInvestigationsCount = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT"
+ ).length
+
// 미실사 PQ가 선택되었는지 확인
- const hasNonInspectionPQ = selectedRows.some(row =>
+ const hasNonInspectionPQ = selectedRows.some(row =>
row.original.type === "NON_INSPECTION"
)
@@ -651,6 +706,22 @@ const handleOpenRequestDialog = async () => {
<span className="hidden sm:inline">실사 재의뢰</span>
</Button>
+ {/* 재실사 요청 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsReinspectionDialogOpen(true)}
+ disabled={
+ isLoading ||
+ selectedRows.length === 0 ||
+ reinspectInvestigationsCount === 0
+ }
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">재방문 실사 요청</span>
+ </Button>
+
{/* 실사 결과 발송 버튼 */}
<Button
variant="outline"
@@ -727,6 +798,24 @@ const handleOpenRequestDialog = async () => {
auditResults={auditResults}
/>
+ {/* 재방문실사 요청 Dialog */}
+ <SiteVisitDialog
+ isOpen={isReinspectionDialogOpen}
+ onClose={() => setIsReinspectionDialogOpen(false)}
+ onSubmit={handleRequestReinspection}
+ investigation={{
+ id: 0, // 재실사용으로 0으로 설정 (기존 데이터 로드 안함)
+ investigationMethod: "SITE_VISIT_EVAL",
+ investigationAddress: "",
+ vendorName: "재실사 대상",
+ vendorCode: "N/A",
+ projectName: "",
+ projectCode: "",
+ pqItems: null
+ }}
+ isReinspection={true}
+ />
+
{/* 결재 미리보기 Dialog - 실사 의뢰 */}
{session?.user && investigationFormData && (
<ApprovalPreviewDialog
diff --git a/lib/pq/pq-review-table-new/vendors-table.tsx b/lib/pq/pq-review-table-new/vendors-table.tsx
index f8f9928e..e55da8c5 100644
--- a/lib/pq/pq-review-table-new/vendors-table.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table.tsx
@@ -310,7 +310,10 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
enableAdvancedFilter: true,
enableRowSelection: true,
maxSelections: 1,
- initialState,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
clearOnDefault: true,
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 8b1986ce..0bc575a6 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -555,6 +555,28 @@ export async function submitPQAction({
const targetSubmissionId = existingSubmission?.id || '';
const targetRequesterId = existingSubmission?.requesterId || null;
+ // QM 담당자 이메일 조회 (해당 PQ와 연결된 실사에 배정된 경우)
+ let qmManagerEmail: string | null = null;
+ if (targetSubmissionId) {
+ try {
+ const inv = await db
+ .select({ qmManagerId: vendorInvestigations.qmManagerId })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.pqSubmissionId, Number(targetSubmissionId)))
+ .then(rows => rows[0]);
+ if (inv?.qmManagerId) {
+ const qmUser = await db
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, inv.qmManagerId))
+ .then(rows => rows[0]);
+ qmManagerEmail = qmUser?.email || null;
+ }
+ } catch (e) {
+ console.warn("Failed to fetch QM manager email for PQ submission", e);
+ }
+ }
+
if (targetRequesterId !== null) {
try {
// 요청자 정보 조회
@@ -577,6 +599,7 @@ export async function submitPQAction({
await sendEmail({
to: requester.email,
+ cc: qmManagerEmail ? [qmManagerEmail] : undefined,
subject: emailSubject,
template: "pq-submitted-admin",
context: {
@@ -1238,7 +1261,7 @@ export async function requestPqChangesAction({
await db
.update(vendorPQSubmissions)
.set({
- status: "SUBMITTED", // 변경 요청 상태로 설정
+ status: "IN_PROGRESS", // 변경 요청 상태로 설정
updatedAt: new Date(),
})
.where(
@@ -2209,53 +2232,54 @@ export async function approvePQAction({
projectName = projectData?.name || 'Unknown Project';
}
-
- // 5. PQ 상태를 QM_REVIEWING으로 업데이트 (TO-BE: QM 검토 단계 추가)
+
+ // 5. PQ 상태 업데이트
await db
- .update(vendorPQSubmissions)
+ .update(vendorPQSubmissions)
+ .set({
+ status: "APPROVED",
+ approvedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
.set({
- status: "QM_REVIEWING",
+ status: "PQ_APPROVED",
updatedAt: currentDate,
})
- .where(eq(vendorPQSubmissions.id, pqSubmissionId));
-
- // 6. 일반 PQ인 경우 벤더 상태를 IN_PQ로 업데이트 (QM 검토 중)
- if (pqSubmission.type === "GENERAL") {
- await db
- .update(vendors)
- .set({
- status: "IN_PQ",
- updatedAt: currentDate,
- })
- .where(eq(vendors.id, vendorId));
+ .where(eq(vendors.id, vendorId));
}
-
+
// 7. 벤더에게 이메일 알림 발송
if (vendor.email) {
- try {
- const emailSubject = pqSubmission.projectId
- ? `[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-qm-review-vendor",
- context: {
- vendorName: vendor.vendorName,
- projectId: pqSubmission.projectId,
- projectName: projectName,
- isProjectPQ: !!pqSubmission.projectId,
- reviewDate: currentDate.toLocaleString(),
- portalUrl,
- }
- });
- } catch (emailError) {
- console.error("Failed to send vendor notification:", emailError);
- // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
- }
+ 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);
+ // 이메일 발송 실패가 전체 프로세스를 중단하지 않음
+ }
}
// 8. 캐시 무효화
@@ -2314,12 +2338,12 @@ export async function approveQMReviewAction({
}
// 2. 상태 확인 (QM_REVIEWING 상태만 승인 가능)
- if (pqSubmission.status !== "QM_REVIEWING") {
- return {
- ok: false,
- error: `Cannot approve QM review in current status: ${pqSubmission.status}`
- };
- }
+ // if (pqSubmission.status !== "QM_REVIEWING") {
+ // return {
+ // ok: false,
+ // error: `Cannot approve QM review in current status: ${pqSubmission.status}`
+ // };
+ // }
// 3. 벤더 정보 조회
const vendor = await db
@@ -2373,22 +2397,41 @@ export async function approveQMReviewAction({
.where(eq(vendors.id, vendorId));
}
- // 7. 실사 요청 생성 (QM 승인 후 실사 프로세스 시작)
- await db
- .insert(vendorInvestigations)
- .values({
- vendorId: vendorId,
- pqSubmissionId: pqSubmissionId,
- investigationStatus: "PLANNED",
- investigationMethod: "DOCUMENT_EVAL", // 기본값, 나중에 변경 가능
- });
+ // 7. 실사 상태 변경: QM 승인 시 QM_REVIEW_CONFIRMED로 전환
+ try {
+ const existingInvestigation = await db
+ .select({ id: vendorInvestigations.id })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.pqSubmissionId, pqSubmissionId))
+ .then(rows => rows[0]);
+
+ if (existingInvestigation) {
+ await db
+ .update(vendorInvestigations)
+ .set({ investigationStatus: "QM_REVIEW_CONFIRMED", updatedAt: currentDate })
+ .where(eq(vendorInvestigations.id, existingInvestigation.id));
+ } else {
+ await db
+ .insert(vendorInvestigations)
+ .values({
+ vendorId: vendorId,
+ pqSubmissionId: pqSubmissionId,
+ investigationStatus: "QM_REVIEW_CONFIRMED",
+ investigationMethod: "DOCUMENT_EVAL",
+ requestedAt: currentDate,
+ updatedAt: currentDate,
+ });
+ }
+ } catch (e) {
+ console.error("Failed to set investigation QM_REVIEW_CONFIRMED on QM approve", e);
+ }
// 8. 벤더에게 이메일 알림 발송
if (vendor.email) {
try {
const emailSubject = pqSubmission.projectId
- ? `[eVCP] Project PQ Approved for ${projectName}`
- : "[eVCP] General PQ Approved";
+ ? `[eVCP] Project PQ QM Approved for ${projectName}`
+ : "[eVCP] General PQ QM Approved";
const portalUrl = `${host}/partners/pq`;
@@ -2525,7 +2568,25 @@ export async function rejectQMReviewAction({
.where(eq(vendors.id, vendorId));
}
- // 7. 벤더에게 이메일 알림 발송
+ // 7. 실사 상태 변경: QM 거절 시 CANCELED로 전환
+ try {
+ const existingInvestigation = await db
+ .select({ id: vendorInvestigations.id })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.pqSubmissionId, pqSubmissionId))
+ .then(rows => rows[0]);
+
+ if (existingInvestigation) {
+ await db
+ .update(vendorInvestigations)
+ .set({ investigationStatus: "CANCELED", updatedAt: currentDate })
+ .where(eq(vendorInvestigations.id, existingInvestigation.id));
+ }
+ } catch (e) {
+ console.error("Failed to set investigation CANCELED on QM reject", e);
+ }
+
+ // 8. 벤더에게 이메일 알림 발송
if (vendor.email) {
try {
const emailSubject = pqSubmission.projectId
@@ -2553,11 +2614,12 @@ export async function rejectQMReviewAction({
}
}
- // 8. 캐시 무효화
+ // 9. 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
revalidateTag("pq-submissions");
revalidateTag("vendor-pq-submissions");
+ revalidateTag("vendor-investigations");
revalidatePath("/evcp/pq_new");
return { ok: true };
@@ -2714,10 +2776,76 @@ export async function rejectPQAction({
}
}
+// PQ 보완요청 메일 발송 액션
+export async function requestPqSupplementAction({
+ pqSubmissionId,
+ vendorId,
+ comment,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+ comment: string;
+}) {
+ unstable_noStore();
+ try {
+ const session = await getServerSession(authOptions);
+ const currentUserEmail = session?.user?.email || null;
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // PQ/벤더/요청자 정보 조회
+ const pq = await db
+ .select({ id: vendorPQSubmissions.id, pqNumber: vendorPQSubmissions.pqNumber, requesterId: vendorPQSubmissions.requesterId, projectId: vendorPQSubmissions.projectId })
+ .from(vendorPQSubmissions)
+ .where(and(eq(vendorPQSubmissions.id, pqSubmissionId), eq(vendorPQSubmissions.vendorId, vendorId)))
+ .then(rows => rows[0]);
+ if (!pq) return { ok: false, error: 'PQ submission not found' };
+
+ const vendor = await db
+ .select({ vendorName: vendors.vendorName, email: vendors.email })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+ if (!vendor?.email) return { ok: false, error: 'Vendor email not found' };
+
+ let requesterEmail: string | null = null;
+ if (pq.requesterId) {
+ const requester = await db
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, pq.requesterId))
+ .then(rows => rows[0]);
+ requesterEmail = requester?.email || null;
+ }
+
+ const reviewUrl = `http://${host}/evcp/pq/${vendorId}/${pqSubmissionId}`;
+
+ await sendEmail({
+ to: vendor.email,
+ cc: [currentUserEmail, requesterEmail].filter(Boolean) as string[],
+ subject: `[eVCP] PQ 보완 요청: ${vendor.vendorName}`,
+ template: 'pq-supplement-request',
+ context: {
+ vendorName: vendor.vendorName,
+ pqNumber: pq.pqNumber,
+ comment,
+ reviewUrl,
+ },
+ });
+
+ revalidateTag('pq-submissions');
+ return { ok: true };
+ } catch (error) {
+ console.error('PQ supplement request error:', error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
// 실사 의뢰 생성 서버 액션
export async function requestInvestigationAction(
pqSubmissionIds: number[],
+ currentUser: { id: number; epId: string | null; email?: string },
data: {
qmManagerId: number,
forecastedAt: Date,
@@ -2727,10 +2855,7 @@ export async function requestInvestigationAction(
) {
try {
// 세션에서 요청자 정보 가져오기
- const session = await getServerSession(authOptions);
- const requesterId = session?.user?.id ? Number(session.user.id) : null;
-
- if (!requesterId) {
+ if (!currentUser.id) {
return { success: false, error: "인증된 사용자만 실사를 의뢰할 수 있습니다." };
}
@@ -2755,7 +2880,7 @@ export async function requestInvestigationAction(
const now = new Date();
- // 각 PQ에 대한 실사 요청 생성 - 타입이 정확히 맞는지 확인
+ // 실사 요청 생성
const investigations = pqSubmissions.map((pq) => {
return {
vendorId: pq.vendorId,
@@ -2765,12 +2890,21 @@ export async function requestInvestigationAction(
forecastedAt: data.forecastedAt,
investigationAddress: data.investigationAddress,
investigationNotes: data.investigationNotes || null,
- requesterId: requesterId,
+ requesterId: currentUser.id,
requestedAt: now,
createdAt: now,
updatedAt: now,
};
});
+ //PQ 제출 정보 업데이트, status를 QM_REVIEWING로 업데이트
+ await tx
+ .update(vendorPQSubmissions)
+ .set({
+ status: "QM_REVIEWING",
+ updatedAt: now,
+ })
+ .where(inArray(vendorPQSubmissions.id, pqSubmissionIds));
+
// 실사 요청 저장
const created = await tx
diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts
index d5e4a59b..1dc07c77 100644
--- a/lib/site-visit/service.ts
+++ b/lib/site-visit/service.ts
@@ -18,6 +18,137 @@ import { users } from "@/db/schema"
+// 실사 ID로 모든 siteVisitRequests 조회 (복수 확정정보 지원)
+export async function getAllSiteVisitRequestsForInvestigationAction(investigationId: number) {
+ try {
+ const confirmations = await db
+ .select({
+ id: siteVisitRequests.id,
+ status: siteVisitRequests.status,
+ inspectionDuration: siteVisitRequests.inspectionDuration,
+ requestedStartDate: siteVisitRequests.requestedStartDate,
+ requestedEndDate: siteVisitRequests.requestedEndDate,
+ additionalRequests: siteVisitRequests.additionalRequests,
+ createdAt: siteVisitRequests.createdAt,
+ updatedAt: siteVisitRequests.updatedAt,
+ })
+ .from(siteVisitRequests)
+ .where(eq(siteVisitRequests.investigationId, investigationId))
+ .orderBy(desc(siteVisitRequests.createdAt))
+
+ return { success: true, confirmations }
+ } catch (error) {
+ console.error("실사 확정정보 조회 오류:", error)
+ return { success: false, error: "실사 확정정보 조회에 실패했습니다." }
+ }
+}
+
+// 재방문 실사 요청을 위한 방문실사 생성 (동일 investigationId에 대해 여러 개 허용)
+export async function createReinspectionSiteVisitAction(input: {
+ investigationId: number;
+ inspectionDuration: number;
+ requestedStartDate: Date;
+ requestedEndDate: Date;
+ shiAttendees: Record<string, string>; // {userId: name}
+ vendorRequests: Record<string, boolean>;
+ additionalRequests?: string;
+ investigationAddress: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+
+ // 실사 정보 확인
+ const investigation = await db
+ .select()
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, input.investigationId))
+ .limit(1);
+
+ if (!investigation.length) {
+ return {
+ success: false,
+ error: "실사 정보를 찾을 수 없습니다."
+ };
+ }
+
+ // PQ 정보 확인
+ const pqSubmission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, investigation[0].pqSubmissionId!))
+ .limit(1);
+
+ if (!pqSubmission.length) {
+ return {
+ success: false,
+ error: "PQ 정보를 찾을 수 없습니다."
+ };
+ }
+
+ // 방문실사 요청 생성 (재실사이므로 기존 확인 로직 생략)
+ const [newRequest] = await db
+ .insert(siteVisitRequests)
+ .values({
+ investigationId: input.investigationId,
+ inspectionDuration: input.inspectionDuration,
+ requestedStartDate: input.requestedStartDate,
+ requestedEndDate: input.requestedEndDate,
+ shiAttendees: input.shiAttendees,
+ vendorRequests: input.vendorRequests,
+ additionalRequests: input.additionalRequests,
+ status: "REQUESTED",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 벤더에게 이메일 발송
+ const vendor = await db
+ .select({ email: vendors.email, vendorName: vendors.vendorName })
+ .from(vendors)
+ .where(eq(vendors.id, investigation[0].vendorId))
+ .limit(1);
+
+ if (vendor.length && vendor[0].email) {
+ const headersList = await import("next/headers").then(m => m.headers());
+ const host = headersList.get('host') || 'localhost:3000';
+ const portalUrl = process.env.NEXTAUTH_URL || `http://${host}`;
+
+ await sendEmail({
+ to: vendor[0].email,
+ subject: `[eVCP] 재실사 방문요청 - ${vendor[0].vendorName}`,
+ template: "site-visit-request",
+ context: {
+ vendorName: vendor[0].vendorName,
+ inspectionDuration: input.inspectionDuration,
+ requestedStartDate: format(input.requestedStartDate, "yyyy년 MM월 dd일"),
+ requestedEndDate: format(input.requestedEndDate, "yyyy년 MM월 dd일"),
+ investigationAddress: input.investigationAddress,
+ additionalRequests: input.additionalRequests || "",
+ reviewUrl: `${portalUrl}/evcp/vendor-investigation`,
+ year: new Date().getFullYear(),
+ }
+ });
+ }
+
+ revalidatePath("/evcp/vendor-investigation");
+
+ return {
+ success: true,
+ siteVisitRequestId: newRequest.id
+ };
+ } catch (error) {
+ console.error("재실사 방문요청 생성 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
// 방문실사 요청 서버 액션
export async function createSiteVisitRequestAction(input: {
investigationId: number;
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
index 431069b3..48aefeb0 100644
--- a/lib/site-visit/vendor-info-view-dialog.tsx
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
interface VendorInfo {
@@ -53,46 +54,63 @@ interface VendorInfoViewDialogProps {
isOpen: boolean
onClose: () => void
siteVisitRequestId: number | null
+ investigationId?: number | null // 실사 ID 추가 - 여러 확정정보 조회용
+ isReinspection?: boolean // 재실사 모드 플래그
}
export function VendorInfoViewDialog({
isOpen,
onClose,
siteVisitRequestId,
+ investigationId,
}: VendorInfoViewDialogProps) {
const [data, setData] = React.useState<VendorInfo | null>(null)
const [attachments, setAttachments] = React.useState<Attachment[]>([])
+ const [allConfirmations, setAllConfirmations] = React.useState<any[]>([]) // 여러 확정정보
const [isLoading, setIsLoading] = React.useState(false)
// 데이터 로드
React.useEffect(() => {
- if (isOpen && siteVisitRequestId) {
- loadVendorInfo()
+ if (isOpen && (siteVisitRequestId || investigationId)) {
+ loadData()
}
- }, [isOpen, siteVisitRequestId])
+ }, [isOpen, siteVisitRequestId, investigationId])
- const loadVendorInfo = async () => {
- if (!siteVisitRequestId) return
+ const loadData = async () => {
+ if (!siteVisitRequestId && !investigationId) return
setIsLoading(true)
try {
- const { getVendorSiteVisitInfoAction } = await import("./service")
- const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
-
- if (result.success && result.data) {
- setData(result.data.vendorInfo)
- setAttachments(result.data.attachments || [])
- } else {
- toast.error("협력업체 정보를 불러올 수 없습니다.")
+ // 단일 확정정보 조회 (기존)
+ if (siteVisitRequestId) {
+ const { getVendorSiteVisitInfoAction } = await import("./service")
+ const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
+
+ if (result.success && result.data) {
+ setData(result.data.vendorInfo)
+ setAttachments(result.data.attachments || [])
+ } else {
+ toast.error("협력업체 정보를 불러올 수 없습니다.")
+ }
+ }
+
+ // 여러 확정정보 조회 (신규 - 실사 ID로 모든 siteVisitRequests 조회)
+ if (investigationId) {
+ const { getAllSiteVisitRequestsForInvestigationAction } = await import("./service")
+ const result = await getAllSiteVisitRequestsForInvestigationAction(investigationId)
+ if (result.success) {
+ setAllConfirmations(result.confirmations || [])
+ }
}
} catch (error) {
- console.error("협력업체 정보 로드 오류:", error)
- toast.error("협력업체 정보를 불러오는 중 오류가 발생했습니다.")
+ console.error("데이터 로드 오류:", error)
+ toast.error("데이터를 불러오는 중 오류가 발생했습니다.")
} finally {
setIsLoading(false)
}
}
+
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -110,10 +128,11 @@ export function VendorInfoViewDialog({
<p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
</div>
</div>
- ) : data ? (
+ ) : (data || allConfirmations.length > 0) ? (
<div className="space-y-6">
- {/* 협력업체 정보 */}
- <Card>
+ {/* 협력업체 정보 - 단일 확정정보 조회 시에만 표시 */}
+ {data && (
+ <Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
@@ -173,6 +192,7 @@ export function VendorInfoViewDialog({
</div>
</CardContent>
</Card>
+ )}
{/* 첨부파일 */}
{attachments.length > 0 && (
@@ -226,8 +246,54 @@ export function VendorInfoViewDialog({
</Card>
)}
+ {/* 실사 실시 확정정보 (복수 지원) */}
+ {allConfirmations.length > 0 && (
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">실사 실시 확정정보</h3>
+ {allConfirmations.map((confirmation, index) => (
+ <Card key={confirmation.id}>
+ <CardHeader>
+ <CardTitle className="flex items-center justify-between">
+ <span className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 실사 확정정보 #{index + 1}
+ </span>
+ <Badge variant={confirmation.status === "COMPLETED" ? "default" : "secondary"}>
+ {confirmation.status === "COMPLETED" ? "완료" : "진행중"}
+ </Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">실사 기간:</span> {confirmation.inspectionDuration}일
+ </div>
+ <div>
+ <span className="font-medium">요청 시작일:</span>
+ {confirmation.requestedStartDate ? formatDate(confirmation.requestedStartDate, "kr") : "미정"}
+ </div>
+ <div>
+ <span className="font-medium">요청 종료일:</span>
+ {confirmation.requestedEndDate ? formatDate(confirmation.requestedEndDate, "kr") : "미정"}
+ </div>
+ <div>
+ <span className="font-medium">생성일:</span> {formatDate(confirmation.createdAt, "kr")}
+ </div>
+ {confirmation.additionalRequests && (
+ <div className="col-span-2">
+ <span className="font-medium">추가 요청사항:</span>
+ <div className="bg-muted p-2 rounded mt-1">{confirmation.additionalRequests}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+
{/* 기타 정보 */}
- {data.otherInfo && (
+ {data?.otherInfo && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -236,7 +302,7 @@ export function VendorInfoViewDialog({
</CardTitle>
</CardHeader>
<CardContent>
- <p className="text-sm whitespace-pre-wrap">{data.otherInfo}</p>
+ <p className="text-sm whitespace-pre-wrap">{data?.otherInfo}</p>
</CardContent>
</Card>
)}
@@ -253,8 +319,8 @@ export function VendorInfoViewDialog({
<div className="grid grid-cols-2 gap-4">
<div>
<div className="space-y-2 text-sm">
- <div><span className="font-medium">제출일:</span> {formatDate(data.submittedAt, "kr")}</div>
- <div><span className="font-medium">첨부파일:</span> {data.hasAttachments ? "있음" : "없음"}</div>
+ <div><span className="font-medium">제출일:</span> {formatDate(data?.submittedAt, "kr")}</div>
+ <div><span className="font-medium">첨부파일:</span> {data?.hasAttachments ? "있음" : "없음"}</div>
</div>
</div>
</div>
diff --git a/lib/vendor-investigation/approval-actions.ts b/lib/vendor-investigation/approval-actions.ts
index a75b9b70..607580d8 100644
--- a/lib/vendor-investigation/approval-actions.ts
+++ b/lib/vendor-investigation/approval-actions.ts
@@ -97,6 +97,7 @@ export async function requestPQInvestigationWithApproval(data: {
investigationAddress: data.investigationAddress,
investigationNotes: data.investigationNotes,
vendorNames: data.vendorNames,
+ currentUser: data.currentUser,
},
// approvalConfig: 결재 상신 정보 (템플릿 포함)
diff --git a/lib/vendor-investigation/handlers.ts b/lib/vendor-investigation/handlers.ts
index 6c0edbd7..24cad870 100644
--- a/lib/vendor-investigation/handlers.ts
+++ b/lib/vendor-investigation/handlers.ts
@@ -24,10 +24,12 @@ export async function requestPQInvestigationInternal(payload: {
investigationAddress: string;
investigationNotes?: string;
vendorNames?: string; // 복수 업체 이름 (표시용)
+ currentUser: { id: number; epId: string | null; email?: string };
}) {
debugLog('[PQInvestigationHandler] 실사 의뢰 핸들러 시작', {
pqCount: payload.pqSubmissionIds.length,
qmManagerId: payload.qmManagerId,
+ currentUser: payload.currentUser,
vendorNames: payload.vendorNames,
});
@@ -36,6 +38,7 @@ export async function requestPQInvestigationInternal(payload: {
debugLog('[PQInvestigationHandler] requestInvestigationAction 호출');
const result = await requestInvestigationAction(
payload.pqSubmissionIds,
+ payload.currentUser,
{
qmManagerId: payload.qmManagerId,
forecastedAt: payload.forecastedAt,
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index f81f78f6..3ccbe880 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -1,6 +1,6 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests } from "@/db/schema/"
+import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests, vendorPQSubmissions } from "@/db/schema/"
import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema, updateVendorInvestigationProgressSchema, updateVendorInvestigationResultSchema } from "./validations"
import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
import { revalidateTag, unstable_noStore, revalidatePath } from "next/cache";
@@ -131,6 +131,46 @@ export async function getExistingInvestigationsForVendors(vendorIds: number[]) {
}
}
+// PQ 제출 타입 조회 (investigation.pqSubmissionId → type)
+export default async function getPQSubmissionTypeAction(pqSubmissionId: number) {
+ try {
+ const row = await db
+ .select({ type: vendorPQSubmissions.type })
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId))
+ .limit(1)
+ .then(rows => rows[0]);
+ if (!row) return { success: false, error: "PQ submission not found" };
+ return { success: true, type: row.type as "GENERAL" | "PROJECT" | "NON_INSPECTION" };
+ } catch (e) {
+ return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
+ }
+}
+
+// 실사 계획 취소 액션: 상태를 QM_REVIEW_CONFIRMED로 되돌림
+export async function cancelInvestigationPlanAction(investigationId: number) {
+ try {
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "QM_REVIEW_CONFIRMED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId))
+
+ revalidateTag("vendor-investigations")
+ revalidatePath("/evcp/vendor-investigation")
+
+ return { success: true }
+ } catch (error) {
+ console.error("실사 계획 취소 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ }
+ }
+}
+
interface RequestInvestigateVendorsInput {
ids: number[]
}
@@ -228,7 +268,7 @@ export async function updateVendorInvestigationProgressAction(formData: FormData
processedEntries.confirmedAt = new Date(textEntries.confirmedAt)
}
- // 3) Zod로 파싱/검증
+ // 3) Zod로 파싱/검증 (4개 필수값 규칙 포함)
const parsed = updateVendorInvestigationProgressSchema.parse(processedEntries)
// 4) 업데이트 데이터 준비
@@ -250,7 +290,7 @@ export async function updateVendorInvestigationProgressAction(formData: FormData
updateData.confirmedAt = parsed.confirmedAt
}
- // 실사 방법이 설정되면 PLANNED -> IN_PROGRESS로 상태 변경
+ // 실사 방법이 설정되면 QM_REVIEW_CONFIRMED -> IN_PROGRESS로 상태 변경
if (parsed.investigationMethod) {
updateData.investigationStatus = "IN_PROGRESS"
}
@@ -334,10 +374,12 @@ export async function updateVendorInvestigationResultAction(formData: FormData)
if (parsed.evaluationResult) {
if (parsed.evaluationResult === "REJECTED") {
updateData.investigationStatus = "CANCELED"
- } else if (parsed.evaluationResult === "SUPPLEMENT" ||
- parsed.evaluationResult === "SUPPLEMENT_REINSPECT" ||
+ } else if (parsed.evaluationResult === "SUPPLEMENT" ||
+ parsed.evaluationResult === "SUPPLEMENT_REINSPECT" ||
parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") {
updateData.investigationStatus = "SUPPLEMENT_REQUIRED"
+ // 보완 요청이 있었음을 기록
+ updateData.hasSupplementRequested = true
} else if (parsed.evaluationResult === "APPROVED") {
updateData.investigationStatus = "COMPLETED"
}
@@ -1150,6 +1192,93 @@ export async function completeSupplementReinspectionAction({
}
}
+// 실사 보완요청 메일 발송 액션
+export async function requestInvestigationSupplementAction({
+ investigationId,
+ vendorId,
+ comment,
+}: {
+ investigationId: number;
+ vendorId: number;
+ comment: string;
+}) {
+ unstable_noStore();
+ try {
+ const headersList = await import("next/headers").then(m => m.headers());
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 실사/벤더 정보 조회
+ const investigation = await db
+ .select({
+ id: vendorInvestigations.id,
+ pqSubmissionId: vendorInvestigations.pqSubmissionId,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, investigationId))
+ .then(rows => rows[0]);
+
+ const vendor = await db
+ .select({ email: vendors.email, vendorName: vendors.vendorName })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor?.email) {
+ return { success: false, error: "벤더 이메일 정보가 없습니다." };
+ }
+
+ // PQ 번호 조회
+ let pqNumber = "N/A";
+ if (investigation?.pqSubmissionId) {
+ const pqRow = await db
+ .select({ pqNumber: vendorPQSubmissions.pqNumber })
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, investigation.pqSubmissionId))
+ .then(rows => rows[0]);
+ if (pqRow) pqNumber = pqRow.pqNumber;
+ }
+
+ // 메일 발송
+ const portalUrl = process.env.NEXTAUTH_URL || `http://${host}`;
+ const reviewUrl = `${portalUrl}/evcp/vendor-investigation`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: `[eVCP] 실사 보완요청 - ${vendor.vendorName}`,
+ template: "pq-investigation-supplement-request",
+ context: {
+ vendorName: vendor.vendorName,
+ investigationNumber: pqNumber,
+ supplementComment: comment,
+ requestedAt: new Date().toLocaleString('ko-KR'),
+ reviewUrl: reviewUrl,
+ year: new Date().getFullYear(),
+ }
+ });
+
+ // 실사 상태를 SUPPLEMENT_REQUIRED로 변경 (이미 되어있을 수 있음)
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: "SUPPLEMENT_REQUIRED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ revalidateTag("vendor-investigations");
+ revalidateTag("pq-submissions");
+
+ return { success: true };
+ } catch (error) {
+ console.error("실사 보완요청 메일 발송 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
// 보완 서류제출 응답 제출 액션
export async function submitSupplementDocumentResponseAction({
investigationId,
diff --git a/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx b/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx
new file mode 100644
index 00000000..26016742
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-cancel-plan-button.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+import { Button } from "@/components/ui/button"
+import { RotateCcw } from "lucide-react"
+import { toast } from "sonner"
+import { cancelInvestigationPlanAction } from "../service"
+import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
+
+interface Props {
+ table: Table<VendorInvestigationsViewWithContacts>
+}
+
+export function InvestigationCancelPlanButton({ table }: Props) {
+ const [loading, setLoading] = React.useState(false)
+ const selected = table.getSelectedRowModel().rows[0]?.original as VendorInvestigationsViewWithContacts | undefined
+
+ const canCancel = React.useMemo(() => {
+ if (!selected) return false
+ // 이미 취소 상태로 되돌릴 필요가 없거나, QM_REVIEW_CONFIRMED이면 취소 불필요
+ if (selected.investigationStatus === "QM_REVIEW_CONFIRMED") return false
+ if (!selected.investigationMethod) return false
+
+ const method = selected.investigationMethod
+ // 1) 서류평가: 실사결과 입력 전까지 (평가 결과 없을 때)
+ if (method === "DOCUMENT_EVAL") {
+ return selected.evaluationResult == null
+ }
+ // 2) 구매자체평가: 자체평가 입력 전까지 (간주: investigationNotes가 비어있을 때)
+ if (method === "PURCHASE_SELF_EVAL") {
+ return !selected.investigationNotes && selected.evaluationResult == null
+ }
+ // 3) 방문/제품평가: 방문요청 전까지 (site visit request 없을 때)
+ if (method === "PRODUCT_INSPECTION" || method === "SITE_VISIT_EVAL") {
+ // 낙관적으로 UI에선 일단 true로 두고, 클릭 시 서버 확인
+ return true
+ }
+ return false
+ }, [selected])
+
+ const onCancel = async () => {
+ if (!selected) return
+ try {
+ setLoading(true)
+
+ // 방문/제품평가인 경우, 방문요청 존재 여부 서버 확인
+ if (selected.investigationMethod === "PRODUCT_INSPECTION" || selected.investigationMethod === "SITE_VISIT_EVAL") {
+ try {
+ const req = await getSiteVisitRequestAction(selected.investigationId)
+ if (req.success && req.data) {
+ toast.error("방문요청 이후에는 실사계획을 취소할 수 없습니다.")
+ setLoading(false)
+ return
+ }
+ } catch {}
+ }
+
+ const res = await cancelInvestigationPlanAction(selected.investigationId)
+ if (!res.success) {
+ toast.error(res.error || "실사계획 취소에 실패했습니다.")
+ setLoading(false)
+ return
+ }
+ toast.success("실사계획을 취소하고 상태를 'QM 검토 완료'로 되돌렸습니다.")
+ // 선택 해제 및 테이블 리프레시 유도
+ table.resetRowSelection()
+ } catch (e) {
+ toast.error("실사계획 취소 중 오류가 발생했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onCancel}
+ disabled={loading || !canCancel}
+ className="gap-2"
+ title="실사계획 취소"
+ >
+ <RotateCcw className="size-4" />
+ 취소
+ </Button>
+ )
+}
+
+
diff --git a/lib/vendor-investigation/table/investigation-progress-sheet.tsx b/lib/vendor-investigation/table/investigation-progress-sheet.tsx
index c0357f5c..a9fbdfdb 100644
--- a/lib/vendor-investigation/table/investigation-progress-sheet.tsx
+++ b/lib/vendor-investigation/table/investigation-progress-sheet.tsx
@@ -45,7 +45,7 @@ import {
updateVendorInvestigationProgressSchema,
type UpdateVendorInvestigationProgressSchema,
} from "../validations"
-import { updateVendorInvestigationProgressAction } from "../service"
+import getPQSubmissionTypeAction, { updateVendorInvestigationProgressAction } from "../service"
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
interface InvestigationProgressSheetProps
@@ -61,6 +61,7 @@ export function InvestigationProgressSheet({
...props
}: InvestigationProgressSheetProps) {
const [isPending, startTransition] = React.useTransition()
+ const [isProjectPQ, setIsProjectPQ] = React.useState<boolean>(false)
// RHF + Zod
const form = useForm<UpdateVendorInvestigationProgressSchema>({
@@ -84,6 +85,14 @@ export function InvestigationProgressSheet({
forecastedAt: investigation.forecastedAt ?? undefined,
confirmedAt: investigation.confirmedAt ?? undefined,
})
+ // PQ 타입 조회 (PROJECT면 구매자체평가 비활성화)
+ if (investigation.pqSubmissionId) {
+ getPQSubmissionTypeAction(investigation.pqSubmissionId).then((res) => {
+ if (res.success) setIsProjectPQ(res.type === "PROJECT")
+ })
+ } else {
+ setIsProjectPQ(false)
+ }
}
}, [investigation, form])
@@ -211,7 +220,7 @@ export function InvestigationProgressSheet({
</SelectTrigger>
<SelectContent>
<SelectGroup>
- <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
+ <SelectItem value="PURCHASE_SELF_EVAL" disabled={isProjectPQ}>구매자체평가</SelectItem>
<SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
<SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem>
<SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem>
@@ -237,6 +246,7 @@ export function InvestigationProgressSheet({
<Button
variant="outline"
className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={form.watch("investigationMethod") === "PRODUCT_INSPECTION" || form.watch("investigationMethod") === "SITE_VISIT_EVAL"}
>
{field.value ? (
format(field.value, "yyyy년 MM월 dd일")
diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx
index b7577daa..36000333 100644
--- a/lib/vendor-investigation/table/investigation-result-sheet.tsx
+++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { CalendarIcon, Loader, X, Download } from "lucide-react"
+import { CalendarIcon, Loader, X, Download, AlertTriangle } from "lucide-react"
import { format } from "date-fns"
import { toast } from "sonner"
import { updateVendorInvestigationResultAction } from "../service"
@@ -68,6 +68,7 @@ import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInv
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
import prettyBytes from "pretty-bytes"
import { downloadFile } from "@/lib/file-download"
+import { Dialog as SystemDialog, DialogContent as SystemDialogContent, DialogHeader as SystemDialogHeader, DialogTitle as SystemDialogTitle, DialogFooter as SystemDialogFooter } from "@/components/ui/dialog"
interface InvestigationResultSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
investigation: VendorInvestigationsViewWithContacts | null
@@ -117,6 +118,11 @@ export function InvestigationResultSheet({
const [loadingAttachments, setLoadingAttachments] = React.useState(false)
const [uploadingFiles, setUploadingFiles] = React.useState(false)
+ // 불합격 안내 팝업 상태
+ const [showRejectedDialog, setShowRejectedDialog] = React.useState(false)
+ // 보완 세부 항목 (재실사/자료제출)
+ const [supplementType, setSupplementType] = React.useState<string>("")
+
// RHF + Zod
const form = useForm<UpdateVendorInvestigationResultSchema>({
resolver: zodResolver(updateVendorInvestigationResultSchema),
@@ -130,6 +136,39 @@ export function InvestigationResultSheet({
},
})
+ // 평가점수 변화 → 자동 평가 & 보완 타입 초기화
+ React.useEffect(() => {
+ const score = form.watch("evaluationScore")
+ let nextResult: string | undefined = undefined
+ if (typeof score === "number") {
+ if (score >= 80) nextResult = "APPROVED"
+ else if (score >= 70) {
+ // 70~79점일 때는 보완방법 선택을 기다리므로 바로 설정하지 않음
+ nextResult = undefined
+ setSupplementType("")
+ }
+ else if (score < 70) nextResult = "REJECTED"
+ }
+ if (nextResult) {
+ form.setValue("evaluationResult", nextResult as any)
+ } else if (score >= 70 && score < 80) {
+ // 70~79점 범위에서는 보완방법 선택이 필요하다는 표시
+ form.setValue("evaluationResult", "SUPPLEMENT" as any)
+ }
+ }, [form.watch("evaluationScore")])
+
+ // 보완방법 선택 변화 → 평가결과 변경
+ React.useEffect(() => {
+ // 70~79점 범위에서만 보완방법 선택에 따라 결과 변경
+ const score = form.watch("evaluationScore")
+ if (typeof score === "number" && score >= 70 && score < 80) {
+ if (supplementType === "REINSPECT")
+ form.setValue("evaluationResult", "SUPPLEMENT_REINSPECT" as any)
+ else if (supplementType === "DOCUMENT")
+ form.setValue("evaluationResult", "SUPPLEMENT_DOCUMENT" as any)
+ }
+ }, [supplementType, form.watch("evaluationScore")])
+
// investigation이 변경될 때마다 폼 리셋
React.useEffect(() => {
if (investigation) {
@@ -214,9 +253,9 @@ export function InvestigationResultSheet({
// 파일 업로드 섹션 렌더링
const renderFileUploadSection = () => {
- const currentStatus = form.watch("investigationStatus")
+ const currentStatus = form.watch("evaluationResult") as string | undefined
const selectedFiles = form.watch("attachments") as File[] | undefined
- const config = getFileUploadConfig(currentStatus)
+ const config = getFileUploadConfig(currentStatus ?? "")
if (!config.enabled) return null
@@ -454,6 +493,26 @@ export function InvestigationResultSheet({
return
}
+ // 보완-서류제출 선택 시 메일 발송
+ if (values.evaluationResult === "SUPPLEMENT_DOCUMENT") {
+ try {
+ const { requestInvestigationSupplementAction } = await import('../service')
+ const mailResult = await requestInvestigationSupplementAction({
+ investigationId: values.investigationId,
+ vendorId: investigation?.vendorId || 0,
+ comment: values.investigationNotes || "실사 보완이 필요합니다. 첨부된 내용을 확인하시고 필요한 자료를 제출해 주시기 바랍니다."
+ })
+
+ if (!mailResult.success) {
+ console.warn("보완 메일 발송 실패:", mailResult.error)
+ toast.warning("실사 결과는 저장되었지만 보완 메일 발송에 실패했습니다.")
+ }
+ } catch (mailError) {
+ console.warn("보완 메일 발송 중 오류:", mailError)
+ toast.warning("실사 결과는 저장되었지만 보완 메일 발송에 실패했습니다.")
+ }
+ }
+
// 2) 파일이 있으면 업로드
if (values.attachments && values.attachments.length > 0) {
setUploadingFiles(true)
@@ -484,325 +543,216 @@ export function InvestigationResultSheet({
})
}
- // 디버깅을 위한 버튼 클릭 핸들러
+ // 저장버튼 커스텀(불합격시: 팝업 → 확인하면 제출 / 아니면 중단)
const handleSaveClick = async () => {
- console.log("저장 버튼 클릭됨")
- console.log("현재 폼 값:", form.getValues())
- console.log("폼 에러:", form.formState.errors)
-
- // 폼 검증 실행
- const isValid = await form.trigger()
- console.log("폼 검증 결과:", isValid)
-
- if (isValid) {
- form.handleSubmit(onSubmit)()
- } else {
- console.log("폼 검증 실패, 에러:", form.formState.errors)
+ const score = form.getValues("evaluationScore")
+ const result = form.getValues("evaluationResult")
+ if (result === "REJECTED" && !showRejectedDialog) {
+ setShowRejectedDialog(true)
+ return
}
+ const isValid = await form.trigger()
+ if (isValid) form.handleSubmit(onSubmit)()
+ }
+ // 불합격 안내(확정) 처리
+ const handleRejectedConfirm = () => {
+ setShowRejectedDialog(false)
+ form.handleSubmit(onSubmit)()
}
return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col h-full sm:max-w-xl" >
- <SheetHeader className="text-left flex-shrink-0">
- <SheetTitle>실사 결과 입력</SheetTitle>
- <SheetDescription>
- {investigation?.vendorName && (
- <span className="font-medium">{investigation.vendorName}</span>
- )}의 실사 결과를 입력합니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="flex-1 overflow-y-auto py-4">
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- id="update-investigation-form"
- >
- {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */}
- {/* <FormField
- control={form.control}
- name="investigationStatus"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 상태</FormLabel>
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="상태를 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="PLANNED">계획됨</SelectItem>
- <SelectItem value="IN_PROGRESS">진행 중</SelectItem>
- <SelectItem value="COMPLETED">완료됨</SelectItem>
- <SelectItem value="CANCELED">취소됨</SelectItem>
- <SelectItem value="SUPPLEMENT_REQUIRED">보완 요구됨</SelectItem>
- <SelectItem value="RESULT_SENT">실사결과발송</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */}
- {/* <FormField
- control={form.control}
- name="investigationAddress"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 주소</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사가 진행될 주소를 입력하세요..."
- {...field}
- className="min-h-[60px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */}
- {/* <FormField
- control={form.control}
- name="investigationMethod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 방법</FormLabel>
- <FormControl>
- <Select value={field.value || ""} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="실사 방법을 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem>
- <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem>
- <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem>
- <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */}
- {/* <FormField
- control={form.control}
- name="forecastedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 수행 예정일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 실사 확정일 - 주석처리 (실사 진행 관리에서 처리) */}
- {/* <FormField
- control={form.control}
- name="confirmedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 계획 확정일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
+ <>
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col h-full sm:max-w-xl" >
+ <SheetHeader className="text-left flex-shrink-0">
+ <SheetTitle>실사 결과 입력</SheetTitle>
+ <SheetDescription>
+ {investigation?.vendorName && (
+ <span className="font-medium">{investigation.vendorName}</span>
+ )}의 실사 결과를 입력합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ id="update-investigation-form"
+ >
+ {/* 실제 실사일 */}
+ <FormField
+ control={form.control}
+ name="completedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실제 실사일<span className="text-red-500 ml-1">*</span></FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar mode="single" selected={field.value} onSelect={field.onChange} initialFocus />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 점수 */}
+ <FormField
+ control={form.control}
+ name="evaluationScore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 점수<span className="text-red-500 ml-1">*</span></FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min={0}
+ max={100}
+ placeholder="0-100점"
+ maxLength={3}
+ {...field}
+ value={field.value || ""}
+ onChange={e => {
+ const inputValue = e.target.value
+
+ // 빈 값이거나 숫자가 아닌 경우
+ if (inputValue === "") {
+ field.onChange(undefined)
+ return
+ }
+
+ // 3자리 초과 입력 방지
+ if (inputValue.length > 3) {
+ return
+ }
+
+ const numericValue = parseInt(inputValue, 10)
+
+ // 100 이상 입력 시 alert
+ if (numericValue > 100) {
+ toast.error("평가 점수는 100점을 초과할 수 없습니다.")
+ return
+ }
+
+ field.onChange(numericValue)
+ }}
/>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 실제 실사일 */}
- <FormField
- control={form.control}
- name="completedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실제 실사일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 평가 점수 */}
- <FormField
- control={form.control}
- name="evaluationScore"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가 점수</FormLabel>
- <FormControl>
- <Input
- type="number"
- min={0}
- max={100}
- placeholder="0-100점"
- {...field}
- value={field.value || ""}
- onChange={(e) => {
- const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10)
- field.onChange(value)
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 평가 결과 */}
- <FormField
- control={form.control}
- name="evaluationResult"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가 결과</FormLabel>
- <FormControl>
- <Select value={field.value || ""} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="평가 결과를 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="APPROVED">승인</SelectItem>
- <SelectItem value="SUPPLEMENT">보완</SelectItem>
- <SelectItem value="SUPPLEMENT_REINSPECT">보완-재실사</SelectItem>
- <SelectItem value="SUPPLEMENT_DOCUMENT">보완-서류제출</SelectItem>
- <SelectItem value="REJECTED">불가</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* QM 의견 */}
- <FormField
- control={form.control}
- name="investigationNotes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>QM 의견</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사에 대한 QM 의견을 입력하세요..."
- {...field}
- className="min-h-[80px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 결과 VIEW (자동) */}
+ <div>
+ <FormLabel>평가 결과</FormLabel>
+ <div className="min-h-10 flex items-center gap-2 mt-1 font-bold">
+ {(() => {
+ const result = form.watch("evaluationResult")
+ if (result === "APPROVED") return <span className="text-green-600">합격 (승인)</span>
+ if (result === "SUPPLEMENT") return <span className="text-yellow-600">보완 필요 (방법 선택)</span>
+ if (result === "SUPPLEMENT_REINSPECT") return <span className="text-yellow-600">보완 필요 - 재실사</span>
+ if (result === "SUPPLEMENT_DOCUMENT") return <span className="text-yellow-600">보완 필요 - 자료제출</span>
+ if (result === "REJECTED") return <span className="text-destructive">불합격</span>
+ return <span className="text-muted-foreground">-</span>
+ })()}
+ </div>
+ </div>
+
+ {/* 보완 세부항목(70~79점) */}
+ {(() => {
+ const score = form.watch("evaluationScore")
+ return typeof score === "number" && score >= 70 && score < 80
+ })() && (
+ <div>
+ <FormLabel>보완 방법<span className="text-red-500 ml-1">*</span></FormLabel>
+ <Select value={supplementType} onValueChange={setSupplementType}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="REINSPECT">재실사</SelectItem>
+ <SelectItem value="DOCUMENT">자료제출</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </div>
)}
- />
-
- {/* 파일 첨부 섹션 */}
- {renderFileUploadSection()}
- </form>
- </Form>
- </div>
-
- {/* Footer Buttons */}
- <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0">
- <SheetClose asChild>
- <Button type="button" variant="outline" disabled={isPending || uploadingFiles}>
- 취소
+
+ {/* QM 의견 */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 의견</FormLabel>
+ <FormControl>
+ <Textarea placeholder="실사에 대한 QM 의견을 입력하세요..." {...field} className="min-h-[80px]" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 섹션 */}
+ {renderFileUploadSection()}
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer Buttons */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline" disabled={isPending || uploadingFiles}>
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ disabled={isPending || uploadingFiles}
+ onClick={handleSaveClick}
+ >
+ {(isPending || uploadingFiles) && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"}
</Button>
- </SheetClose>
- <Button
- disabled={isPending || uploadingFiles}
- onClick={handleSaveClick}
- >
- {(isPending || uploadingFiles) && (
- <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
- )}
- {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"}
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+
+ {/* 불합격 안내 팝업 */}
+ <SystemDialog open={showRejectedDialog} onOpenChange={setShowRejectedDialog}>
+ <SystemDialogContent>
+ <SystemDialogHeader>
+ <SystemDialogTitle><AlertTriangle className="mr-2 inline h-6 w-6 text-destructive" />불합격 확정 시 안내</SystemDialogTitle>
+ </SystemDialogHeader>
+ <div className="mt-2 mb-4 text-base leading-relaxed">
+ 불합격 확정 시 <b>결과입력완료일부터 1년간 동일 건에 대한 실사는 불가합니다.</b><br/>
+ 정말 확정 처리하시겠습니까?
+ </div>
+ <SystemDialogFooter className="flex flex-row justify-end gap-2">
+ <Button variant="outline" onClick={() => setShowRejectedDialog(false)}>취소</Button>
+ <Button variant="destructive" onClick={handleRejectedConfirm}>확인</Button>
+ </SystemDialogFooter>
+ </SystemDialogContent>
+ </SystemDialog>
+ </>
)
} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx
index 28ecc2ec..9f4944c3 100644
--- a/lib/vendor-investigation/table/investigation-table-columns.tsx
+++ b/lib/vendor-investigation/table/investigation-table-columns.tsx
@@ -5,7 +5,7 @@ import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
-import { Edit, Ellipsis, AlertTriangle } from "lucide-react"
+import { Edit, Ellipsis, AlertTriangle, FileEdit, Eye } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,7 +22,7 @@ import {
vendorInvestigationsColumnsConfig,
VendorInvestigationsViewWithContacts
} from "@/config/vendorInvestigationsColumnsConfig"
-
+import { useRouter } from "next/navigation"
// Props for the column generator function
interface GetVendorInvestigationsColumnsProps {
setRowAction?: React.Dispatch<
@@ -93,8 +93,11 @@ export function getColumns({
id: "actions",
enableHiding: false,
cell: ({ row }) => {
+ const router = useRouter()
const isCanceled = row.original.investigationStatus === "CANCELED"
const isCompleted = row.original.investigationStatus === "COMPLETED"
+ const canReviewPQ = !isCanceled && row.original.investigationStatus === "PLANNED" && !!row.original.pqSubmissionId
+ const reviewUrl = `/evcp/pq_new/${row.original.vendorId}/${row.original.pqSubmissionId}`
const canRequestSupplement = (row.original.investigationMethod === "PRODUCT_INSPECTION" ||
row.original.investigationMethod === "SITE_VISIT_EVAL") &&
row.original.investigationStatus === "COMPLETED" &&
@@ -116,23 +119,60 @@ export function getColumns({
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onSelect={() => {
- if (!isCanceled && row.original.investigationStatus === "PLANNED") {
- setRowAction?.({ type: "update-progress", row })
+ if (!canReviewPQ) return
+ if (router) {
+ router.push(reviewUrl)
+ } else if (typeof window !== "undefined") {
+ window.location.href = reviewUrl
}
}}
- disabled={isCanceled || row.original.investigationStatus !== "PLANNED"}
+ disabled={!canReviewPQ}
+ >
+ <FileEdit className="mr-2 h-4 w-4" />
+ 검토
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ if (!isCanceled && row.original.investigationStatus === "QM_REVIEW_CONFIRMED") {
+ (setRowAction as any)?.({ type: "update-progress", row })
+ }
+ }}
+ disabled={isCanceled || row.original.investigationStatus !== "QM_REVIEW_CONFIRMED"}
>
<Edit className="mr-2 h-4 w-4" />
실사 진행 관리
</DropdownMenuItem>
<DropdownMenuItem
- onSelect={() => {
- if (!isCanceled && row.original.investigationStatus === "IN_PROGRESS") {
- setRowAction?.({ type: "update-result", row })
+ onSelect={async () => {
+ if (isCanceled || row.original.investigationStatus !== "IN_PROGRESS") return
+ // 구매자체평가일 경우 결과입력 비활성화
+ if (row.original.investigationMethod === "PURCHASE_SELF_EVAL") {
+ return
+ }
+ // 방문/제품 평가 시: 벤더 회신 여부 확인 후 열기 (없으면 토스트)
+ if (
+ row.original.investigationMethod === "PRODUCT_INSPECTION" ||
+ row.original.investigationMethod === "SITE_VISIT_EVAL"
+ ) {
+ try {
+ const { getSiteVisitRequestAction } = await import("@/lib/site-visit/service")
+ const req = await getSiteVisitRequestAction(row.original.investigationId)
+ const canProceed = req.success && req.data && req.data.status === "VENDOR_SUBMITTED"
+ if (!canProceed) {
+ const { toast } = await import("sonner")
+ toast.error("협력업체 방문실사 정보 회신 전에는 결과 입력이 불가합니다.")
+ return
+ }
+ } catch {}
}
+ ;(setRowAction as any)?.({ type: "update-result", row })
}}
- disabled={isCanceled || row.original.investigationStatus !== "IN_PROGRESS"}
+ disabled={
+ isCanceled ||
+ row.original.investigationStatus !== "IN_PROGRESS" ||
+ row.original.investigationMethod === "PURCHASE_SELF_EVAL"
+ }
>
<Edit className="mr-2 h-4 w-4" />
실사 결과 입력
@@ -377,6 +417,8 @@ function formatStatus(status: string): string {
switch (status) {
case "PLANNED":
return "계획됨"
+ case "QM_REVIEW_CONFIRMED":
+ return "QM 검토 확정"
case "IN_PROGRESS":
return "진행 중"
case "COMPLETED":
diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
index 9f89a6ac..991c1ad6 100644
--- a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
+++ b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
@@ -2,12 +2,13 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check } from "lucide-react"
+import { Download, RotateCcw } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+import { InvestigationCancelPlanButton } from "./investigation-cancel-plan-button"
interface VendorsTableToolbarActionsProps {
@@ -20,6 +21,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
return (
<div className="flex items-center gap-2">
+ <InvestigationCancelPlanButton table={table} />
{/** 4) Export 버튼 */}
<Button
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index 19412539..891ef178 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -61,24 +61,44 @@ export const searchParamsInvestigationCache = createSearchParamsCache({
export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>>
// 실사 진행 관리용 스키마
-export const updateVendorInvestigationProgressSchema = z.object({
- investigationId: z.number({
- required_error: "Investigation ID is required",
- }),
- investigationAddress: z.string().optional(),
- investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(),
-
- // 날짜 필드들
- forecastedAt: z.union([
- z.date(),
- z.string().transform((str) => str ? new Date(str) : undefined)
- ]).optional(),
-
- confirmedAt: z.union([
- z.date(),
- z.string().transform((str) => str ? new Date(str) : undefined)
- ]).optional(),
-})
+export const updateVendorInvestigationProgressSchema = z
+ .object({
+ investigationId: z.number({
+ required_error: "Investigation ID is required",
+ }),
+ investigationAddress: z
+ .string({ required_error: "실사 주소는 필수입니다." })
+ .min(1, "실사 주소는 필수입니다."),
+ investigationMethod: z.enum([
+ "PURCHASE_SELF_EVAL",
+ "DOCUMENT_EVAL",
+ "PRODUCT_INSPECTION",
+ "SITE_VISIT_EVAL",
+ ], { required_error: "실사 방법은 필수입니다." }),
+
+ // 날짜 필드들
+ forecastedAt: z.union([
+ z.date(),
+ z.string().transform((str) => (str ? new Date(str) : undefined)),
+ ]),
+
+ confirmedAt: z.union([
+ z.date(),
+ z.string().transform((str) => (str ? new Date(str) : undefined)),
+ ], { required_error: "실사 계획 확정일은 필수입니다." }),
+ })
+ .superRefine((data, ctx) => {
+ // 방문/제품 평가일 경우 forecastedAt은 필수 아님, 그 외에는 필수
+ const method = data.investigationMethod
+ const requiresForecast = method !== "PRODUCT_INSPECTION" && method !== "SITE_VISIT_EVAL"
+ if (requiresForecast && !data.forecastedAt) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["forecastedAt"],
+ message: "실사 수행 예정일은 필수입니다.",
+ })
+ }
+ })
export type UpdateVendorInvestigationProgressSchema = z.infer<typeof updateVendorInvestigationProgressSchema>
@@ -87,21 +107,22 @@ export const updateVendorInvestigationResultSchema = z.object({
investigationId: z.number({
required_error: "Investigation ID is required",
}),
-
+
// 날짜 필드들
completedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
- ]).optional(),
-
+ ]),
+
evaluationScore: z.number()
.int("평가 점수는 정수여야 합니다.")
.min(0, "평가 점수는 0점 이상이어야 합니다.")
- .max(100, "평가 점수는 100점 이하여야 합니다.")
- .optional(),
- evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(),
+ .max(100, "평가 점수는 100점 이하여야 합니다."),
+ evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.any().optional(), // File 업로드를 위한 필드
+ attachments: z.any({
+ required_error: "첨부파일은 필수입니다."
+ }),
})
export type UpdateVendorInvestigationResultSchema = z.infer<typeof updateVendorInvestigationResultSchema>
diff --git a/lib/vendor-regular-registrations/approval-actions.ts b/lib/vendor-regular-registrations/approval-actions.ts
index 02c7e412..298591e9 100644
--- a/lib/vendor-regular-registrations/approval-actions.ts
+++ b/lib/vendor-regular-registrations/approval-actions.ts
@@ -11,6 +11,9 @@ import { withApproval } from '@/lib/approval/approval-workflow';
import { mapRegistrationToTemplateVariables } from './handlers';
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
import type { RegistrationRequestData } from '@/components/vendor-regular-registrations/registration-request-dialog';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { vendorRegularRegistrations } from '@/db/schema/vendorRegistrations';
/**
* 결재를 거쳐 정규업체 등록을 처리하는 서버 액션
@@ -90,6 +93,17 @@ export async function registerVendorWithApproval(data: {
}
);
+ // 3. 결재 상신 성공 시 상태를 pending_approval로 변경
+ if (result.status === 'pending_approval') {
+ debugLog('[VendorRegistrationApproval] 상태를 pending_approval로 변경');
+ await db.update(vendorRegularRegistrations)
+ .set({
+ status: 'pending_approval',
+ updatedAt: new Date()
+ })
+ .where(eq(vendorRegularRegistrations.id, data.registrationId));
+ }
+
debugSuccess('[VendorRegistrationApproval] 결재 워크플로우 완료', {
approvalId: result.approvalId,
status: result.status,
diff --git a/lib/vendor-regular-registrations/handlers.ts b/lib/vendor-regular-registrations/handlers.ts
index 4b21263d..95acde23 100644
--- a/lib/vendor-regular-registrations/handlers.ts
+++ b/lib/vendor-regular-registrations/handlers.ts
@@ -6,19 +6,20 @@
'use server';
-import { submitRegistrationRequest } from './service';
+import { sendRegistrationRequestToMDG } from './service';
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
import type { RegistrationRequestData } from '@/components/vendor-regular-registrations/registration-request-dialog';
import db from '@/db/db';
import { eq } from 'drizzle-orm';
import { vendors } from '@/db/schema/vendors';
-import { vendorAdditionalInfo } from '@/db/schema/vendorRegistrations';
+import { vendorAdditionalInfo, vendorRegularRegistrations } from '@/db/schema/vendorRegistrations';
/**
- * 정규업체 등록 핸들러 (결재 승인 후 실행됨)
- *
+ * 정규업체 등록 핸들러 (결재 승인 후 MDG 전송 실행)
+ *
+ * 결재 승인 후 자동으로 MDG로 정규업체 등록 요청을 전송함
* 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
- *
+ *
* @param payload - withApproval()에서 전달한 actionPayload
*/
export async function registerVendorInternal(payload: {
@@ -31,28 +32,61 @@ export async function registerVendorInternal(payload: {
});
try {
- // 실제 정규업체 등록 처리
- debugLog('[VendorRegistrationHandler] submitRegistrationRequest 호출');
- const result = await submitRegistrationRequest(
+ // 1. MDG로 정규업체 등록 요청 데이터 전송
+ debugLog('[VendorRegistrationHandler] sendRegistrationRequestToMDG 호출');
+ const mdgResult = await sendRegistrationRequestToMDG(
payload.registrationId,
payload.requestData
);
- if (!result.success) {
- debugError('[VendorRegistrationHandler] 정규업체 등록 실패', result.error);
- throw new Error(result.error || '정규업체 등록에 실패했습니다.');
+ if (!mdgResult.success) {
+ debugError('[VendorRegistrationHandler] MDG 전송 실패', mdgResult.error);
+
+ // MDG 전송 실패 시 상태를 registration_failed로 변경
+ await db.update(vendorRegularRegistrations)
+ .set({
+ status: 'registration_failed',
+ updatedAt: new Date()
+ })
+ .where(eq(vendorRegularRegistrations.id, payload.registrationId));
+
+ throw new Error(mdgResult.error || 'MDG 전송에 실패했습니다.');
}
+ // 3. MDG 전송 성공 시 상태를 registration_completed로 변경
+ debugLog('[VendorRegistrationHandler] MDG 전송 성공, 상태를 registration_completed로 변경');
+ await db.update(vendorRegularRegistrations)
+ .set({
+ status: 'registration_completed',
+ updatedAt: new Date()
+ })
+ .where(eq(vendorRegularRegistrations.id, payload.registrationId));
+
debugSuccess('[VendorRegistrationHandler] 정규업체 등록 완료', {
registrationId: payload.registrationId,
+ mdgResult: mdgResult
});
return {
success: true,
message: '정규업체 등록이 완료되었습니다.',
+ mdgResult: mdgResult
};
} catch (error) {
debugError('[VendorRegistrationHandler] 정규업체 등록 중 에러', error);
+
+ // 에러 발생 시 상태를 registration_failed로 변경
+ try {
+ await db.update(vendorRegularRegistrations)
+ .set({
+ status: 'registration_failed',
+ updatedAt: new Date()
+ })
+ .where(eq(vendorRegularRegistrations.id, payload.registrationId));
+ } catch (updateError) {
+ debugError('[VendorRegistrationHandler] 상태 업데이트 실패', updateError);
+ }
+
throw error;
}
}
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
index 6f73b98f..e69e78bf 100644
--- a/lib/vendor-regular-registrations/repository.ts
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -169,8 +169,7 @@ export async function getVendorRegularRegistrations(
const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
// 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- // 단, 이미 registration_requested 상태라면 자동 업데이트하지 않음
- if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "registration_requested") {
+ if (shouldUpdateStatus && registration.status !== "approval_ready") {
// 비동기 업데이트 (백그라운드에서 실행)
updateVendorRegularRegistration(registration.id, {
status: "approval_ready"
@@ -236,7 +235,7 @@ export async function createVendorRegularRegistration(data: {
.insert(vendorRegularRegistrations)
.values({
vendorId: data.vendorId,
- status: data.status || "audit_pass",
+ status: data.status || "under_review",
potentialCode: data.potentialCode,
majorItems: data.majorItems,
assignedDepartment: data.assignedDepartment,
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index 372212fc..2a6695fa 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -35,13 +35,13 @@ async function updatePendingApprovals() {
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
- // 3개월 이상 정규등록검토 상태인 등록들을 조회
+ // 3개월 이상 조건충족 상태인 등록들을 조회
const outdatedRegistrations = await db
.select()
.from(vendorRegularRegistrations)
.where(
and(
- eq(vendorRegularRegistrations.status, "in_review"),
+ eq(vendorRegularRegistrations.status, "approval_ready"),
lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
)
);
@@ -51,12 +51,13 @@ async function updatePendingApprovals() {
await db
.update(vendorRegularRegistrations)
.set({
- status: "pending_approval",
+ status: "registration_failed",
updatedAt: new Date(),
+ remarks: "3개월 이상 조건충족 상태로 장기미등록으로 변경",
})
.where(
and(
- eq(vendorRegularRegistrations.status, "in_review"),
+ eq(vendorRegularRegistrations.status, "approval_ready"),
lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
)
);
@@ -141,152 +142,6 @@ export async function getCurrentUserInfo() {
};
}
-export async function createVendorRegistration(data: {
- vendorId: number;
- status?: string;
- potentialCode?: string;
- majorItems?: Record<string, unknown>[];
- assignedDepartment?: string;
- assignedDepartmentCode?: string;
- assignedUser?: string;
- assignedUserCode?: string;
- remarks?: string;
-}) {
- try {
- const majorItemsJson = data.majorItems ? JSON.stringify(data.majorItems) : undefined;
-
- const registration = await createVendorRegularRegistration({
- ...data,
- status: data.status || "under_review", // 기본 상태를 '검토중'으로 설정
- majorItems: majorItemsJson,
- });
-
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
- return { success: true, data: registration };
- } catch (error) {
- console.error("Error in createVendorRegistration:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록을 생성하는 중 오류가 발생했습니다.",
- };
- }
-}
-
-export async function updateVendorRegistration(
- id: number,
- data: Partial<{
- status: string;
- potentialCode: string;
- majorItems: Record<string, unknown>[];
- registrationRequestDate: string;
- assignedDepartment: string;
- assignedDepartmentCode: string;
- assignedUser: string;
- assignedUserCode: string;
- remarks: string;
- }>
-) {
- try {
- const updateData: Partial<{
- status: string;
- potentialCode: string;
- majorItems: string;
- registrationRequestDate: string;
- assignedDepartment: string;
- assignedDepartmentCode: string;
- assignedUser: string;
- assignedUserCode: string;
- remarks: string;
- }> = {};
-
- // majorItems를 제외한 다른 필드들을 복사
- Object.keys(data).forEach(key => {
- if (key !== 'majorItems') {
- updateData[key as keyof typeof updateData] = data[key as keyof typeof data] as never;
- }
- });
-
- if (data.majorItems) {
- updateData.majorItems = JSON.stringify(data.majorItems);
- }
-
- const registration = await updateVendorRegularRegistration(id, updateData);
-
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
- return { success: true, data: registration };
- } catch (error) {
- console.error("Error in updateVendorRegistration:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록을 수정하는 중 오류가 발생했습니다.",
- };
- }
-}
-
-export async function fetchVendorRegistrationById(id: number) {
- try {
- const registration = await getVendorRegularRegistrationById(id);
- return { success: true, data: registration };
- } catch (error) {
- console.error("Error in fetchVendorRegistrationById:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.",
- };
- }
-}
-
-
-
-export async function requestRegularRegistration(registrationId: number) {
- try {
- // 정규업체 등록 요청 처리
- const now = new Date().toISOString().split('T')[0];
-
- const registration = await updateVendorRegularRegistration(registrationId, {
- status: "in_review",
- registrationRequestDate: now,
- });
-
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
- return { success: true, message: "정규업체 등록 요청이 완료되었습니다.", data: registration };
- } catch (error) {
- console.error("Error in requestRegularRegistration:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다.",
- };
- }
-}
-
-export async function approveRegularRegistration(registrationId: number) {
- try {
- // 정규업체 등록 승인 처리
- const registration = await updateVendorRegularRegistration(registrationId, {
- status: "approval_ready",
- });
-
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
- return { success: true, message: "정규업체 등록이 승인되었습니다.", data: registration };
- } catch (error) {
- console.error("Error in approveRegularRegistration:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록 승인 중 오류가 발생했습니다.",
- };
- }
-}
-
-
-
// 누락계약요청 이메일 발송
export async function sendMissingContractRequestEmails(vendorIds: number[]) {
try {
@@ -530,90 +385,6 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
}
}
-// 안전적격성평가 Skip 기능 (삭제됨 - 개별 입력으로 대체)
-/*
-export async function skipSafetyQualification(vendorIds: number[], skipReason: string) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- return { success: false, error: "로그인이 필요합니다." };
- }
-
- let successCount = 0;
- let errorCount = 0;
-
- for (const vendorId of vendorIds) {
- try {
- // 해당 벤더의 registration 찾기 또는 생성
- const vendorList = await db
- .select({ id: vendors.id })
- .from(vendors)
- .where(eq(vendors.id, vendorId));
-
- if (vendorList.length === 0) {
- errorCount++;
- continue;
- }
-
- // registration 조회
- const existingRegistrations = await db
- .select()
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.vendorId, vendorId));
-
- let registrationId;
- if (existingRegistrations.length === 0) {
- // 새로 생성
- const newRegistration = await createVendorRegularRegistration({
- vendorId: vendorId,
- status: "audit_pass",
- remarks: `안전적격성평가 Skip: ${skipReason}`,
- });
- registrationId = newRegistration.id;
- } else {
- // 기존 registration 업데이트
- registrationId = existingRegistrations[0].id;
- const currentRemarks = existingRegistrations[0].remarks || "";
- const newRemarks = currentRemarks
- ? `${currentRemarks}\n안전적격성평가 Skip: ${skipReason}`
- : `안전적격성평가 Skip: ${skipReason}`;
-
- await updateVendorRegularRegistration(registrationId, {
- remarks: newRemarks,
- });
- }
-
- // 안전적격성평가 상태를 완료로 처리 (계약 동의 현황은 이제 실시간으로 조회하므로 별도 처리 불필요)
- // updateContractAgreement 함수는 제거되었으므로 계약 동의 현황은 basic_contract와 vendor_pq_submissions에서 실시간으로 조회됩니다.
-
- successCount++;
- } catch (error) {
- console.error(`Failed to skip safety qualification for vendor ${vendorId}:`, error);
- errorCount++;
- }
- }
-
- if (errorCount > 0) {
- return {
- success: false,
- error: `${successCount}개 업체 처리 성공, ${errorCount}개 업체 처리 실패`,
- };
- }
-
- return {
- success: true,
- message: `${successCount}개 업체의 안전적격성평가를 Skip 처리했습니다.`,
- };
- } catch (error) {
- console.error("Error skipping safety qualification:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "안전적격성평가 Skip 처리 중 오류가 발생했습니다.",
- };
- }
-}
-*/
-
// 주요품목 업데이트
export async function updateMajorItems(
registrationId: number,
@@ -1045,52 +816,6 @@ export async function updateSafetyQualification(
}
}
-// GTC Skip 처리
-export async function updateGtcSkip(
- registrationId: number,
- skipReason: string
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- return { success: false, error: "로그인이 필요합니다." };
- }
-
- // 현재 비고 가져오기
- const existingRegistration = await getVendorRegularRegistrationById(registrationId);
- if (!existingRegistration) {
- return { success: false, error: "등록 정보를 찾을 수 없습니다." };
- }
-
- const currentRemarks = existingRegistration.remarks || "";
- const newRemarks = currentRemarks
- ? `${currentRemarks}\nGTC Skip: ${skipReason}`
- : `GTC Skip: ${skipReason}`;
-
- const result = await updateVendorRegularRegistration(registrationId, {
- gtcSkipped: true,
- remarks: newRemarks,
- });
-
- if (!result) {
- return { success: false, error: "등록 정보를 찾을 수 없습니다." };
- }
-
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
- return {
- success: true,
- message: "GTC Skip이 처리되었습니다.",
- };
- } catch (error) {
- console.error("Error updating GTC skip:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "GTC Skip 처리 중 오류가 발생했습니다.",
- };
- }
-}
// 정규업체 등록 요청을 위한 상세 데이터 조회
export async function fetchRegistrationRequestData(registrationId: number) {
@@ -1166,108 +891,108 @@ export async function fetchRegistrationRequestData(registrationId: number) {
}
// 정규업체 등록 요청 서버 액션
-export async function submitRegistrationRequest(
- registrationId: number,
- requestData: RegistrationRequestData
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user) {
- return { success: false, error: "인증이 필요합니다." };
- }
-
- // 현재 등록 정보 조회
- const registration = await db
- .select()
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.id, registrationId))
- .limit(1);
-
- if (!registration[0]) {
- return { success: false, error: "등록 정보를 찾을 수 없습니다." };
- }
-
- // 조건충족 상태인지 확인
- console.log("📋 업데이트 전 현재 데이터:", {
- registrationId,
- currentStatus: registration[0].status,
- currentRemarks: registration[0].remarks,
- currentUpdatedAt: registration[0].updatedAt
- });
-
- if (registration[0].status !== "approval_ready") {
- return { success: false, error: "조건충족 상태가 아닙니다." };
- }
-
- // 정규업체 등록 요청 데이터를 JSON으로 저장
- const registrationRequestData = {
- requestDate: new Date(),
- requestedBy: session.user.id,
- requestedByName: session.user.name,
- requestData: requestData,
- status: "requested" // 요청됨
- };
-
- // 트랜잭션으로 상태 변경
- const updateResult = await db.transaction(async (tx) => {
- return await tx
- .update(vendorRegularRegistrations)
- .set({
- status: "registration_requested",
- remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
- updatedAt: new Date(),
- })
- .where(eq(vendorRegularRegistrations.id, registrationId));
- });
-
- console.log("🔄 업데이트 결과:", {
- registrationId,
- updateResult,
- statusToSet: "registration_requested"
- });
-
-
-
- // MDG 인터페이스 연동
- const mdgResult = await sendRegistrationRequestToMDG(registrationId, requestData);
+// export async function submitRegistrationRequest(
+// registrationId: number,
+// requestData: RegistrationRequestData
+// ) {
+// try {
+// const session = await getServerSession(authOptions);
+// if (!session?.user) {
+// return { success: false, error: "인증이 필요합니다." };
+// }
+
+// // 현재 등록 정보 조회
+// const registration = await db
+// .select()
+// .from(vendorRegularRegistrations)
+// .where(eq(vendorRegularRegistrations.id, registrationId))
+// .limit(1);
+
+// if (!registration[0]) {
+// return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+// }
+
+// // 조건충족 상태인지 확인
+// console.log("📋 업데이트 전 현재 데이터:", {
+// registrationId,
+// currentStatus: registration[0].status,
+// currentRemarks: registration[0].remarks,
+// currentUpdatedAt: registration[0].updatedAt
+// });
+
+// if (registration[0].status !== "approval_ready") {
+// return { success: false, error: "조건충족 상태가 아닙니다." };
+// }
+
+// // 정규업체 등록 요청 데이터를 JSON으로 저장
+// const registrationRequestData = {
+// requestDate: new Date(),
+// requestedBy: session.user.id,
+// requestedByName: session.user.name,
+// requestData: requestData,
+// status: "requested" // 요청됨
+// };
+
+// // 트랜잭션으로 상태 변경
+// const updateResult = await db.transaction(async (tx) => {
+// return await tx
+// .update(vendorRegularRegistrations)
+// .set({
+// status: "registration_requested",
+// remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
+// updatedAt: new Date(),
+// })
+// .where(eq(vendorRegularRegistrations.id, registrationId));
+// });
+
+// console.log("🔄 업데이트 결과:", {
+// registrationId,
+// updateResult,
+// statusToSet: "registration_requested"
+// });
+
+
+
+// // MDG 인터페이스 연동
+// const mdgResult = await sendRegistrationRequestToMDG(registrationId, requestData);
- if (!mdgResult.success) {
- console.error('❌ MDG 송신 실패:', mdgResult.error);
- // MDG 송신 실패해도 등록 요청은 성공으로 처리 (재시도 가능하도록)
- } else {
- console.log('✅ MDG 송신 성공:', mdgResult.message);
- }
-
- // Knox 결재 연동은 별도의 결재 워크플로우에서 처리됩니다.
- // UI에서 registerVendorWithApproval()을 호출하여 결재 프로세스를 시작합니다.
-
- console.log("✅ 정규업체 등록 요청 데이터:", {
- registrationId,
- companyName: requestData.companyNameKor,
- businessNumber: requestData.businessNumber,
- representative: requestData.representativeNameKor,
- requestedBy: session.user.name,
- requestDate: new Date().toISOString()
- });
-
- // 캐시 무효화 - 더 강력한 무효화
- revalidateTag("vendor-regular-registrations");
- revalidateTag(`vendor-regular-registration-${registrationId}`);
- revalidateTag("vendor-registration-status");
-
- return {
- success: true,
- message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\n결재 승인 후 정규업체 등록이 완료됩니다.`
- };
-
- } catch (error) {
- console.error("정규업체 등록 요청 오류:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다."
- };
- }
-}
+// if (!mdgResult.success) {
+// console.error('❌ MDG 송신 실패:', mdgResult.error);
+// // MDG 송신 실패해도 등록 요청은 성공으로 처리 (재시도 가능하도록)
+// } else {
+// console.log('✅ MDG 송신 성공:', mdgResult.message);
+// }
+
+// // Knox 결재 연동은 별도의 결재 워크플로우에서 처리됩니다.
+// // UI에서 registerVendorWithApproval()을 호출하여 결재 프로세스를 시작합니다.
+
+// console.log("✅ 정규업체 등록 요청 데이터:", {
+// registrationId,
+// companyName: requestData.companyNameKor,
+// businessNumber: requestData.businessNumber,
+// representative: requestData.representativeNameKor,
+// requestedBy: session.user.name,
+// requestDate: new Date().toISOString()
+// });
+
+// // 캐시 무효화 - 더 강력한 무효화
+// revalidateTag("vendor-regular-registrations");
+// revalidateTag(`vendor-regular-registration-${registrationId}`);
+// revalidateTag("vendor-registration-status");
+
+// return {
+// success: true,
+// message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\n결재 승인 후 정규업체 등록이 완료됩니다.`
+// };
+
+// } catch (error) {
+// console.error("정규업체 등록 요청 오류:", error);
+// return {
+// success: false,
+// error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다."
+// };
+// }
+// }
// MDG로 정규업체 등록 요청 데이터를 보내는 함수
export async function sendRegistrationRequestToMDG(
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
index c823bc9d..b6f9289f 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"
import { format } from "date-fns"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+import { VendorRegularRegistration, statusLabels, statusColors } from "@/config/vendorRegularRegistrationsColumnsConfig"
import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
@@ -16,23 +16,6 @@ import { useState } from "react"
import { SafetyQualificationUpdateDialog } from "./safety-qualification-update-dialog"
import { MajorItemsUpdateDialog } from "./major-items-update-dialog"
-
-const statusLabels = {
- under_review: "검토중",
- approval_ready: "조건충족",
- in_review: "정규등록검토",
- completed: "등록완료",
- pending_approval: "장기미등록",
-}
-
-const statusColors = {
- under_review: "bg-blue-100 text-blue-800",
- approval_ready: "bg-emerald-100 text-emerald-800",
- in_review: "bg-orange-100 text-orange-800",
- completed: "bg-green-100 text-green-800",
- pending_approval: "bg-red-100 text-red-800",
-}
-
export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
return [
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
index d88cd7b7..f40a41f7 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
@@ -6,11 +6,10 @@ import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Mail, FileWarning, Scale, FileText } from "lucide-react"
import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
-import {
+import {
sendMissingContractRequestEmails,
sendAdditionalInfoRequestEmails,
- skipLegalReview,
- submitRegistrationRequest
+ skipLegalReview
} from "../service"
import { useState } from "react"
import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
index 8b477dba..c1229b42 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
@@ -61,7 +61,6 @@ export function VendorRegularRegistrationsTable({ promises }: VendorRegularRegis
{ label: "CP검토", value: "cp_review" },
{ label: "CP완료", value: "cp_finished" },
{ label: "조건충족", value: "approval_ready" },
- { label: "정규등록검토", value: "in_review" },
{ label: "장기미등록", value: "pending_approval" },
]
},