From a4ceade24d28af0bde985bf750017efc02f053ff Mon Sep 17 00:00:00 2001 From: 0-Zz-ang Date: Thu, 13 Nov 2025 11:36:15 +0900 Subject: (박서영)준법설문조사 RedFlag관련 요청사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/upload/signed-contract/route.ts | 27 ++- db/schema/compliance.ts | 23 +++ .../compliance-question-create-dialog.tsx | 114 +++++++++--- .../compliance-questions-draggable-list.tsx | 7 +- lib/compliance/red-flag-notifier.ts | 142 ++++++++++++++ lib/compliance/services.ts | 94 +++++++++- .../table/compliance-survey-templates-toolbar.tsx | 4 + lib/compliance/table/red-flag-managers-dialog.tsx | 203 +++++++++++++++++++++ lib/mail/templates/compliance-red-flag-alert.hbs | 87 +++++++++ 9 files changed, 675 insertions(+), 26 deletions(-) create mode 100644 lib/compliance/red-flag-notifier.ts create mode 100644 lib/compliance/table/red-flag-managers-dialog.tsx create mode 100644 lib/mail/templates/compliance-red-flag-alert.hbs diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts index 880f54b2..a8e2bead 100644 --- a/app/api/upload/signed-contract/route.ts +++ b/app/api/upload/signed-contract/route.ts @@ -1,10 +1,11 @@ // app/api/upload/signed-contract/route.ts import { NextRequest, NextResponse } from 'next/server'; import db from "@/db/db"; -import { basicContract } from '@/db/schema'; +import { basicContract, vendors } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { revalidateTag } from 'next/cache'; import { saveBuffer } from '@/lib/file-stroage'; +import { getTriggeredRedFlagQuestions, notifyComplianceRedFlagManagers } from '@/lib/compliance/red-flag-notifier'; export async function POST(request: NextRequest) { try { @@ -55,6 +56,30 @@ export async function POST(request: NextRequest) { .where(eq(basicContract.id, tableRowId)); }); + const [contractInfo] = await db + .select({ + templateId: basicContract.templateId, + vendorName: vendors.vendorName, + }) + .from(basicContract) + .leftJoin(vendors, eq(basicContract.vendorId, vendors.id)) + .where(eq(basicContract.id, tableRowId)) + .limit(1); + + try { + const triggeredRedFlags = await getTriggeredRedFlagQuestions(tableRowId); + if (triggeredRedFlags.length > 0) { + await notifyComplianceRedFlagManagers({ + contractId: tableRowId, + templateId: contractInfo?.templateId ?? null, + vendorName: contractInfo?.vendorName ?? undefined, + triggeredQuestions: triggeredRedFlags, + }); + } + } catch (error) { + console.error('레드플래그 알림 발송 실패:', error); + } + // 캐시 무효화 revalidateTag("basic-contract-requests"); revalidateTag("basicContractView-vendor"); diff --git a/db/schema/compliance.ts b/db/schema/compliance.ts index 18df510b..d878abe1 100644 --- a/db/schema/compliance.ts +++ b/db/schema/compliance.ts @@ -23,6 +23,7 @@ export const complianceQuestions = pgTable('compliance_questions', { questionType: varchar('question_type', { length: 20 }).notNull(), // 'RADIO', 'CHECKBOX', 'TEXT', 'TEXTAREA', 'DROPDOWN', 'FILE', 'PERCENTAGE', 'CONDITIONAL' isRequired: boolean('is_required').notNull().default(true), + isRedFlag: boolean('is_red_flag').notNull().default(false), // 레드플래그 질문 여부 hasDetailText: boolean('has_detail_text').notNull().default(false), // 상세 기술 필요 여부 hasFileUpload: boolean('has_file_upload').notNull().default(false), // 첨부파일 필요 여부 parentQuestionId: integer('parent_question_id'), // 조건부 질문의 부모 @@ -79,6 +80,15 @@ export const complianceResponseFiles = pgTable('compliance_response_files', { uploadedAt: timestamp('uploaded_at').defaultNow(), }); +// 7. 레드플래그 담당자 관리 +export const redFlagManagers = pgTable('red_flag_managers', { + id: serial('id').primaryKey(), + purchasingManagerId: integer('purchasing_manager_id').references(() => users.id), // 구매기획 담당자 + complianceManagerId: integer('compliance_manager_id').references(() => users.id), // 준법 담당자 + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + // Relations 정의 export const complianceSurveyTemplatesRelations = relations(complianceSurveyTemplates, ({ many }) => ({ questions: many(complianceQuestions), @@ -137,6 +147,17 @@ export const complianceResponseFilesRelations = relations(complianceResponseFile }), })); +export const redFlagManagersRelations = relations(redFlagManagers, ({ one }) => ({ + purchasingManager: one(users, { + fields: [redFlagManagers.purchasingManagerId], + references: [users.id], + }), + complianceManager: one(users, { + fields: [redFlagManagers.complianceManagerId], + references: [users.id], + }), +})); + // 타입 정의 export type ComplianceSurveyTemplate = typeof complianceSurveyTemplates.$inferSelect; export type ComplianceQuestion = typeof complianceQuestions.$inferSelect; @@ -144,6 +165,7 @@ export type ComplianceQuestionOption = typeof complianceQuestionOptions.$inferSe export type ComplianceResponse = typeof complianceResponses.$inferSelect; export type ComplianceResponseAnswer = typeof complianceResponseAnswers.$inferSelect; export type ComplianceResponseFile = typeof complianceResponseFiles.$inferSelect; +export type RedFlagManager = typeof redFlagManagers.$inferSelect; // Insert 타입 export type NewComplianceSurveyTemplate = typeof complianceSurveyTemplates.$inferInsert; @@ -152,6 +174,7 @@ export type NewComplianceQuestionOption = typeof complianceQuestionOptions.$infe export type NewComplianceResponse = typeof complianceResponses.$inferInsert; export type NewComplianceResponseAnswer = typeof complianceResponseAnswers.$inferInsert; export type NewComplianceResponseFile = typeof complianceResponseFiles.$inferInsert; +export type NewRedFlagManager = typeof redFlagManagers.$inferInsert; // 질문 타입 상수 export const QUESTION_TYPES = { 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({ )} /> + + {/* 레드플래그 체크박스 */} + ( + + + + +
+ 레드플래그 질문 + + 질문 유형 - RADIO || 옵션 - YES/NO + +
+
+ )} + /> {/* 유효성 검사 에러 메시지 */} {(form.formState.errors.isRequired || form.formState.errors.isConditional) && ( @@ -301,7 +352,7 @@ export function ComplianceQuestionCreateDialog({