diff options
| author | joonhoekim <26rote@gmail.com> | 2025-08-14 00:26:53 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-08-14 00:26:53 +0000 |
| commit | dd20ba9785cdbd3d61f6b014d003d3bd9646ad13 (patch) | |
| tree | 4e99d62311a6c115dbc894084714a29c34bca11a /lib | |
| parent | 33be47506f0aa62b969d82521580a29e95080268 (diff) | |
(고건) 리스크 관리 페이지 추가
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/mail/templates/risks-notification.hbs | 196 | ||||
| -rw-r--r-- | lib/risk-management/repository.ts | 123 | ||||
| -rw-r--r-- | lib/risk-management/service.ts | 636 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-columns.tsx | 352 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-dashboard.tsx | 244 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-date-range-picker.tsx | 157 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-mail-dialog.tsx | 560 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-table-toolbar-actions.tsx | 161 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-table.tsx | 176 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-update-sheet.tsx | 406 | ||||
| -rw-r--r-- | lib/risk-management/table/user-combo-box.tsx | 127 | ||||
| -rw-r--r-- | lib/risk-management/validations.ts | 69 |
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;">
+
+ </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 |
