summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/actions/check-red-flags.ts48
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx56
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx45
-rw-r--r--lib/compliance/compliance-response-detail.tsx69
-rw-r--r--lib/compliance/red-flag-notifier.ts55
-rw-r--r--lib/mail/templates/compliance-red-flag-alert.hbs16
6 files changed, 246 insertions, 43 deletions
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<Record<number, boolean>> {
+ const result: Record<number, boolean> = {};
+
+ // 준법서약 템플릿인 계약서만 필터링
+ 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<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }>
isLoadingGtcData: boolean
+ redFlagData: Record<number, boolean>
+ isLoadingRedFlagData: boolean
+ isComplianceTemplate: boolean
router: NextRouter;
}
@@ -56,6 +59,9 @@ export function getDetailColumns({
setRowAction,
gtcData,
isLoadingGtcData,
+ redFlagData,
+ isLoadingRedFlagData,
+ isComplianceTemplate,
router
}: GetColumnsProps): ColumnDef<BasicContractView>[] {
@@ -184,7 +190,41 @@ export function getDetailColumns({
maxSize: 80,
}
- return [
+ // Red Flag 발생여부 컬럼 (준법서약 템플릿만)
+ const redFlagColumn: ColumnDef<BasicContractView> = {
+ id: "redFlag",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Red Flag" />
+ ),
+ cell: ({ row }) => {
+ const contract = row.original;
+ const contractId = contract.id;
+
+ // 로딩 중이면 로딩 표시
+ if (isLoadingRedFlagData) {
+ return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
+ }
+
+ const hasRedFlag = redFlagData[contractId] || false;
+
+ if (hasRedFlag) {
+ return (
+ <Badge variant="destructive" className="font-medium">
+ Red Flag
+ </Badge>
+ );
+ }
+
+ return (
+ <div className="text-sm text-gray-400">-</div>
+ );
+ },
+ minSize: 120,
+ enableHiding: false,
+ }
+
+ // 기본 컬럼 배열
+ const baseColumns: ColumnDef<BasicContractView>[] = [
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<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>>({})
const [isLoadingGtcData, setIsLoadingGtcData] = React.useState(false)
+
+ // Red Flag data 상태 관리
+ const [redFlagData, setRedFlagData] = React.useState<Record<number, boolean>>({})
+ 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<BasicContractView>[] = [
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
</div>
) : (
<div className="space-y-4">
- {answers.map((answer) => (
- <div key={answer.id} className="border rounded-lg p-4 space-y-3">
- <div className="flex items-center gap-2 pb-3 border-b">
- <Badge variant="outline">
- {getQuestionNumber(answer.questionId)}
- </Badge>
- <span className="font-medium">
- {getQuestionText(answer.questionId)}
- </span>
- </div>
- <div className="space-y-3">
+ {answers.map((answer) => {
+ const redFlagTriggered = isRedFlagTriggered(answer)
+ return (
+ <div
+ key={answer.id}
+ className={`border-2 rounded-lg p-4 space-y-3 ${
+ redFlagTriggered
+ ? 'border-red-500 bg-red-50/50'
+ : 'border-gray-200'
+ }`}
+ >
+ <div className="flex items-center gap-2 pb-3 border-b">
+ <Badge
+ variant={redFlagTriggered ? "destructive" : "outline"}
+ >
+ {getQuestionNumber(answer.questionId)}
+ </Badge>
+ <span className="font-medium">
+ {getQuestionText(answer.questionId)}
+ </span>
+ {/* {isQuestionRedFlag(answer.questionId) && (
+ <Badge variant="destructive" className="ml-auto">
+ RED FLAG
+ </Badge>
+ )} */}
+ </div>
+ <div className="space-y-3">
{/* 답변 값 */}
{answer.answerValue && (
<div>
<label className="text-sm font-medium text-muted-foreground">답변</label>
- <p className="mt-1 p-2 bg-muted rounded">{answer.answerValue}</p>
+ <div className="mt-1 flex items-center gap-2">
+ <p className={`flex-1 p-2 rounded ${
+ redFlagTriggered
+ ? 'bg-red-100 text-red-900 font-semibold'
+ : 'bg-muted'
+ }`}>
+ {answer.answerValue}
+ </p>
+ {/* {redFlagTriggered && (
+ <Badge variant="destructive" className="shrink-0">
+ Red Flag 발생
+ </Badge>
+ )} */}
+ </div>
</div>
)}
@@ -331,9 +371,10 @@ export function ComplianceResponseDetail({ templateId, responseId, promises }: C
'-'
}
</div>
+ </div>
</div>
- </div>
- ))}
+ )
+ })}
</div>
)}
</CardContent>
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<string>();
-
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 @@
<div class="guide-box">
<p>
- 알람을 받은 임직원께서는 <strong>e-VCP &gt; 기준정보 &gt; 준법설문관리 &gt; 준법설문조사 &gt; 응답현황보기</strong>에서
- 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후
+ 알람을 받은 임직원께서는 아래 버튼을 클릭하여 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후
<strong>1주일 이내에 준법경영시스템에 문의 등록</strong>하여 Compliance팀의 가이드에 따라 조치 바랍니다.
</p>
</div>
+ {{#if responseLink}}
+ <div style="text-align: center; margin: 30px 0;">
+ <a href="{{responseLink}}" style="display: inline-block; padding: 14px 32px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold;">
+ 준법설문 응답현황 보기
+ </a>
+ </div>
+
+ <p style="color: #666666; font-size: 14px; line-height: 1.6; margin-top: 20px;">
+ 버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:<br>
+ <a href="{{responseLink}}" style="color: #2563eb; word-break: break-all;">{{responseLink}}</a>
+ </p>
+ {{/if}}
+
<p>
기타 문의사항은 <strong>Compliance팀 박지은 프로</strong>에게 문의 바랍니다.
</p>