diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/compliance/questions/compliance-question-create-dialog.tsx | 114 | ||||
| -rw-r--r-- | lib/compliance/questions/compliance-questions-draggable-list.tsx | 7 | ||||
| -rw-r--r-- | lib/compliance/red-flag-notifier.ts | 142 | ||||
| -rw-r--r-- | lib/compliance/services.ts | 94 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-survey-templates-toolbar.tsx | 4 | ||||
| -rw-r--r-- | lib/compliance/table/red-flag-managers-dialog.tsx | 203 | ||||
| -rw-r--r-- | lib/mail/templates/compliance-red-flag-alert.hbs | 87 |
7 files changed, 626 insertions, 25 deletions
diff --git a/lib/compliance/questions/compliance-question-create-dialog.tsx b/lib/compliance/questions/compliance-question-create-dialog.tsx index b05c2e0d..4abf1eb2 100644 --- a/lib/compliance/questions/compliance-question-create-dialog.tsx +++ b/lib/compliance/questions/compliance-question-create-dialog.tsx @@ -40,12 +40,19 @@ import { QUESTION_TYPES } from "@/db/schema/compliance"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; +type OptionItem = { optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number }; +const RED_FLAG_OPTIONS: OptionItem[] = [ + { optionValue: "YES", optionText: "YES", allowsOtherInput: false, displayOrder: 1 }, + { optionValue: "NO", optionText: "NO", allowsOtherInput: false, displayOrder: 2 }, +]; + const questionSchema = z.object({ questionNumber: z.string().min(1, "질문 번호를 입력하세요"), questionText: z.string().min(1, "질문 내용을 입력하세요"), questionType: z.string().min(1, "질문 유형을 선택하세요"), isRequired: z.boolean(), isConditional: z.boolean(), + isRedFlag: z.boolean(), hasDetailText: z.boolean(), hasFileUpload: z.boolean(), conditionalValue: z.string().optional(), @@ -80,6 +87,7 @@ export function ComplianceQuestionCreateDialog({ questionType: "", isRequired: false, isConditional: false, + isRedFlag: false, hasDetailText: false, hasFileUpload: false, conditionalValue: "", @@ -98,11 +106,32 @@ export function ComplianceQuestionCreateDialog({ const [newOptionOther, setNewOptionOther] = React.useState(false); const [showOptionForm, setShowOptionForm] = React.useState(false); + const isRedFlag = form.watch("isRedFlag"); + const isRequired = form.watch("isRequired"); + const isConditional = form.watch("isConditional"); + const questionTypeValue = form.watch("questionType"); + // 선택형 질문인지 확인 const isSelectionType = React.useMemo(() => { - const questionType = form.watch("questionType"); - return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((questionType || "").toUpperCase() as any); - }, [form.watch("questionType")]); + return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((questionTypeValue || "").toUpperCase() as any); + }, [questionTypeValue]); + + // 레드플래그 선택 시 질문 유형을 RADIO로 자동 설정 + React.useEffect(() => { + if (isRedFlag) { + form.setValue("questionType", QUESTION_TYPES.RADIO); + } + }, [form, isRedFlag]); + + // 레드플래그 선택 시 옵션을 YES/NO로 고정 + React.useEffect(() => { + if (isRedFlag) { + setOptions(RED_FLAG_OPTIONS.map((option) => ({ ...option }))); + setShowOptionForm(false); + } else { + setOptions([]); + } + }, [isRedFlag]); // 시트/다이얼로그 열릴 때 부모 후보 로드 (같은 템플릿 내 선택형 질문만) React.useEffect(() => { @@ -283,6 +312,28 @@ export function ComplianceQuestionCreateDialog({ )} /> </div> + + {/* 레드플래그 체크박스 */} + <FormField + control={form.control} + name="isRedFlag" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-red-50"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel className="text-red-700">레드플래그 질문</FormLabel> + <FormDescription> + 질문 유형 - RADIO || 옵션 - YES/NO + </FormDescription> + </div> + </FormItem> + )} + /> {/* 유효성 검사 에러 메시지 */} {(form.formState.errors.isRequired || form.formState.errors.isConditional) && ( @@ -301,7 +352,7 @@ export function ComplianceQuestionCreateDialog({ <Textarea placeholder="질문 내용을 입력하세요" className="min-h-[100px]" - disabled={!form.watch("isRequired") && !form.watch("isConditional")} + disabled={!isRequired && !isConditional} {...field} /> </FormControl> @@ -316,7 +367,11 @@ export function ComplianceQuestionCreateDialog({ render={({ field }) => ( <FormItem> <FormLabel>질문 유형</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value} disabled={!form.watch("isRequired") && !form.watch("isConditional")}> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={(!isRequired && !isConditional) || isRedFlag} + > <FormControl> <SelectTrigger> <SelectValue placeholder="질문 유형을 선택하세요" /> @@ -330,6 +385,11 @@ export function ComplianceQuestionCreateDialog({ ))} </SelectContent> </Select> + {/* {isRedFlag && ( + <FormDescription className="text-red-600"> + 레드플래그 질문은 RADIO 유형으로 고정됩니다 + </FormDescription> + )} */} <FormMessage /> </FormItem> )} @@ -337,14 +397,14 @@ export function ComplianceQuestionCreateDialog({ {/* 옵션 관리 (선택형 질문일 때만) */} {isSelectionType && ( - <div className={`space-y-3 ${(!form.watch("isRequired") && !form.watch("isConditional")) ? "opacity-50 pointer-events-none" : ""}`}> + <div className={`space-y-3 ${(!isRequired && !isConditional) ? "opacity-50 pointer-events-none" : ""}`}> <div className="flex items-center justify-between"> <div className="text-sm font-medium">옵션 관리</div> <Button type="button" variant="outline" size="sm" - disabled={!form.watch("isRequired") && !form.watch("isConditional")} + disabled={(!isRequired && !isConditional) || isRedFlag} onClick={() => { setNewOptionValue(""); setNewOptionText(""); @@ -358,7 +418,7 @@ export function ComplianceQuestionCreateDialog({ </div> {/* 옵션 추가 폼 */} - {showOptionForm && ( + {showOptionForm && !isRedFlag && ( <div className="space-y-3 p-3 border rounded-lg bg-muted/50"> <div className="grid grid-cols-2 gap-3"> <div> @@ -439,29 +499,37 @@ export function ComplianceQuestionCreateDialog({ <div className="text-sm font-mono">{opt.optionValue}</div> <div className="text-sm flex-1">{opt.optionText}</div> {opt.allowsOtherInput && <Badge variant="secondary">기타 허용</Badge>} - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => { - const newOptions = options.filter((_, i) => i !== index); - setOptions(newOptions); - toast.success("옵션이 제거되었습니다."); - }} - > - <Trash2 className="h-4 w-4" /> - </Button> + {!isRedFlag && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => { + const newOptions = options.filter((_, i) => i !== index); + setOptions(newOptions.map((item, idx) => ({ ...item, displayOrder: idx + 1 }))); + toast.success("옵션이 제거되었습니다."); + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + )} </div> )) )} </div> + + {/* {isRedFlag && ( + <div className="text-xs text-red-600"> + 레드플래그 질문의 라디오 옵션은 YES/NO로 고정됩니다. + </div> + )} */} </div> )} {/* 조건부 질문일 때만 부모 질문과 조건값 표시 */} - {form.watch("isConditional") && ( + {isConditional && ( <div className="space-y-2"> {/* 조건 질문 선택 */} <div> @@ -530,13 +598,13 @@ export function ComplianceQuestionCreateDialog({ type="button" variant="outline" onClick={() => setOpen(false)} - disabled={isLoading} + disabled={isLoading || (!isRequired && !isConditional)} > 취소 </Button> <Button type="submit" - disabled={isLoading || (!form.watch("isRequired") && !form.watch("isConditional"))} + disabled={isLoading || (!isRequired && !isConditional)} > {isLoading ? "추가 중..." : "질문 추가"} </Button> diff --git a/lib/compliance/questions/compliance-questions-draggable-list.tsx b/lib/compliance/questions/compliance-questions-draggable-list.tsx index 6a226b54..3dd82138 100644 --- a/lib/compliance/questions/compliance-questions-draggable-list.tsx +++ b/lib/compliance/questions/compliance-questions-draggable-list.tsx @@ -35,7 +35,12 @@ function SortableQuestionItem({ question, onSuccess }: SortableQuestionItemProps > <GripVertical className="h-3 w-3 text-muted-foreground" /> </SortableDragHandle> - <Badge variant="outline">{question.questionNumber}</Badge> + <Badge + variant={question.isRedFlag ? "destructive" : "outline"} + className={question.isRedFlag ? "bg-red-600 text-white border-red-600" : ""} + > + {question.questionNumber} + </Badge> <span className="font-medium flex-1 leading-tight">{question.questionText}</span> <div className="flex items-center gap-2"> <ComplianceQuestionEditSheet question={question} onSuccess={onSuccess} /> diff --git a/lib/compliance/red-flag-notifier.ts b/lib/compliance/red-flag-notifier.ts new file mode 100644 index 00000000..ad55de54 --- /dev/null +++ b/lib/compliance/red-flag-notifier.ts @@ -0,0 +1,142 @@ +import db from '@/db/db'; +import { + basicContract, + complianceQuestions, + complianceResponseAnswers, + complianceResponses, + redFlagManagers, + users, +} from '@/db/schema'; +import { and, desc, eq } from 'drizzle-orm'; +import { sendEmail } from '@/lib/mail/sendEmail'; + +export type TriggeredRedFlagInfo = { + questionId: number; + questionNumber: string; + questionText: string; + answerValue: string; +}; + +export async function getTriggeredRedFlagQuestions( + contractId: number, +): Promise<TriggeredRedFlagInfo[]> { + const rows = await db + .select({ + questionId: complianceResponseAnswers.questionId, + answerValue: complianceResponseAnswers.answerValue, + questionNumber: complianceQuestions.questionNumber, + questionText: complianceQuestions.questionText, + }) + .from(complianceResponses) + .innerJoin( + complianceResponseAnswers, + eq(complianceResponseAnswers.responseId, complianceResponses.id), + ) + .innerJoin( + complianceQuestions, + eq(complianceQuestions.id, complianceResponseAnswers.questionId), + ) + .where( + and( + eq(complianceResponses.basicContractId, contractId), + eq(complianceQuestions.isRedFlag, true), + ), + ); + + return rows + .filter( + (row) => (row.answerValue ?? '').toString().trim().toUpperCase() === 'YES', + ) + .map((row) => ({ + questionId: row.questionId, + questionNumber: row.questionNumber ?? '', + questionText: row.questionText ?? '', + answerValue: 'YES', + })); +} + +export async function notifyComplianceRedFlagManagers(params: { + contractId: number; + templateId?: number | null; + vendorName?: string | null; + triggeredQuestions: TriggeredRedFlagInfo[]; +}) { + if (!params.triggeredQuestions.length) { + return; + } + + const recipientEmails = new Set<string>(); + + const managerRow = await db + .select({ + purchasingManagerId: redFlagManagers.purchasingManagerId, + complianceManagerId: redFlagManagers.complianceManagerId, + }) + .from(redFlagManagers) + .orderBy(desc(redFlagManagers.createdAt)) + .limit(1); + + const managerIds = managerRow[0]; + + const fetchUserEmail = async (userId?: number | null) => { + if (!userId) { + return null; + } + const rows = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return rows[0]?.email ?? null; + }; + + const [purchasingEmail, complianceEmail] = await Promise.all([ + fetchUserEmail(managerIds?.purchasingManagerId), + fetchUserEmail(managerIds?.complianceManagerId), + ]); + + if (purchasingEmail) { + recipientEmails.add(purchasingEmail); + } + + if (complianceEmail) { + recipientEmails.add(complianceEmail); + } + + const contractOwner = await db + .select({ + requestedBy: basicContract.requestedBy, + requestedByEmail: users.email, + }) + .from(basicContract) + .leftJoin(users, eq(basicContract.requestedBy, users.id)) + .where(eq(basicContract.id, params.contractId)) + .limit(1); + + const ownerRecord = contractOwner[0]; + if (ownerRecord?.requestedByEmail) { + recipientEmails.add(ownerRecord.requestedByEmail); + } + + if (recipientEmails.size === 0) { + return; + } + + const context = { + contractId: params.contractId, + templateId: params.templateId ?? null, + vendorName: params.vendorName ?? '협력업체', + triggeredCount: params.triggeredQuestions.length, + }; + + await Promise.all( + Array.from(recipientEmails).map((email) => + sendEmail({ + to: email, + subject: '[eVCP] 컴플라이언스 레드플래그 알림', + template: 'compliance-red-flag-alert', + context, + }), + ), + ); +} diff --git a/lib/compliance/services.ts b/lib/compliance/services.ts index 2d3ec092..a603a091 100644 --- a/lib/compliance/services.ts +++ b/lib/compliance/services.ts @@ -10,6 +10,7 @@ import { complianceResponses, complianceResponseAnswers, complianceResponseFiles, + redFlagManagers, } from "@/db/schema/compliance"; import { users } from "@/db/schema"; import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet"; @@ -861,7 +862,6 @@ export async function deleteComplianceSurveyTemplate(templateId: number) { .where(eq(complianceSurveyTemplates.id, templateId)) .returning(); - console.log(`✅ 템플릿 ${templateId} 삭제 완료:`, template); // 캐시 무효화 revalidatePath("/evcp/compliance"); @@ -961,3 +961,95 @@ export async function getComplianceResponseByBasicContractId(basicContractId: nu return null; } } + +// ==================== 레드플래그 담당자 관리 ==================== + +// 레드플래그 담당자 조회 +export async function getRedFlagManagers() { + try { + const managers = await db.query.redFlagManagers.findFirst({ + with: { + purchasingManager: true, + complianceManager: true, + }, + orderBy: [desc(redFlagManagers.createdAt)], + }); + + return managers || null; + } catch (error) { + console.error("Error fetching red flag managers:", error); + return null; + } +} + +// 레드플래그 담당자 생성 +export async function createRedFlagManagers(data: { + purchasingManagerId: number | null; + complianceManagerId: number | null; +}) { + try { + const [newManager] = await db + .insert(redFlagManagers) + .values({ + purchasingManagerId: data.purchasingManagerId, + complianceManagerId: data.complianceManagerId, + }) + .returning(); + + revalidatePath("/[lng]/evcp/compliance"); + return newManager; + } catch (error) { + + throw error; + } +} + +// 레드플래그 담당자 수정 +export async function updateRedFlagManagers( + id: number, + data: { + purchasingManagerId?: number | null; + complianceManagerId?: number | null; + } +) { + try { + const [updated] = await db + .update(redFlagManagers) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(redFlagManagers.id, id)) + .returning(); + + revalidatePath("/[lng]/evcp/compliance"); + return updated; + } catch (error) { + + throw error; + } +} + +// 레드플래그 담당자 조회 또는 생성 +export async function getOrCreateRedFlagManagers() { + try { + const existing = await getRedFlagManagers(); + + if (existing) { + return existing; + } + + // 존재하지 않으면 빈 레코드 생성 + await createRedFlagManagers({ + purchasingManagerId: null, + complianceManagerId: null, + }); + + // 다시 relations를 포함해서 조회 + const newManager = await getRedFlagManagers(); + return newManager; + } catch (error) { + + throw error; + } +} diff --git a/lib/compliance/table/compliance-survey-templates-toolbar.tsx b/lib/compliance/table/compliance-survey-templates-toolbar.tsx index e093550c..6776b70a 100644 --- a/lib/compliance/table/compliance-survey-templates-toolbar.tsx +++ b/lib/compliance/table/compliance-survey-templates-toolbar.tsx @@ -8,6 +8,7 @@ import { exportTableToExcel } from "@/lib/export"; import { Button } from "@/components/ui/button"; import { ComplianceTemplateCreateDialog } from "./compliance-template-create-dialog"; import { DeleteComplianceTemplatesDialog } from "./delete-compliance-templates-dialog"; +import { RedFlagManagersDialog } from "./red-flag-managers-dialog"; import { complianceSurveyTemplates } from "@/db/schema/compliance"; interface ComplianceSurveyTemplatesToolbarActionsProps { @@ -28,6 +29,9 @@ export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSur <ComplianceTemplateCreateDialog /> + {/** 2) 레드플래그 담당자 관리 */} + <RedFlagManagersDialog /> + {/** 3) Export 버튼 */} <Button variant="outline" diff --git a/lib/compliance/table/red-flag-managers-dialog.tsx b/lib/compliance/table/red-flag-managers-dialog.tsx new file mode 100644 index 00000000..08244f9e --- /dev/null +++ b/lib/compliance/table/red-flag-managers-dialog.tsx @@ -0,0 +1,203 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { AlertCircle, Users } from "lucide-react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { UserSelector, UserSelectItem } from "@/components/common/user/user-selector"; +import { + getOrCreateRedFlagManagers, + updateRedFlagManagers +} from "@/lib/compliance/services"; + +export function RedFlagManagersDialog() { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [isFetching, setIsFetching] = React.useState(false); + const router = useRouter(); + + // 담당자 state + const [managerId, setManagerId] = React.useState<number | null>(null); + const [purchasingManager, setPurchasingManager] = React.useState<UserSelectItem[]>([]); + const [complianceManager, setComplianceManager] = React.useState<UserSelectItem[]>([]); + + // 다이얼로그 열릴 때 현재 담당자 정보 가져오기 + React.useEffect(() => { + if (open) { + loadManagers(); + } + }, [open]); + + const loadManagers = async () => { + setIsFetching(true); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const managers: any = await getOrCreateRedFlagManagers(); + + if (managers) { + setManagerId(managers.id); + + // 구매기획 담당자 설정 + if (managers.purchasingManager) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pm: any = managers.purchasingManager; + setPurchasingManager([{ + id: pm.id, + name: pm.name, + email: pm.email, + epId: pm.epId, + deptCode: pm.deptCode, + deptName: pm.deptName, + imageUrl: pm.imageUrl, + domain: pm.domain, + }]); + } + + // 준법 담당자 설정 + if (managers.complianceManager) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cm: any = managers.complianceManager; + setComplianceManager([{ + id: cm.id, + name: cm.name, + email: cm.email, + epId: cm.epId, + deptCode: cm.deptCode, + deptName: cm.deptName, + imageUrl: cm.imageUrl, + domain: cm.domain, + }]); + } + } + } catch (error) { + console.error("Error loading red flag managers:", error); + toast.error("담당자 정보를 불러오는 중 오류가 발생했습니다."); + } finally { + setIsFetching(false); + } + }; + + const handleSave = async () => { + if (!managerId) { + toast.error("담당자 정보를 불러오지 못했습니다."); + return; + } + + setIsLoading(true); + try { + await updateRedFlagManagers(managerId, { + purchasingManagerId: purchasingManager[0]?.id || null, + complianceManagerId: complianceManager[0]?.id || null, + }); + + toast.success("레드플래그 담당자가 저장되었습니다."); + setOpen(false); + router.refresh(); + } catch (error) { + console.error("Error saving red flag managers:", error); + toast.error("담당자 저장 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <AlertCircle className="mr-2 h-4 w-4 text-red-600" /> + 레드플래그 담당자 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <AlertCircle className="h-5 w-5 text-red-600" /> + 레드플래그 담당자 관리 + </DialogTitle> + <DialogDescription> + 레드플래그 발생 시 알림을 받을 담당자를 지정합니다. + </DialogDescription> + </DialogHeader> + + {isFetching ? ( + <div className="space-y-4 py-4"> + <div className="text-center text-sm text-muted-foreground"> + 담당자 정보를 불러오는 중... + </div> + </div> + ) : ( + <div className="space-y-6 py-4"> + {/* 구매기획 담당자 */} + <div className="space-y-2"> + <Label htmlFor="purchasing-manager" className="flex items-center gap-2"> + <Users className="h-4 w-4" /> + 구매기획 담당자 + </Label> + <UserSelector + selectedUsers={purchasingManager} + onUsersChange={setPurchasingManager} + singleSelect={true} + placeholder="구매기획 담당자를 검색하세요..." + domainFilter={{ type: "exclude", domains: ["partners"] }} + closeOnSelect={true} + /> + <p className="text-xs text-muted-foreground"> + 레드플래그 발생 시 알림을 받을 구매기획 담당자를 지정합니다. + </p> + </div> + + {/* 준법 담당자 */} + <div className="space-y-2"> + <Label htmlFor="compliance-manager" className="flex items-center gap-2"> + <Users className="h-4 w-4" /> + 준법 담당자 + </Label> + <UserSelector + selectedUsers={complianceManager} + onUsersChange={setComplianceManager} + singleSelect={true} + placeholder="준법 담당자를 검색하세요..." + domainFilter={{ type: "exclude", domains: ["partners"] }} + closeOnSelect={true} + /> + <p className="text-xs text-muted-foreground"> + 레드플래그 발생 시 알림을 받을 준법 담당자를 지정합니다. + </p> + </div> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isLoading || isFetching} + > + 취소 + </Button> + <Button + type="button" + onClick={handleSave} + disabled={isLoading || isFetching} + > + {isLoading ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/mail/templates/compliance-red-flag-alert.hbs b/lib/mail/templates/compliance-red-flag-alert.hbs new file mode 100644 index 00000000..9680e7fb --- /dev/null +++ b/lib/mail/templates/compliance-red-flag-alert.hbs @@ -0,0 +1,87 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>협력사 Red Flag 발생 공지</title> + <style> + body { + font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; + line-height: 1.6; + color: #333333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: #ffffff; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + .header { + text-align: center; + border-bottom: 2px solid #e9ecef; + padding-bottom: 20px; + margin-bottom: 30px; + } + .header h1 { + color: #dc2626; + font-size: 24px; + margin: 0; + } + .content p { + margin: 16px 0; + } + .guide-box { + background-color: #f8f9fa; + border-left: 4px solid #2563eb; + padding: 16px 20px; + border-radius: 6px; + margin: 24px 0; + } + .guide-box strong { + color: #1f2937; + } + .footer { + border-top: 1px solid #e9ecef; + padding-top: 20px; + margin-top: 30px; + text-align: center; + color: #6b7280; + font-size: 14px; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>협력사 Red Flag 발생 공지</h1> + </div> + + <div class="content"> + <p> + 협력사 <strong>{{vendorName}}</strong>에서 e-VCP를 통하여 실시한 준법설문 결과 + <strong>Red FLAG 이슈(준법 리스크)</strong>가 발생하여 공지 드립니다. + </p> + + <div class="guide-box"> + <p> + 알람을 받은 임직원께서는 <strong>e-VCP > 기준정보 > 준법설문관리 > 준법설문조사 > 응답현황보기</strong>에서 + 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후 + <strong>1주일 이내에 준법경영시스템에 문의 등록</strong>하여 Compliance팀의 가이드에 따라 조치 바랍니다. + </p> + </div> + + <p> + 기타 문의사항은 <strong>Compliance팀 박지은 프로</strong>에게 문의 바랍니다. + </p> + </div> + + <div class="footer"> + <p>이 메일은 eVCP 시스템에서 자동으로 발송되었습니다.</p> + </div> + </div> +</body> +</html> |
