summaryrefslogtreecommitdiff
path: root/lib/risk-management
diff options
context:
space:
mode:
Diffstat (limited to 'lib/risk-management')
-rw-r--r--lib/risk-management/repository.ts123
-rw-r--r--lib/risk-management/service.ts636
-rw-r--r--lib/risk-management/table/risks-columns.tsx352
-rw-r--r--lib/risk-management/table/risks-dashboard.tsx244
-rw-r--r--lib/risk-management/table/risks-date-range-picker.tsx157
-rw-r--r--lib/risk-management/table/risks-mail-dialog.tsx560
-rw-r--r--lib/risk-management/table/risks-table-toolbar-actions.tsx161
-rw-r--r--lib/risk-management/table/risks-table.tsx176
-rw-r--r--lib/risk-management/table/risks-update-sheet.tsx406
-rw-r--r--lib/risk-management/table/user-combo-box.tsx127
-rw-r--r--lib/risk-management/validations.ts69
11 files changed, 3011 insertions, 0 deletions
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<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ 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<any, any, any>,
+ 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<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ 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<any, any, any>,
+ data: NewRiskEvents,
+) {
+ const [insertRes] = await tx
+ .insert(riskEvents)
+ .values(data)
+ .returning();
+
+ return insertRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* UPDATE RISK EVENTS TRANSACTION */
+async function updateRiskEvents(
+ tx: PgTransaction<any, any, any>,
+ eventId: number,
+ data: Partial<RiskEvents>,
+) {
+ 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<number> {
+ 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<RiskEvents>,
+) {
+ 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<string, RisksView[]>,
+ 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<string, string> = {};
+
+ 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}" <dujin.kim@dtsolution.co.kr>`,
+ 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<ArrayBuffer> {
+ 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<SetStateAction<DataTableRowAction<RisksView> | null>>,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING COLUMNS SETTING */
+function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RisksView>[] {
+
+ // [1] SELECT COLUMN - CHECKBOX
+ const selectColumn: ColumnDef<RisksView> = {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="select-all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="select-row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ };
+
+ // [2] SOURCE COLUMNS
+ const sourceColumns: ColumnDef<RisksView>[] = [
+ {
+ accessorKey: 'eventType',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="항목" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('eventType');
+ return (
+ <Badge variant="default">
+ {value}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Risk Category',
+ group: 'Risk Information',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'vendorCode',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('vendorCode');
+ return (
+ <div className="font-regular">
+ {value}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Vendor Code',
+ group: 'Risk Information',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'vendorName',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('vendorName');
+ return (
+ <div className="font-regular">
+ {value}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Vendor Name',
+ group: 'Risk Information',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'businessNumber',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사업자등록번호" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('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 (
+ <div className="font-regular">
+ {formattedValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Business Number',
+ group: 'Risk Information',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'provider',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="신용평가사" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('provider');
+ return (
+ <Badge variant="secondary">
+ {value}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Provider',
+ group: 'Risk Information',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'content',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상세 내용" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('content') ?? '-';
+ return (
+ <div className="font-regular max-w-[150px] truncate" title={value}>
+ {value}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Risk Content',
+ group: 'Risk Information',
+ type: 'text',
+ },
+ size: 100,
+ },
+ {
+ accessorKey: 'occuredAt',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="발생일시" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue<Date>('occuredAt');
+ return (
+ <div className="font-regular">
+ {date ? new Date(date).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Occured At',
+ group: 'Risk Information',
+ type: 'date',
+ },
+ },
+ ];
+
+ // [3] INPUT COLUMNS
+ const inputColumns: ColumnDef<RisksView>[] = [
+ {
+ accessorKey: 'eventStatus',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="리스크 해소 여부" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<boolean>('eventStatus');
+
+ if (value) {
+ return (
+ <div className="flex items-center gap-2 text-destructive font-bold">
+ <CircleX size={20} strokeWidth={2} />
+ 아니오
+ </div>
+ );
+ }
+ return (
+ <div className="flex items-center gap-2 text-primary font-bold">
+ <CircleCheckBig size={20} strokeWidth={2} />
+ 예
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Risk Not Cleared',
+ group: 'Risk Management',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'managerName',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구매 담당자" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('managerName') ?? '-';
+ return (
+ <div className="font-regular">
+ {value}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Procurement Manager Name',
+ group: 'Risk Management',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'adminComment',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="관리 담당자 의견" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('adminComment') ?? '-';
+ return (
+ <div className="font-regular max-w-[150px] truncate" title={value}>
+ {value}
+ </div>
+ );
+ },
+ 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<RisksView> = {
+ id: 'actions',
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-60">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ className="cursor-pointer"
+ >
+ <OctagonAlert className="mr-2 size-4" />
+ 리스크 관리
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 신용정보 관리화면으로 이동
+ }}
+ className="cursor-pointer"
+ >
+ <Handshake className="mr-2 size-4" />
+ 신용정보 확인
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ 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<CountData>({});
+ 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 (
+ <div className="flex items-center justify-center gap-16">
+ <div className="w-3/5 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-16 gap-y-4 p-8">
+ {targetValues.map((targetValue) => (
+ <div key={targetValue} className="w-full">
+ <div className="w-32 flex flex-col items-center justify-center gap-2">
+ <div className="font-bold">{targetValue}</div>
+ <Button
+ className="w-full h-12 text-lg font-bold"
+ onClick={() => handleButtonClick(targetValue)}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <LoaderCircle width={16} className="animate-spin" />
+ ) : (
+ <>
+ {counts[targetValue] ?? 0}건
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ <Card className="w-1/3">
+ <CardHeader>
+ <CardTitle className="text-lg font-semibold">주요 리스크 현황</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {chartData.filter(item => item.count > 0).length === 0 ? (
+ <div className="flex items-center justify-center h-[300px] text-gray-500">
+ 주요 리스크가 존재하지 않아요.
+ </div>
+ ) : (
+ <ChartContainer config={chartConfig} className="h-[300px]">
+ <BarChart
+ data={chartData.filter(item => item.count > 0)}
+ layout="vertical"
+ margin={{ left: 30, right: 30 }}
+ >
+ <XAxis type="number" dataKey="count" hide />
+ <YAxis
+ dataKey="name"
+ type="category"
+ tickLine={false}
+ tickMargin={10}
+ axisLine={false}
+ />
+ <ChartTooltip
+ content={<ChartTooltipContent className="flex justify-center" />}
+ formatter={(value) => [`${value}건`]}
+ />
+ <Bar dataKey="count" radius={[8, 8, 8, 8]}>
+ <LabelList
+ position="right"
+ offset={12}
+ className="fill-foreground"
+ fontSize={12}
+ formatter={(value: ValueType) => [`${value}건`]}
+ />
+ {chartData.map((entry, index) => (
+ <Cell key={`cell-${index}`} fill={entry.color} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ChartContainer>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* 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<typeof PopoverContent> {
+ defaultDateRange?: DateRange;
+ placeholder?: string;
+ triggerVariant?: Exclude<ButtonProps['variant'], 'destructive' | 'link'>;
+ triggerSize?: Exclude<ButtonProps['size'], 'icon'>;
+ 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 (
+ <div className="grid gap-2">
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant={triggerVariant}
+ size={triggerSize}
+ className={cn(
+ 'relative w-full justify-start gap-2 truncate text-left font-normal',
+ !date && 'text-muted-foreground',
+ triggerClassName
+ )}
+ >
+ <CalendarIcon className="size-4" />
+ {date?.from ? (
+ date.to ? (
+ <>
+ {format(date.from, 'LLL dd, y')} -{' '}
+ {format(date.to, 'LLL dd, y')}
+ </>
+ ) : (
+ format(date.from, 'LLL dd, y')
+ )
+ ) : (
+ <span>{placeholder}</span>
+ )}
+
+ {showClearButton && hasSelectedDate && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full rounded-l-none px-3 hover:bg-background"
+ onClick={clearDates}
+ >
+ <X className="size-4" />
+ <span className="sr-only">초기화</span>
+ </Button>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className={cn("w-auto p-0", className)} {...otherProps}>
+ <Calendar
+ initialFocus
+ mode="range"
+ defaultMonth={date?.from}
+ selected={date?.from && date?.to ? date : undefined}
+ onSelect={(newDateRange) => {
+ 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}
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+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<typeof risksMailFormSchema>;
+
+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<string, typeof riskDataList>);
+ }, [riskDataList]);
+ const [isPending, startTransition] = useTransition();
+ const form = useForm<RisksMailFormData>({
+ resolver: zodResolver(risksMailFormSchema),
+ defaultValues: {
+ managerId: undefined,
+ adminComment: '',
+ attachment: undefined,
+ },
+ });
+ const selectedFile = form.watch('attachment');
+ const [selectedVendorId, setSelectedVendorId] = useState<number | null>(riskDataList[0]?.vendorId ?? null);
+ const [selectedCommentType, setSelectedCommentType] = useState('');
+ const [managerList, setManagerList] = useState<Partial<User>[]>([]);
+ const [isLoadingManagerList, setIsLoadingManagerList] = useState(false);
+ const [riskCheckMap, setRiskCheckMap] = useState<Record<string, boolean>>({});
+
+ useEffect(() => {
+ if (!selectedVendorId) {
+ return;
+ }
+
+ const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce<Record<string, RisksView[]>>((acc, item) => {
+ if (!acc[item.eventType]) {
+ acc[item.eventType] = [];
+ }
+ acc[item.eventType].push(item);
+ return acc;
+ }, {});
+
+ const initialRiskCheckMap: Record<string, boolean> = {};
+ 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<Record<string, RisksView[]>>((acc, item) => {
+ if (!acc[item.eventType]) {
+ acc[item.eventType] = [];
+ }
+ acc[item.eventType].push(item);
+ return acc;
+ }, {});
+ const filteredEventTypeMap: Record<string, RisksView[]> = {};
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="font-bold">
+ 리스크 알림 메일 발송
+ </DialogTitle>
+ <DialogDescription>
+ 구매 담당자에게 리스크 알림 메일을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <Tabs
+ value={selectedVendorId !== null ? String(selectedVendorId) : undefined}
+ onValueChange={(value) => setSelectedVendorId(value ? Number(value) : null)}
+ className="mb-4"
+ >
+ <TabsList>
+ {Object.entries(riskDataMap).map(([vendorId, items]) => (
+ <TabsTrigger key={vendorId} value={vendorId}>
+ {items[0].vendorName}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ {Object.entries(riskDataMap).map(([vendorId, items]) => (
+ <TabsContent key={vendorId} value={vendorId} className="overflow-y-auto max-h-[60vh]">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ <ScrollArea className="flex-1 pr-4 overflow-y-auto">
+ <div className="space-y-6">
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>협력업체 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4 text-sm text-muted-foreground">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <span className="font-medium text-foreground">협력업체명: </span>
+ {items[0].vendorName ?? '정보 없음'}
+ </div>
+ <div>
+ <span className="font-medium text-foreground">사업자등록번호: </span>
+ {formatBusinessNumber(items[0].businessNumber ?? '')}
+ </div>
+ <div>
+ <span className="font-medium text-foreground">협력업체 코드: </span>
+ {items[0].vendorCode ?? '정보 없음'}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>리스크 정보</CardTitle>
+ <CardDescription>메일로 전송할 리스크 정보를 선택하세요.</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {Object.entries(
+ items?.reduce<Record<string, typeof items>>((acc, item) => {
+ if (!acc[item.eventType]) acc[item.eventType] = [];
+ acc[item.eventType].push(item);
+ return acc;
+ }, {}) || {}
+ ).map(([eventType, groupedItems]) => (
+ <Collapsible key={eventType} defaultOpen={false} className="rounded-md border gap-2 p-4 text-sm">
+ <div className="flex items-center justify-between gap-4 px-4">
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={riskCheckMap[eventType]}
+ disabled={ALWAYS_CHECKED_TYPES.includes(eventType)}
+ onCheckedChange={() => handleCheckboxChange(eventType)}
+ />
+ <span className="text-sm font-semibold">{eventType}</span>
+ </div>
+ <CollapsibleTrigger className="flex justify-between items-center">
+ <Button type="button" variant="ghost" size="icon" className="size-8">
+ <ChevronsUpDown />
+ </Button>
+ </CollapsibleTrigger>
+ </div>
+ <CollapsibleContent>
+ {/* Table로 변경할 것 */}
+ <div className="flex items-center justify-between rounded-md border my-2 px-4 py-2 text-sm">
+ <div className="font-bold">신용평가사</div>
+ <div className="font-bold">상세 내용</div>
+ <div className="font-bold">발생일자</div>
+ </div>
+ {groupedItems.map(item => (
+ <div key={item.id} className="flex items-center justify-between rounded-md border gap-2 px-4 py-2 text-sm">
+ <Badge variant="secondary">{item.provider}</Badge>
+ <div className="text-sm text-muted-foreground">{item.content}</div>
+ <div>{format(item.occuredAt, 'yyyy-MM-dd')}</div>
+ </div>
+ ))}
+ </CollapsibleContent>
+ </Collapsible>
+ ))}
+ </CardContent>
+ </Card>
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>메일 발송 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="managerId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매 담당자</FormLabel>
+ <UserComboBox
+ users={managerList}
+ value={field.value ?? null}
+ onChange={field.onChange}
+ placeholder={isLoadingManagerList ? '구매 담당자 로딩 중...' : '구매 담당자 선택...'}
+ disabled={isPending || isLoadingManagerList}
+ />
+ <FormControl>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div className="flex flex-col gap-4">
+ <FormItem>
+ <FormLabel>관리 담당자 의견</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={(value) => {
+ setSelectedCommentType(value);
+ if (value !== '기타') {
+ form.setValue('adminComment', value);
+ } else {
+ form.setValue('adminComment', '');
+ }
+ }}
+ value={selectedCommentType}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="의견 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {RISK_ADMIN_COMMENTS_LIST.map((comment) => (
+ <SelectItem key={comment} value={comment}>
+ {comment}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ </FormItem>
+ {selectedCommentType === '기타' && (
+ <FormField
+ control={form.control}
+ name="adminComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Textarea
+ placeholder="관리 담당자 의견을 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </div>
+ </div>
+ <FormField
+ control={form.control}
+ name="attachment"
+ render={() => (
+ <FormItem>
+ <FormLabel>첨부파일</FormLabel>
+ <FormControl>
+ <div className="space-y-3">
+ <Dropzone
+ onDrop={(acceptedFiles) => {
+ handleFileChange(acceptedFiles)
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar']
+ }}
+ maxSize={10 * 1024 * 1024}
+ multiple={false}
+ disabled={isPending}
+ >
+ <DropzoneZone className="flex flex-col items-center gap-2">
+ <DropzoneUploadIcon />
+ <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle>
+ <DropzoneDescription>
+ PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개)
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ {selectedFile && (
+ <div className="space-y-2">
+ <FileListHeader>
+ 선택된 파일
+ </FileListHeader>
+ <FileList>
+ <FileListItem className="flex items-center justify-between gap-3">
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{selectedFile.name}</FileListName>
+ <FileListDescription>
+ <FileListSize>{selectedFile.size}</FileListSize>
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction
+ onClick={removeFile}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </FileListAction>
+ </FileListItem>
+ </FileList>
+ </div>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+ <DialogFooter className="flex-shrink-0 mt-4 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || isLoadingManagerList}>
+ {isLoadingManagerList ? '로딩 중...' : isPending ? '저장 중...' : '메일 발송'}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </TabsContent>
+ ))}
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RisksMailDialog;
diff --git a/lib/risk-management/table/risks-table-toolbar-actions.tsx b/lib/risk-management/table/risks-table-toolbar-actions.tsx
new file mode 100644
index 00000000..2d4ba2d4
--- /dev/null
+++ b/lib/risk-management/table/risks-table-toolbar-actions.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import { ChangeEvent, useRef } from 'react';
+import { Download, FileInput, Mail, Upload } from 'lucide-react';
+import { exportTableToExcel } from '@/lib/export';
+import { generateRiskEventsTemplate, importRiskEventsExcel } from '../service';
+import { toast } from 'sonner';
+import { type DataTableRowAction } from '@/types/table';
+import { type RisksView } from '@/db/schema';
+import { type Table } from '@tanstack/react-table';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RisksTableToolbarActionsProps {
+ table: Table<RisksView>;
+ onOpenMailDialog: () => void;
+ onRefresh: () => void;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* RISKS TABLE TOOLBAR ACTIONS COMPONENT */
+function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) {
+ const { table, onOpenMailDialog, onRefresh } = props;
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const hasSelection = selectedRows.length > 0;
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ // EXCEL IMPORT
+ function handleImport() {
+ fileInputRef.current?.click();
+ };
+ async function onFileChange(event: ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0];
+ if (!file) {
+ toast.error('가져올 파일을 선택해주세요.');
+ return;
+ }
+ if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
+ toast.error('.xlsx 또는 .xls 확장자인 Excel 파일만 업로드 가능해요.');
+ return;
+ }
+ event.target.value = '';
+
+ try {
+ const { errorFile, errorMessage, successMessage } = await importRiskEventsExcel(file);
+
+ if (errorMessage) {
+ toast.error(errorMessage);
+
+ if (errorFile) {
+ const url = URL.createObjectURL(errorFile);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'errors.xlsx';
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+ } else {
+ toast.success(successMessage || 'Excel 파일이 성공적으로 업로드되었어요.');
+ }
+ } catch (error) {
+ toast.error('Excel 파일을 업로드하는 중 오류가 발생했습니다.');
+ console.error('Error in Excel File Upload: ', error);
+ } finally {
+ onRefresh();
+ }
+ };
+
+ // EXCEL EXPORT
+ const handleExport = async () => {
+ try {
+ exportTableToExcel(table, {
+ filename: '협력업체_리스크_관리',
+ excludeColumns: ['id', 'actions'],
+ });
+ toast.success('Excel 파일이 다운로드되었어요.');
+ } catch (error) {
+ console.error('Error in Exporting to Excel: ', error);
+ toast.error('Excel 파일 내보내기 중 오류가 발생했어요.');
+ }
+ };
+
+ // EXCEL TEMPLATE DOWNLOAD
+ const handleTemplateDownload = async () => {
+ try {
+ const buffer = await generateRiskEventsTemplate();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = "협력업체_리스크_템플릿.xlsx";
+ link.click();
+ URL.revokeObjectURL(url);
+ toast.success('템플릿 파일이 다운로드되었어요.');
+ } catch (error) {
+ console.error('Error in Template Download: ', error);
+ toast.error('템플릿 다운로드 중 오류가 발생했어요.');
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ size="sm"
+ className="gap-2"
+ onClick={onOpenMailDialog}
+ disabled={!hasSelection}
+ >
+ <Mail className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 메일 발송
+ </span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={handleImport}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTemplateDownload}
+ className="gap-2"
+ >
+ <FileInput className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Template</span>
+ </Button>
+ </div>
+ );
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RisksTableToolbarActions; \ No newline at end of file
diff --git a/lib/risk-management/table/risks-table.tsx b/lib/risk-management/table/risks-table.tsx
new file mode 100644
index 00000000..d6317c26
--- /dev/null
+++ b/lib/risk-management/table/risks-table.tsx
@@ -0,0 +1,176 @@
+'use client';
+
+/* IMPORT */
+import { DataTable } from '@/components/data-table/data-table';
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar';
+import getColumns from './risks-columns';
+import { getRisksView } from '../service';
+import { type RisksView } from '@/db/schema';
+import RisksMailDialog from './risks-mail-dialog';
+import RisksTableToolbarActions from './risks-table-toolbar-actions';
+import RisksUpdateSheet from './risks-update-sheet';
+import {
+ type DataTableFilterField,
+ type DataTableRowAction,
+ type DataTableAdvancedFilterField,
+} from '@/types/table';
+import { useDataTable } from '@/hooks/use-data-table';
+import { use, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { RISK_EVENT_TYPE_LIST, RISK_PROVIDER_LIST } from '@/config/risksConfig';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RisksTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getRisksView>>,
+ ]>;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TABLE COMPONENT */
+function RisksTable({ promises }: RisksTableProps) {
+ const router = useRouter();
+ const [rowAction, setRowAction] = useState<DataTableRowAction<RisksView> | null>(null);
+ const [isMailDialogOpen, setIsMailDialogOpen] = useState(false);
+ const [promiseData] = use(promises);
+ const tableData = promiseData;
+ const columns = useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction],
+ );
+
+ const filterFields: DataTableFilterField<RisksView>[] = [
+ {
+ id: 'eventType',
+ label: '리스크 항목',
+ placeholder: '리스크 항목 선택...',
+ },
+ {
+ id: 'vendorName',
+ label: '협력업체명',
+ placeholder: '협력업체명 입력...',
+ },
+ {
+ id: 'businessNumber',
+ label: '사업자등록번호',
+ placeholder: '사업자등록번호 입력...',
+ },
+ {
+ id: 'provider',
+ label: '신용평가사',
+ placeholder: '신용평가사 선택...',
+ },
+ ]
+ const advancedFilterFields: DataTableAdvancedFilterField<RisksView>[] = [
+ {
+ id: 'eventType',
+ label: '리스크 항목',
+ type: 'select',
+ options: RISK_EVENT_TYPE_LIST.map((item: string) => ({
+ label: item,
+ value: item,
+ })),
+ },
+ {
+ id: 'provider',
+ label: '신용평가사',
+ type: 'select',
+ options: RISK_PROVIDER_LIST.map((item: string) => ({
+ label: item,
+ value: item,
+ })),
+ },
+ {
+ id: 'vendorName',
+ label: '협력업체명',
+ type: 'text',
+ },
+ {
+ id: 'content',
+ label: '상세 내용',
+ type: 'text',
+ },
+ ];
+
+ // Data Table Setting
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [
+ { id: 'occuredAt', desc: true },
+ ],
+ columnPinning: { left: ['select'], right: ['actions'] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ const emptyRiskData: RisksView = {
+ id: 0,
+ vendorName: '',
+ vendorCode: '',
+ vendorId: 0,
+ businessNumber: '',
+ provider: '',
+ eventType: '',
+ content: '',
+ eventStatus: true,
+ managerId: 0,
+ managerName: '',
+ adminComment: '',
+ occuredAt: new Date(),
+ };
+
+ const refreshData = useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ const handleModifySuccess = useCallback(() => {
+ setRowAction(null);
+ refreshData();
+ }, [refreshData]);
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RisksTableToolbarActions
+ table={table}
+ onOpenMailDialog={() => setIsMailDialogOpen(true)}
+ onRefresh={refreshData}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <RisksUpdateSheet
+ open={rowAction?.type === 'update'}
+ onOpenChange={() => setRowAction(null)}
+ riskData={rowAction?.row.original ?? emptyRiskData}
+ onSuccess={handleModifySuccess}
+ />
+ <RisksMailDialog
+ open={isMailDialogOpen}
+ onOpenChange={setIsMailDialogOpen}
+ riskDataList={table.getFilteredSelectedRowModel().rows?.map(row => row.original) ?? []}
+ onSuccess={refreshData}
+ />
+ </>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RisksTable; \ No newline at end of file
diff --git a/lib/risk-management/table/risks-update-sheet.tsx b/lib/risk-management/table/risks-update-sheet.tsx
new file mode 100644
index 00000000..727a7634
--- /dev/null
+++ b/lib/risk-management/table/risks-update-sheet.tsx
@@ -0,0 +1,406 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import { Calendar } from '@/components/ui/calendar';
+import { CalendarIcon } from 'lucide-react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { format } from 'date-fns';
+import { getProcurementManagerList, getRiskEventsById, modifyRiskEvents } from '../service';
+import { ko } from 'date-fns/locale';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { RISK_ADMIN_COMMENTS_LIST, RISK_EVENT_TYPE_LIST, RISK_PROVIDER_LIST } from '@/config/risksConfig';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { type RisksView, type User } from '@/db/schema';
+import { useEffect, useState, useTransition } from 'react';
+import { useForm } from 'react-hook-form';
+import UserComboBox from './user-combo-box';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+const risksUpdateFormSchema = z.object({
+ eventType: z.enum(RISK_EVENT_TYPE_LIST as [string, ...string[]]),
+ provider: z.enum(RISK_PROVIDER_LIST as [string, ...string[]]),
+ occuredAt: z.date(),
+ content: z.string().optional(),
+ eventStatus: z.boolean(),
+ managerId: z.number().optional(),
+ adminComment: z.string().optional(),
+});
+
+type RisksUpdateFormData = z.infer<typeof risksUpdateFormSchema>;
+
+interface RisksUpdateSheetProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ riskData: RisksView,
+ onSuccess: () => void,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* RISKS UPDATE FORM SHEET COMPONENT */
+function RisksUpdateSheet(props: RisksUpdateSheetProps) {
+ const {
+ open,
+ onOpenChange,
+ riskData,
+ onSuccess,
+ } = props;
+ const [isPending, startTransition] = useTransition();
+ const form = useForm<RisksUpdateFormData>({
+ resolver: zodResolver(risksUpdateFormSchema),
+ defaultValues: {
+ eventType: '',
+ provider: '',
+ occuredAt: new Date(),
+ content: '',
+ eventStatus: true,
+ managerId: undefined,
+ adminComment: '',
+ },
+ });
+ const watchEventStatus = form.watch('eventStatus');
+ const [selectedCommentType, setSelectedCommentType] = useState('');
+ const [managerList, setManagerList] = useState<Partial<User>[]>([]);
+ const [isLoadingManagerList, setIsLoadingManagerList] = useState(false);
+
+ useEffect(() => {
+ if (open && riskData?.id) {
+ startTransition(async () => {
+ try {
+ const targetData = await getRiskEventsById(riskData.id);
+ if (targetData) {
+ const targetRiskEvent = targetData[0];
+ form.reset({
+ eventType: targetRiskEvent.eventType,
+ provider: targetRiskEvent.provider,
+ occuredAt: targetRiskEvent.occuredAt,
+ content: targetRiskEvent.content ?? '',
+ eventStatus: targetRiskEvent.eventStatus,
+ managerId: targetRiskEvent.managerId || undefined,
+ adminComment: targetRiskEvent.adminComment ?? '',
+ });
+ setSelectedCommentType(
+ RISK_ADMIN_COMMENTS_LIST.includes(targetRiskEvent.adminComment ?? '') ? targetRiskEvent.adminComment! : '기타',
+ );
+ const managerList = await getProcurementManagerList();
+ setManagerList(managerList);
+ }
+ } catch (error) {
+ console.error('Error in Loading Risk Event for Updating:', error);
+ toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했어요.');
+ } finally {
+ setIsLoadingManagerList(false);
+ }
+ });
+ }
+ }, [open, form]);
+
+ const onSubmit = async (data: RisksUpdateFormData) => {
+ startTransition(async () => {
+ try {
+ const newRiskEventData = {
+ eventType: data.eventType,
+ provider: data.provider,
+ occuredAt: data.occuredAt,
+ content: data.content || null,
+ eventStatus: data.eventStatus,
+ managerId: !data.eventStatus ? null : data.managerId === 0 ? null : data.managerId,
+ adminComment: !data.eventStatus ? null : data.adminComment || null,
+ };
+ await modifyRiskEvents(riskData.id, newRiskEventData);
+ toast.success('리스크 이벤트가 수정되었어요.');
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error in Saving Risk Event:', error);
+ toast.error(
+ error instanceof Error ? error.message : '리스크 이벤트 저장 중 오류가 발생했어요.',
+ );
+ }
+ })
+ }
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, height: '100vh'}}>
+ <SheetHeader className="flex-shrink-0 pb-4 border-b">
+ <SheetTitle className="font-bold">
+ 리스크 정보 관리
+ </SheetTitle>
+ <SheetDescription>
+ 리스크 정보를 수정할 수 있어요.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ <div className="flex-1 overflow-y-auto py-4 min-h-0">
+ <div className="space-y-6 pr-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="eventType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>항목</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {RISK_EVENT_TYPE_LIST.map((option) => (
+ <SelectItem key={option} value={option}>
+ {option}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="provider"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>신용평가사</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {RISK_PROVIDER_LIST.map((option) => (
+ <SelectItem key={option} value={option}>
+ {option}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="occuredAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>발생일자</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value
+ ? format(field.value, "yyyy-MM-dd", { locale: ko })
+ : "날짜 선택"}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value ?? undefined}
+ onSelect={(date) => field.onChange(date || undefined)}
+ locale={ko}
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세 내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 내용을 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>관리 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="eventStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리스크 해소 여부</FormLabel>
+ <FormControl>
+ <Select onValueChange={(value) => field.onChange(value === 'true')} value={String(field.value)}>
+ <SelectTrigger>
+ <SelectValue placeholder="리스크 해소 여부 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="true">아니오</SelectItem>
+ <SelectItem value="false">예</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {watchEventStatus && (
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="managerId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매 담당자</FormLabel>
+ <UserComboBox
+ users={managerList}
+ value={field.value ?? null}
+ onChange={field.onChange}
+ placeholder={isLoadingManagerList ? '구매 담당자 로딩 중...' : '구매 담당자 선택...'}
+ disabled={isPending || isLoadingManagerList}
+ />
+ <FormControl>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div className="flex flex-col gap-4">
+ <FormItem>
+ <FormLabel>관리 담당자 의견</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={(value) => {
+ setSelectedCommentType(value);
+ if (value !== '기타') {
+ form.setValue('adminComment', value);
+ } else {
+ form.setValue('adminComment', '');
+ }
+ }}
+ value={selectedCommentType}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="의견 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {RISK_ADMIN_COMMENTS_LIST.map((comment) => (
+ <SelectItem key={comment} value={comment}>
+ {comment}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ </FormItem>
+ {selectedCommentType === '기타' && (
+ <FormField
+ control={form.control}
+ name="adminComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Textarea
+ placeholder="관리 담당자 의견을 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ <div className="flex-shrink-0 flex justify-end gap-2 bg-background">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? '저장 중...' : '수정'}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RisksUpdateSheet;
diff --git a/lib/risk-management/table/user-combo-box.tsx b/lib/risk-management/table/user-combo-box.tsx
new file mode 100644
index 00000000..e319b538
--- /dev/null
+++ b/lib/risk-management/table/user-combo-box.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { useMemo, useState } from 'react';
+import { User } from '@/db/schema';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface UserComboBoxProps {
+ users: Partial<User>[];
+ value: number | null;
+ onChange: (value: number) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* User Combo Box Component */
+function UserComboBox(props: UserComboBoxProps) {
+ const {
+ users,
+ value,
+ onChange,
+ placeholder = '담당자 선택...',
+ disabled = false,
+ } = props;
+ const [open, setOpen] = useState(false);
+ const [inputValue, setInputValue] = useState('');
+ const selectedUser = useMemo(() => {
+ return users.find(user => user.id === value)
+ }, [users, value]);
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className={cn(
+ "w-full justify-between",
+ !value && "text-muted-foreground"
+ )}
+ disabled={disabled}
+ >
+ {selectedUser ? (
+ <span className="flex items-center">
+ <span className="font-medium">{selectedUser.name}</span>
+ {selectedUser.deptName && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({selectedUser.deptName})
+ </span>
+ )}
+ </span>
+ ) : (
+ placeholder
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="담당자 검색..."
+ value={inputValue}
+ onValueChange={setInputValue}
+ />
+ <CommandEmpty>검색 결과가 존재하지 않아요.</CommandEmpty>
+ <CommandGroup className="max-h-[200px] overflow-y-auto">
+ {users.map((user) => (
+ <CommandItem
+ key={user.id}
+ value={user.name}
+ onSelect={() => {
+ onChange(user.id!)
+ setOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ 'mr-2 h-4 w-4',
+ value === user.id ? 'opacity-100' : 'opacity-0',
+ )}
+ />
+ <div className="flex flex-col truncate">
+ <div className="flex items-center">
+ <span className="font-medium">{user.name}</span>
+ {user.deptName && (
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({user.deptName})
+ </span>
+ )}
+ </div>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default UserComboBox;
diff --git a/lib/risk-management/validations.ts b/lib/risk-management/validations.ts
new file mode 100644
index 00000000..494a0b74
--- /dev/null
+++ b/lib/risk-management/validations.ts
@@ -0,0 +1,69 @@
+/* IMPORT */
+import * as z from 'zod';
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from 'nuqs/server';
+import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers';
+import { RisksView } from '@/db/schema';
+
+// ----------------------------------------------------------------------------------------------------
+
+function getPreviousWeekday(date: Date) {
+ const referenceTime = new Date(date);
+ referenceTime.setHours(6, 0, 0, 0);
+
+ const result = new Date(date);
+ if (date < referenceTime) {
+ result.setDate(result.getDate() - 1);
+ }
+
+ const day = result.getDay();
+ if (day === 1) {
+ result.setDate(result.getDate() - 3);
+ } else if (day === 0) {
+ result.setDate(result.getDate() - 2);
+ } else {
+ result.setDate(result.getDate() - 1);
+ }
+ return result;
+}
+const previousWorkday = getPreviousWeekday(new Date());
+const defaultFrom = new Date(previousWorkday);
+defaultFrom.setHours(0, 0, 0, 0);
+const defaultTo = new Date(previousWorkday);
+defaultTo.setHours(23, 59, 59, 999);
+const defaultFromStr = defaultFrom.toISOString();
+const defaultToStr = defaultTo.toISOString();
+
+/* QUERY PARAMETER SCHEMATA */
+const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RisksView>().withDefault([
+ { id: 'occuredAt', desc: true },
+ ]),
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'),
+ search: parseAsString.withDefault(''),
+ from: parseAsString.withDefault(defaultFromStr),
+ to: parseAsString.withDefault(defaultToStr),
+});
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+type GetRisksSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ getPreviousWeekday,
+ searchParamsCache,
+ type GetRisksSchema,
+}; \ No newline at end of file