From f95fbb0719c5754360472d066b0bbed4bda6a40a Mon Sep 17 00:00:00 2001 From: 0-Zz-ang Date: Thu, 20 Nov 2025 17:39:10 +0900 Subject: (박서영)준법설문조사 redFlag관련사항수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/actions/check-red-flags.ts | 48 +++++++++++++++ .../basic-contracts-detail-columns.tsx | 56 +++++++++++++++++- .../status-detail/basic-contracts-detail-table.tsx | 45 +++++++++++++- lib/compliance/compliance-response-detail.tsx | 69 +++++++++++++++++----- lib/compliance/red-flag-notifier.ts | 55 +++++++++-------- lib/mail/templates/compliance-red-flag-alert.hbs | 16 ++++- 6 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 lib/basic-contract/actions/check-red-flags.ts (limited to 'lib') diff --git a/lib/basic-contract/actions/check-red-flags.ts b/lib/basic-contract/actions/check-red-flags.ts new file mode 100644 index 00000000..dd45a56a --- /dev/null +++ b/lib/basic-contract/actions/check-red-flags.ts @@ -0,0 +1,48 @@ +"use server"; + +import { BasicContractView } from "@/db/schema"; +import { getTriggeredRedFlagQuestions } from "@/lib/compliance/red-flag-notifier"; + +/** + * 여러 계약서에 대한 Red Flag 발생 여부를 한 번에 확인 + */ +export async function checkRedFlagsForContracts( + contracts: BasicContractView[] +): Promise> { + const result: Record = {}; + + // 준법서약 템플릿인 계약서만 필터링 + const complianceContracts = contracts.filter(contract => + contract.templateName?.includes('준법') + ); + + if (complianceContracts.length === 0) { + return result; + } + + // 각 계약서에 대해 Red Flag 발생 여부 확인 + const redFlagChecks = await Promise.all( + complianceContracts.map(async (contract) => { + try { + const triggeredFlags = await getTriggeredRedFlagQuestions(contract.id); + return { + contractId: contract.id, + hasRedFlag: triggeredFlags.length > 0 + }; + } catch (error) { + console.error(`Error checking red flags for contract ${contract.id}:`, error); + return { + contractId: contract.id, + hasRedFlag: false + }; + } + }) + ); + + // 결과를 Record 형태로 변환 + redFlagChecks.forEach(check => { + result[check.contractId] = check.hasRedFlag; + }); + + return result; +} 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 5a875541..c872aede 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -36,6 +36,9 @@ interface GetColumnsProps { setRowAction: React.Dispatch | null>> gtcData: Record isLoadingGtcData: boolean + redFlagData: Record + isLoadingRedFlagData: boolean + isComplianceTemplate: boolean router: NextRouter; } @@ -56,6 +59,9 @@ export function getDetailColumns({ setRowAction, gtcData, isLoadingGtcData, + redFlagData, + isLoadingRedFlagData, + isComplianceTemplate, router }: GetColumnsProps): ColumnDef[] { @@ -184,7 +190,41 @@ export function getDetailColumns({ maxSize: 80, } - return [ + // Red Flag 발생여부 컬럼 (준법서약 템플릿만) + const redFlagColumn: ColumnDef = { + id: "redFlag", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contract = row.original; + const contractId = contract.id; + + // 로딩 중이면 로딩 표시 + if (isLoadingRedFlagData) { + return ; + } + + const hasRedFlag = redFlagData[contractId] || false; + + if (hasRedFlag) { + return ( + + Red Flag + + ); + } + + return ( +
-
+ ); + }, + minSize: 120, + enableHiding: false, + } + + // 기본 컬럼 배열 + const baseColumns: ColumnDef[] = [ selectColumn, // 업체 코드 @@ -513,4 +553,18 @@ export function getDetailColumns({ actionsColumn, ] + + // 준법서약 템플릿인 경우 Red Flag 컬럼을 법무검토 상태 뒤에 추가 + if (isComplianceTemplate) { + const legalReviewStatusIndex = baseColumns.findIndex((col) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (col as any).accessorKey === 'legalReviewStatus' + }) + + if (legalReviewStatusIndex !== -1) { + baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn) + } + } + + return baseColumns } \ No newline at end of file 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 0df46066..93853560 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 { checkRedFlagsForContracts } from "@/lib/basic-contract/actions/check-red-flags" import { BasicContractView } from "@/db/schema" import { BasicContractDetailTableToolbarActions } from "./basic-contract-detail-table-toolbar-actions" import { toast } from "sonner" @@ -32,6 +33,10 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac // GTC data 상태 관리 const [gtcData, setGtcData] = React.useState>({}) const [isLoadingGtcData, setIsLoadingGtcData] = React.useState(false) + + // Red Flag data 상태 관리 + const [redFlagData, setRedFlagData] = React.useState>({}) + const [isLoadingRedFlagData, setIsLoadingRedFlagData] = React.useState(false) const [{ data, pageCount }] = React.use(promises) const router = useRouter() @@ -66,14 +71,50 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac loadGtcData(); }, [data]); + // Red Flag data 로딩 + React.useEffect(() => { + const loadRedFlagData = async () => { + if (!data || data.length === 0) return; + + // 준법서약 템플릿이 있는지 확인 + const hasComplianceTemplates = data.some(contract => + contract.templateName?.includes('준법') + ); + + if (!hasComplianceTemplates) return; + + setIsLoadingRedFlagData(true); + try { + const redFlagResults = await checkRedFlagsForContracts(data); + setRedFlagData(redFlagResults); + } catch (error) { + console.error('Error checking Red Flag data:', error); + toast.error("Red Flag 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingRedFlagData(false); + } + }; + + loadRedFlagData(); + }, [data]); + + // 준법서약 템플릿인지 확인 + const isComplianceTemplate = React.useMemo(() => { + if (!data || data.length === 0) return false; + return data.some(contract => contract.templateName?.includes('준법')); + }, [data]); + const columns = React.useMemo( () => getDetailColumns({ setRowAction, gtcData, - isLoadingGtcData , + isLoadingGtcData, + redFlagData, + isLoadingRedFlagData, + isComplianceTemplate, router }), - [setRowAction, gtcData, isLoadingGtcData, router] + [setRowAction, gtcData, isLoadingGtcData, redFlagData, isLoadingRedFlagData, isComplianceTemplate, router] ) const advancedFilterFields: DataTableAdvancedFilterField[] = [ diff --git a/lib/compliance/compliance-response-detail.tsx b/lib/compliance/compliance-response-detail.tsx index 709f3ede..4c2062d0 100644 --- a/lib/compliance/compliance-response-detail.tsx +++ b/lib/compliance/compliance-response-detail.tsx @@ -112,6 +112,17 @@ export function ComplianceResponseDetail({ templateId, responseId, promises }: C return question ? question.hasFileUpload : false } + const isQuestionRedFlag = (questionId: number) => { + const question = questions.find(q => q.id === questionId) + return question ? question.isRedFlag : false + } + + const isRedFlagTriggered = (answer: any) => { + const isRedFlag = isQuestionRedFlag(answer.questionId) + const answerValue = (answer.answerValue ?? '').toString().trim().toUpperCase() + return isRedFlag && answerValue === 'YES' + } + // 파일 다운로드 핸들러 const handleFileDownload = async (file: any) => { try { @@ -245,22 +256,51 @@ export function ComplianceResponseDetail({ templateId, responseId, promises }: C ) : (
- {answers.map((answer) => ( -
-
- - {getQuestionNumber(answer.questionId)} - - - {getQuestionText(answer.questionId)} - -
-
+ {answers.map((answer) => { + const redFlagTriggered = isRedFlagTriggered(answer) + return ( +
+
+ + {getQuestionNumber(answer.questionId)} + + + {getQuestionText(answer.questionId)} + + {/* {isQuestionRedFlag(answer.questionId) && ( + + RED FLAG + + )} */} +
+
{/* 답변 값 */} {answer.answerValue && (
-

{answer.answerValue}

+
+

+ {answer.answerValue} +

+ {/* {redFlagTriggered && ( + + Red Flag 발생 + + )} */} +
)} @@ -331,9 +371,10 @@ export function ComplianceResponseDetail({ templateId, responseId, promises }: C '-' }
+
-
- ))} + ) + })}
)} diff --git a/lib/compliance/red-flag-notifier.ts b/lib/compliance/red-flag-notifier.ts index ad55de54..27ca2a03 100644 --- a/lib/compliance/red-flag-notifier.ts +++ b/lib/compliance/red-flag-notifier.ts @@ -9,6 +9,7 @@ import { } from '@/db/schema'; import { and, desc, eq } from 'drizzle-orm'; import { sendEmail } from '@/lib/mail/sendEmail'; +import { getComplianceResponseByBasicContractId } from '@/lib/compliance/services'; export type TriggeredRedFlagInfo = { questionId: number; @@ -65,8 +66,6 @@ export async function notifyComplianceRedFlagManagers(params: { return; } - const recipientEmails = new Set(); - const managerRow = await db .select({ purchasingManagerId: redFlagManagers.purchasingManagerId, @@ -95,14 +94,6 @@ export async function notifyComplianceRedFlagManagers(params: { fetchUserEmail(managerIds?.complianceManagerId), ]); - if (purchasingEmail) { - recipientEmails.add(purchasingEmail); - } - - if (complianceEmail) { - recipientEmails.add(complianceEmail); - } - const contractOwner = await db .select({ requestedBy: basicContract.requestedBy, @@ -114,29 +105,45 @@ export async function notifyComplianceRedFlagManagers(params: { .limit(1); const ownerRecord = contractOwner[0]; - if (ownerRecord?.requestedByEmail) { - recipientEmails.add(ownerRecord.requestedByEmail); - } + const contractRequestorEmail = ownerRecord?.requestedByEmail; - if (recipientEmails.size === 0) { + // 구매 담당자가 없으면 발송하지 않음 + if (!purchasingEmail) { return; } + // 준법설문 응답 정보 조회하여 링크 생성 + const complianceResponse = await getComplianceResponseByBasicContractId(params.contractId); + let responseLink: string | null = null; + + if (complianceResponse && complianceResponse.templateId && complianceResponse.id) { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_BASE_URL || ''; + responseLink = `${baseUrl}/ko/evcp/compliance/${complianceResponse.templateId}/responses/${complianceResponse.id}`; + } + const context = { contractId: params.contractId, templateId: params.templateId ?? null, vendorName: params.vendorName ?? '협력업체', triggeredCount: params.triggeredQuestions.length, + responseLink: responseLink, }; - await Promise.all( - Array.from(recipientEmails).map((email) => - sendEmail({ - to: email, - subject: '[eVCP] 컴플라이언스 레드플래그 알림', - template: 'compliance-red-flag-alert', - context, - }), - ), - ); + // CC 이메일 배열 구성 + const ccEmails: string[] = []; + if (complianceEmail) { + ccEmails.push(complianceEmail); + } + if (contractRequestorEmail) { + ccEmails.push(contractRequestorEmail); + } + + // 구매 담당자에게 To로, 준법 담당자와 계약 요청자는 CC로 발송 + await sendEmail({ + to: purchasingEmail, + cc: ccEmails.length > 0 ? ccEmails : undefined, + subject: '[eVCP] 컴플라이언스 레드플래그 알림', + template: 'compliance-red-flag-alert', + context, + }); } diff --git a/lib/mail/templates/compliance-red-flag-alert.hbs b/lib/mail/templates/compliance-red-flag-alert.hbs index 9680e7fb..f3c35177 100644 --- a/lib/mail/templates/compliance-red-flag-alert.hbs +++ b/lib/mail/templates/compliance-red-flag-alert.hbs @@ -68,12 +68,24 @@

- 알람을 받은 임직원께서는 e-VCP > 기준정보 > 준법설문관리 > 준법설문조사 > 응답현황보기에서 - 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후 + 알람을 받은 임직원께서는 아래 버튼을 클릭하여 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후 1주일 이내에 준법경영시스템에 문의 등록하여 Compliance팀의 가이드에 따라 조치 바랍니다.

+ {{#if responseLink}} + + +

+ 버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:
+ {{responseLink}} +

+ {{/if}} +

기타 문의사항은 Compliance팀 박지은 프로에게 문의 바랍니다.

-- cgit v1.2.3