summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/compliance/questions/compliance-question-create-dialog.tsx114
-rw-r--r--lib/compliance/questions/compliance-questions-draggable-list.tsx7
-rw-r--r--lib/compliance/red-flag-notifier.ts142
-rw-r--r--lib/compliance/services.ts94
-rw-r--r--lib/compliance/table/compliance-survey-templates-toolbar.tsx4
-rw-r--r--lib/compliance/table/red-flag-managers-dialog.tsx203
-rw-r--r--lib/mail/templates/compliance-red-flag-alert.hbs87
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 &gt; 기준정보 &gt; 준법설문관리 &gt; 준법설문조사 &gt; 응답현황보기</strong>에서
+ 해당 협력사의 준법리스크 설문 결과를 확인하시고, 준법리스크 해당 사항을 협력사에 확인한 후
+ <strong>1주일 이내에 준법경영시스템에 문의 등록</strong>하여 Compliance팀의 가이드에 따라 조치 바랍니다.
+ </p>
+ </div>
+
+ <p>
+ 기타 문의사항은 <strong>Compliance팀 박지은 프로</strong>에게 문의 바랍니다.
+ </p>
+ </div>
+
+ <div class="footer">
+ <p>이 메일은 eVCP 시스템에서 자동으로 발송되었습니다.</p>
+ </div>
+ </div>
+</body>
+</html>