summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mail/templates/risks-notification.hbs196
-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
12 files changed, 3207 insertions, 0 deletions
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 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <title>eVCP 메일</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ </head>
+ <body>
+ <table border="0" cellpadding="0" cellspacing="0" width="100%">
+ <tr>
+ <td>
+ <table align="center" cellpadding="0" cellspacing="0" width="600" style="border: 1px solid #cccccc; border-collapse: collapse;">
+ <tr>
+ <td align="center" bgcolor="#003cdc" style="padding: 20px 0 20px 0;">
+ <table border="0" cellpadding="0" cellspacing="0" width="100%">
+ <tr>
+ <td align="center" style="color: #ffffff; font-family: Noto Sans KR, sans-serif; font-size: 24px;">
+ 🚒 <b>eVCP</b>
+ </td>
+ </tr>
+ <tr>
+ <td align="center" style="color: #ffffff; font-family: Noto Sans KR, sans-serif; font-size: 16px;">
+ ν˜‘λ ₯업체 리슀크 μ•Œλ¦Ό 및 관리 μš”μ²­
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
+ <table border="0" cellpadding="0" cellspacing="0" width="100%">
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>{{vendorName}}</b>({{vendorCode}})에 μ‹ μš©ν‰κ°€μƒ λ¦¬μŠ€ν¬κ°€ λ°œμƒν•˜μ˜€μŠ΅λ‹ˆλ‹€.
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ μ£Όμš”μ‚¬ν•­ <b>{{adminComment}}</b>(으)둜 μΆ”μ •λ©λ‹ˆλ‹€.
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ ν˜‘λ ₯μ—…μ²΄λ‘œ ν™•μΈν•˜μ‹œμ–΄ 거래 관계상 재무 리슀크 유무λ₯Ό ν™•μΈν•˜μ‹œκΈ° λ°”λžλ‹ˆλ‹€.
+ </td>
+ </tr>
+ <tr>
+ <td style="padding: 40px 0 40px 0;">
+ <table cellspacing="0" cellpadding="5" width="100%" style="border: 3px solid #cccccc; border-collapse: collapse;">
+ <tr bgcolor="#d9d9d9">
+ <td colspan="4" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 16px;">
+ [<b>ν˜‘λ ₯업체 정보</b>]
+ </td>
+ </tr>
+ <tr style="border: 1px solid #cccccc;">
+ <td bgcolor="#f2f2f2" colspan="2" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>μ‚¬μ—…μžλ“±λ‘λ²ˆν˜Έ</b>
+ </td>
+ <td colspan="2" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{businessNumber}}
+ </td>
+ </tr>
+ <tr>
+ <td bgcolor="#f2f2f2" colspan="2" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>μ½”λ“œ</b>
+ </td>
+ <td colspan="2" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{vendorCode}}
+ </td>
+ </tr>
+ <tr>
+ <td bgcolor="#f2f2f2" colspan="2" align="center" style="border: 1px solid #cccccc; olor: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>업체λͺ…</b>
+ </td>
+ <td colspan="2" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>{{vendorName}}</b>
+ </td>
+ </tr>
+ <tr bgcolor="#d9d9d9">
+ <td colspan="4" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 16px;">
+ [<b>μ‹ μš©λ“±κΈ‰ 정보</b>]
+ </td>
+ </tr>
+ <tr bgcolor="#f2f2f2">
+ <td align="center" width="25%" style="border: 1px solid #cccccc; olor: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>μ’…ν•©λ“±κΈ‰</b>
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; olor: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>μ‹ μš©λ“±κΈ‰</b>
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; olor: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>ν˜„κΈˆνλ¦„λ“±κΈ‰</b>
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; olor: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>WATCHλ“±κΈ‰</b>
+ </td>
+ </tr>
+ <tr>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{ratingTotal}}
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{ratingCredit}}
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{ratingCashflow}}
+ </td>
+ <td align="center" width="25%" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{ratingWatch}}
+ </td>
+ </tr>
+ <tr bgcolor="#d9d9d9">
+ <td colspan="4" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 16px;">
+ [<b>리슀크 정보</b>]
+ </td>
+ </tr>
+ <tr bgcolor="#f2f2f2">
+ <td colspan="1" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>ν•­λͺ©</b>
+ </td>
+ <td colspan="3" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>상세 λ‚΄μš©</b>
+ </td>
+ </tr>
+ {{#if riskItems.length}}
+ {{#each riskItems}}
+ <tr>
+ <td colspan="1" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>{{this.eventType}}</b>
+ </td>
+ <td colspan="3" align="center" style="border: 1px solid #cccccc; color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ {{this.content}}
+ </td>
+ </tr>
+ {{/each}}
+ {{else}}
+ <tr>
+ <td colspan="4" align="center" style="border: 1px solid #cccccc; color: #999999; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ 리슀크 정보가 μ—†μŠ΅λ‹ˆλ‹€.
+ </td>
+ </tr>
+ {{/if}}
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ 상기 내역에 λŒ€ν•΄ λ¬Έμ˜μ‚¬ν•­μ΄ μžˆμ„ 경우 리슀크 관리 λ‹΄λ‹Ήμžμ—κ²Œ 연락 λ°”λžλ‹ˆλ‹€.
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ κ°μ‚¬ν•©λ‹ˆλ‹€.
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td bgcolor="#5bc2e7" style="padding: 20px 20px 20px 20px;">
+ <table border="0" cellspacing="0" cellpadding="0" width="100%">
+ <tr>
+ <td style="color: #000000; font-family: Noto Sans KR, sans-serif; font-size: 14px;">
+ <b>{{senderName}}</b> / Risk Manager /
+ <a href="mailto:{{senderEmail}}" style="color: #000000; text-decoration: none;">{{senderEmail}}</a>
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #212529; font-family: Noto Sans KR, sans-serif; font-size: 12px; line-height: 1;">
+ <b>SAMSUNG HEAVY INDUSTRIES CO., LTD.</b>
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #495057; font-family: Noto Sans KR, sans-serif; font-size: 12px; line-height: 1;">
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </td>
+ </tr>
+ <tr>
+ <td style="color: #495057; font-family: Noto Sans KR, sans-serif; font-size: 12px; line-height: 1;">
+ &nbsp;
+ </td>
+ </tr>
+ <tr>
+ <td align="right" style="color: #495057; font-family: Noto Sans KR, sans-serif; font-size: 12px; line-height: 1;">
+ β“’ 2025. eVCP. All Rights Reserved.
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </body>
+</html>
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