summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx197
-rw-r--r--lib/approval/handlers-registry.ts4
-rw-r--r--lib/approval/templates/컴플라이언스 Red Flag 해소요청.html135
-rw-r--r--lib/basic-contract/actions/check-red-flag-resolution.ts82
-rw-r--r--lib/basic-contract/agreement-comments/actions.ts76
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx16
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx74
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx46
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx52
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx17
-rw-r--r--lib/compliance/approval-handlers.ts64
-rw-r--r--lib/compliance/red-flag-resolution.ts529
-rw-r--r--lib/knox-api/approval/approval.ts90
-rw-r--r--lib/mail/templates/agreement-comment-notification.hbs6
-rw-r--r--lib/mail/templates/negotiation-complete-notification.hbs4
15 files changed, 929 insertions, 463 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx
new file mode 100644
index 00000000..359efbed
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx
@@ -0,0 +1,197 @@
+import * as React from "react";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { eq } from "drizzle-orm";
+
+import db from "@/db/db";
+import { basicContractView } from "@/db/schema";
+import { Shell } from "@/components/shell";
+import {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,
+ BreadcrumbPage,
+} from "@/components/ui/breadcrumb";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { AgreementCommentList } from "@/lib/basic-contract/agreement-comments/agreement-comment-list";
+import { formatDateTime } from "@/lib/utils";
+import { checkNegotiationStatus } from "@/lib/basic-contract/agreement-comments/actions";
+import { MessageCircle, Building2, FileText, Calendar, ArrowLeft } from "lucide-react";
+
+interface ComplianceCommentsPageProps {
+ params: Promise<{ id: string }>;
+}
+
+export const revalidate = 0;
+
+export default async function ComplianceCommentsPage(props: ComplianceCommentsPageProps) {
+ const params = await props.params;
+ const contractId = Number(params.id);
+
+ if (Number.isNaN(contractId)) {
+ notFound();
+ }
+
+ const contract = await db
+ .select()
+ .from(basicContractView)
+ .where(eq(basicContractView.id, contractId))
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ if (!contract) {
+ notFound();
+ }
+
+ const negotiationSummary = await checkNegotiationStatus(contractId);
+
+ const negotiationStatusLabel = contract.negotiationCompletedAt
+ ? "협의 완료"
+ : negotiationSummary.hasComments
+ ? `협의 진행중 (${negotiationSummary.commentCount}건)`
+ : "협의 없음";
+
+ const negotiationBadgeClass = contract.negotiationCompletedAt
+ ? "bg-green-50 text-green-700 border-green-200"
+ : negotiationSummary.hasComments
+ ? "bg-orange-50 text-orange-700 border-orange-200"
+ : "bg-gray-50 text-gray-600 border-gray-200";
+
+ const templateLink = contract.templateId
+ ? `/evcp/basic-contract/${contract.templateId}`
+ : "/evcp/basic-contract";
+
+ return (
+ <Shell className="gap-4">
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp">EVCP</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp/basic-contract">기본계약서/서약서 관리</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage>
+ {contract.vendorName ? `${contract.vendorName} 협의 코멘트` : "협의 코멘트"}
+ </BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <Button asChild variant="outline" size="sm">
+ <Link href={templateLink}>
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 계약 상세로 돌아가기
+ </Link>
+ </Button>
+ </div>
+
+ <Card>
+ <CardHeader className="pb-3">
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <div className="space-y-1">
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <FileText className="h-4 w-4 text-blue-500" />
+ {contract.templateName || "기본계약서"}
+ </CardTitle>
+ <CardDescription>
+ 계약서 ID {contract.id} · 템플릿 ID {contract.templateId ?? "-"}
+ </CardDescription>
+ </div>
+ <Badge variant="outline" className={`flex items-center gap-1 ${negotiationBadgeClass}`}>
+ <MessageCircle className="h-3 w-3" />
+ {negotiationStatusLabel}
+ </Badge>
+ </div>
+ </CardHeader>
+ <CardContent className="grid gap-4 md:grid-cols-2">
+ <div className="space-y-3">
+ <h4 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
+ <Building2 className="h-4 w-4 text-gray-500" />
+ 협력업체 정보
+ </h4>
+ <div className="rounded border bg-gray-50 p-3 text-sm space-y-1">
+ <div className="flex justify-between">
+ <span className="text-gray-500">업체명</span>
+ <span className="font-medium text-gray-900">
+ {contract.vendorName || "미지정"}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-gray-500">업체코드</span>
+ <span className="font-mono text-gray-900">
+ {contract.vendorCode || "-"}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-gray-500">이메일</span>
+ <span className="text-gray-900">{contract.vendorEmail || "-"}</span>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <h4 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
+ <Calendar className="h-4 w-4 text-gray-500" />
+ 진행 정보
+ </h4>
+ <div className="rounded border bg-gray-50 p-3 text-sm space-y-1">
+ <div className="flex justify-between">
+ <span className="text-gray-500">요청일</span>
+ <span className="text-gray-900">
+ {formatDateTime(contract.createdAt, "KR")}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-gray-500">서명 상태</span>
+ <span className="text-gray-900">
+ {contract.vendorSignedAt ? "협력업체 서명완료" : "협력업체 서명대기"}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-gray-500">협의 완료일</span>
+ <span className="text-gray-900">
+ {contract.negotiationCompletedAt
+ ? formatDateTime(contract.negotiationCompletedAt, "KR")
+ : "-"}
+ </span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-3">
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <div>
+ <CardTitle className="text-lg">협의 코멘트</CardTitle>
+ <CardDescription>
+ {contract.vendorName
+ ? `${contract.vendorName}과(와)의 협의 내용을 기록하고 공유합니다.`
+ : "협의 코멘트를 작성하고 상대방과 공유합니다."}
+ </CardDescription>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="h-[620px] pt-0">
+ <AgreementCommentList
+ basicContractId={contractId}
+ currentUserType="SHI"
+ readOnly={false}
+ isNegotiationCompleted={!!contract.negotiationCompletedAt}
+ />
+ </CardContent>
+ </Card>
+ </Shell>
+ );
+}
+
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index 7aec3ae5..5c173565 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -59,6 +59,10 @@ export async function initializeApprovalHandlers() {
// 기술영업 RFQ 재발송 핸들러 등록 (결재 승인 후 실행될 함수 resendTechSalesRfqWithDrmInternal)
registerActionHandler('tech_sales_rfq_resend_with_drm', resendTechSalesRfqWithDrmInternal);
+ // 8. 컴플라이언스 Red Flag 해소 핸들러
+ const { resolveRedFlagAfterApproval } = await import('@/lib/compliance/approval-handlers');
+ registerActionHandler('compliance_red_flag_resolution', resolveRedFlagAfterApproval);
+
// 8. 입찰초대 핸들러
const { requestBiddingInvitationInternal } = await import('@/lib/bidding/handlers');
// 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal)
diff --git a/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html b/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html
new file mode 100644
index 00000000..ebff8de0
--- /dev/null
+++ b/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html
@@ -0,0 +1,135 @@
+<div
+ style="
+ max-width: 900px;
+ margin: 0 auto;
+ font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
+ color: #1f2937;
+ line-height: 1.6;
+ "
+>
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 2px solid #0f172a;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #0f172a;
+ color: #fff;
+ padding: 20px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ "
+ >
+ 컴플라이언스 Red Flag 해소요청
+ </th>
+ </tr>
+ </thead>
+ </table>
+
+ <section style="margin-bottom: 24px;">
+ <h3
+ style="
+ font-size: 18px;
+ font-weight: 600;
+ color: #0f172a;
+ margin-bottom: 12px;
+ "
+ >
+ ■ 요청자 정보
+ </h3>
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ border: 1px solid #cbd5f5;
+ "
+ >
+ <tbody>
+ <tr>
+ <td
+ style="
+ width: 25%;
+ background-color: #f1f5f9;
+ font-weight: 600;
+ padding: 10px;
+ border: 1px solid #cbd5f5;
+ "
+ >
+ 요청자
+ </td>
+ <td style="padding: 10px; border: 1px solid #e2e8f0;">
+ {{요청자이름}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f1f5f9;
+ font-weight: 600;
+ padding: 10px;
+ border: 1px solid #cbd5f5;
+ "
+ >
+ 요청일시
+ </td>
+ <td style="padding: 10px; border: 1px solid #e2e8f0;">
+ {{요청일시}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f1f5f9;
+ font-weight: 600;
+ padding: 10px;
+ border: 1px solid #cbd5f5;
+ "
+ >
+ 요청 사유
+ </td>
+ <td style="padding: 10px; border: 1px solid #e2e8f0;">
+ {{요청사유}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </section>
+
+ <section style="margin-bottom: 24px;">
+ <h3
+ style="
+ font-size: 18px;
+ font-weight: 600;
+ color: #0f172a;
+ margin-bottom: 12px;
+ "
+ >
+ ■ 대상 계약 요약
+ </h3>
+ {{RedFlag요약테이블}}
+ </section>
+
+ <section>
+ <h3
+ style="
+ font-size: 18px;
+ font-weight: 600;
+ color: #0f172a;
+ margin-bottom: 12px;
+ "
+ >
+ ■ 상세 내역
+ </h3>
+ <div style="border: 1px solid #e2e8f0; padding: 16px; border-radius: 8px;">
+ {{RedFlag상세내역}}
+ </div>
+ </section>
+</div>
+
diff --git a/lib/basic-contract/actions/check-red-flag-resolution.ts b/lib/basic-contract/actions/check-red-flag-resolution.ts
index 84dcdf75..3ce21bde 100644
--- a/lib/basic-contract/actions/check-red-flag-resolution.ts
+++ b/lib/basic-contract/actions/check-red-flag-resolution.ts
@@ -2,96 +2,60 @@
import { BasicContractView } from "@/db/schema";
import { getComplianceResponseByBasicContractId } from "@/lib/compliance/services";
-import { syncSpecificApprovalStatusAction } from "@/lib/knox-api/approval/approval";
+
+type RedFlagResolutionState = {
+ resolved: boolean;
+ resolvedAt: Date | null;
+ pendingApprovalId: string | null;
+};
/**
* 여러 계약서에 대한 RED FLAG 해제 상태를 한 번에 확인
*/
export async function checkRedFlagResolutionForContracts(
contracts: BasicContractView[]
-): Promise<Record<number, { resolved: boolean; resolvedAt: Date | null }>> {
- const result: Record<number, { resolved: boolean; resolvedAt: Date | null }> = {};
-
+): Promise<Record<number, RedFlagResolutionState>> {
+ const result: Record<number, RedFlagResolutionState> = {};
+
// 준법서약 템플릿인 계약서만 필터링
- const complianceContracts = contracts.filter(contract =>
- contract.templateName?.includes('준법')
+ const complianceContracts = contracts.filter((contract) =>
+ contract.templateName?.includes("준법")
);
-
+
if (complianceContracts.length === 0) {
return result;
}
- // 1. 먼저 DB에서 현재 상태 조회
- const initialChecks = await Promise.all(
+ const checks = await Promise.all(
complianceContracts.map(async (contract) => {
try {
const response = await getComplianceResponseByBasicContractId(contract.id);
return {
contractId: contract.id,
- response
+ resolved: Boolean(response?.redFlagResolvedAt),
+ resolvedAt: response?.redFlagResolvedAt || null,
+ pendingApprovalId: response?.redFlagResolutionApprovalId ?? null,
};
} catch (error) {
console.error(`Error fetching compliance response for contract ${contract.id}:`, error);
return {
contractId: contract.id,
- response: null
- };
- }
- })
- );
-
- // 2. 진행 중인 결재(해소요청)가 있는지 확인하고 Knox 상태 동기화
- const pendingApprovalIds: string[] = [];
-
- initialChecks.forEach(check => {
- const { response } = check;
- // 해소요청은 했으나(approvalId 있음) 아직 해소되지 않은(resolvedAt 없음) 경우
- if (response?.redFlagResolutionApprovalId && !response.redFlagResolvedAt) {
- pendingApprovalIds.push(response.redFlagResolutionApprovalId);
- }
- });
-
- if (pendingApprovalIds.length > 0) {
- try {
- // Knox API를 통해 최신 결재 상태 동기화
- // 이 과정에서 결재가 완료되었다면 DB의 redFlagResolvedAt도 업데이트됨 (syncSpecificApprovalStatusAction 내부 로직)
- await syncSpecificApprovalStatusAction(pendingApprovalIds);
- } catch (error) {
- console.error('Error syncing approval status:', error);
- }
- }
-
- // 3. 동기화 후 최종 상태 다시 확인
- // (동기화 과정에서 DB가 업데이트되었을 수 있으므로 다시 조회하거나,
- // 성능을 위해 위에서 동기화된 건만 다시 조회하는 방식도 가능하지만,
- // 여기서는 안전하게 다시 조회하는 방식을 택함)
- const finalChecks = await Promise.all(
- complianceContracts.map(async (contract) => {
- try {
- const response = await getComplianceResponseByBasicContractId(contract.id);
- return {
- contractId: contract.id,
- resolved: response?.redFlagResolvedAt !== null && response?.redFlagResolvedAt !== undefined,
- resolvedAt: response?.redFlagResolvedAt || null
- };
- } catch (error) {
- return {
- contractId: contract.id,
resolved: false,
- resolvedAt: null
+ resolvedAt: null,
+ pendingApprovalId: null,
};
}
})
);
-
- // 결과를 Record 형태로 변환
- finalChecks.forEach(check => {
+
+ checks.forEach((check) => {
result[check.contractId] = {
resolved: check.resolved,
- resolvedAt: check.resolvedAt
+ resolvedAt: check.resolvedAt,
+ pendingApprovalId: check.pendingApprovalId,
};
});
-
+
return result;
}
diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts
index c4ded36e..2f60c3d8 100644
--- a/lib/basic-contract/agreement-comments/actions.ts
+++ b/lib/basic-contract/agreement-comments/actions.ts
@@ -2,7 +2,7 @@
import { revalidateTag } from "next/cache";
import db from "@/db/db";
-import { eq, and, desc } from "drizzle-orm";
+import { eq, and, desc, inArray, sql } from "drizzle-orm";
import { agreementComments, basicContract, vendors, users } from "@/db/schema";
import { saveFile, deleteFile } from "@/lib/file-stroage";
import { sendEmail } from "@/lib/mail/sendEmail";
@@ -32,6 +32,23 @@ export interface AgreementCommentData {
updatedAt: Date;
}
+export interface AgreementCommentSummary {
+ hasComments: boolean;
+ commentCount: number;
+}
+
+function getContractDocumentLabel(templateName?: string | null) {
+ if (!templateName) return "기본계약서";
+ const normalized = templateName.toLowerCase();
+ if (normalized.includes('준법')) {
+ return "준법서약";
+ }
+ if (normalized.includes('gtc')) {
+ return "GTC 기본계약서";
+ }
+ return templateName;
+}
+
/**
* 특정 기본계약서의 모든 코멘트 조회
*/
@@ -496,6 +513,55 @@ export async function checkNegotiationStatus(
}
/**
+ * 다수의 계약서에 대한 협의 코멘트 상태 조회
+ */
+export async function checkAgreementCommentsForContracts(
+ contracts: { id: number }[]
+): Promise<Record<number, AgreementCommentSummary>> {
+ if (!contracts || contracts.length === 0) {
+ return {};
+ }
+
+ try {
+ const contractIds = contracts.map(contract => contract.id);
+
+ const commentCounts = await db
+ .select({
+ contractId: agreementComments.basicContractId,
+ count: sql<number>`count(*)`,
+ })
+ .from(agreementComments)
+ .where(
+ and(
+ inArray(agreementComments.basicContractId, contractIds),
+ eq(agreementComments.isDeleted, false)
+ )
+ )
+ .groupBy(agreementComments.basicContractId);
+
+ const countsMap = new Map<number, number>();
+ commentCounts.forEach(({ contractId, count }) => {
+ countsMap.set(contractId, Number(count || 0));
+ });
+
+ const result: Record<number, AgreementCommentSummary> = {};
+
+ contractIds.forEach((contractId) => {
+ const count = countsMap.get(contractId) ?? 0;
+ result[contractId] = {
+ hasComments: count > 0,
+ commentCount: count,
+ };
+ });
+
+ return result;
+ } catch (error) {
+ console.error("협의 상태 조회 실패:", error);
+ return {};
+ }
+}
+
+/**
* 이메일 알림 발송
*/
async function sendCommentNotificationEmail(params: {
@@ -509,6 +575,7 @@ async function sendCommentNotificationEmail(params: {
attachmentCount?: number;
}) {
const { comment, contract, vendor, requester, templateName, authorType, authorName, attachmentCount = 0 } = params;
+ const documentTypeLabel = getContractDocumentLabel(templateName);
// 수신자 결정
let recipientEmail: string | undefined;
@@ -536,7 +603,7 @@ async function sendCommentNotificationEmail(params: {
// 이메일 발송
await sendEmail({
to: recipientEmail,
- subject: `[eVCP] GTC 기본계약서 협의 코멘트 제출 - ${templateName || '기본계약서'}`,
+ subject: `[eVCP] ${documentTypeLabel} 협의 코멘트 제출 - ${templateName || '기본계약서'}`,
template: "agreement-comment-notification",
context: {
language: "ko",
@@ -545,6 +612,7 @@ async function sendCommentNotificationEmail(params: {
authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체',
comment: comment.comment,
templateName: templateName || '기본계약서',
+ documentTypeLabel,
vendorName: vendor?.vendorName || '',
attachmentCount,
contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`,
@@ -617,18 +685,20 @@ export async function completeNegotiation(
.limit(1);
templateName = template?.templateName || null;
}
+ const documentTypeLabel = getContractDocumentLabel(templateName);
// 이메일 알림 발송
try {
if (requester) {
await sendEmail({
to: requester.email || '',
- subject: `[eVCP] GTC 기본계약서 협의 완료 - ${templateName || '기본계약서'}`,
+ subject: `[eVCP] ${documentTypeLabel} 협의 완료 - ${templateName || '기본계약서'}`,
template: "negotiation-complete-notification",
context: {
language: "ko",
recipientName: requester.name || "담당자",
templateName: templateName || '기본계약서',
+ documentTypeLabel,
vendorName: vendor?.vendorName || '',
contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`,
systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com',
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index daa410f0..e62a6cb7 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -414,15 +414,17 @@ export function BasicContractDetailTableToolbarActions({
const contractIds = redFlagResolutionContracts.map(c => c.id)
const result = await requestRedFlagResolution(contractIds)
- if (result.success) {
- toast.success(result.message)
- table.toggleAllPageRowsSelected(false)
- } else {
- toast.error(result.message)
- }
+ toast.success("RED FLAG 해소요청 결재가 상신되었습니다.", {
+ description: `결재 ID: ${result.approvalId}`,
+ })
+ table.toggleAllPageRowsSelected(false)
} catch (error) {
console.error("RED FLAG 해소요청 오류:", error)
- toast.error("RED FLAG 해소요청 중 오류가 발생했습니다")
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "RED FLAG 해소요청 중 오류가 발생했습니다."
+ )
} finally {
setLoading(false)
}
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
index b2c811fd..2ab39880 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -32,13 +32,21 @@ import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { getComplianceResponseByBasicContractId } from "@/lib/compliance/services"
-interface GetColumnsProps {
+type RedFlagResolutionState = {
+ resolved: boolean
+ resolvedAt: Date | null
+ pendingApprovalId: string | null
+}
+
+export interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }>
isLoadingGtcData: boolean
+ agreementCommentData: Record<number, { hasComments: boolean; commentCount: number }>
+ isLoadingAgreementCommentData: boolean
redFlagData: Record<number, boolean>
isLoadingRedFlagData: boolean
- redFlagResolutionData: Record<number, { resolved: boolean; resolvedAt: Date | null }>
+ redFlagResolutionData: Record<number, RedFlagResolutionState>
isLoadingRedFlagResolutionData: boolean
isComplianceTemplate: boolean
router: NextRouter;
@@ -61,6 +69,8 @@ export function getDetailColumns({
setRowAction,
gtcData,
isLoadingGtcData,
+ agreementCommentData,
+ isLoadingAgreementCommentData,
redFlagData,
isLoadingRedFlagData,
redFlagResolutionData,
@@ -134,7 +144,7 @@ export function getDetailColumns({
}
const handleResend = () => {
- setRowAction({ type: "resend", row })
+ setRowAction({ type: "resend", row } as DataTableRowAction<BasicContractView>)
}
return (
@@ -256,6 +266,19 @@ export function getDetailColumns({
</div>
);
}
+
+ if (resolution?.pendingApprovalId) {
+ return (
+ <div className="text-sm">
+ <Badge variant="secondary" className="font-medium">
+ 해소요청 진행중
+ </Badge>
+ <div className="text-xs text-gray-500 mt-1">
+ 결재 ID: {resolution.pendingApprovalId.slice(-6)}
+ </div>
+ </div>
+ );
+ }
return (
<div className="text-sm text-gray-400">-</div>
@@ -296,7 +319,10 @@ export function getDetailColumns({
const name = row.getValue("vendorName") as string | null
const contract = row.original
const isGTCTemplate = contract.templateName?.includes('GTC')
+ const isComplianceContract = contract.templateName?.includes('준법')
const contractGtcData = gtcData[contract.id]
+ const complianceNegotiation = agreementCommentData[contract.id]
+ const isNegotiationCompleted = !!contract.negotiationCompletedAt
const handleOpenGTC = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -345,6 +371,48 @@ export function getDetailColumns({
)}
</div>
)}
+ {isComplianceContract && (
+ <div className="flex items-center gap-1">
+ {isLoadingAgreementCommentData ? (
+ <Loader2 className="h-3 w-3 animate-spin text-gray-400" />
+ ) : isNegotiationCompleted ? (
+ <Badge
+ variant="outline"
+ className="text-xs bg-green-50 text-green-700 border-green-200"
+ >
+ <MessageCircle className="h-3 w-3 mr-1" />
+ 협의 완료
+ </Badge>
+ ) : complianceNegotiation?.hasComments ? (
+ <Badge
+ variant="outline"
+ className="text-xs bg-orange-50 text-orange-700 border-orange-200"
+ title={`협의 코멘트 ${complianceNegotiation.commentCount}개`}
+ onClick={(event) => {
+ event.stopPropagation();
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams();
+ if (contract.templateId) {
+ params.set("templateId", contract.templateId.toString());
+ }
+ if (contract.vendorId) {
+ params.set("vendorId", contract.vendorId.toString());
+ }
+ if (contract.vendorName) {
+ params.set("vendorName", contract.vendorName);
+ }
+ const query = params.toString();
+ const complianceUrl = `/evcp/basic-contract/compliance-comments/${contract.id}${query ? `?${query}` : ""}`;
+ window.open(complianceUrl, "_blank", "noopener,noreferrer");
+ }}
+ style={{ cursor: "pointer" }}
+ >
+ <MessageCircle className="h-3 w-3 mr-1" />
+ 협의 진행중 ({complianceNegotiation.commentCount})
+ </Badge>
+ ) : null}
+ </div>
+ )}
</div>
)
},
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
index 2d747c85..010b4713 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -11,6 +11,7 @@ import type {
import { getDetailColumns } from "./basic-contracts-detail-columns"
import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service"
import { checkGTCCommentsForContracts } from "@/lib/basic-contract/actions/check-gtc-comments"
+import { checkAgreementCommentsForContracts } from "@/lib/basic-contract/agreement-comments/actions"
import { checkRedFlagsForContracts } from "@/lib/basic-contract/actions/check-red-flags"
import { checkRedFlagResolutionForContracts } from "@/lib/basic-contract/actions/check-red-flag-resolution"
import { BasicContractView } from "@/db/schema"
@@ -35,20 +36,27 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac
const [gtcData, setGtcData] = React.useState<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>>({})
const [isLoadingGtcData, setIsLoadingGtcData] = React.useState(false)
+ // 협의 코멘트 상태 (준법 포함) 관리
+ const [agreementCommentData, setAgreementCommentData] = React.useState<Record<number, { hasComments: boolean; commentCount: number }>>({})
+ const [isLoadingAgreementCommentData, setIsLoadingAgreementCommentData] = React.useState(false)
+
// Red Flag data 상태 관리
const [redFlagData, setRedFlagData] = React.useState<Record<number, boolean>>({})
const [isLoadingRedFlagData, setIsLoadingRedFlagData] = React.useState(false)
+type RedFlagResolutionState = {
+ resolved: boolean
+ resolvedAt: Date | null
+ pendingApprovalId: string | null
+}
+
// Red Flag 해제 data 상태 관리
- const [redFlagResolutionData, setRedFlagResolutionData] = React.useState<Record<number, { resolved: boolean; resolvedAt: Date | null }>>({})
+ const [redFlagResolutionData, setRedFlagResolutionData] = React.useState<Record<number, RedFlagResolutionState>>({})
const [isLoadingRedFlagResolutionData, setIsLoadingRedFlagResolutionData] = React.useState(false)
const [{ data, pageCount }] = React.use(promises)
const router = useRouter()
- console.log(gtcData, "gtcData")
- console.log(data, "data")
-
// GTC data 로딩
React.useEffect(() => {
const loadGtcData = async () => {
@@ -76,6 +84,32 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac
loadGtcData();
}, [data]);
+ // 협의 코멘트 상태 로딩 (준법 포함)
+ React.useEffect(() => {
+ const loadAgreementComments = async () => {
+ if (!data || data.length === 0) return;
+
+ const hasNegotiationTemplates = data.some(contract =>
+ contract.templateName?.includes('준법') || contract.templateName?.includes('GTC')
+ );
+
+ if (!hasNegotiationTemplates) return;
+
+ setIsLoadingAgreementCommentData(true);
+ try {
+ const results = await checkAgreementCommentsForContracts(data);
+ setAgreementCommentData(results);
+ } catch (error) {
+ console.error('협의 코멘트 정보를 불러오는데 실패했습니다:', error);
+ toast.error("협의 코멘트 정보를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingAgreementCommentData(false);
+ }
+ };
+
+ loadAgreementComments();
+ }, [data]);
+
// Red Flag data 로딩
React.useEffect(() => {
const loadRedFlagData = async () => {
@@ -141,6 +175,8 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac
setRowAction,
gtcData,
isLoadingGtcData,
+ agreementCommentData,
+ isLoadingAgreementCommentData,
redFlagData,
isLoadingRedFlagData,
redFlagResolutionData,
@@ -148,7 +184,7 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac
isComplianceTemplate,
router
}),
- [setRowAction, gtcData, isLoadingGtcData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, isLoadingRedFlagResolutionData, isComplianceTemplate, router]
+ [setRowAction, gtcData, isLoadingGtcData, agreementCommentData, isLoadingAgreementCommentData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, isLoadingRedFlagResolutionData, isComplianceTemplate, router]
)
const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 662d7ea9..e5aab10d 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -126,17 +126,18 @@ const canCompleteCurrentContract = React.useMemo(() => {
const isComplianceTemplate = selectedContract.templateName?.includes('준법');
const isGTCTemplate = selectedContract.templateName?.includes('GTC');
+ const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate;
const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
- // GTC 체크 수정
- const gtcStatus = gtcCommentStatus[contractId];
- const gtcCompleted = isGTCTemplate ?
- (!gtcStatus?.hasComments || gtcStatus?.isComplete === true) : true;
+ const negotiationStatus = gtcCommentStatus[contractId];
+ const negotiationCleared = requiresNegotiationComplete
+ ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
+ : true;
const signatureCompleted = signatureStatus[contractId] === true;
- return surveyCompleted && gtcCompleted && signatureCompleted;
+ return surveyCompleted && negotiationCleared && signatureCompleted;
}, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]);
@@ -341,7 +342,11 @@ const canCompleteCurrentContract = React.useMemo(() => {
const isComplianceTemplate = selectedContract.templateName?.includes('준법');
const isGTCTemplate = selectedContract.templateName?.includes('GTC');
const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
- const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.isComplete !== true) : true;
+ const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate;
+ const negotiationStatus = gtcCommentStatus[contractId];
+ const negotiationCleared = requiresNegotiationComplete
+ ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
+ : true;
const signatureCompleted = signatureStatus[contractId] === true;
if (!surveyCompleted) {
@@ -353,7 +358,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
return;
}
- if (!gtcCompleted) {
+ if (!negotiationCleared) {
toast({
title: "코멘트가 있어 서명할 수 없습니다.",
description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
@@ -705,9 +710,13 @@ const canCompleteCurrentContract = React.useMemo(() => {
// 계약서별 완료 상태 확인
const isComplianceTemplate = contract.templateName?.includes('준법');
- const isGTCTemplate = contract.templateName?.includes('GTC');
+ const isGTCTemplate = contract.templateName?.includes('GTC');
+ const requiresNegotiation = isComplianceTemplate || isGTCTemplate;
const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true;
- const hasGtcCompleted = isGTCTemplate ? (gtcCommentStatus[contract.id]?.hasComments !== true) : true;
+ const negotiationStatus = gtcCommentStatus[contract.id];
+ const hasNegotiationCompleted = requiresNegotiation
+ ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
+ : true;
const hasSignatureCompleted = signatureStatus[contract.id] === true;
return (
@@ -776,9 +785,9 @@ const canCompleteCurrentContract = React.useMemo(() => {
설문
</span>
)}
- {isGTCTemplate && (
- <span className={`flex items-center ${hasGtcCompleted ? 'text-green-600' : 'text-red-600'}`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${hasGtcCompleted ? 'text-green-500' : 'text-red-500'}`} />
+ {requiresNegotiation && (
+ <span className={`flex items-center ${hasNegotiationCompleted ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${hasNegotiationCompleted ? 'text-green-500' : 'text-red-500'}`} />
협의
</span>
)}
@@ -953,11 +962,20 @@ const canCompleteCurrentContract = React.useMemo(() => {
설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'}
</span>
)}
- {selectedContract.templateName?.includes('GTC') && (
- <span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-600' : 'text-red-600'}`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-500' : 'text-red-500'}`} />
- 협의 {(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? '완료' :
- `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`}
+ {(selectedContract.templateName?.includes('GTC') || selectedContract.templateName?.includes('준법')) && (
+ <span className={`flex items-center ${
+ (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments
+ ? 'text-green-600'
+ : 'text-red-600'
+ }`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${
+ (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments
+ ? 'text-green-500'
+ : 'text-red-500'
+ }`} />
+ 협의 {(!gtcCommentStatus[selectedContract.id]?.hasComments || gtcCommentStatus[selectedContract.id]?.isComplete === true)
+ ? '완료'
+ : `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`}
</span>
)}
<span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 54e9c18c..6bf0dfb1 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -745,6 +745,7 @@ export function BasicContractSignViewer({
const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법');
const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA'));
const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC');
+ const hasNegotiationTab = mode === 'buyer' ? false : (isGTCTemplate || isComplianceTemplate);
const allFiles: FileInfo[] = React.useMemo(() => {
const files: FileInfo[] = [];
@@ -780,7 +781,7 @@ export function BasicContractSignViewer({
});
}
- if (isGTCTemplate) {
+ if (hasNegotiationTab) {
files.push({
path: "",
name: "협의 코멘트",
@@ -789,7 +790,7 @@ export function BasicContractSignViewer({
}
return files;
- }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]);
+ }, [filePath, additionalFiles, templateName, isComplianceTemplate, hasNegotiationTab, mode]);
const cleanupHtmlStyle = () => {
const elements = document.querySelectorAll('.Document_container');
@@ -982,7 +983,7 @@ export function BasicContractSignViewer({
if (!isWidgetSignature) {
if (annot.Subject === 'Signature') {
// 지정된 서명란 외 서명 → 즉시 삭제
- annotationManager.deleteAnnotation(annot, false);
+ annotationManager.deleteAnnotation(annot, { imported: false });
}
continue;
}
@@ -994,7 +995,7 @@ export function BasicContractSignViewer({
if (name && allowed.length > 0 && !allowed.includes(name)) {
// 우리가 만든 서명 필드가 아니면 막기
- annotationManager.deleteAnnotation(annot, false);
+ annotationManager.deleteAnnotation(annot, { imported: false });
continue;
}
@@ -1285,7 +1286,7 @@ export function BasicContractSignViewer({
return;
}
- if (isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) {
+ if (hasNegotiationTab && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) {
toast({
title: "미해결 코멘트가 있어 서명할 수 없습니다.",
description: "모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요.",
@@ -1555,7 +1556,7 @@ export function BasicContractSignViewer({
{mode !== 'buyer' && isComplianceTemplate && (
<span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
)}
- {mode !== 'buyer' && isGTCTemplate && (
+ {mode !== 'buyer' && hasNegotiationTab && (
<span className="block mt-1 text-blue-600">📋 협의 코멘트를 확인하고 모든 협의가 완료되었는지 확인해주세요.</span>
)}
{hasSignatureFields && !isAutoSignProcessing && (
@@ -1568,12 +1569,12 @@ export function BasicContractSignViewer({
✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다.
</span>
)}
- {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && (
+ {mode !== 'buyer' && hasNegotiationTab && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && (
<span className="block mt-1 text-red-600">
⚠️ {gtcCommentStatus.commentCount}개의 미해결 코멘트가 있어 서명할 수 없습니다.
</span>
)}
- {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && (
+ {mode !== 'buyer' && hasNegotiationTab && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && (
<span className="block mt-1 text-green-600">
✅ 협의가 완료되어 서명 가능합니다.
</span>
diff --git a/lib/compliance/approval-handlers.ts b/lib/compliance/approval-handlers.ts
new file mode 100644
index 00000000..05f92a28
--- /dev/null
+++ b/lib/compliance/approval-handlers.ts
@@ -0,0 +1,64 @@
+"use server"
+
+import db from "@/db/db"
+import { and, inArray, isNull } from "drizzle-orm"
+import { complianceResponses } from "@/db/schema/compliance"
+import { resolveRedFlag } from "./red-flag-resolution"
+import { revalidatePath } from "next/cache"
+
+interface RedFlagResolutionPayload {
+ contractIds: number[]
+}
+
+/**
+ * 결재 승인 후 RED FLAG 해제를 처리하는 핸들러
+ *
+ * approval-workflow에서 자동으로 호출됩니다.
+ */
+export async function resolveRedFlagAfterApproval(payload: RedFlagResolutionPayload) {
+ if (!payload?.contractIds || payload.contractIds.length === 0) {
+ return {
+ success: false,
+ message: "처리할 계약서가 없습니다.",
+ }
+ }
+
+ const uniqueContractIds = Array.from(new Set(payload.contractIds))
+
+ // 이미 해제된 계약을 제외한 대상을 조회
+ const targets = await db
+ .select({
+ basicContractId: complianceResponses.basicContractId,
+ approvalId: complianceResponses.redFlagResolutionApprovalId,
+ })
+ .from(complianceResponses)
+ .where(
+ and(
+ inArray(complianceResponses.basicContractId, uniqueContractIds),
+ isNull(complianceResponses.redFlagResolvedAt)
+ )
+ )
+
+ if (targets.length === 0) {
+ return {
+ success: true,
+ message: "해제 대상이 없습니다.",
+ }
+ }
+
+ for (const target of targets) {
+ await resolveRedFlag(target.basicContractId, {
+ approvalId: target.approvalId ?? undefined,
+ revalidate: false,
+ })
+ }
+
+ await revalidatePath("/evcp/basic-contract")
+ await revalidatePath("/evcp/compliance")
+
+ return {
+ success: true,
+ updated: targets.length,
+ }
+}
+
diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts
index 184630f6..423f5a46 100644
--- a/lib/compliance/red-flag-resolution.ts
+++ b/lib/compliance/red-flag-resolution.ts
@@ -1,303 +1,294 @@
"use server"
import db from "@/db/db"
-import { eq, and } from "drizzle-orm"
+import { and, eq, inArray } from "drizzle-orm"
import { complianceResponses, redFlagManagers } from "@/db/schema/compliance"
-import { users } from "@/db/schema"
-import { basicContract } from "@/db/schema/basicContractDocumnet"
+import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet"
import { vendors } from "@/db/schema/vendors"
+import { users } from "@/db/schema"
+import { getTriggeredRedFlagQuestions, type TriggeredRedFlagInfo } from "./red-flag-notifier"
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import {
- submitApproval,
- createSubmitApprovalRequest,
- createApprovalLine,
- type ApprovalLine
-} from "@/lib/knox-api/approval/approval"
-import { getTriggeredRedFlagQuestions } from "./red-flag-notifier"
+import { ApprovalSubmissionSaga } from "@/lib/approval"
+import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils"
+import type { ApprovalResult } from "@/lib/approval/types"
import { revalidatePath } from "next/cache"
+type ContractSummary = {
+ contractId: number
+ vendorName: string | null
+ vendorCode: string | null
+ templateName: string | null
+ createdAt: Date | null
+ triggeredFlags: TriggeredRedFlagInfo[]
+}
+
/**
- * RED FLAG 해소요청 - 구매기획 담당자에게 합의 요청
+ * RED FLAG 해소요청 - Approval Saga를 통해 상신
*/
-export async function requestRedFlagResolution(contractIds: number[]): Promise<{
- success: boolean
- message: string
- failed: number[]
-}> {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다.",
- failed: contractIds
- }
- }
+export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> {
+ if (!contractIds || contractIds.length === 0) {
+ throw new Error("RED FLAG 해소요청을 위한 계약서를 선택해주세요.")
+ }
- const currentUser = session.user
- const userId = currentUser.id
- const epId = currentUser.epId || ""
- const emailAddress = currentUser.email || ""
+ const uniqueContractIds = Array.from(new Set(contractIds))
- if (!epId || !emailAddress) {
- return {
- success: false,
- message: "사용자 정보가 불완전합니다. epId와 email이 필요합니다.",
- failed: contractIds
- }
- }
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 구매기획 담당자 조회
- const managerRow = await db
- .select({
- purchasingManagerId: redFlagManagers.purchasingManagerId,
- })
- .from(redFlagManagers)
- .orderBy(redFlagManagers.createdAt)
- .limit(1)
+ const currentUser = session.user
+ if (!currentUser.epId) {
+ throw new Error("Knox EP ID가 필요합니다.")
+ }
- const purchasingManagerId = managerRow[0]?.purchasingManagerId
- if (!purchasingManagerId) {
- return {
- success: false,
- message: "구매기획 담당자가 설정되지 않았습니다.",
- failed: contractIds
- }
- }
+ const currentUserId = Number(currentUser.id)
+ if (Number.isNaN(currentUserId)) {
+ throw new Error("유효한 사용자 정보가 필요합니다.")
+ }
- // 구매기획 담당자 정보 조회
- const purchasingManager = await db
- .select({
- id: users.id,
- name: users.name,
- email: users.email,
- epId: users.epId,
- })
- .from(users)
- .where(eq(users.id, purchasingManagerId))
- .limit(1)
+ const purchasingManagerEpId = await getPurchasingManagerEpId()
+ if (!purchasingManagerEpId) {
+ throw new Error("구매기획 담당자의 EP ID가 설정되지 않았습니다.")
+ }
- if (!purchasingManager[0] || !purchasingManager[0].epId || !purchasingManager[0].email) {
- return {
- success: false,
- message: "구매기획 담당자 정보를 찾을 수 없습니다.",
- failed: contractIds
- }
- }
+ const contractSummaries = await fetchContractsWithFlags(uniqueContractIds)
+ if (contractSummaries.length === 0) {
+ throw new Error("선택한 계약서에 RED FLAG가 존재하지 않습니다.")
+ }
- const pm = purchasingManager[0]
-
- // 각 계약서에 대해 RED FLAG 해소요청 처리
- const failed: number[] = []
-
- for (const contractId of contractIds) {
- try {
- // 계약서 정보 조회
- const contractInfo = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- templateId: basicContract.templateId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- })
- .from(basicContract)
- .leftJoin(vendors, eq(basicContract.vendorId, vendors.id))
- .where(eq(basicContract.id, contractId))
- .limit(1)
-
- if (!contractInfo[0]) {
- failed.push(contractId)
- continue
- }
-
- const contract = contractInfo[0]
-
- // RED FLAG 발생 여부 확인
- const triggeredFlags = await getTriggeredRedFlagQuestions(contractId)
- if (triggeredFlags.length === 0) {
- // RED FLAG가 없는 경우는 스킵
- continue
- }
-
- // 이미 해소요청이 진행 중인지 확인
- const existingResponse = await db
- .select()
- .from(complianceResponses)
- .where(eq(complianceResponses.basicContractId, contractId))
- .limit(1)
-
- if (existingResponse[0]?.redFlagResolutionApprovalId) {
- // 이미 해소요청이 진행 중
- continue
- }
-
- // 합의 요청 본문 생성
- const triggeredQuestionsText = triggeredFlags
- .map((flag, idx) => `${idx + 1}. ${flag.questionText}`)
- .join("\n")
-
- const contents = `
-RED FLAG 해소요청
-
-계약서 ID: ${contractId}
-업체명: ${contract.vendorName || "정보 없음"}
-업체코드: ${contract.vendorCode || "정보 없음"}
-
-발생한 RED FLAG 질문:
-${triggeredQuestionsText}
-
-위 RED FLAG에 대한 해소를 요청드립니다.
-합의해 주시면 RED FLAG가 해제됩니다.
- `.trim()
-
- const subject = `[RED FLAG 해소요청] ${contract.vendorName || "협력업체"} - 계약서 ID: ${contractId}`
-
- // 결재 경로 생성
- // 기안자: 현재 사용자
- const drafterLine: ApprovalLine = await createApprovalLine(
- { epId, emailAddress },
- "0", // 기안
- "1"
- )
-
- // 합의자: 구매기획 담당자
- const approverLine: ApprovalLine = await createApprovalLine(
- { epId: pm.epId, emailAddress: pm.email },
- "2", // 합의
- "2"
- )
-
- const approvalLines = [drafterLine, approverLine]
-
- // 결재 상신 요청 생성
- const approvalRequest = await createSubmitApprovalRequest(
- contents,
- subject,
- approvalLines,
- {
- contentsType: "TEXT",
- docSecuType: "PERSONAL",
- notifyOption: "0",
- urgYn: "N",
- importantYn: "N",
- }
- )
-
- // 결재 상신
- const approvalResponse = await submitApproval(
- approvalRequest,
- {
- userId,
- epId,
- emailAddress,
- }
- )
-
- if (approvalResponse.result === "success") {
- // compliance_responses 업데이트 (red_flag_resolution_approval_id 저장)
- if (existingResponse[0]) {
- await db
- .update(complianceResponses)
- .set({
- redFlagResolutionApprovalId: approvalResponse.data.apInfId,
- updatedAt: new Date(),
- })
- .where(eq(complianceResponses.id, existingResponse[0].id))
- } else {
- // compliance_response가 없는 경우 생성 (템플릿 ID는 계약서에서 가져와야 함)
- // 이 경우는 실제로는 발생하지 않을 수 있지만, 안전을 위해 처리
- console.warn(`Compliance response not found for contract ${contractId}`)
- }
- } else {
- failed.push(contractId)
- }
- } catch (error) {
- console.error(`Error processing contract ${contractId}:`, error)
- failed.push(contractId)
- }
- }
+ const validContractIds = contractSummaries.map((contract) => contract.contractId)
- revalidatePath("/evcp/basic-contract")
+ // 중복 해소요청 방지 (진행 중인 결재가 있는지 확인)
+ const responses = await db
+ .select({
+ basicContractId: complianceResponses.basicContractId,
+ redFlagResolvedAt: complianceResponses.redFlagResolvedAt,
+ redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId,
+ })
+ .from(complianceResponses)
+ .where(inArray(complianceResponses.basicContractId, validContractIds))
- if (failed.length === 0) {
- return {
- success: true,
- message: `${contractIds.length}건의 RED FLAG 해소요청이 완료되었습니다.`,
- failed: []
- }
- } else if (failed.length < contractIds.length) {
- return {
- success: true,
- message: `${contractIds.length - failed.length}건 성공, ${failed.length}건 실패`,
- failed
- }
- } else {
- return {
- success: false,
- message: "모든 RED FLAG 해소요청이 실패했습니다.",
- failed
- }
- }
- } catch (error) {
- console.error("RED FLAG 해소요청 오류:", error)
- return {
- success: false,
- message: `RED FLAG 해소요청 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
- failed: contractIds
- }
+ const missingResponses = validContractIds.filter(
+ (contractId) => !responses.some((response) => response.basicContractId === contractId)
+ )
+
+ if (missingResponses.length > 0) {
+ throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.")
}
-}
-/**
- * RED FLAG 해소 처리 (합의 완료 시 호출)
- */
-export async function resolveRedFlag(contractId: number, approvalId: string): Promise<{
- success: boolean
- message: string
-}> {
- try {
- // compliance_responses 조회
- const response = await db
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, contractId),
- eq(complianceResponses.redFlagResolutionApprovalId, approvalId)
- )
- )
- .limit(1)
-
- if (!response[0]) {
- return {
- success: false,
- message: "해소요청 정보를 찾을 수 없습니다.",
- }
+ const blockedContracts = responses
+ .filter((response) => response.redFlagResolutionApprovalId && !response.redFlagResolvedAt)
+ .map((response) => response.basicContractId)
+
+ if (blockedContracts.length > 0) {
+ const blockedSummaries = contractSummaries
+ .filter((contract) => blockedContracts.includes(contract.contractId))
+ .map((contract) => contract.vendorName ?? `계약 ${contract.contractId}`)
+
+ const preview =
+ blockedSummaries.length > 2
+ ? `${blockedSummaries.slice(0, 2).join(", ")} 외 ${blockedSummaries.length - 2}건`
+ : blockedSummaries.join(", ")
+
+ throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`)
+ }
+
+ const now = new Date()
+ const variables = await buildTemplateVariables(contractSummaries, {
+ requesterName: currentUser.name || currentUser.email || "요청자",
+ requestedAt: now,
+ })
+
+ const title = buildApprovalTitle(contractSummaries)
+
+ const saga = new ApprovalSubmissionSaga(
+ "compliance_red_flag_resolution",
+ {
+ contractIds: validContractIds,
+ requestedBy: currentUserId,
+ requestedAt: now.toISOString(),
+ },
+ {
+ title,
+ description: "컴플라이언스 Red Flag 해소요청",
+ templateName: "컴플라이언스 Red Flag 해소요청",
+ variables,
+ approvers: [purchasingManagerEpId],
+ currentUser: {
+ id: currentUserId,
+ epId: currentUser.epId,
+ email: currentUser.email ?? undefined,
+ },
}
+ )
+
+ const result = await saga.execute()
- // RED FLAG 해제 처리
+ if (result.status === "pending_approval") {
await db
.update(complianceResponses)
.set({
- redFlagResolvedAt: new Date(),
+ redFlagResolutionApprovalId: result.approvalId,
+ redFlagResolvedAt: null,
updatedAt: new Date(),
})
- .where(eq(complianceResponses.id, response[0].id))
+ .where(inArray(complianceResponses.basicContractId, validContractIds))
- revalidatePath("/evcp/basic-contract")
+ await revalidatePath("/evcp/basic-contract")
+ await revalidatePath("/evcp/compliance")
+ }
- return {
- success: true,
- message: "RED FLAG가 해제되었습니다.",
- }
- } catch (error) {
- console.error("RED FLAG 해제 오류:", error)
- return {
- success: false,
- message: `RED FLAG 해제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
- }
+ return result
+}
+
+/**
+ * RED FLAG 해소 처리 (승인 후 실행)
+ */
+export async function resolveRedFlag(
+ contractId: number,
+ options: { approvalId?: string; revalidate?: boolean } = {}
+): Promise<{ success: boolean; message: string }> {
+ const conditions = [eq(complianceResponses.basicContractId, contractId)]
+ if (options.approvalId) {
+ conditions.push(eq(complianceResponses.redFlagResolutionApprovalId, options.approvalId))
+ }
+
+ const [updated] = await db
+ .update(complianceResponses)
+ .set({
+ redFlagResolvedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(and(...conditions))
+ .returning({ id: complianceResponses.id })
+
+ if (!updated) {
+ throw new Error("해소요청 정보를 찾을 수 없습니다.")
+ }
+
+ if (options.revalidate !== false) {
+ await revalidatePath("/evcp/basic-contract")
+ await revalidatePath("/evcp/compliance")
+ }
+
+ return {
+ success: true,
+ message: "RED FLAG가 해제되었습니다.",
}
}
+async function getPurchasingManagerEpId(): Promise<string | null> {
+ const [manager] = await db
+ .select({
+ purchasingManagerId: redFlagManagers.purchasingManagerId,
+ })
+ .from(redFlagManagers)
+ .orderBy(redFlagManagers.createdAt)
+ .limit(1)
+
+ if (!manager?.purchasingManagerId) {
+ return null
+ }
+
+ const [user] = await db
+ .select({
+ epId: users.epId,
+ })
+ .from(users)
+ .where(eq(users.id, manager.purchasingManagerId))
+ .limit(1)
+
+ return user?.epId ?? null
+}
+
+async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> {
+ const contracts = await db
+ .select({
+ contractId: basicContract.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ templateName: basicContractTemplates.templateName,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(vendors, eq(basicContract.vendorId, vendors.id))
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
+ .where(inArray(basicContract.id, contractIds))
+
+ const withFlags = await Promise.all(
+ contracts.map(async (contract) => {
+ const triggeredFlags = await getTriggeredRedFlagQuestions(contract.contractId)
+ return {
+ ...contract,
+ triggeredFlags,
+ }
+ })
+ )
+
+ return withFlags.filter((contract) => contract.triggeredFlags.length > 0)
+}
+
+async function buildTemplateVariables(
+ contracts: ContractSummary[],
+ meta: { requesterName: string; requestedAt: Date }
+): Promise<Record<string, string>> {
+ const summaryRows = contracts.map((contract) => ({
+ contractId: contract.contractId,
+ vendorName: contract.vendorName ?? "-",
+ templateName: contract.templateName ?? "-",
+ redFlagCount: contract.triggeredFlags.length,
+ }))
+
+ const summaryTable = await htmlTableConverter(summaryRows, [
+ { key: "contractId", label: "계약 ID" },
+ { key: "vendorName", label: "업체명" },
+ { key: "templateName", label: "템플릿" },
+ { key: "redFlagCount", label: "RED FLAG 수" },
+ ])
+
+ const detailSections = await Promise.all(
+ contracts.map(async (contract) => {
+ const questionList = contract.triggeredFlags.map((flag, index) => {
+ const prefix = flag.questionNumber || `${index + 1}`
+ return `${prefix}. ${flag.questionText}`
+ })
+
+ const listHtml = await htmlListConverter(questionList)
+ return `
+ <div style="margin-bottom: 24px;">
+ <div style="font-weight:600;margin-bottom:8px;">
+ 계약 ID: ${contract.contractId} / ${contract.vendorName ?? "-"}
+ </div>
+ <div>${listHtml}</div>
+ </div>
+ `
+ })
+ )
+
+ const detailHtml = detailSections.join("")
+ const formattedDate = new Intl.DateTimeFormat("ko-KR", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(meta.requestedAt)
+
+ return {
+ 요청자이름: meta.requesterName,
+ 요청일시: formattedDate,
+ 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.",
+ RedFlag요약테이블: summaryTable,
+ RedFlag상세내역: detailHtml,
+ }
+}
+
+function buildApprovalTitle(contracts: ContractSummary[]) {
+ if (contracts.length === 0) return "컴플라이언스 Red Flag 해소요청"
+ const firstVendor = contracts[0].vendorName ?? `계약 ${contracts[0].contractId}`
+
+ if (contracts.length === 1) {
+ return `Red Flag 해소요청 - ${firstVendor}`
+ }
+
+ return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건`
+}
diff --git a/lib/knox-api/approval/approval.ts b/lib/knox-api/approval/approval.ts
index 5599c066..51fb3f7f 100644
--- a/lib/knox-api/approval/approval.ts
+++ b/lib/knox-api/approval/approval.ts
@@ -4,7 +4,6 @@ import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common';
import { randomUUID } from 'crypto';
import { saveApprovalToDatabase, deleteApprovalFromDatabase, upsertApprovalStatus } from './service';
import { debugLog, debugError } from '@/lib/debug-utils'
-import { resolveRedFlag } from '@/lib/compliance/red-flag-resolution'
// Knox API Approval 서버 액션들
// 가이드: lib/knox-api/approval/guide.html
@@ -831,34 +830,6 @@ export async function syncApprovalStatusAction(): Promise<{
// upsert를 사용한 상태 업데이트
await upsertApprovalStatus(statusData.apInfId, statusData.status);
updated++;
-
- // RED FLAG 해소 결재 완료 확인 및 처리
- if (['2', '5', '6'].includes(statusData.status)) {
- // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리
- try {
- const db = (await import('@/db/db')).default;
- const { complianceResponses } = await import('@/db/schema/compliance');
- const { eq } = await import('drizzle-orm');
-
- // 해당 결재 ID로 compliance response 조회
- const response = await db
- .select({
- id: complianceResponses.id,
- basicContractId: complianceResponses.basicContractId,
- })
- .from(complianceResponses)
- .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId))
- .limit(1);
-
- if (response[0]) {
- // RED FLAG 해소 처리
- await resolveRedFlag(response[0].basicContractId, statusData.apInfId);
- }
- } catch (redFlagError) {
- console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError);
- // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음
- }
- }
}
} catch (updateError) {
console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError);
@@ -945,37 +916,9 @@ export async function syncSpecificApprovalStatusAction(
// 조회된 상태로 데이터베이스 업데이트
for (const statusData of statusResponse.data) {
try {
- // upsert를 사용한 상태 업데이트
- await upsertApprovalStatus(statusData.apInfId, statusData.status);
- updated++;
-
- // RED FLAG 해소 결재 완료 확인 및 처리
- if (['2', '5', '6'].includes(statusData.status)) {
- // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리
- try {
- const db = (await import('@/db/db')).default;
- const { complianceResponses } = await import('@/db/schema/compliance');
- const { eq } = await import('drizzle-orm');
-
- // 해당 결재 ID로 compliance response 조회
- const response = await db
- .select({
- id: complianceResponses.id,
- basicContractId: complianceResponses.basicContractId,
- })
- .from(complianceResponses)
- .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId))
- .limit(1);
-
- if (response[0]) {
- // RED FLAG 해소 처리
- await resolveRedFlag(response[0].basicContractId, statusData.apInfId);
- }
- } catch (redFlagError) {
- console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError);
- // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음
- }
- }
+ // upsert를 사용한 상태 업데이트
+ await upsertApprovalStatus(statusData.apInfId, statusData.status);
+ updated++;
} catch (updateError) {
console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError);
failed.push(statusData.apInfId);
@@ -1128,33 +1071,6 @@ export async function getApprovalLogsAction(): Promise<{
// 메모리상의 데이터도 업데이트
currentLog.status = statusData.status;
updatedCount++;
-
- // RED FLAG 해소 결재 완료 확인 및 처리
- if (['2', '5', '6'].includes(statusData.status)) {
- // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리
- try {
- const { complianceResponses } = await import('@/db/schema/compliance');
- const { eq } = await import('drizzle-orm');
-
- // 해당 결재 ID로 compliance response 조회
- const response = await db
- .select({
- id: complianceResponses.id,
- basicContractId: complianceResponses.basicContractId,
- })
- .from(complianceResponses)
- .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId))
- .limit(1);
-
- if (response[0]) {
- // RED FLAG 해소 처리
- await resolveRedFlag(response[0].basicContractId, statusData.apInfId);
- }
- } catch (redFlagError) {
- console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError);
- // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음
- }
- }
} catch (updateError) {
console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError);
}
diff --git a/lib/mail/templates/agreement-comment-notification.hbs b/lib/mail/templates/agreement-comment-notification.hbs
index 67ccbdd4..9732af6c 100644
--- a/lib/mail/templates/agreement-comment-notification.hbs
+++ b/lib/mail/templates/agreement-comment-notification.hbs
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>GTC 기본계약서 협의 코멘트 알림</title>
+ <title>{{documentTypeLabel}} 협의 코멘트 알림</title>
<style>
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -99,7 +99,7 @@
<body>
<div class="container">
<div class="header">
- <h1>📝 GTC 기본계약서 협의 알림</h1>
+ <h1>📝 {{documentTypeLabel}} 협의 알림</h1>
</div>
<div class="content">
@@ -107,7 +107,7 @@
<p>
<strong class="highlight">{{authorType}}</strong>의 <strong>{{authorName}}</strong>님이
- GTC 기본계약서에 새로운 협의 코멘트를 작성했습니다.
+ {{documentTypeLabel}}에 새로운 협의 코멘트를 작성했습니다.
</p>
<div class="info-box">
diff --git a/lib/mail/templates/negotiation-complete-notification.hbs b/lib/mail/templates/negotiation-complete-notification.hbs
index d82d312f..e5220a97 100644
--- a/lib/mail/templates/negotiation-complete-notification.hbs
+++ b/lib/mail/templates/negotiation-complete-notification.hbs
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>GTC 기본계약서 협의 완료</title>
+ <title>{{documentTypeLabel}} 협의 완료</title>
</head>
<body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
@@ -27,7 +27,7 @@
</p>
<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #555555;">
- <strong>{{vendorName}}</strong>와(과)의 <strong>{{templateName}}</strong> 협의가 완료되었습니다.
+ <strong>{{vendorName}}</strong>와(과)의 <strong>{{documentTypeLabel}}</strong> 협의가 완료되었습니다.
</p>
<div style="background-color: #f8f9fa; border-left: 4px solid #28a745; padding: 15px; margin: 20px 0; border-radius: 4px;">