diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-30 10:44:47 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-30 10:44:47 +0000 |
| commit | 871a6d46a769cbe9e87146434f4bcb2d6792ab81 (patch) | |
| tree | acc25b3645e2253625e68f3721a203131ff4f3c4 /lib | |
| parent | 17b9d2016be7c0ab6571de6aba36b3f4ea37bdb1 (diff) | |
(최겸) 구매 PQ/실사 재개발(테스트 필요), 정규업체등록 결재 개발, 실사 의뢰 결재 후처리 등
Diffstat (limited to 'lib')
30 files changed, 1519 insertions, 958 deletions
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" },
]
},
|
