From dd20ba9785cdbd3d61f6b014d003d3bd9646ad13 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 14 Aug 2025 00:26:53 +0000 Subject: (고건) 리스크 관리 페이지 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mail/templates/risks-notification.hbs | 196 +++++++ lib/risk-management/repository.ts | 123 ++++ lib/risk-management/service.ts | 636 +++++++++++++++++++++ lib/risk-management/table/risks-columns.tsx | 352 ++++++++++++ lib/risk-management/table/risks-dashboard.tsx | 244 ++++++++ .../table/risks-date-range-picker.tsx | 157 +++++ lib/risk-management/table/risks-mail-dialog.tsx | 560 ++++++++++++++++++ .../table/risks-table-toolbar-actions.tsx | 161 ++++++ lib/risk-management/table/risks-table.tsx | 176 ++++++ lib/risk-management/table/risks-update-sheet.tsx | 406 +++++++++++++ lib/risk-management/table/user-combo-box.tsx | 127 ++++ lib/risk-management/validations.ts | 69 +++ 12 files changed, 3207 insertions(+) create mode 100644 lib/mail/templates/risks-notification.hbs create mode 100644 lib/risk-management/repository.ts create mode 100644 lib/risk-management/service.ts create mode 100644 lib/risk-management/table/risks-columns.tsx create mode 100644 lib/risk-management/table/risks-dashboard.tsx create mode 100644 lib/risk-management/table/risks-date-range-picker.tsx create mode 100644 lib/risk-management/table/risks-mail-dialog.tsx create mode 100644 lib/risk-management/table/risks-table-toolbar-actions.tsx create mode 100644 lib/risk-management/table/risks-table.tsx create mode 100644 lib/risk-management/table/risks-update-sheet.tsx create mode 100644 lib/risk-management/table/user-combo-box.tsx create mode 100644 lib/risk-management/validations.ts (limited to 'lib') diff --git a/lib/mail/templates/risks-notification.hbs b/lib/mail/templates/risks-notification.hbs new file mode 100644 index 00000000..3c947574 --- /dev/null +++ b/lib/mail/templates/risks-notification.hbs @@ -0,0 +1,196 @@ + + + + + eVCP 메일 + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + +
+ 🚢 eVCP +
+ 협력업체 리스크 알림 및 관리 요청 +
+
+ + + + + + + + + + + + + + + + + + + +
+ {{vendorName}}({{vendorCode}})에 신용평가상 리스크가 발생하였습니다. +
+ 주요사항 {{adminComment}}(으)로 추정됩니다. +
+ 협력업체로 확인하시어 거래 관계상 재무 리스크 유무를 확인하시기 바랍니다. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#if riskItems.length}} + {{#each riskItems}} + + + + + {{/each}} + {{else}} + + + + {{/if}} +
+ [협력업체 정보] +
+ 사업자등록번호 + + {{businessNumber}} +
+ 코드 + + {{vendorCode}} +
+ 업체명 + + {{vendorName}} +
+ [신용등급 정보] +
+ 종합등급 + + 신용등급 + + 현금흐름등급 + + WATCH등급 +
+ {{ratingTotal}} + + {{ratingCredit}} + + {{ratingCashflow}} + + {{ratingWatch}} +
+ [리스크 정보] +
+ 항목 + + 상세 내용 +
+ {{this.eventType}} + + {{this.content}} +
+ 리스크 정보가 없습니다. +
+
+ 상기 내역에 대해 문의사항이 있을 경우 리스크 관리 담당자에게 연락 바랍니다. +
+ 감사합니다. +
+
+ + + + + + + + + + + + + + + + +
+ {{senderName}} / Risk Manager / + {{senderEmail}} +
+ SAMSUNG HEAVY INDUSTRIES CO., LTD. +
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261 +
+   +
+ ⓒ 2025. eVCP. All Rights Reserved. +
+
+
+ + diff --git a/lib/risk-management/repository.ts b/lib/risk-management/repository.ts new file mode 100644 index 00000000..9fec0f29 --- /dev/null +++ b/lib/risk-management/repository.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* IMPORT */ +import { asc, count, desc, eq } from 'drizzle-orm'; +import { PgTransaction } from 'drizzle-orm/pg-core'; +import { + riskEvents, + risksView, + type NewRiskEvents, + type RiskEvents, +} from '@/db/schema'; + +// ---------------------------------------------------------------------------------------------------- + +/* SELECT RISKS VIEW TRANSACTION */ +async function selectRisksView( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + }, +) { + const { + where, + orderBy, + offset = 0, + limit = 10, + } = params; + const result = await tx + .select() + .from(risksView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + + return result; +} + +/* SELECT COUNT TRANSACTION */ +async function countRisksView( + tx: PgTransaction, + where?: any, +) { + const result = await tx + .select({ count: count() }) + .from(risksView) + .where(where); + + return result[0]?.count ?? 0; +} + +/* SELECT RISK EVENTS TRANSACTION */ +async function selectRiskEvents( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + }, +) { + const { + where, + orderBy, + offset = 0, + limit = 10, + } = params; + const result = await tx + .select() + .from(riskEvents) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + + return result; +} + +// ---------------------------------------------------------------------------------------------------- + +/* INSERT RISK EVENTS TRANSACTION */ +async function insertRiskEvents( + tx: PgTransaction, + data: NewRiskEvents, +) { + const [insertRes] = await tx + .insert(riskEvents) + .values(data) + .returning(); + + return insertRes; +} + +// ---------------------------------------------------------------------------------------------------- + +/* UPDATE RISK EVENTS TRANSACTION */ +async function updateRiskEvents( + tx: PgTransaction, + eventId: number, + data: Partial, +) { + const [updateRes] = await tx + .update(riskEvents) + .set(data) + .where(eq(riskEvents.id, eventId)) + .returning(); + + return updateRes; +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export { + countRisksView, + insertRiskEvents, + selectRisksView, + selectRiskEvents, + updateRiskEvents, +}; \ No newline at end of file diff --git a/lib/risk-management/service.ts b/lib/risk-management/service.ts new file mode 100644 index 00000000..1c58657c --- /dev/null +++ b/lib/risk-management/service.ts @@ -0,0 +1,636 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use server'; + +/* IMPORT */ +import { + and, + asc, + desc, + eq, + gte, + ilike, + lte, + or, +} from 'drizzle-orm'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { + countRisksView, + insertRiskEvents, + selectRiskEvents, + selectRisksView, + updateRiskEvents, +} from './repository'; +import db from '@/db/db'; +import ExcelJS from 'exceljs'; +import { filterColumns } from '@/lib/filter-columns'; +import { getServerSession } from 'next-auth'; +import { RISK_EVENT_TYPE_LIST, RISK_PROVIDER_LIST } from '@/config/risksConfig'; +import { + riskEvents, + risksView, + type RiskEvents, + type RisksView, + users, + vendors, +} from '@/db/schema'; +import { selectUsers } from '../admin-users/repository'; +import { selectVendors } from '../vendors/repository'; +import { sendEmail } from '../mail/sendEmail'; +import { type GetRisksSchema } from './validations'; + +// ---------------------------------------------------------------------------------------------------- + +/* HELPER FUNCTION FOR GETTING CURRENT USER ID */ +async function getCurrentUserId(): Promise { + try { + const session = await getServerSession(authOptions); + return session?.user?.id ? Number(session.user.id) : 3; // 기본값 3, 실제 환경에서는 적절한 기본값 설정 + } catch (error) { + console.error('Error in Getting Current Session User ID:', error); + return 3; // 기본값 3 + } +} + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR GETTING RISKS */ +async function getRisksView(input: GetRisksSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + const dateWhere = and( + gte(risksView.occuredAt, new Date(input.from)), + lte(risksView.occuredAt, new Date(input.to)), + ); + + const advancedWhere = filterColumns({ + table: risksView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // Filtering + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(risksView.eventType, s), + ilike(risksView.vendorCode, s), + ilike(risksView.vendorName, s), + ilike(risksView.businessNumber, s), + ilike(risksView.provider, s), + ); + } + const finalWhere = and(dateWhere, advancedWhere, globalWhere); + + // Sorting + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + return item.desc + ? desc(risksView[item.id]) + : asc(risksView[item.id]); + }) + : [asc(risksView.id)]; + + // Getting Data + const { data, total } = await db.transaction(async (tx) => { + const data = await selectRisksView(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countRisksView(tx, finalWhere); + + return { data, total }; + }); + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + console.error('Error in Getting Risk Data: ', err); + return { data: [], pageCount: 0 }; + } +} + +/* FUNCTION FOR GETTING RISKS VIEW BY ID */ +async function getRisksViewById(id: number) { + try { + return await db.transaction(async (tx) => { + return await selectRisksView(tx, { + where: eq(risksView.id, id), + }); + }); + } catch (err) { + console.error('Error in Getting Risk Events by ID: ', err); + return null; + } +} + +/* FUNCTION FOR GETTING RISK EVENTS BY ID */ +async function getRiskEventsById(id: number) { + try { + return await db.transaction(async (tx) => { + return await selectRiskEvents(tx, { + where: eq(riskEvents.id, id), + }); + }); + } catch (err) { + console.error('Error in Getting Risk Events by ID: ', err); + return null; + } +} + +/* FUNCTION FOR GETTING RISKS VIEW DATA COUNT */ +async function getRisksViewCount(input: GetRisksSchema) { + try { + const dateWhere = and( + gte(risksView.occuredAt, new Date(input.from)), + lte(risksView.occuredAt, new Date(input.to)), + ); + + const advancedWhere = filterColumns({ + table: risksView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(risksView.eventType, s), + ilike(risksView.vendorCode, s), + ilike(risksView.vendorName, s), + ilike(risksView.businessNumber, s), + ilike(risksView.provider, s), + ); + } + const statusWhere = eq(risksView.eventStatus, true); + const finalWhere = and(dateWhere, advancedWhere, globalWhere, statusWhere); + + const { total } = await db.transaction(async (tx) => { + const total = await countRisksView(tx, finalWhere); + + return { total }; + }); + + return { count: total }; + } catch (err) { + console.error('Error in Counting Risks: ', err); + return { count: 0 }; + } +} + +/* FUNCTION FOR GETTING PROCUREMENT MANAGER LIST */ +async function getProcurementManagerList() { + try { + return await db.transaction(async (tx) => { + const managerData = await selectUsers(tx, { + where: eq(users.deptName, '구매'), // 추후 수정 필요 + }); + + return managerData.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + deptName: user.deptName, + })); + }); + } catch (err) { + console.error('Error in Getting Procurement Manager List: ', err); + return []; + } +} + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR MODIFYING RISK EVENTS */ +async function modifyRiskEvents( + id: number, + riskData: Partial, +) { + try { + const currentUserId = await getCurrentUserId(); + + return await db.transaction(async (tx) => { + const modifiedRiskEvent = await updateRiskEvents(tx, id, { + ...riskData, + updatedAt: new Date(), + updatedBy: currentUserId, + }); + + return { ...modifiedRiskEvent }; + }); + } catch (error) { + console.error('Error in Modifying Risk Events: ', error); + throw new Error('Failed to Modify Risk Events'); + } +} + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR SENDING RISK EMAIL */ +async function sendRiskEmail( + vendorId: number, + managerId: number, + adminComment: string, + selectedEventTypeMap: Record, + attachment?: { filename: string; content: Buffer }, +) { + try { + // Getting Vendor Information + const { vendorList } = await db.transaction(async (tx) => { + const selectRes = await selectVendors(tx, { + where: eq(vendors.id, vendorId), + }); + + return { vendorList: selectRes }; + }); + if (vendorList.length === 0) { + throw new Error('존재하지 않는 협력업체에요. 다시 한 번 확인해주세요.'); + } + const { vendorName, vendorCode, taxId } = vendorList[0]; + const businessNumber = /^\d{10}$/.test(taxId) + ? `${taxId.slice(0, 3)}-${taxId.slice(3, 5)}-${taxId.slice(5)}` + : taxId; + + // Getting Procurement Manager Information + const procurementManagerList = await db.transaction(async (tx) => { + const managerData = await selectUsers(tx, { + where: eq(users.id, managerId), + }); + + return managerData.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + deptName: user.deptName, + })); + }); + if (!procurementManagerList || procurementManagerList.length === 0) { + throw new Error('해당하는 구매 담당자가 존재하지 않아요.'); + } + const procurementManager = procurementManagerList[0]; + + // Getting Risk Manager Information + const riskManagerId = await getCurrentUserId(); + const riskManagerList = await db.transaction(async (tx) => { + const managerData = await selectUsers(tx, { + where: eq(users.id, riskManagerId), + }); + + return managerData.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + deptName: user.deptName, + })); + }); + if (!riskManagerList || riskManagerList.length === 0) { + throw new Error('해당하는 리스크 관리 담당자가 존재하지 않아요.'); + } + const riskManager = riskManagerList[0]; + + // Getting Ranking Information + const ratingList = ['종합등급', '신용등급', '현금흐름등급', 'WATCH등급']; + const { ratingMap } = await db.transaction(async (tx) => { + const selectResList: Record = {}; + + for (const rating of ratingList) { + const selectRes = await selectRiskEvents(tx, { + where: and( + eq(riskEvents.vendorId, vendorId), + eq(riskEvents.eventType, rating), + ), + orderBy: [desc(riskEvents.occuredAt)], + limit: 1, + }); + + selectResList[rating] = selectRes.length > 0 && selectRes[0].content !== null ? selectRes[0].content : '-'; + } + + return { ratingMap: selectResList }; + }); + + const riskItems = Object.entries(selectedEventTypeMap).map(([eventType, items]) => { + const contents = items.map(item => item.content).join(', '); + return { eventType, content: contents }; + }); + + const attachments = attachment + ? [ + { + filename: attachment.filename, + content: attachment.content, + }, + ] + : []; + + await sendEmail({ + to: procurementManager.email, + // from: `"${riskManager.name}" <${riskManager.email}>`, // 추후 수정 필요 + from: `"${riskManager.name}" `, + subject: `[eVCP] 협력업체 리스크 알림 및 관리 요청 - ${vendorName}(${vendorCode}, 사업자등록번호: ${businessNumber})`, + template: 'risks-notification', + context: { + adminComment, + vendorName, + vendorCode, + businessNumber, + ratingTotal: ratingMap['종합등급'], + ratingCredit: ratingMap['신용등급'], + ratingCashflow: ratingMap['현금흐름등급'], + ratingWatch: ratingMap['WATCH등급'], + riskItems, + senderName: riskManager.name, + senderEmail: riskManager.email, + }, + attachments, + }); + } catch (error) { + console.error('Error in Sending Risk Email: ', error); + throw new Error('Failed to Send Risk Email'); + } +} + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR GENERATING EXCEL TEMPLATE */ +async function generateRiskEventsTemplate(): Promise { + try { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('협력업체 리스크 입력 템플릿'); + workbook.creator = 'EVCP System'; + workbook.created = new Date(); + + const templateHeaders = [ + { key: 'eventType', header: '항목', width: 30 }, + { key: 'vendorName', header: '협력업체명', width: 30 }, + { key: 'businessNumber', header: '사업자등록번호', width: 30 }, + { key: 'provider', header: '신용평가사', width: 30 }, + { key: 'content', header: '상세 내용', width: 50 }, + { key: 'occuredAt', header: '발생일자', width: 50 }, + ]; + + worksheet.columns = templateHeaders; + + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFCCCCCC' }, + }; + headerRow.alignment = { horizontal: 'center', vertical: 'middle' }; + + const exampleData = [ + { + eventType: '리스크 항목을 입력하세요.', + vendorName: '협력업체명을 입력하세요.', + businessNumber: '사업자등록번호를 입력하세요.', + provider: '신용평가사를 입력하세요.', + content: '상세 내용을 입력하세요.', + occuredAt: '발생일자를 YYYY-MM-DD형식으로 입력하세요.' + }, + ]; + + exampleData.forEach((row) => { + worksheet.addRow(row); + }); + + const validationSheet = workbook.addWorksheet('ValidationData'); + validationSheet.state = 'hidden'; + validationSheet.getColumn(1).values = ['리스크 항목', ...RISK_EVENT_TYPE_LIST]; + validationSheet.getColumn(2).values = ['신용평가사', ...RISK_PROVIDER_LIST]; + + const eventTypeColIndex = templateHeaders.findIndex(col => col.key === 'eventType') + 1; + const providerColIndex = templateHeaders.findIndex(col => col.key === 'provider') + 1; + + if (eventTypeColIndex > 0) { + (worksheet as any).dataValidations.add(`${worksheet.getColumn(eventTypeColIndex).letter}2:${worksheet.getColumn(eventTypeColIndex).letter}1000`, { + type: 'list', + allowBlank: false, + formulae: [`ValidationData!$A$2:$A$${RISK_EVENT_TYPE_LIST.length + 1}`], + }); + } + + if (providerColIndex > 0) { + (worksheet as any).dataValidations.add(`${worksheet.getColumn(providerColIndex).letter}2:${worksheet.getColumn(providerColIndex).letter}1000`, { + type: 'list', + allowBlank: false, + formulae: [`ValidationData!$B$2:$B$${RISK_PROVIDER_LIST.length + 1}`], + }); + } + + worksheet.eachRow((row) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' }, + }; + }); + }); + + return await workbook.xlsx.writeBuffer() as ArrayBuffer; + } catch (error) { + console.error('Error in generating Excel template:', error); + throw new Error('Failed to generate Excel template'); + } +} + +/* FUNCTION FOR IMPORTING EXCEL FILE */ +async function importRiskEventsExcel(file: File): Promise<{ + errorFile?: Blob; + errorMessage?: string; + successMessage?: string; +}> { + try { + const arrayBuffer = await file.arrayBuffer(); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + const worksheet = workbook.getWorksheet(1); + if (!worksheet) { + return { errorMessage: '워크시트를 찾을 수 없어요.' }; + } + const errors: string[] = []; + const importDataList: { + eventType: string; + vendorId: number; + vendorName: string; + businessNumber: string; + provider: string; + content: string; + occuredAt: string; + }[] = []; + + const rows = worksheet.getRows(2, worksheet.rowCount - 1); + + if (!rows) { + return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않아요.' }; + } + + for (const [index, row] of rows.entries()) { + const rowIndex = index + 2; + try { + const rowData = { + eventType: row.getCell(1).value?.toString()?.trim() || '', + vendorId: 0, + vendorName: row.getCell(2).value?.toString()?.trim() || '', + businessNumber: row.getCell(3).value?.toString()?.trim() || '', + provider: row.getCell(4).value?.toString()?.trim() || '', + content: row.getCell(5).value?.toString()?.trim() || '', + occuredAt : '', + }; + + let occuredAtRaw = row.getCell(6).value; + let occuredAt = ''; + + if (occuredAtRaw instanceof Date) { + const year = occuredAtRaw.getFullYear(); + const month = String(occuredAtRaw.getMonth() + 1).padStart(2, '0'); + const day = String(occuredAtRaw.getDate()).padStart(2, '0'); + occuredAt = `${year}-${month}-${day}`; + } else if (typeof occuredAtRaw === 'number') { + const excelEpoch = new Date(Date.UTC(1899, 11, 30)); + const dateObj = new Date(excelEpoch.getTime() + occuredAtRaw * 86400 * 1000); + const year = dateObj.getUTCFullYear(); + const month = String(dateObj.getUTCMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getUTCDate()).padStart(2, '0'); + occuredAt = `${year}-${month}-${day}`; + } else if (typeof occuredAtRaw === 'string') { + occuredAt = occuredAtRaw.trim(); + } + + rowData.occuredAt = occuredAt || ''; + + if (!rowData.eventType && !rowData.vendorName && !rowData.businessNumber && !rowData.provider && !rowData.occuredAt) { + continue; + } + + if (!rowData.eventType || !rowData.vendorName || !rowData.businessNumber || !rowData.provider || !rowData.occuredAt) { + errors.push(`행 ${rowIndex}: 필수 필드(항목, 협력업체명, 사업자등록번호, 신용평가사, 발생일자)가 누락되었어요.`); + continue; + } + + rowData.businessNumber = rowData.businessNumber.replace(/\D/g, ''); + + if (rowData.businessNumber.length !== 10) { + errors.push(`행 ${rowIndex}: 사업자등록번호는 숫자 10자리여야 해요.`); + continue; + } + + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(rowData.occuredAt)) { + errors.push(`행 ${rowIndex}: 발생일자는 YYYY-MM-DD 형식이어야 해요.`); + continue; + } + + const dateObj = new Date(rowData.occuredAt); + const [year, month, day] = rowData.occuredAt.split('-').map(Number); + if ( + dateObj.getFullYear() !== year || + dateObj.getMonth() + 1 !== month || + dateObj.getDate() !== day + ) { + errors.push(`행 ${rowIndex}: 발생일자가 올바른 날짜가 아니에요.`); + continue; + } + + const { vendorList } = await db.transaction(async (tx) => { + const selectRes = await selectVendors(tx, { + where: eq(vendors.taxId, rowData.businessNumber), + }); + + return { vendorList: selectRes }; + }); + + if (vendorList.length === 0) { + errors.push(`행 ${rowIndex}: 협력업체로 등록되지 않은 사업자등록번호에요. 다시 한 번 확인해주세요.`); + continue; + } + + rowData.vendorId = vendorList[0].id; + rowData.vendorName = vendorList[0].vendorName; + importDataList.push(rowData); + + } catch (error) { + errors.push(`행 ${rowIndex}: 데이터 처리 중 오류가 발생했어요 - ${error}`); + } + } + + if (errors.length > 0) { + const errorWorkbook = new ExcelJS.Workbook(); + const errorWorksheet = errorWorkbook.addWorksheet('Import Errors'); + + errorWorksheet.columns = [ + { header: '오류 내용', key: 'error', width: 80 }, + ]; + + errorWorksheet.getRow(1).font = { bold: true }; + errorWorksheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFF0000' } + }; + + errors.forEach(error => { + errorWorksheet.addRow({ error }); + }); + + const buffer = await errorWorkbook.xlsx.writeBuffer(); + const errorFile = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + + return { + errorFile, + errorMessage: `${errors.length}개의 오류가 발견되었어요. 오류 파일을 확인하세요.` + }; + } + + if (importDataList.length === 0) { + return { errorMessage: '새로 추가할 리스크 데이터가 존재하지 않아요. 파일을 다시 한 번 확인해주세요.' }; + } + + const currentUserId = await getCurrentUserId(); + + for (const importData of importDataList) { + const { vendorId, eventType, provider, content, occuredAt } = importData; + await db.transaction(async (tx) => { + await insertRiskEvents(tx, { + vendorId, + provider, + eventType, + content, + occuredAt: new Date(occuredAt), + createdBy: currentUserId, + updatedBy: currentUserId, + }); + }); + } + + return { successMessage: `Excel 파일이 성공적으로 업로드되었어요. ${importDataList.length}개의 리스크 이벤트가 추가되었어요.` }; + } catch (error) { + console.error('Error in Importing Regular Evaluation Criteria from Excel:', error); + return { errorMessage: 'Excel 파일 처리 중 오류가 발생했어요.' }; + } +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export { + getProcurementManagerList, + getRisksView, + getRisksViewById, + getRisksViewCount, + getRiskEventsById, + generateRiskEventsTemplate, + importRiskEventsExcel, + modifyRiskEvents, + sendRiskEmail, +}; \ No newline at end of file diff --git a/lib/risk-management/table/risks-columns.tsx b/lib/risk-management/table/risks-columns.tsx new file mode 100644 index 00000000..fe98448a --- /dev/null +++ b/lib/risk-management/table/risks-columns.tsx @@ -0,0 +1,352 @@ +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { CircleCheckBig, CircleX, Ellipsis, Handshake, OctagonAlert } from 'lucide-react'; +import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header'; +import { Dispatch, SetStateAction } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { type ColumnDef } from '@tanstack/react-table'; +import { type DataTableRowAction } from '@/types/table'; +import { type RisksView } from '@/db/schema'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface GetColumnsProps { + setRowAction: Dispatch | null>>, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR GETTING COLUMNS SETTING */ +function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + + // [1] SELECT COLUMN - CHECKBOX + const selectColumn: ColumnDef = { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="select-all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="select-row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }; + + // [2] SOURCE COLUMNS + const sourceColumns: ColumnDef[] = [ + { + accessorKey: 'eventType', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('eventType'); + return ( + + {value} + + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Category', + group: 'Risk Information', + type: 'select', + }, + }, + { + accessorKey: 'vendorCode', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('vendorCode'); + return ( +
+ {value} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Vendor Code', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'vendorName', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('vendorName'); + return ( +
+ {value} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Vendor Name', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'businessNumber', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('businessNumber'); + const digits = value.replace(/\D/g, ''); + const formattedValue = digits.length === 10 + ? `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}` + : value; + return ( +
+ {formattedValue} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Business Number', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'provider', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('provider'); + return ( + + {value} + + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Provider', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'content', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('content') ?? '-'; + return ( +
+ {value} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Content', + group: 'Risk Information', + type: 'text', + }, + size: 100, + }, + { + accessorKey: 'occuredAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue('occuredAt'); + return ( +
+ {date ? new Date(date).toLocaleDateString('ko-KR') : '-'} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Occured At', + group: 'Risk Information', + type: 'date', + }, + }, + ]; + + // [3] INPUT COLUMNS + const inputColumns: ColumnDef[] = [ + { + accessorKey: 'eventStatus', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('eventStatus'); + + if (value) { + return ( +
+ + 아니오 +
+ ); + } + return ( +
+ + 예 +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Not Cleared', + group: 'Risk Management', + type: 'text', + }, + }, + { + accessorKey: 'managerName', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('managerName') ?? '-'; + return ( +
+ {value} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Procurement Manager Name', + group: 'Risk Management', + type: 'text', + }, + }, + { + accessorKey: 'adminComment', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('adminComment') ?? '-'; + return ( +
+ {value} +
+ ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Manager Comment', + group: 'Risk Management', + type: 'text', + }, + size: 300, + }, + ]; + + // [4] ACTIONS COLUMN - DROPDOWN MENU WITH VIEW ACTION + const actionsColumn: ColumnDef = { + id: 'actions', + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + setRowAction({ row, type: "update" })} + className="cursor-pointer" + > + + 리스크 관리 + + { + // 신용정보 관리화면으로 이동 + }} + className="cursor-pointer" + > + + 신용정보 확인 + + + + ) + }, + size: 80, + }; + + return [ + selectColumn, + { + id: 'riskSource', + header: '리스크 정보', + columns: sourceColumns, + }, + { + id: 'riskInput', + header: '리스크 관리', + columns: inputColumns, + }, + actionsColumn, + ]; +}; + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default getColumns; \ No newline at end of file diff --git a/lib/risk-management/table/risks-dashboard.tsx b/lib/risk-management/table/risks-dashboard.tsx new file mode 100644 index 00000000..1f26d48a --- /dev/null +++ b/lib/risk-management/table/risks-dashboard.tsx @@ -0,0 +1,244 @@ +'use client'; + +/* IMPORT */ +import { Bar, BarChart, Cell, LabelList, XAxis, YAxis } from 'recharts'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; +import { getRisksViewCount } from '../service'; +import { LoaderCircle } from 'lucide-react'; +import { type DateRange } from 'react-day-picker'; +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { ValueType } from 'recharts/types/component/DefaultTooltipContent'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksDashboardProps { + targetValues: string[]; + defaultDateRange: DateRange; +} + +interface CountData { + [key: string]: number; +} + +interface ChartData { + name: string; + count: number; + color: string; +} + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS DASHBOARD COMPONENT */ +function RisksDashboard(props: RisksDashboardProps) { + const { targetValues, defaultDateRange } = props; + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + const [counts, setCounts] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [dateQuery, setDateQuery] = useState({ + from: defaultDateRange.from?.toISOString() ?? '', + to: defaultDateRange.to?.toISOString() ?? '', + search: '', + }); + const language = params?.lng as string; + + const chartData: ChartData[] = useMemo(() => { + const chartItems = ['단기연체', '노무비체불', '세금체납', '채무불이행', '행정처분', '당좌거래정지', '기업회생', '휴/폐업']; + const colors = [ + '#22c55e', + '#6dd85d', + '#b4e85b', + '#f0f06d', + '#fce46d', + '#fcb36d', + '#f98b6d', + '#ef4444', + ]; + + return chartItems.map((item, index) => ({ + name: item, + count: counts[item] ?? 0, + color: colors[index], + })); + }, [counts]); + const chartConfig: ChartConfig = { + count: { + label: '건수', + }, + }; + + const fetchAllCounts = useCallback(async () => { + if (!dateQuery.from || !dateQuery.to) { + return; + } + + setIsLoading(true); + try { + const countPromises = targetValues.map(async (targetValue) => { + const filters = [ + { + id: 'eventType', + value: targetValue, + type: 'text', + operator: 'iLike', + rowId: '', + } + ]; + + const searchParams = { + filters, + joinOperator: 'and', + from: dateQuery.from, + to: dateQuery.to, + search: dateQuery.search, + flags: [], + page: 1, + perPage: 10, + sort: [{ id: 'createdAt', desc: true }], + }; + + const { count } = await getRisksViewCount(searchParams as any); + return { targetValue, count }; + }); + + const results = await Promise.all(countPromises); + const newCounts: CountData = {}; + results.forEach(({ targetValue, count }) => { + newCounts[targetValue] = count; + }); + + setCounts(newCounts); + } catch (error) { + console.error('리스크 데이터 개수 조회에 실패했어요:', error); + const resetCounts: CountData = {}; + targetValues.forEach(value => { + resetCounts[value] = 0; + }); + setCounts(resetCounts); + } finally { + setIsLoading(false); + } + }, [dateQuery, targetValues]); + + useEffect(() => { + const urlParams = new URLSearchParams(searchParams?.toString()); + const from = urlParams.get('from') ?? defaultDateRange.from?.toISOString() ?? ''; + const to = urlParams.get('to') ?? defaultDateRange.to?.toISOString() ?? ''; + const search = urlParams.get('search') ?? ''; + + setDateQuery((prev) => { + if (prev.from === from && prev.to === to && prev.search === search) { + return prev; + } + return { from, to, search }; + }); + }, [searchParams, defaultDateRange]); + + useEffect(() => { + fetchAllCounts(); + }, [fetchAllCounts]); + + const handleButtonClick = useCallback((targetValue: string) => { + const newFilters = [ + { + id: 'eventType', + value: targetValue, + type: 'text', + operator: 'iLike', + rowId: '', + } + ]; + + const newUrlParams = new URLSearchParams(searchParams?.toString()); + newUrlParams.set('filters', JSON.stringify(newFilters)); + + const baseUrl = `/${language}/evcp/risk-management`; + const fullUrl = `${baseUrl}?${newUrlParams.toString()}`; + const decodedUrl = decodeURIComponent(fullUrl); + + router.push(decodedUrl); + router.refresh(); + }, [searchParams, language, router]); + + return ( +
+
+ {targetValues.map((targetValue) => ( +
+
+
{targetValue}
+ +
+
+ ))} +
+ + + 주요 리스크 현황 + + + {chartData.filter(item => item.count > 0).length === 0 ? ( +
+ 주요 리스크가 존재하지 않아요. +
+ ) : ( + + item.count > 0)} + layout="vertical" + margin={{ left: 30, right: 30 }} + > + + + } + formatter={(value) => [`${value}건`]} + /> + + [`${value}건`]} + /> + {chartData.map((entry, index) => ( + + ))} + + + + )} +
+
+
+ ); +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksDashboard; diff --git a/lib/risk-management/table/risks-date-range-picker.tsx b/lib/risk-management/table/risks-date-range-picker.tsx new file mode 100644 index 00000000..96acff6c --- /dev/null +++ b/lib/risk-management/table/risks-date-range-picker.tsx @@ -0,0 +1,157 @@ +'use client'; + +/* IMPORT */ +import { format } from 'date-fns'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { type ComponentPropsWithoutRef, type MouseEvent, useMemo } from 'react'; +import { type DateRange } from 'react-day-picker'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksDateRangePickerProps extends ComponentPropsWithoutRef { + defaultDateRange?: DateRange; + placeholder?: string; + triggerVariant?: Exclude; + triggerSize?: Exclude; + triggerClassName?: string; + shallow?: boolean; + showClearButton?: boolean; +} + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS DATE RANGE PICKER COMPONENT */ +function RisksDateRangePicker(props: RisksDateRangePickerProps) { + const { + defaultDateRange, + placeholder = '날짜를 선택하세요.', + triggerVariant = 'outline', + triggerSize = 'default', + triggerClassName, + showClearButton = false, + shallow = true, + className, + ...otherProps + } = props; + const [dateParams, setDateParams] = useQueryStates( + { + from: parseAsString.withDefault( + defaultDateRange?.from?.toISOString() ?? '' + ), + to: parseAsString.withDefault(defaultDateRange?.to?.toISOString() ?? ''), + }, + { + clearOnDefault: true, + shallow, + } + ) + + const date = useMemo(() => { + function parseDate(dateString: string | null) { + if (!dateString) { + return undefined; + } + const parsedDate = new Date(dateString); + return isNaN(parsedDate.getTime()) ? undefined : parsedDate; + } + + return { + from: parseDate(dateParams.from) ?? defaultDateRange?.from, + to: parseDate(dateParams.to) ?? defaultDateRange?.to, + }; + }, [dateParams, defaultDateRange]); + + const clearDates = (e: MouseEvent) => { + e.stopPropagation(); + void setDateParams({ + from: "", + to: "", + }); + }; + + const hasSelectedDate = Boolean(date?.from || date?.to); + + return ( +
+ + + + )} + + + + { + const from = newDateRange?.from; + const to = newDateRange?.to; + + if (from && to && from > to) { + void setDateParams({ + from: to.toISOString(), + to: from.toISOString(), + }); + } else { + void setDateParams({ + from: from?.toISOString() ?? '', + to: to?.toISOString() ?? '', + }); + } + }} + numberOfMonths={2} + /> + + +
+ ) +} + +// ---------------------------------------------------------------------------------------------------- + +export default RisksDateRangePicker; diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx new file mode 100644 index 00000000..8bee1191 --- /dev/null +++ b/lib/risk-management/table/risks-mail-dialog.tsx @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ChevronsUpDown, X } from 'lucide-react'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from '@/components/ui/dropzone'; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from '@/components/ui/file-list'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { format } from 'date-fns'; +import { getProcurementManagerList, modifyRiskEvents } from '../service'; +import { RISK_ADMIN_COMMENTS_LIST } from '@/config/risksConfig'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { type RisksView, type User } from '@/db/schema'; +import { useForm } from 'react-hook-form'; +import { useEffect, useMemo, useState, useTransition } from 'react'; +import UserComboBox from './user-combo-box'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { se } from 'date-fns/locale'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +const risksMailFormSchema = z.object({ + managerId: z.number({ required_error: '구매 담당자를 반드시 선택해야 해요.' }), + adminComment: z.string().min(1, { message: '구매 담당자 의견을 반드시 작성해야 해요.' }), + attachment: z + .instanceof(File) + .refine((file) => file.size <= 10485760, { + message: '파일 크기는 10MB를 초과할 수 없어요.', + }) + .optional(), +}); + +type RisksMailFormData = z.infer; + +interface RisksMailDialogProps { + open: boolean, + onOpenChange: (open: boolean) => void, + riskDataList: RisksView[], + onSuccess: () => void, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* CONSTATNS */ +const ALWAYS_CHECKED_TYPES = ['종합등급', '신용등급', '현금흐름등급', 'WATCH등급']; + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS MAIL DIALOG COPONENT */ +function RisksMailDialog(props: RisksMailDialogProps) { + const { open, onOpenChange, riskDataList, onSuccess } = props; + const riskDataMap = useMemo(() => { + return riskDataList.reduce((acc, item) => { + if (!acc[item.vendorId]) { + acc[item.vendorId] = []; + } + acc[item.vendorId].push(item); + return acc; + }, {} as Record); + }, [riskDataList]); + const [isPending, startTransition] = useTransition(); + const form = useForm({ + resolver: zodResolver(risksMailFormSchema), + defaultValues: { + managerId: undefined, + adminComment: '', + attachment: undefined, + }, + }); + const selectedFile = form.watch('attachment'); + const [selectedVendorId, setSelectedVendorId] = useState(riskDataList[0]?.vendorId ?? null); + const [selectedCommentType, setSelectedCommentType] = useState(''); + const [managerList, setManagerList] = useState[]>([]); + const [isLoadingManagerList, setIsLoadingManagerList] = useState(false); + const [riskCheckMap, setRiskCheckMap] = useState>({}); + + useEffect(() => { + if (!selectedVendorId) { + return; + } + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + + const initialRiskCheckMap: Record = {}; + Object.keys(eventTypeMap).forEach((type) => { + initialRiskCheckMap[type] = true; + }); + + setRiskCheckMap(initialRiskCheckMap); + setSelectedCommentType('기타'); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + }, [open, selectedVendorId]); + + useEffect(() => { + if (open) { + startTransition(async () => { + try { + setIsLoadingManagerList(true); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + setSelectedCommentType('기타'); + const managerList = await getProcurementManagerList(); + setManagerList(managerList); + } catch (error) { + console.error('Error in Loading Risk Event for Managing:', error); + toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했어요.'); + } finally { + setIsLoadingManagerList(false); + } + }); + } + }, [open, form]); + + const formatBusinessNumber = (numberString: string) => { + if (!numberString) { + return '정보 없음'; + } + return /^\d{10}$/.test(numberString) + ? `${numberString.slice(0, 3)}-${numberString.slice(3, 5)}-${numberString.slice(5)}` + : numberString; + }; + + const handleCheckboxChange = (type: string) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + setRiskCheckMap(prev => ({ + ...prev, + [type]: !prev[type], + })); + }; + + const handleFileChange = (files: File[]) => { + if (files.length === 0) { + return; + } + const file = files[0]; + const maxFileSize = 10 * 1024 * 1024 + if (file.size > maxFileSize) { + toast.error('파일 크기는 10MB를 초과할 수 없어요.'); + return; + } + form.setValue('attachment', file); + form.clearErrors('attachment'); + } + + const removeFile = () => { + form.resetField('attachment'); + } + + const onSubmit = async (data: RisksMailFormData) => { + startTransition(async () => { + try { + if (!selectedVendorId) { + throw Error('선택된 협력업체가 존재하지 않아요.'); + } + + const newRiskEventData = { + managerId: data.managerId , + adminComment: data.adminComment, + }; + + await Promise.all( + (riskDataMap[selectedVendorId] ?? []).map(riskEvent => + modifyRiskEvents(riskEvent.id, newRiskEventData) + ) + ); + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + const filteredEventTypeMap: Record = {}; + Object.entries(eventTypeMap).forEach(([type, items]) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + if (riskCheckMap[type]) { + filteredEventTypeMap[type] = items; + } + }); + + const formData = new FormData(); + formData.append('vendorId', String(selectedVendorId)); + formData.append('managerId', String(data.managerId)); + formData.append('adminComment', data.adminComment); + if (data.attachment) { + formData.append('attachment', data.attachment); + } + formData.append('selectedEventTypeMap', JSON.stringify(filteredEventTypeMap)); + + const res = await fetch('/api/risks/send-risk-email', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했어요.'); + } + + toast.success('리스크 알림 메일이 구매 담당자에게 발송되었어요.'); + onSuccess(); + } catch (error) { + console.error('Error in Saving Risk Event:', error); + toast.error( + error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했어요.', + ); + } + }) + } + + if (!open) { + return null; + } + + return ( + + + + + 리스크 알림 메일 발송 + + + 구매 담당자에게 리스크 알림 메일을 발송합니다. + + + setSelectedVendorId(value ? Number(value) : null)} + className="mb-4" + > + + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + + {items[0].vendorName} + + ))} + + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + +
+ + +
+ + + 협력업체 정보 + + +
+
+ 협력업체명: + {items[0].vendorName ?? '정보 없음'} +
+
+ 사업자등록번호: + {formatBusinessNumber(items[0].businessNumber ?? '')} +
+
+ 협력업체 코드: + {items[0].vendorCode ?? '정보 없음'} +
+
+
+
+ + + 리스크 정보 + 메일로 전송할 리스크 정보를 선택하세요. + + + {Object.entries( + items?.reduce>((acc, item) => { + if (!acc[item.eventType]) acc[item.eventType] = []; + acc[item.eventType].push(item); + return acc; + }, {}) || {} + ).map(([eventType, groupedItems]) => ( + +
+
+ handleCheckboxChange(eventType)} + /> + {eventType} +
+ + + +
+ + {/* Table로 변경할 것 */} +
+
신용평가사
+
상세 내용
+
발생일자
+
+ {groupedItems.map(item => ( +
+ {item.provider} +
{item.content}
+
{format(item.occuredAt, 'yyyy-MM-dd')}
+
+ ))} +
+
+ ))} +
+
+ + + 메일 발송 정보 + + +
+ ( + + 구매 담당자 + + + + + + )} + /> +
+ + 관리 담당자 의견 + + + + + {selectedCommentType === '기타' && ( + ( + + +