summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
commite9897d416b3e7327bbd4d4aef887eee37751ae82 (patch)
treebd20ce6eadf9b21755bd7425492d2d31c7700a0e /lib
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'lib')
-rw-r--r--lib/b-rfq/service.ts2
-rw-r--r--lib/evaluation-criteria/service.ts389
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx69
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-create-dialog.tsx536
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx8
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx80
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table.tsx42
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-update-sheet.tsx554
-rw-r--r--lib/evaluation-criteria/validations.ts5
-rw-r--r--lib/evaluation-target-list/service.ts22
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx109
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx4
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx4
-rw-r--r--lib/evaluation/service.ts266
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx341
-rw-r--r--lib/evaluation/table/evaluation-table.tsx24
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx373
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx218
-rw-r--r--lib/password-policy/service.ts225
-rw-r--r--lib/techsales-rfq/service.ts2
-rw-r--r--lib/users/auth/partners-auth.ts374
-rw-r--r--lib/users/auth/passwordUtil.ts608
-rw-r--r--lib/users/auth/validataions-password.ts230
-rw-r--r--lib/users/auth/verifyCredentails.ts620
-rw-r--r--lib/users/service.ts70
-rw-r--r--lib/users/verifyOtp.ts26
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts18
-rw-r--r--lib/vendor-document-list/import-service.ts816
-rw-r--r--lib/vendor-document-list/service.ts18
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx3
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx82
-rw-r--r--lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx94
-rw-r--r--lib/vendor-users/repository.ts172
-rw-r--r--lib/vendor-users/service.ts491
-rw-r--r--lib/vendor-users/table/add-ausers-dialog.tsx291
-rw-r--r--lib/vendor-users/table/ausers-table-columns.tsx228
-rw-r--r--lib/vendor-users/table/ausers-table-floating-bar.tsx302
-rw-r--r--lib/vendor-users/table/ausers-table-toolbar-actions.tsx118
-rw-r--r--lib/vendor-users/table/ausers-table.tsx163
-rw-r--r--lib/vendor-users/table/delete-ausers-dialog.tsx149
-rw-r--r--lib/vendor-users/table/update-auser-sheet.tsx273
-rw-r--r--lib/vendor-users/validations.ts83
-rw-r--r--lib/vendors/repository.ts2
-rw-r--r--lib/vendors/service.ts45
-rw-r--r--lib/vendors/validations.ts146
45 files changed, 8291 insertions, 404 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
index 83f0bbb5..8aa79084 100644
--- a/lib/b-rfq/service.ts
+++ b/lib/b-rfq/service.ts
@@ -40,6 +40,8 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) {
const rfqFilterMapping = createRFQFilterMapping();
const joinedTables = getRFQJoinedTables();
+ console.log(input, "견적 인풋")
+
// 1) 고급 필터 조건
let advancedWhere: SQL<unknown> | undefined = undefined;
if (input.filters && input.filters.length > 0) {
diff --git a/lib/evaluation-criteria/service.ts b/lib/evaluation-criteria/service.ts
index ec23c9e4..5d5e5b8f 100644
--- a/lib/evaluation-criteria/service.ts
+++ b/lib/evaluation-criteria/service.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
'use server';
/* IMPORT */
@@ -20,18 +22,61 @@ import {
updateRegEvalCriteriaDetails,
} from './repository';
import db from '@/db/db';
+import * as ExcelJS from 'exceljs';
import { filterColumns } from '@/lib/filter-columns';
+import {
+ regEvalCriteriaColumnsConfig,
+} from '@/config/regEvalCriteriaColumnsConfig';
import {
+ REG_EVAL_CRITERIA_CATEGORY2_ENUM,
+ REG_EVAL_CRITERIA_CATEGORY_ENUM,
+ REG_EVAL_CRITERIA_ITEM_ENUM,
regEvalCriteriaView,
type NewRegEvalCriteria,
type NewRegEvalCriteriaDetails,
type RegEvalCriteria,
type RegEvalCriteriaDetails,
+ type RegEvalCriteriaView,
} from '@/db/schema';
import { type GetRegEvalCriteriaSchema } from './validations';
// ----------------------------------------------------------------------------------------------------
+/* TYPES */
+interface ImportResult {
+ errorFile: File | null,
+ errorMessage: string | null,
+ successMessage?: string,
+}
+type ExcelRowData = {
+ criteriaData: NewRegEvalCriteria,
+ detailList: (NewRegEvalCriteriaDetails & { rowIndex: number, toDelete: boolean })[],
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* CONSTANTS */
+const HEADER_ROW_INDEX = 2;
+const DATA_START_ROW_INDEX = 3;
+const EXCEL_HEADERS = [
+ 'Category',
+ 'Score Category',
+ 'Item',
+ 'Classification',
+ 'Range',
+ 'Detail',
+ 'Remarks',
+ 'ID',
+ 'Criteria ID',
+ 'Order Index',
+ 'Equipment-Shipbuilding Score',
+ 'Equipment-Marine Engineering Score',
+ 'Bulk-Shipbuilding Score',
+ 'Bulk-Marine Engineering Score',
+];
+
+// ----------------------------------------------------------------------------------------------------
+
/* FUNCTION FOR GETTING CRITERIA */
async function getRegEvalCriteria(input: GetRegEvalCriteriaSchema) {
try {
@@ -109,7 +154,7 @@ async function createRegEvalCriteriaWithDetails(
const newDetailList = detailList.map((detailItem, index) => ({
...detailItem,
criteriaId,
- orderIndex: index,
+ orderIndex: detailItem.orderIndex || index,
}));
const criteriaDetails: NewRegEvalCriteriaDetails[] = [];
@@ -136,10 +181,6 @@ async function modifyRegEvalCriteriaWithDetails(
try {
return await db.transaction(async (tx) => {
const modifiedCriteria = await updateRegEvalCriteria(tx, id, criteriaData);
-
- console.log('here!');
- console.log(detailList);
-
const originCriteria = await getRegEvalCriteriaWithDetails(id);
const originCriteriaDetails = originCriteria?.criteriaDetails || [];
const detailIdList = detailList
@@ -167,7 +208,7 @@ async function modifyRegEvalCriteriaWithDetails(
...detailItem,
criteriaId: id,
detail: detailItem.detail!,
- orderIndex: idx,
+ orderIndex: detailItem.orderIndex || idx,
};
const insertedDetail = await insertRegEvalCriteriaDetails(tx, newDetailItem);
criteriaDetails.push(insertedDetail);
@@ -210,12 +251,348 @@ async function removeRegEvalCriteriaDetails(id: number) {
// ----------------------------------------------------------------------------------------------------
+/* FUNCTION FOR IMPORTING EXCEL FILES */
+async function importRegEvalCriteriaExcel(file: File): Promise<ImportResult> {
+ try {
+ const buffer = await file.arrayBuffer();
+ const workbook = new ExcelJS.Workbook();
+ try {
+ await workbook.xlsx.load(buffer);
+ } catch {
+ throw new Error('유효한 Excel 파일이 아닙니다. 파일을 다시 확인해주세요.');
+ }
+
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error('Excel 파일에 워크시트가 없습니다.');
+ };
+ if (worksheet.rowCount === 0) {
+ throw new Error('워크시트에 데이터가 없습니다.');
+ }
+
+ const headerRow = worksheet.getRow(HEADER_ROW_INDEX);
+ if (!headerRow || headerRow.cellCount < EXCEL_HEADERS.length || !Array.isArray(headerRow.values)) {
+ throw new Error('Excel 파일의 워크시트에서 유효한 헤더 행을 찾지 못했습니다.');
+ }
+ const headerValues = headerRow?.values?.slice(1);
+ const isHeaderMatched = EXCEL_HEADERS.every((header, idx) => {
+ const actualHeader = (headerValues[idx] ?? '').toString().trim();
+ return actualHeader === header;
+ });
+ if (!isHeaderMatched) {
+ throw new Error('Excel 파일의 워크시트에서 유효한 헤더 행을 찾지 못했습니다.');
+ }
+
+ const columnIndexMap = new Map<string, number>();
+ headerRow.eachCell((cell, colIndex) => {
+ if (typeof cell.value === 'string') {
+ columnIndexMap.set(cell.value.trim(), colIndex);
+ }
+ });
+
+ const columnToFieldMap = new Map<number, keyof RegEvalCriteriaView>();
+ regEvalCriteriaColumnsConfig.forEach((cfg) => {
+ if (!cfg.excelHeader) {
+ return;
+ }
+ const colIndex = columnIndexMap.get(cfg.excelHeader.trim());
+ if (colIndex !== undefined) {
+ columnToFieldMap.set(colIndex, cfg.id);
+ }
+ });
+ const errorRows: { rowIndex: number; message: string }[] = [];
+ const rowDataList: ExcelRowData[] = [];
+ const criteriaMap = new Map<string, {
+ criteria: NewRegEvalCriteria,
+ criteriaDetails: (NewRegEvalCriteriaDetails & { rowIndex: number, toDelete: boolean })[],
+ }>();
+
+ for (let r = DATA_START_ROW_INDEX; r <= worksheet.rowCount; r += 1) {
+ const row = worksheet.getRow(r);
+ if (!row) {
+ continue;
+ }
+
+ const lastCellValue = row.getCell(row.cellCount).value;
+ const isDelete = typeof lastCellValue === 'string' && lastCellValue.toLowerCase() === 'd';
+
+ const rowFields = {} as Record<string, any>;
+ columnToFieldMap.forEach((fieldId, colIdx) => {
+ const cellValue = row.getCell(colIdx).value;
+ rowFields[fieldId] = cellValue ?? null;
+ });
+
+ const requiredFields = ['category', 'category2', 'item', 'classification', 'detail'];
+ for (const field of requiredFields) {
+ if (!rowFields[field]) {
+ errorRows.push({ rowIndex: r, message: `필수 필드 누락: ${field}` });
+ }
+ }
+
+ if (!REG_EVAL_CRITERIA_CATEGORY_ENUM.includes(rowFields.category)) {
+ errorRows.push({ rowIndex: r, message: `유효하지 않은 Category 값: ${rowFields.category}` });
+ }
+
+ if (!REG_EVAL_CRITERIA_CATEGORY2_ENUM.includes(rowFields.category2)) {
+ errorRows.push({ rowIndex: r, message: `유효하지 않은 Score Category 값: ${rowFields.category2}` });
+ }
+
+ if (!REG_EVAL_CRITERIA_ITEM_ENUM.includes(rowFields.item)) {
+ errorRows.push({ rowIndex: r, message: `유효하지 않은 Item 값: ${rowFields.item}` });
+ }
+
+ const criteriaKey = [
+ rowFields.criteriaId ?? '',
+ rowFields.category,
+ rowFields.category2,
+ rowFields.item,
+ rowFields.classification,
+ rowFields.range ?? '',
+ ].join('|');
+
+ const criteriaDetail: NewRegEvalCriteriaDetails = {
+ id: rowFields.id,
+ criteriaId: rowFields.criteriaId,
+ detail: rowFields.detail,
+ orderIndex: rowFields.orderIndex,
+ scoreEquipShip: rowFields.scoreEquipShip,
+ scoreEquipMarine: rowFields.scoreEquipMarine,
+ scoreBulkShip: rowFields.scoreBulkShip,
+ scoreBulkMarine: rowFields.scoreBulkMarine,
+ };
+
+ if (!criteriaMap.has(criteriaKey)) {
+ const criteria: NewRegEvalCriteria = {
+ id: rowFields.criteriaId,
+ category: rowFields.category,
+ category2: rowFields.category2,
+ item: rowFields.item,
+ classification: rowFields.classification,
+ range: rowFields.range,
+ remarks: rowFields.remarks,
+ };
+
+ criteriaMap.set(criteriaKey, {
+ criteria,
+ criteriaDetails: [{
+ ...criteriaDetail,
+ rowIndex: r,
+ toDelete: isDelete,
+ }],
+ });
+ } else {
+ const existing = criteriaMap.get(criteriaKey)!;
+ existing.criteriaDetails.push({
+ ...criteriaDetail,
+ rowIndex: r,
+ toDelete: isDelete,
+ });
+ }
+ }
+
+ criteriaMap.forEach(({ criteria, criteriaDetails }) => {
+ rowDataList.push({
+ criteriaData: criteria,
+ detailList: criteriaDetails,
+ });
+ });
+ // console.log('원본 데이터: ');
+ // console.dir(rowDataList, { depth: null });
+
+ if (errorRows.length > 0) {
+ const workbook = new ExcelJS.Workbook();
+ const sheet = workbook.addWorksheet('Error List');
+ sheet.columns = [
+ { header: 'Row Index', key: 'rowIndex', width: 10 },
+ { header: 'Error Message', key: 'message', width: 50 },
+ ];
+ errorRows.forEach((errorRow) => {
+ sheet.addRow({
+ rowIndex: errorRow.rowIndex,
+ message: errorRow.message,
+ });
+ });
+ const buffer = await workbook.xlsx.writeBuffer();
+ const errorFile = new File(
+ [buffer],
+ 'error_rows.xlsx',
+ {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ lastModified: Date.now(),
+ }
+ );
+
+ return {
+ errorFile,
+ errorMessage: '입력된 데이터 중에서 잘못된 데이터가 있어 오류 파일을 생성했습니다.',
+ };
+ }
+
+ const existingData = await db.transaction(async (tx) => {
+ return await selectRegEvalCriteria(tx, { limit: Number.MAX_SAFE_INTEGER });
+ });
+ const existingIds = existingData.map((row) => row.criteriaId!)
+ const existingIdSet = new Set<number>(existingIds);
+
+ // console.log('기존 데이터: ');
+ // console.dir(existingData, { depth: null });
+
+ const createList: {
+ criteriaData: NewRegEvalCriteria,
+ detailList: Omit<NewRegEvalCriteriaDetails, 'criteriaId'>[],
+ }[] = [];
+ const updateList: {
+ id: number,
+ criteriaData: Partial<RegEvalCriteria>,
+ detailList: Partial<RegEvalCriteriaDetails>[],
+ }[] = [];
+ const deleteIdList: number[] = [];
+
+ for (const { criteriaData, detailList } of rowDataList) {
+ const { id: criteriaId } = criteriaData;
+ const allMarkedForDelete = detailList.every(d => d.toDelete);
+ if (allMarkedForDelete) {
+ if (criteriaId && existingIdSet.has(criteriaId)) {
+ deleteIdList.push(criteriaId);
+ }
+ continue;
+ }
+
+ if (!criteriaId) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { id, ...newCriteriaData } = criteriaData;
+ const newDetailList = detailList.map(d => {
+ if (d.id != null) {
+ throw new Error(`새로운 기준 항목에 ID가 존재합니다: ${d.rowIndex}행`);
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { rowIndex, toDelete, id, criteriaId, ...rest } = d;
+ return rest;
+ });
+
+ createList.push({
+ criteriaData: newCriteriaData,
+ detailList: newDetailList,
+ });
+ } else if (existingIdSet.has(criteriaId)) {
+ const matchedExistingDetails = existingData.filter(d => d.criteriaId === criteriaId);
+ const hasDeletedDetail = detailList.some(d => d.toDelete === true);
+ const hasNewDetail = detailList.some(d => d.id == null);
+ const matchedExistingCriteria = matchedExistingDetails[0];
+ const criteriaChanged = (
+ matchedExistingCriteria.category !== criteriaData.category ||
+ matchedExistingCriteria.category2 !== criteriaData.category2 ||
+ matchedExistingCriteria.item !== criteriaData.item ||
+ matchedExistingCriteria.classification !== criteriaData.classification ||
+ matchedExistingCriteria.range !== criteriaData.range ||
+ matchedExistingCriteria.remarks !== criteriaData.remarks
+ );
+ const detailChanged = detailList.some(d => {
+ if (!d.id) {
+ return false;
+ }
+ const matched = matchedExistingDetails.find(e => e.id === d.id);
+ if (!matched) {
+ throw Error(`존재하지 않는 잘못된 ID(${d.id})가 있습니다.`);
+ }
+ return (
+ matched.detail !== d.detail ||
+ matched.orderIndex !== d.orderIndex ||
+ matched.scoreEquipShip !== d.scoreEquipShip ||
+ matched.scoreEquipMarine !== d.scoreEquipMarine ||
+ matched.scoreBulkShip !== d.scoreBulkShip ||
+ matched.scoreBulkMarine !== d.scoreBulkMarine
+ );
+ });
+
+ if (hasDeletedDetail || hasNewDetail || criteriaChanged || detailChanged) {
+ const updatedDetails = detailList
+ .filter(d => !d.toDelete)
+ .map(d => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { rowIndex, toDelete, ...rest } = d;
+ const cleaned = Object.fromEntries(
+ Object.entries(rest).map(([key, value]) => [
+ key,
+ value === '' ? null : value,
+ ])
+ );
+ return cleaned;
+ });
+
+ updateList.push({
+ id: criteriaId,
+ criteriaData,
+ detailList: updatedDetails,
+ });
+ }
+ } else {
+ throw Error(`존재하지 않는 잘못된 Criteria ID(${criteriaId})가 있습니다.`);
+ }
+ }
+ console.log('생성: ');
+ console.dir(createList, { depth: null });
+ console.log('업뎃: ');
+ console.dir(updateList, { depth: null });
+ console.log('삭제: ', deleteIdList);
+
+ if (createList.length > 0) {
+ for (const { criteriaData, detailList } of createList) {
+ await createRegEvalCriteriaWithDetails(criteriaData, detailList);
+ }
+ }
+ if (updateList.length > 0) {
+ for (const { id, criteriaData, detailList } of updateList) {
+ await modifyRegEvalCriteriaWithDetails(id, criteriaData, detailList);
+ }
+ }
+ if (deleteIdList.length > 0) {
+ for (const id of deleteIdList) {
+ await removeRegEvalCriteria(id);
+ }
+ }
+
+ const msg: string[] = [];
+ if (createList.length > 0) {
+ msg.push(`${createList.length}건 생성`);
+ }
+ if (updateList.length > 0) {
+ msg.push(`${updateList.length}건 수정`);
+ }
+ if (deleteIdList.length > 0) {
+ msg.push(`${deleteIdList.length}건 삭제`);
+ }
+ const successMessage = msg.length > 0
+ ? '기준 항목이 정상적으로 ' + msg.join(', ') + '되었습니다.'
+ : '변경사항이 존재하지 않습니다.';
+
+ return {
+ errorFile: null,
+ errorMessage: null,
+ successMessage,
+ };
+ } catch (error) {
+ let message = 'Excel 파일을 읽는 중 오류가 발생했습니다.';
+ if (error instanceof Error) {
+ message = error.message;
+ }
+
+ return {
+ errorFile: null,
+ errorMessage: message,
+ };
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
/* EXPORT */
export {
createRegEvalCriteriaWithDetails,
modifyRegEvalCriteriaWithDetails,
getRegEvalCriteria,
getRegEvalCriteriaWithDetails,
+ importRegEvalCriteriaExcel,
removeRegEvalCriteria,
removeRegEvalCriteriaDetails,
}; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
index 7367fabb..77e6118d 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
@@ -15,8 +15,8 @@ import {
import { PenToolIcon, TrashIcon } from 'lucide-react';
import {
REG_EVAL_CRITERIA_CATEGORY,
- REG_EVAL_CRITERIA_ITEM,
REG_EVAL_CRITERIA_CATEGORY2,
+ REG_EVAL_CRITERIA_ITEM,
type RegEvalCriteriaView,
} from '@/db/schema';
import { type ColumnDef } from '@tanstack/react-table';
@@ -85,12 +85,12 @@ function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteri
},
},
{
- accessorKey: 'scoreCategory',
+ accessorKey: 'category2',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="점수구분" />
),
cell: ({ row }) => {
- const value = row.getValue<string>('scoreCategory');
+ const value = row.getValue<string>('category2');
const label = REG_EVAL_CRITERIA_CATEGORY2.find(item => item.value === value)?.label ?? value;
return (
<Badge variant="secondary">
@@ -250,7 +250,7 @@ function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteri
enableSorting: true,
enableHiding: false,
meta: {
- excelHeader: 'Bulk-Shipbuiling Score',
+ excelHeader: 'Bulk-Shipbuilding Score',
group: 'Bulk Score',
type: 'number',
},
@@ -300,7 +300,65 @@ function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteri
},
};
- // [5] ACTIONS COLUMN - DROPDOWN MENU
+ // [5] HIDDEN ID COLUMN
+ const hiddenColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'id',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="ID" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('id')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: 'ID',
+ group: 'Meta Data',
+ type: 'number',
+ },
+ },
+ {
+ accessorKey: 'criteriaId',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="기준 ID" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('criteriaId')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: 'Criteria ID',
+ group: 'Meta Data',
+ type: 'criteriaId',
+ },
+ },
+ {
+ accessorKey: 'orderIndex',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="정렬 순서" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('orderIndex')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: 'Order Index',
+ group: 'Meta Data',
+ type: 'number',
+ },
+ },
+ ];
+
+ // [6] ACTIONS COLUMN - DROPDOWN MENU
const actionsColumn: ColumnDef<RegEvalCriteriaView> = {
id: 'actions',
header: '작업',
@@ -353,6 +411,7 @@ function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteri
},
],
},
+ ...hiddenColumns,
remarksColumn,
actionsColumn,
];
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-create-dialog.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-create-dialog.tsx
new file mode 100644
index 00000000..2a668ca8
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-create-dialog.tsx
@@ -0,0 +1,536 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { createRegEvalCriteriaWithDetails } from '../service';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Plus, Trash2 } from 'lucide-react';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ REG_EVAL_CRITERIA_ITEM,
+} from '@/db/schema';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { useEffect, useTransition } from 'react';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+const regEvalCriteriaFormSchema = z.object({
+ category: z.string().min(1, '평가부문은 필수 항목입니다.'),
+ category2: z.string().min(1, '점수구분은 필수 항목입니다.'),
+ item: z.string().min(1, '항목은 필수 항목입니다.'),
+ classification: z.string().min(1, '구분은 필수 항목입니다.'),
+ range: z.string().nullable().optional(),
+ remarks: z.string().nullable().optional(),
+ criteriaDetails: z.array(
+ z.object({
+ id: z.number().optional(),
+ detail: z.string().min(1, '평가내용은 필수 항목입니다.'),
+ scoreEquipShip: z.coerce.number().nullable().optional(),
+ scoreEquipMarine: z.coerce.number().nullable().optional(),
+ scoreBulkShip: z.coerce.number().nullable().optional(),
+ scoreBulkMarine: z.coerce.number().nullable().optional(),
+ })
+ ).min(1, '최소 1개의 평가 내용이 필요합니다.'),
+});
+type RegEvalCriteriaFormData = z.infer<typeof regEvalCriteriaFormSchema>;
+interface CriteriaDetailFormProps {
+ index: number
+ form: any
+ onRemove: () => void
+ canRemove: boolean
+ disabled?: boolean
+}
+interface RegEvalCriteriaFormSheetProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ onSuccess: () => void,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* CRITERIA DETAIL FORM COPONENT */
+function CriteriaDetailForm({
+ index,
+ form,
+ onRemove,
+ canRemove,
+ disabled = false,
+}: CriteriaDetailFormProps) {
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg">Detail Item - {index + 1}</CardTitle>
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ className="text-destructive hover:text-destructive"
+ disabled={disabled}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.id`}
+ render={({ field }) => (
+ <Input type="hidden" {...field} />
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.detail`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="평가내용을 입력하세요."
+ {...field}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ )
+}
+
+/* CRITERIA FORM SHEET COPONENT */
+function RegEvalCriteriaCreateDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+}: RegEvalCriteriaFormSheetProps) {
+ const [isPending, startTransition] = useTransition();
+ const form = useForm<RegEvalCriteriaFormData>({
+ resolver: zodResolver(regEvalCriteriaFormSchema),
+ defaultValues: {
+ category: '',
+ category2: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'criteriaDetails',
+ });
+
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ category: '',
+ category2: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ })
+ }
+ }, [open, form]);
+
+ const onSubmit = async (data: RegEvalCriteriaFormData) => {
+ startTransition(async () => {
+ try {
+ const criteriaData = {
+ category: data.category,
+ category2: data.category2,
+ item: data.item,
+ classification: data.classification,
+ range: data.range,
+ remarks: data.remarks,
+ };
+ const detailList = data.criteriaDetails.map((detailItem) => ({
+ id: detailItem.id,
+ detail: detailItem.detail,
+ scoreEquipShip: detailItem.scoreEquipShip != null
+ ? String(detailItem.scoreEquipShip) : null,
+ scoreEquipMarine: detailItem.scoreEquipMarine != null
+ ? String(detailItem.scoreEquipMarine) : null,
+ scoreBulkShip: detailItem.scoreBulkShip != null
+ ? String(detailItem.scoreBulkShip) : null,
+ scoreBulkMarine: detailItem.scoreBulkMarine != null
+ ? String(detailItem.scoreBulkMarine) : null,
+ }));
+
+ await createRegEvalCriteriaWithDetails(criteriaData, detailList);
+ toast.success('평가 기준표가 생성되었습니다.');
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error in Saving Regular Evaluation Criteria:', error);
+ toast.error(
+ error instanceof Error ? error.message : '평가 기준표 저장 중 오류가 발생했습니다.'
+ );
+ }
+ })
+ }
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-1/3 max-w-[50vw] max-h-[90vh] overflow-y-auto">
+ <DialogHeader className="mb-4">
+ <DialogTitle className="font-bold">
+ 새 협력업체 평가 기준표 생성
+ </DialogTitle>
+ <DialogDescription>
+ 새로운 협력업체 평가 기준표를 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <ScrollArea className="overflow-y-auto">
+ <div className="space-y-6">
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>Criterion Info</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가부문</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ''}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="category2"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>점수구분</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ''}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY2.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="item"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>항목</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ''}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_ITEM.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="classification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <FormControl>
+ <Input placeholder="구분을 입력하세요." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="range"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>범위</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="범위를 입력하세요." {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고를 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>Evaluation Criteria Item</CardTitle>
+ <CardDescription>
+ Set Evaluation Criteria Item.
+ </CardDescription>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="ml-4"
+ onClick={() =>
+ append({
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ })
+ }
+ disabled={isPending}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ New Item
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {fields.map((field, index) => (
+ <CriteriaDetailForm
+ key={field.id}
+ index={index}
+ form={form}
+ onRemove={() => remove(index)}
+ canRemove={fields.length > 1}
+ disabled={isPending}
+ />
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? 'Saving...' : 'Create'}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaCreateDialog; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
index aac7db29..b5772ee7 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
@@ -18,8 +18,8 @@ import { LoaderCircle } from 'lucide-react';
import { toast } from 'sonner';
import {
REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_CATEGORY2,
REG_EVAL_CRITERIA_ITEM,
- REG_EVAL_CRITERIA_SCORE_CATEGORY,
type RegEvalCriteriaView,
type RegEvalCriteriaWithDetails,
} from '@/db/schema';
@@ -90,10 +90,10 @@ function RegEvalCriteriaDeleteDialog(props: RegEvalCriteriaDeleteDialogProps) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{isLoading ? (
- <div className="flex flex-col items-center justify-center h-32">
+ <DialogHeader className="flex flex-col items-center justify-center h-32">
<LoaderCircle className="h-8 w-8 animate-spin" />
<p className="mt-4 text-base text-gray-700">Loading...</p>
- </div>
+ </DialogHeader>
) : (
<>
<DialogHeader>
@@ -106,7 +106,7 @@ function RegEvalCriteriaDeleteDialog(props: RegEvalCriteriaDeleteDialogProps) {
<br />
• 평가부문: {REG_EVAL_CRITERIA_CATEGORY.find((c) => c.value === criteriaViewData.category)?.label ?? '-'}
<br />
- • 점수구분: {REG_EVAL_CRITERIA_SCORE_CATEGORY.find((c) => c.value === criteriaViewData.scoreCategory)?.label ?? '-'}
+ • 점수구분: {REG_EVAL_CRITERIA_CATEGORY2.find((c) => c.value === criteriaViewData.category2)?.label ?? '-'}
<br />
• 항목: {REG_EVAL_CRITERIA_ITEM.find((c) => c.value === criteriaViewData.item)?.label ?? '-'}
<br />
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
index 95b2171e..b14cb22f 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
@@ -13,21 +13,21 @@ import {
AlertDialogTrigger
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
-import { Download, Plus, Trash2 } from 'lucide-react';
+import { Download, Plus, Trash2, Upload } from 'lucide-react';
import { exportTableToExcel } from '@/lib/export';
-import { removeRegEvalCriteria } from '../service';
+import { importRegEvalCriteriaExcel, removeRegEvalCriteria } from '../service';
+import { toast } from 'sonner';
import { type RegEvalCriteriaView } from '@/db/schema';
import { type Table } from '@tanstack/react-table';
-import { toast } from 'sonner';
-import { useMemo, useState } from 'react';
+import { ChangeEvent, useMemo, useRef, useState } from 'react';
// ----------------------------------------------------------------------------------------------------
/* TYPES */
interface RegEvalCriteriaTableToolbarActionsProps {
table: Table<RegEvalCriteriaView>,
- onCreateCriteria?: () => void,
- onRefresh?: () => void,
+ onCreateCriteria: () => void,
+ onRefresh: () => void,
}
// ----------------------------------------------------------------------------------------------------
@@ -41,12 +41,10 @@ function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarAc
const selectedIds = useMemo(() => {
return [...new Set(selectedRows.map(row => row.original.criteriaId))];
}, [selectedRows]);
+ const fileInputRef = useRef<HTMLInputElement>(null);
// Function for Create New Criteria
const handleCreateNew = () => {
- if (!onCreateCriteria) {
- return;
- }
onCreateCriteria();
}
@@ -64,12 +62,7 @@ function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarAc
}
table.resetRowSelection();
toast.success(`${selectedIds.length}개의 평가 기준이 삭제되었습니다.`);
-
- if (onRefresh) {
- onRefresh();
- } else {
- window.location.reload();
- }
+ onRefresh();
} catch (error) {
console.error('Error in Deleting Regular Evaluation Critria: ', error);
toast.error(
@@ -82,6 +75,47 @@ function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarAc
}
}
+ // 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 importRegEvalCriteriaExcel(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 = () => {
try {
@@ -145,6 +179,22 @@ function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarAc
<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"
>
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
index a2242309..d73eb5bd 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
@@ -7,12 +7,13 @@ import getColumns from './reg-eval-criteria-columns';
import { getRegEvalCriteria } from '../service';
import {
REG_EVAL_CRITERIA_CATEGORY,
- REG_EVAL_CRITERIA_ITEM,
REG_EVAL_CRITERIA_CATEGORY2,
+ REG_EVAL_CRITERIA_ITEM,
type RegEvalCriteriaView
} from '@/db/schema';
+import RegEvalCriteriaCreateDialog from './reg-eval-criteria-create-dialog';
import RegEvalCriteriaDeleteDialog from './reg-eval-criteria-delete-dialog';
-import RegEvalCriteriaFormSheet from './reg-eval-criteria-form-sheet';
+import RegEvalCriteriaUpdateSheet from './reg-eval-criteria-update-sheet';
import RegEvalCriteriaTableToolbarActions from './reg-eval-criteria-table-toolbar-actions';
import {
type DataTableFilterField,
@@ -38,7 +39,7 @@ interface RegEvalCriteriaTableProps {
function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
const router = useRouter();
const [rowAction, setRowAction] = useState<DataTableRowAction<RegEvalCriteriaView> | null>(null);
- const [isCreateFormOpen, setIsCreateFormOpen] = useState<boolean>(false);
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState<boolean>(false);
const [promiseData] = use(promises);
const tableData = promiseData;
@@ -54,7 +55,7 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
placeholder: '평가부문 선택...',
},
{
- id: 'scoreCategory',
+ id: 'category2',
label: '점수구분',
placeholder: '점수구분 선택...',
},
@@ -72,7 +73,7 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
options: REG_EVAL_CRITERIA_CATEGORY,
},
{
- id: 'scoreCategory',
+ id: 'category2',
label: '점수구분',
type: 'select',
options: REG_EVAL_CRITERIA_CATEGORY2,
@@ -102,8 +103,16 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [{ id: 'id', desc: false }],
+ sorting: [
+ { id: 'criteriaId', desc: false },
+ { id: 'orderIndex', desc: false },
+ ],
columnPinning: { left: ['select'], right: ['actions'] },
+ columnVisibility: {
+ id: false,
+ criteriaId: false,
+ orderIndex: false,
+ },
},
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
@@ -111,14 +120,14 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
});
const emptyCriteriaViewData: RegEvalCriteriaView = {
- id: 0,
+ id: null,
category: '',
- scoreCategory: '',
+ category2: '',
item: '',
classification: '',
range: null,
remarks: null,
- criteriaId: 0,
+ criteriaId: null,
detail: '',
orderIndex: null,
scoreEquipShip: null,
@@ -131,10 +140,10 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
router.refresh();
}, [router]);
const handleCreateCriteria = () => {
- setIsCreateFormOpen(true);
+ setIsCreateDialogOpen(true);
};
const handleCreateSuccess = useCallback(() => {
- setIsCreateFormOpen(false);
+ setIsCreateDialogOpen(false);
refreshData();
}, [refreshData]);
const handleModifySuccess = useCallback(() => {
@@ -161,16 +170,15 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
/>
</DataTableAdvancedToolbar>
</DataTable>
- <RegEvalCriteriaFormSheet
- open={isCreateFormOpen}
- onOpenChange={setIsCreateFormOpen}
- criteriaViewData={null}
+ <RegEvalCriteriaCreateDialog
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
- <RegEvalCriteriaFormSheet
+ <RegEvalCriteriaUpdateSheet
open={rowAction?.type === 'update'}
onOpenChange={() => setRowAction(null)}
- criteriaViewData={rowAction?.row.original ?? null}
+ criteriaViewData={rowAction?.row.original ?? emptyCriteriaViewData}
onSuccess={handleModifySuccess}
/>
<RegEvalCriteriaDeleteDialog
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-update-sheet.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-update-sheet.tsx
new file mode 100644
index 00000000..7f40b318
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-update-sheet.tsx
@@ -0,0 +1,554 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ getRegEvalCriteriaWithDetails,
+ modifyRegEvalCriteriaWithDetails,
+} from '../service';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Plus, Trash2 } from 'lucide-react';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ REG_EVAL_CRITERIA_ITEM,
+ type RegEvalCriteriaDetails,
+ type RegEvalCriteriaView,
+} from '@/db/schema';
+import { ScrollArea } from '@/components/ui/scroll-area';
+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 { useForm, useFieldArray } from 'react-hook-form';
+import { useEffect, useTransition } from 'react';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+const regEvalCriteriaFormSchema = z.object({
+ category: z.string().min(1, '평가부문은 필수 항목입니다.'),
+ category2: z.string().min(1, '점수구분은 필수 항목입니다.'),
+ item: z.string().min(1, '항목은 필수 항목입니다.'),
+ classification: z.string().min(1, '구분은 필수 항목입니다.'),
+ range: z.string().nullable().optional(),
+ remarks: z.string().nullable().optional(),
+ criteriaDetails: z.array(
+ z.object({
+ id: z.number().optional(),
+ detail: z.string().min(1, '평가내용은 필수 항목입니다.'),
+ scoreEquipShip: z.coerce.number().nullable().optional(),
+ scoreEquipMarine: z.coerce.number().nullable().optional(),
+ scoreBulkShip: z.coerce.number().nullable().optional(),
+ scoreBulkMarine: z.coerce.number().nullable().optional(),
+ })
+ ).min(1, '최소 1개의 평가 내용이 필요합니다.'),
+});
+type RegEvalCriteriaFormData = z.infer<typeof regEvalCriteriaFormSchema>;
+interface CriteriaDetailFormProps {
+ index: number
+ form: any
+ onRemove: () => void
+ canRemove: boolean
+ disabled?: boolean
+}
+interface RegEvalCriteriaUpdateSheetProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ criteriaViewData: RegEvalCriteriaView,
+ onSuccess: () => void,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* CRITERIA DETAIL FORM COPONENT */
+function CriteriaDetailForm({
+ index,
+ form,
+ onRemove,
+ canRemove,
+ disabled = false,
+}: CriteriaDetailFormProps) {
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg">Detail Item - {index + 1}</CardTitle>
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ className="text-destructive hover:text-destructive"
+ disabled={disabled}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.id`}
+ render={({ field }) => (
+ <Input type="hidden" {...field} />
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.detail`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="평가내용을 입력하세요."
+ {...field}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ )
+}
+
+/* CRITERIA FORM SHEET COPONENT */
+function RegEvalCriteriaUpdateSheet({
+ open,
+ onOpenChange,
+ criteriaViewData,
+ onSuccess,
+}: RegEvalCriteriaUpdateSheetProps) {
+ const [isPending, startTransition] = useTransition();
+ const form = useForm<RegEvalCriteriaFormData>({
+ resolver: zodResolver(regEvalCriteriaFormSchema),
+ defaultValues: {
+ category: '',
+ category2: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'criteriaDetails',
+ });
+
+ useEffect(() => {
+ if (open && criteriaViewData) {
+ startTransition(async () => {
+ try {
+ const targetData = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!);
+ if (targetData) {
+ form.reset({
+ category: targetData.category,
+ category2: targetData.category2,
+ item: targetData.item,
+ classification: targetData.classification,
+ range: targetData.range,
+ remarks: targetData.remarks,
+ criteriaDetails: targetData.criteriaDetails?.map((detailItem: RegEvalCriteriaDetails) => ({
+ id: detailItem.id,
+ detail: detailItem.detail,
+ scoreEquipShip: detailItem.scoreEquipShip !== null
+ ? Number(detailItem.scoreEquipShip) : null,
+ scoreEquipMarine: detailItem.scoreEquipMarine !== null
+ ? Number(detailItem.scoreEquipMarine) : null,
+ scoreBulkShip: detailItem.scoreBulkShip !== null
+ ? Number(detailItem.scoreBulkShip) : null,
+ scoreBulkMarine: detailItem.scoreBulkMarine !== null
+ ? Number(detailItem.scoreBulkMarine) : null,
+ })) || [],
+ })
+ }
+ } catch (error) {
+ console.error('Error in Loading Regular Evaluation Criteria for Updating:', error)
+ toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했습니다.')
+ }
+ });
+ }
+ }, [open, criteriaViewData, form]);
+
+ const onSubmit = async (data: RegEvalCriteriaFormData) => {
+ startTransition(async () => {
+ try {
+ const criteriaData = {
+ category: data.category,
+ category2: data.category2,
+ item: data.item,
+ classification: data.classification,
+ range: data.range,
+ remarks: data.remarks,
+ };
+ const detailList = data.criteriaDetails.map((detailItem) => ({
+ id: detailItem.id,
+ detail: detailItem.detail,
+ scoreEquipShip: detailItem.scoreEquipShip != null
+ ? String(detailItem.scoreEquipShip) : null,
+ scoreEquipMarine: detailItem.scoreEquipMarine != null
+ ? String(detailItem.scoreEquipMarine) : null,
+ scoreBulkShip: detailItem.scoreBulkShip != null
+ ? String(detailItem.scoreBulkShip) : null,
+ scoreBulkMarine: detailItem.scoreBulkMarine != null
+ ? String(detailItem.scoreBulkMarine) : null,
+ }));
+ await modifyRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!, criteriaData, detailList);
+ toast.success('평가 기준표가 수정되었습니다.');
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error in Saving Regular Evaluation Criteria:', 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] overflow-y-auto">
+ <SheetHeader className="mb-4">
+ <SheetTitle className="font-bold">
+ 협력업체 평가 기준표 수정
+ </SheetTitle>
+ <SheetDescription>
+ 협력업체 평가 기준표의 정보를 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <ScrollArea className="h-[calc(100vh-200px)] pr-4">
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Criterion Info</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가부문</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="category2"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>점수구분</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY2.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="item"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>항목</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_ITEM.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="classification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <FormControl>
+ <Input placeholder="구분을 입력하세요." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="range"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>범위</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="범위를 입력하세요." {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고를 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>Evaluation Criteria Item</CardTitle>
+ <CardDescription>
+ Set Evaluation Criteria Item.
+ </CardDescription>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="ml-4"
+ onClick={() =>
+ append({
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ })
+ }
+ disabled={isPending}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ New Item
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {fields.map((field, index) => (
+ <CriteriaDetailForm
+ key={field.id}
+ index={index}
+ form={form}
+ onRemove={() => remove(index)}
+ canRemove={fields.length > 1}
+ disabled={isPending}
+ />
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? 'Saving...' : 'Modify'}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaUpdateSheet; \ No newline at end of file
diff --git a/lib/evaluation-criteria/validations.ts b/lib/evaluation-criteria/validations.ts
index e9d5becc..39f0580f 100644
--- a/lib/evaluation-criteria/validations.ts
+++ b/lib/evaluation-criteria/validations.ts
@@ -17,7 +17,10 @@ const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RegEvalCriteriaView>().withDefault([{ id: 'id', desc: true }]),
+ sort: getSortingStateParser<RegEvalCriteriaView>().withDefault([
+ { id: 'criteriaId', desc: false },
+ { id: 'orderIndex', desc: false },
+ ]),
tagTypeLabel: parseAsString.withDefault(''),
classLabel: parseAsString.withDefault(''),
formCode: parseAsString.withDefault(''),
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 0da50fa2..bb47fca4 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -19,8 +19,13 @@ import {
EvaluationTargetWithDepartments,
evaluationTargetsWithDepartments,
periodicEvaluations,
- reviewerEvaluations
+ reviewerEvaluations,
+ evaluationSubmissions,
+ generalEvaluations,
+ esgEvaluationItems
} from "@/db/schema";
+
+
import { GetEvaluationTargetsSchema } from "./validation";
import { PgTransaction } from "drizzle-orm/pg-core";
import { getServerSession } from "next-auth/next"
@@ -62,8 +67,6 @@ export async function countEvaluationTargetsFromView(
}
// ============= 메인 서버 액션도 함께 수정 =============
-
-
export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) {
try {
const offset = (input.page - 1) * input.perPage;
@@ -590,18 +593,18 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
const allRejected = approvals.every(approval => approval === false)
const hasConsensus = allApproved || allRejected
- let newStatus: "PENDING" | "CONFIRMED" | "EXCLUDED" = "PENDING"
- if (hasConsensus) {
- newStatus = allApproved ? "CONFIRMED" : "EXCLUDED"
- }
+ // let newStatus: "PENDING" | "CONFIRMED" | "EXCLUDED" = "PENDING"
+ // if (hasConsensus) {
+ // newStatus = allApproved ? "CONFIRMED" : "EXCLUDED"
+ // }
- console.log("Auto-updating status:", { hasConsensus, newStatus, approvals })
+ // console.log("Auto-updating status:", { hasConsensus, newStatus, approvals })
+ console.log("Auto-updating status:", { hasConsensus, approvals })
await tx
.update(evaluationTargets)
.set({
consensusStatus: hasConsensus,
- status: newStatus,
confirmedAt: hasConsensus ? new Date() : null,
confirmedBy: hasConsensus ? Number(session.user.id) : null,
updatedAt: new Date()
@@ -1128,7 +1131,6 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
await Promise.all(emailPromises)
- revalidatePath("/evaluation-targets")
return {
success: true,
message: `${reviewerEmails.size}명의 담당자에게 의견 요청 이메일이 발송되었습니다.`,
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index 87be3589..b140df0e 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -7,7 +7,7 @@
import * as React from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
+import { HelpCircle, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -28,6 +28,74 @@ import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolb
import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet";
import { EvaluationTargetWithDepartments } from "@/db/schema";
import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+/* -------------------------------------------------------------------------- */
+/* Process Guide Popover */
+/* -------------------------------------------------------------------------- */
+function ProcessGuidePopover() {
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-6 w-6">
+ <HelpCircle className="h-4 w-4 text-muted-foreground" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-96" align="start">
+ <div className="space-y-3">
+ <div className="space-y-1">
+ <h4 className="font-medium">평가 대상 확정 프로세스</h4>
+ <p className="text-sm text-muted-foreground">
+ 발주실적을 기반으로 평가 대상을 확정하는 절차입니다.
+ </p>
+ </div>
+ <div className="space-y-3 text-sm">
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 1
+ </div>
+ <div>
+ <p className="font-medium">발주실적 기반 자동 추출</p>
+ <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 2
+ </div>
+ <div>
+ <p className="font-medium">담당자 지정</p>
+ <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 3
+ </div>
+ <div>
+ <p className="font-medium">검토 및 의견 수렴</p>
+ <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 4
+ </div>
+ <div>
+ <p className="font-medium">최종 확정</p>
+ <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ )
+}
/* -------------------------------------------------------------------------- */
/* Stats Card */
@@ -130,7 +198,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">확정</CardTitle>
- <Badge variant="default">완료</Badge>
+ <Badge variant="success">완료</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div>
@@ -204,10 +272,17 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
const tableData = promiseData;
/* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
- const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
- return searchParams?.get(key) ?? defaultValue ?? "";
- }, [searchParams]);
-
+ const searchString = React.useMemo(
+ () => searchParams.toString(), // query가 바뀔 때만 새로 계산
+ [searchParams]
+ );
+
+ const getSearchParam = React.useCallback(
+ (key: string, def = "") =>
+ new URLSearchParams(searchString).get(key) ?? def,
+ [searchString]
+ );
+
// 제네릭 함수는 useCallback 밖에서 정의
const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
try {
@@ -226,7 +301,7 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
const initialSettings = React.useMemo(() => ({
page: parseInt(getSearchParam("page", "1")),
perPage: parseInt(getSearchParam("perPage", "10")),
- sort: parseSearchParam("sort", [{ id: "createdAt", desc: true }]),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
filters: parseSearchParam("filters", []),
joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
basicFilters: parseSearchParam("basicFilters", []),
@@ -237,7 +312,7 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
pinnedColumns: { left: [], right: ["actions"] },
groupBy: [],
expandedRows: [],
- }), [getSearchParam, parseSearchParamHelper]);
+ }), [getSearchParam]);
/* --------------------- 프리셋 훅 ------------------------------ */
const {
@@ -267,9 +342,6 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
// { accessorKey: "division", header: "구분" }
// ];
-window.addEventListener('beforeunload', () => {
- console.trace('[beforeunload] 문서가 통째로 사라지려 합니다!');
-});
/* 기본 필터 */
const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [
@@ -297,11 +369,16 @@ window.addEventListener('beforeunload', () => {
/* current settings */
const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
- const initialState = React.useMemo(() => ({
- sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)),
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }), [columns, currentSettings, initialSettings.sort]);
+ const initialState = React.useMemo(() => {
+ return {
+ sorting: initialSettings.sort.filter(sortItem => {
+ const columnExists = columns.some(col => col.accessorKey === sortItem.id)
+ return columnExists
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
/* ----------------------- useDataTable ------------------------ */
const { table } = useDataTable({
diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
index e2163cad..b6631f14 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
@@ -42,7 +42,7 @@ const getConsensusBadge = (consensusStatus: boolean | null) => {
return <Badge variant="outline">검토 중</Badge>;
}
if (consensusStatus === true) {
- return <Badge variant="default" className="bg-green-600">의견 일치</Badge>;
+ return <Badge variant="success">의견 일치</Badge>;
}
return <Badge variant="destructive">의견 불일치</Badge>;
};
@@ -383,7 +383,7 @@ export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps):
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
cell: ({ row }) => {
const confirmedAt = row.getValue<Date>("confirmedAt");
- return <span className="text-sm">{formatDate(confirmedAt, "KR")}</span>;
+ return <span className="text-sm">{ confirmedAt ? formatDate(confirmedAt, "KR") :'-'}</span>;
},
size: 100,
},
diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
index 7ea2e0ec..82b7c97c 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
@@ -160,9 +160,9 @@ export function EvaluationTargetsTableToolbarActions({
{/* 확정 버튼 */}
{selectedStats.canConfirm && (
<Button
- variant="default"
+ variant="success"
size="sm"
- className="gap-2 bg-green-600 hover:bg-green-700"
+ className="gap-2"
onClick={() => setConfirmDialogOpen(true)}
disabled={isLoading}
>
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 3cc4ca7d..19e41dff 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -1,5 +1,8 @@
+'use server'
+
import db from "@/db/db"
import {
+ evaluationSubmissions,
periodicEvaluationsView,
type PeriodicEvaluationView
} from "@/db/schema"
@@ -9,7 +12,7 @@ import {
count,
desc,
ilike,
- or,
+ or, sql , eq, avg,
type SQL
} from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
@@ -17,6 +20,7 @@ import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation
export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
try {
+
const offset = (input.page - 1) * input.perPage;
// ✅ getEvaluationTargets 방식과 동일한 필터링 처리
@@ -115,6 +119,8 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
.offset(offset);
const pageCount = Math.ceil(total / input.perPage);
+
+ console.log(periodicEvaluationsData,"periodicEvaluationsData")
return { data: periodicEvaluationsData, pageCount, total };
} catch (err) {
@@ -122,4 +128,262 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
// ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함)
return { data: [], pageCount: 0, total: 0 };
}
+ }
+
+ export interface PeriodicEvaluationsStats {
+ total: number
+ pendingSubmission: number
+ submitted: number
+ inReview: number
+ reviewCompleted: number
+ finalized: number
+ averageScore: number | null
+ completionRate: number
+ averageFinalScore: number | null
+ documentsSubmittedCount: number
+ documentsNotSubmittedCount: number
+ reviewProgress: {
+ totalReviewers: number
+ completedReviewers: number
+ pendingReviewers: number
+ reviewCompletionRate: number
+ }
+ }
+
+ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promise<PeriodicEvaluationsStats> {
+ try {
+ // 기본 WHERE 조건: 해당 연도의 평가만
+ const baseWhere = eq(periodicEvaluationsView.evaluationYear, evaluationYear)
+
+ // 1. 전체 통계 조회
+ const totalStatsResult = await db
+ .select({
+ total: count(),
+ averageScore: avg(periodicEvaluationsView.totalScore),
+ averageFinalScore: avg(periodicEvaluationsView.finalScore),
+ })
+ .from(periodicEvaluationsView)
+ .where(baseWhere)
+
+ const totalStats = totalStatsResult[0] || {
+ total: 0,
+ averageScore: null,
+ averageFinalScore: null
+ }
+
+ // 2. 상태별 카운트 조회
+ const statusStatsResult = await db
+ .select({
+ status: periodicEvaluationsView.status,
+ count: count(),
+ })
+ .from(periodicEvaluationsView)
+ .where(baseWhere)
+ .groupBy(periodicEvaluationsView.status)
+
+ // 상태별 카운트를 객체로 변환
+ const statusCounts = statusStatsResult.reduce((acc, item) => {
+ acc[item.status] = item.count
+ return acc
+ }, {} as Record<string, number>)
+
+ // 3. 문서 제출 상태 통계
+ const documentStatsResult = await db
+ .select({
+ documentsSubmitted: periodicEvaluationsView.documentsSubmitted,
+ count: count(),
+ })
+ .from(periodicEvaluationsView)
+ .where(baseWhere)
+ .groupBy(periodicEvaluationsView.documentsSubmitted)
+
+ const documentCounts = documentStatsResult.reduce((acc, item) => {
+ if (item.documentsSubmitted) {
+ acc.submitted = item.count
+ } else {
+ acc.notSubmitted = item.count
+ }
+ return acc
+ }, { submitted: 0, notSubmitted: 0 })
+
+ // 4. 리뷰어 진행 상황 통계
+ const reviewProgressResult = await db
+ .select({
+ totalReviewers: sql<number>`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'),
+ completedReviewers: sql<number>`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'),
+ pendingReviewers: sql<number>`SUM(${periodicEvaluationsView.pendingReviewers})`.as('pending_reviewers'),
+ })
+ .from(periodicEvaluationsView)
+ .where(baseWhere)
+
+ const reviewProgress = reviewProgressResult[0] || {
+ totalReviewers: 0,
+ completedReviewers: 0,
+ pendingReviewers: 0,
+ }
+
+ // 5. 완료율 계산
+ const finalizedCount = statusCounts['FINALIZED'] || 0
+ const totalCount = totalStats.total
+ const completionRate = totalCount > 0 ? Math.round((finalizedCount / totalCount) * 100) : 0
+
+ // 6. 리뷰 완료율 계산
+ const reviewCompletionRate = reviewProgress.totalReviewers > 0
+ ? Math.round((reviewProgress.completedReviewers / reviewProgress.totalReviewers) * 100)
+ : 0
+
+ // 7. 평균 점수 포맷팅 (소수점 1자리)
+ const formatScore = (score: string | number | null): number | null => {
+ if (score === null || score === undefined) return null
+ return Math.round(Number(score) * 10) / 10
+ }
+
+ return {
+ total: totalCount,
+ pendingSubmission: statusCounts['PENDING_SUBMISSION'] || 0,
+ submitted: statusCounts['SUBMITTED'] || 0,
+ inReview: statusCounts['IN_REVIEW'] || 0,
+ reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0,
+ finalized: finalizedCount,
+ averageScore: formatScore(totalStats.averageScore),
+ averageFinalScore: formatScore(totalStats.averageFinalScore),
+ completionRate,
+ documentsSubmittedCount: documentCounts.submitted,
+ documentsNotSubmittedCount: documentCounts.notSubmitted,
+ reviewProgress: {
+ totalReviewers: reviewProgress.totalReviewers,
+ completedReviewers: reviewProgress.completedReviewers,
+ pendingReviewers: reviewProgress.pendingReviewers,
+ reviewCompletionRate,
+ },
+ }
+
+ } catch (error) {
+ console.error('Error in getPeriodicEvaluationsStats:', error)
+ // 에러 발생 시 기본값 반환
+ return {
+ total: 0,
+ pendingSubmission: 0,
+ submitted: 0,
+ inReview: 0,
+ reviewCompleted: 0,
+ finalized: 0,
+ averageScore: null,
+ averageFinalScore: null,
+ completionRate: 0,
+ documentsSubmittedCount: 0,
+ documentsNotSubmittedCount: 0,
+ reviewProgress: {
+ totalReviewers: 0,
+ completedReviewers: 0,
+ pendingReviewers: 0,
+ reviewCompletionRate: 0,
+ },
+ }
+ }
+ }
+
+
+
+ interface RequestDocumentsData {
+ periodicEvaluationId: number
+ companyId: number
+ evaluationYear: number
+ evaluationRound: string
+ message: string
+ }
+
+ export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) {
+ try {
+ // 각 평가에 대해 evaluationSubmissions 레코드 생성
+ const submissions = await Promise.all(
+ data.map(async (item) => {
+ // 이미 해당 periodicEvaluationId와 companyId로 생성된 submission이 있는지 확인
+ const existingSubmission = await db.query.evaluationSubmissions.findFirst({
+ where: and(
+ eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId),
+ eq(evaluationSubmissions.companyId, item.companyId)
+ )
+ })
+
+ if (existingSubmission) {
+ // 이미 존재하면 reviewComments만 업데이트
+ const [updated] = await db
+ .update(evaluationSubmissions)
+ .set({
+ reviewComments: item.message,
+ updatedAt: new Date()
+ })
+ .where(eq(evaluationSubmissions.id, existingSubmission.id))
+ .returning()
+
+ return updated
+ } else {
+ // 새로 생성
+ const [created] = await db
+ .insert(evaluationSubmissions)
+ .values({
+ periodicEvaluationId: item.periodicEvaluationId,
+ companyId: item.companyId,
+ evaluationYear: item.evaluationYear,
+ evaluationRound: item.evaluationRound,
+ submissionStatus: 'draft', // 기본값
+ reviewComments: item.message,
+ // 진행률 관련 필드들은 기본값 0으로 설정됨
+ totalGeneralItems: 0,
+ completedGeneralItems: 0,
+ totalEsgItems: 0,
+ completedEsgItems: 0,
+ isActive: true
+ })
+ .returning()
+
+ return created
+ }
+ })
+ )
+
+
+ return {
+ success: true,
+ message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`,
+ submissions
+ }
+
+ } catch (error) {
+ console.error("Error requesting documents from vendors:", error)
+ return {
+ success: false,
+ message: "자료 요청 중 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "Unknown error"
+ }
+ }
+ }
+
+ // 기존 요청 상태 확인 함수 추가
+ export async function checkExistingSubmissions(periodicEvaluationIds: number[]) {
+ try {
+ const existingSubmissions = await db.query.evaluationSubmissions.findMany({
+ where: (submissions) => {
+ // periodicEvaluationIds 배열에 포함된 ID들을 확인
+ return periodicEvaluationIds.length === 1
+ ? eq(submissions.periodicEvaluationId, periodicEvaluationIds[0])
+ : periodicEvaluationIds.length > 1
+ ? or(...periodicEvaluationIds.map(id => eq(submissions.periodicEvaluationId, id)))
+ : eq(submissions.id, -1) // 빈 배열인 경우 결과 없음
+ },
+ columns: {
+ id: true,
+ periodicEvaluationId: true,
+ companyId: true,
+ createdAt: true,
+ reviewComments: true
+ }
+ })
+
+ return existingSubmissions
+ } catch (error) {
+ console.error("Error checking existing submissions:", error)
+ return []
+ }
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 821e8182..10aa7704 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -144,14 +144,14 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
// ░░░ 평가기간 ░░░
- {
- accessorKey: "evaluationPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
- cell: ({ row }) => (
- <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
- ),
- size: 100,
- },
+ // {
+ // accessorKey: "evaluationPeriod",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />,
+ // cell: ({ row }) => (
+ // <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge>
+ // ),
+ // size: 100,
+ // },
// ░░░ 구분 ░░░
{
@@ -202,12 +202,113 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
]
},
+
+ {
+ accessorKey: "finalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정점수" />,
+ cell: ({ row }) => {
+ const finalScore = row.getValue<number>("finalScore");
+ return finalScore ? (
+ <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+ {
+ accessorKey: "finalGrade",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정등급" />,
+ cell: ({ row }) => {
+ const finalGrade = row.getValue<string>("finalGrade");
+ return finalGrade ? (
+ <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
+ {finalGrade}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 90,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 진행 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "평가자 진행 현황",
+ columns: [
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => {
+ const status = row.getValue<string>("status");
+ return (
+ <Badge variant={getStatusBadgeVariant(status)}>
+ {getStatusLabel(status)}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ id: "reviewProgress",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
+ cell: ({ row }) => {
+ const totalReviewers = row.original.totalReviewers || 0;
+ const completedReviewers = row.original.completedReviewers || 0;
+
+ return getProgressBadge(completedReviewers, totalReviewers);
+ },
+ size: 120,
+ },
+
+ {
+ accessorKey: "reviewCompletedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />,
+ cell: ({ row }) => {
+ const completedAt = row.getValue<Date>("reviewCompletedAt");
+ return completedAt ? (
+ <span className="text-sm">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(completedAt))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ accessorKey: "finalizedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
+ cell: ({ row }) => {
+ const finalizedAt = row.getValue<Date>("finalizedAt");
+ return finalizedAt ? (
+ <span className="text-sm font-medium">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(finalizedAt))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+ ]
+ },
// ═══════════════════════════════════════════════════════════════
// 제출 현황
// ═══════════════════════════════════════════════════════════════
{
- header: "제출 현황",
+ header: "협력업체 제출 현황",
columns: [
{
accessorKey: "documentsSubmitted",
@@ -266,6 +367,8 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
]
},
+
+
// ═══════════════════════════════════════════════════════════════
// 평가 점수
// ═══════════════════════════════════════════════════════════════
@@ -273,12 +376,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: "평가 점수",
columns: [
{
- accessorKey: "totalScore",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="총점" />,
+ accessorKey: "processScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="공정" />,
cell: ({ row }) => {
- const score = row.getValue<number>("totalScore");
+ const score = row.getValue("processScore");
return score ? (
- <span className="font-medium">{score.toFixed(1)}</span>
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
@@ -287,156 +390,176 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
{
- accessorKey: "evaluationGrade",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등급" />,
+ accessorKey: "priceScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="가격" />,
cell: ({ row }) => {
- const grade = row.getValue<string>("evaluationGrade");
- return grade ? (
- <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
+ const score = row.getValue("priceScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 60,
+ size: 80,
},
-
+
{
- accessorKey: "finalScore",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종점수" />,
+ accessorKey: "deliveryScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="납기" />,
cell: ({ row }) => {
- const finalScore = row.getValue<number>("finalScore");
- return finalScore ? (
- <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span>
+ const score = row.getValue("deliveryScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 90,
+ size: 80,
+ },
+
+ {
+ accessorKey: "selfEvaluationScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자율평가" />,
+ cell: ({ row }) => {
+ const score = row.getValue("selfEvaluationScore");
+ return score ? (
+ <span className="font-medium">{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
},
+ // ✅ 합계 - 4개 점수의 합으로 계산
{
- accessorKey: "finalGrade",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종등급" />,
+ id: "totalScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="합계" />,
cell: ({ row }) => {
- const finalGrade = row.getValue<string>("finalGrade");
- return finalGrade ? (
- <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600">
- {finalGrade}
- </Badge>
+ const processScore = Number(row.getValue("processScore") || 0);
+ const priceScore = Number(row.getValue("priceScore") || 0);
+ const deliveryScore = Number(row.getValue("deliveryScore") || 0);
+ const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0);
+
+ const total = processScore + priceScore + deliveryScore + selfEvaluationScore;
+
+ return total > 0 ? (
+ <span className="font-medium bg-blue-50 px-2 py-1 rounded">
+ {total.toFixed(1)}
+ </span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 90,
+ size: 80,
},
- ]
- },
- // ═══════════════════════════════════════════════════════════════
- // 진행 현황
- // ═══════════════════════════════════════════════════════════════
- {
- header: "진행 현황",
- columns: [
{
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ accessorKey: "participationBonus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여도(가점)" />,
cell: ({ row }) => {
- const status = row.getValue<string>("status");
- return (
- <Badge variant={getStatusBadgeVariant(status)}>
- {getStatusLabel(status)}
- </Badge>
+ const score = row.getValue("participationBonus");
+ return score ? (
+ <span className="font-medium text-green-600">+{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
);
},
size: 100,
},
{
- id: "reviewProgress",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
+ accessorKey: "qualityDeduction",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질(감점)" />,
cell: ({ row }) => {
- const totalReviewers = row.original.totalReviewers || 0;
- const completedReviewers = row.original.completedReviewers || 0;
-
- return getProgressBadge(completedReviewers, totalReviewers);
+ const score = row.getValue("qualityDeduction");
+ return score ? (
+ <span className="font-medium text-red-600">-{Number(score).toFixed(1)}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
},
- size: 120,
+ size: 100,
},
+ // ✅ 새로운 평가점수 컬럼 추가
{
- accessorKey: "reviewCompletedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />,
+ id: "evaluationScore",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가점수" />,
cell: ({ row }) => {
- const completedAt = row.getValue<Date>("reviewCompletedAt");
- return completedAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(completedAt))}
+ const processScore = Number(row.getValue("processScore") || 0);
+ const priceScore = Number(row.getValue("priceScore") || 0);
+ const deliveryScore = Number(row.getValue("deliveryScore") || 0);
+ const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0);
+ const participationBonus = Number(row.getValue("participationBonus") || 0);
+ const qualityDeduction = Number(row.getValue("qualityDeduction") || 0);
+
+ const totalScore = processScore + priceScore + deliveryScore + selfEvaluationScore;
+ const evaluationScore = totalScore + participationBonus - qualityDeduction;
+
+ return totalScore > 0 ? (
+ <span className="font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">
+ {evaluationScore.toFixed(1)}
</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 90,
},
{
- accessorKey: "finalizedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
+ accessorKey: "evaluationGrade",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가등급" />,
cell: ({ row }) => {
- const finalizedAt = row.getValue<Date>("finalizedAt");
- return finalizedAt ? (
- <span className="text-sm font-medium">
- {new Intl.DateTimeFormat("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(finalizedAt))}
- </span>
+ const grade = row.getValue<string>("evaluationGrade");
+ return grade ? (
+ <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge>
) : (
<span className="text-muted-foreground">-</span>
);
},
size: 80,
},
+
]
},
+
+
// ░░░ Actions ░░░
- {
- id: "actions",
- enableHiding: false,
- size: 40,
- minSize: 40,
- cell: ({ row }) => {
- return (
- <div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setRowAction({ row, type: "view" })}
- aria-label="상세보기"
- title="상세보기"
- >
- <Eye className="size-4" />
- </Button>
-
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setRowAction({ row, type: "update" })}
- aria-label="수정"
- title="수정"
- >
- <Pencil className="size-4" />
- </Button>
- </div>
- );
- },
- },
+ // {
+ // id: "actions",
+ // enableHiding: false,
+ // size: 40,
+ // minSize: 40,
+ // cell: ({ row }) => {
+ // return (
+ // <div className="flex items-center gap-1">
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // className="size-8"
+ // onClick={() => setRowAction({ row, type: "view" })}
+ // aria-label="상세보기"
+ // title="상세보기"
+ // >
+ // <Eye className="size-4" />
+ // </Button>
+
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // className="size-8"
+ // onClick={() => setRowAction({ row, type: "update" })}
+ // aria-label="수정"
+ // title="수정"
+ // >
+ // <Pencil className="size-4" />
+ // </Button>
+ // </div>
+ // );
+ // },
+ // },
];
} \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index a628475d..9e32debb 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -23,7 +23,8 @@ import { useMemo } from "react"
import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
import { PeriodicEvaluationView } from "@/db/schema"
-import { getPeriodicEvaluations } from "../service"
+import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service"
+import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions"
interface PeriodicEvaluationsTableProps {
promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
@@ -44,17 +45,9 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
try {
setIsLoading(true)
setError(null)
- // TODO: getPeriodicEvaluationsStats 구현 필요
- const statsData = {
- total: 150,
- pendingSubmission: 25,
- submitted: 45,
- inReview: 30,
- reviewCompleted: 35,
- finalized: 15,
- averageScore: 82.5,
- completionRate: 75
- }
+
+ // 실제 통계 함수 호출
+ const statsData = await getPeriodicEvaluationsStats(evaluationYear)
if (isMounted) {
setStats(statsData)
@@ -76,7 +69,7 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }
return () => {
isMounted = false
}
- }, [])
+ }, [evaluationYear]) // evaluationYear 의존성 추가
if (isLoading) {
return (
@@ -230,6 +223,8 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const [promiseData] = React.use(promises)
const tableData = promiseData
+ console.log(tableData)
+
const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
return searchParams?.get(key) ?? defaultValue ?? "";
}, [searchParams]);
@@ -453,6 +448,9 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
onRenamePreset={renamePreset}
/>
+ <PeriodicEvaluationsTableToolbarActions
+ table={table}
+ />
{/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */}
</div>
</DataTableAdvancedToolbar>
diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
new file mode 100644
index 00000000..30ff9535
--- /dev/null
+++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
@@ -0,0 +1,373 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { FileText, Users, Calendar, Send } from "lucide-react"
+import { toast } from "sonner"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { checkExistingSubmissions, requestDocumentsFromVendors } from "../service"
+
+
+// ================================================================
+// 2. 협력업체 자료 요청 다이얼로그
+// ================================================================
+interface RequestDocumentsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluations: PeriodicEvaluationView[]
+ onSuccess: () => void
+}
+
+interface EvaluationWithSubmissionStatus extends PeriodicEvaluationView {
+ hasExistingSubmission?: boolean
+ submissionDate?: Date | null
+}
+
+export function RequestDocumentsDialog({
+ open,
+ onOpenChange,
+ evaluations,
+ onSuccess,
+}: RequestDocumentsDialogProps) {
+
+ console.log(evaluations)
+
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isCheckingStatus, setIsCheckingStatus] = React.useState(false)
+ const [message, setMessage] = React.useState("")
+ const [evaluationsWithStatus, setEvaluationsWithStatus] = React.useState<EvaluationWithSubmissionStatus[]>([])
+
+ // 제출대기 상태인 평가들만 필터링
+ const pendingEvaluations = React.useMemo(() =>
+ evaluations.filter(e => e.status === "PENDING_SUBMISSION"),
+ [evaluations]
+ )
+
+ React.useEffect(() => {
+ if (!open) return;
+
+ // 대기 중 평가가 없으면 초기화
+ if (pendingEvaluations.length === 0) {
+ setEvaluationsWithStatus([]);
+ setIsCheckingStatus(false);
+ return;
+ }
+
+ // 상태 확인
+ (async () => {
+ setIsCheckingStatus(true);
+ try {
+ const ids = pendingEvaluations.map(e => e.id);
+ const existing = await checkExistingSubmissions(ids);
+
+ setEvaluationsWithStatus(
+ pendingEvaluations.map(e => ({
+ ...e,
+ hasExistingSubmission: existing.some(s => s.periodicEvaluationId === e.id),
+ submissionDate: existing.find(s => s.periodicEvaluationId === e.id)?.createdAt ?? null,
+ })),
+ );
+ } catch (err) {
+ console.error(err);
+ setEvaluationsWithStatus(
+ pendingEvaluations.map(e => ({ ...e, hasExistingSubmission: false })),
+ );
+ } finally {
+ setIsCheckingStatus(false);
+ }
+ })();
+ }, [open, pendingEvaluations]); // 함수 대신 값에만 의존
+
+ // 새 요청과 재요청 분리
+ const newRequests = evaluationsWithStatus.filter(e => !e.hasExistingSubmission)
+ const reRequests = evaluationsWithStatus.filter(e => e.hasExistingSubmission)
+
+ const handleSubmit = async () => {
+ if (!message.trim()) {
+ toast.error("요청 메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버 액션 데이터 준비
+ const requestData = evaluationsWithStatus.map(evaluation => ({
+ periodicEvaluationId: evaluation.id,
+ companyId: evaluation.vendorId,
+ evaluationYear: evaluation.evaluationYear,
+ evaluationRound: evaluation.evaluationPeriod,
+ message: message.trim()
+ }))
+
+ // 서버 액션 호출
+ const result = await requestDocumentsFromVendors(requestData)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess()
+ onOpenChange(false)
+ setMessage("")
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('Error requesting documents:', error)
+ toast.error("자료 요청 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="size-4" />
+ 협력업체 자료 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 평가의 협력업체들에게 평가 자료 제출을 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {isCheckingStatus ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">요청 상태를 확인하고 있습니다...</div>
+ </div>
+ ) : (
+ <>
+ {/* 신규 요청 대상 업체 */}
+ {newRequests.length > 0 && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm text-blue-600">
+ 신규 요청 대상 ({newRequests.length}개 업체)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 max-h-32 overflow-y-auto">
+ {newRequests.map((evaluation) => (
+ <div
+ key={evaluation.id}
+ className="flex items-center justify-between text-sm p-2 bg-blue-50 rounded"
+ >
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.vendorCode}</Badge>
+ <Badge variant="default" className="bg-blue-600">신규</Badge>
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 재요청 대상 업체 */}
+ {reRequests.length > 0 && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm text-orange-600">
+ 재요청 대상 ({reRequests.length}개 업체)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 max-h-32 overflow-y-auto">
+ {reRequests.map((evaluation) => (
+ <div
+ key={evaluation.id}
+ className="flex items-center justify-between text-sm p-2 bg-orange-50 rounded"
+ >
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.vendorCode}</Badge>
+ <Badge variant="secondary" className="bg-orange-100">
+ 재요청
+ </Badge>
+ {evaluation.submissionDate && (
+ <span className="text-xs text-muted-foreground">
+ {new Intl.DateTimeFormat("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(evaluation.submissionDate))}
+ </span>
+ )}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 요청 대상이 없는 경우 */}
+ {!isCheckingStatus && evaluationsWithStatus.length === 0 && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="text-center text-sm text-muted-foreground">
+ 요청할 수 있는 평가가 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
+
+ {/* 요청 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="message">요청 메시지</Label>
+ <Textarea
+ id="message"
+ placeholder="협력업체에게 전달할 메시지를 입력하세요..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ rows={4}
+ disabled={isCheckingStatus}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading || isCheckingStatus}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || isCheckingStatus || evaluationsWithStatus.length === 0}
+ >
+ <Send className="size-4 mr-2" />
+ {isLoading ? "발송 중..." :
+ `요청 발송 (신규 ${newRequests.length}개${reRequests.length > 0 ? `, 재요청 ${reRequests.length}개` : ''})`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+
+
+// ================================================================
+// 3. 평가자 평가 요청 다이얼로그
+// ================================================================
+interface RequestEvaluationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluations: PeriodicEvaluationView[]
+ onSuccess: () => void
+}
+
+export function RequestEvaluationDialog({
+ open,
+ onOpenChange,
+ evaluations,
+ onSuccess,
+}: RequestEvaluationDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [message, setMessage] = React.useState("")
+
+ // 제출완료 상태인 평가들만 필터링
+ const submittedEvaluations = evaluations.filter(e => e.status === "SUBMITTED")
+
+ const handleSubmit = async () => {
+ if (!message.trim()) {
+ toast.error("요청 메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // TODO: 평가자들에게 평가 요청 API 호출
+ toast.success(`${submittedEvaluations.length}개 평가에 대한 평가 요청이 발송되었습니다.`)
+ onSuccess()
+ onOpenChange(false)
+ setMessage("")
+ } catch (error) {
+ console.error('Error requesting evaluation:', error)
+ toast.error("평가 요청 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-4" />
+ 평가자 평가 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 평가들에 대해 평가자들에게 평가를 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 대상 평가 목록 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm">
+ 평가 대상 ({submittedEvaluations.length}개 평가)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 max-h-32 overflow-y-auto">
+ {submittedEvaluations.map((evaluation) => (
+ <div
+ key={evaluation.id}
+ className="flex items-center justify-between text-sm"
+ >
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <div className="flex gap-2">
+ <Badge variant="outline">{evaluation.evaluationPeriod}</Badge>
+ <Badge variant="secondary">제출완료</Badge>
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {/* 요청 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="evaluation-message">요청 메시지</Label>
+ <Textarea
+ id="evaluation-message"
+ placeholder="평가자들에게 전달할 메시지를 입력하세요..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ rows={4}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isLoading}>
+ <Send className="size-4 mr-2" />
+ {isLoading ? "발송 중..." : `${submittedEvaluations.length}개 평가 요청`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
new file mode 100644
index 00000000..2d2bebc1
--- /dev/null
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -0,0 +1,218 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Plus,
+ Send,
+ Users,
+ Download,
+ RefreshCw,
+ FileText,
+ MessageSquare
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ RequestDocumentsDialog,
+ RequestEvaluationDialog,
+} from "./periodic-evaluation-action-dialogs"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { exportTableToExcel } from "@/lib/export"
+
+interface PeriodicEvaluationsTableToolbarActionsProps {
+ table: Table<PeriodicEvaluationView>
+ onRefresh?: () => void
+}
+
+export function PeriodicEvaluationsTableToolbarActions({
+ table,
+ onRefresh
+}: PeriodicEvaluationsTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [createEvaluationDialogOpen, setCreateEvaluationDialogOpen] = React.useState(false)
+ const [requestDocumentsDialogOpen, setRequestDocumentsDialogOpen] = React.useState(false)
+ const [requestEvaluationDialogOpen, setRequestEvaluationDialogOpen] = React.useState(false)
+ const router = useRouter()
+
+ // 선택된 행들
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const hasSelection = selectedRows.length > 0
+ const selectedEvaluations = selectedRows.map(row => row.original)
+
+ // 선택된 항목들의 상태 분석
+ const selectedStats = React.useMemo(() => {
+ const pendingSubmission = selectedEvaluations.filter(e => e.status === "PENDING_SUBMISSION").length
+ const submitted = selectedEvaluations.filter(e => e.status === "SUBMITTED").length
+ const inReview = selectedEvaluations.filter(e => e.status === "IN_REVIEW").length
+ const reviewCompleted = selectedEvaluations.filter(e => e.status === "REVIEW_COMPLETED").length
+ const finalized = selectedEvaluations.filter(e => e.status === "FINALIZED").length
+
+ // 협력업체에게 자료 요청 가능: PENDING_SUBMISSION 상태
+ const canRequestDocuments = pendingSubmission > 0
+
+ // 평가자에게 평가 요청 가능: SUBMITTED 상태 (제출됐지만 아직 평가 시작 안됨)
+ const canRequestEvaluation = submitted > 0
+
+ return {
+ pendingSubmission,
+ submitted,
+ inReview,
+ reviewCompleted,
+ finalized,
+ canRequestDocuments,
+ canRequestEvaluation,
+ total: selectedEvaluations.length
+ }
+ }, [selectedEvaluations])
+
+ // ----------------------------------------------------------------
+ // 신규 정기평가 생성 (자동)
+ // ----------------------------------------------------------------
+ const handleAutoGenerate = async () => {
+ setIsLoading(true)
+ try {
+ // TODO: 평가대상에서 자동 생성 API 호출
+ toast.success("정기평가가 자동으로 생성되었습니다.")
+ router.refresh()
+ } catch (error) {
+ console.error('Error auto generating periodic evaluations:', error)
+ toast.error("자동 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // 신규 정기평가 생성 (수동)
+ // ----------------------------------------------------------------
+ const handleManualCreate = () => {
+ setCreateEvaluationDialogOpen(true)
+ }
+
+ // ----------------------------------------------------------------
+ // 다이얼로그 성공 핸들러
+ // ----------------------------------------------------------------
+ const handleActionSuccess = () => {
+ table.resetRowSelection()
+ onRefresh?.()
+ router.refresh()
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+
+ {/* 유틸리티 버튼들 */}
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "periodic-evaluations",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+
+ {/* 선택된 항목 액션 버튼들 */}
+ {hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ {/* 협력업체 자료 요청 버튼 */}
+ {selectedStats.canRequestDocuments && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2 text-blue-600 border-blue-200 hover:bg-blue-50"
+ onClick={() => setRequestDocumentsDialogOpen(true)}
+ disabled={isLoading}
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 자료 요청 ({selectedStats.pendingSubmission})
+ </span>
+ </Button>
+ )}
+
+ {/* 평가자 평가 요청 버튼 */}
+ {selectedStats.canRequestEvaluation && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2 text-green-600 border-green-200 hover:bg-green-50"
+ onClick={() => setRequestEvaluationDialogOpen(true)}
+ disabled={isLoading}
+ >
+ <Users className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 평가 요청 ({selectedStats.submitted})
+ </span>
+ </Button>
+ )}
+
+ {/* 알림 발송 버튼 (선택사항) */}
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => {
+ // TODO: 선택된 평가에 대한 알림 발송
+ toast.info("알림이 발송되었습니다.")
+ }}
+ disabled={isLoading}
+ >
+ <MessageSquare className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 알림 발송 ({selectedStats.total})
+ </span>
+ </Button>
+ </div>
+ )}
+ </div>
+
+
+ {/* 협력업체 자료 요청 다이얼로그 */}
+ <RequestDocumentsDialog
+ open={requestDocumentsDialogOpen}
+ onOpenChange={setRequestDocumentsDialogOpen}
+ evaluations={selectedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 평가자 평가 요청 다이얼로그 */}
+ <RequestEvaluationDialog
+ open={requestEvaluationDialogOpen}
+ onOpenChange={setRequestEvaluationDialogOpen}
+ evaluations={selectedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 선택 정보 표시 (디버깅용 - 필요시 주석 해제) */}
+ {/* {hasSelection && (
+ <div className="text-xs text-muted-foreground mt-2">
+ 선택된 {selectedRows.length}개 항목:
+ 제출대기 {selectedStats.pendingSubmission}개,
+ 제출완료 {selectedStats.submitted}개,
+ 검토중 {selectedStats.inReview}개,
+ 검토완료 {selectedStats.reviewCompleted}개,
+ 최종확정 {selectedStats.finalized}개
+ </div>
+ )} */}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/password-policy/service.ts b/lib/password-policy/service.ts
new file mode 100644
index 00000000..c4a0685c
--- /dev/null
+++ b/lib/password-policy/service.ts
@@ -0,0 +1,225 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { eq } from 'drizzle-orm'
+import db from '@/db/db'
+import { securitySettings } from '@/db/schema'
+import { SecuritySettings } from '@/components/system/passwordPolicy'
+
+// 보안 설정 조회
+export async function getSecuritySettings(): Promise<SecuritySettings> {
+ try {
+ const settings = await db.select().from(securitySettings).limit(1)
+
+ if (settings.length === 0) {
+ // 기본 설정으로 초기화
+ const defaultSettings = {
+ minPasswordLength: 8,
+ requireUppercase: true,
+ requireLowercase: true,
+ requireNumbers: true,
+ requireSymbols: true,
+ passwordExpiryDays: 90,
+ passwordHistoryCount: 5,
+ maxFailedAttempts: 5,
+ lockoutDurationMinutes: 30,
+ requireMfaForPartners: true,
+ smsTokenExpiryMinutes: 5,
+ maxSmsAttemptsPerDay: 10,
+ sessionTimeoutMinutes: 480,
+ }
+
+ const [newSettings] = await db
+ .insert(securitySettings)
+ .values(defaultSettings)
+ .returning()
+
+ return newSettings as SecuritySettings
+ }
+
+ return settings[0] as SecuritySettings
+ } catch (error) {
+ console.error('Failed to get security settings:', error)
+ throw new Error('보안 설정을 가져오는 중 오류가 발생했습니다.')
+ }
+}
+
+// 보안 설정 업데이트
+export async function updateSecuritySettings(
+ updates: Partial<Omit<SecuritySettings, 'id' | 'createdAt' | 'updatedAt'>>
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ // 유효성 검사
+ const validationResult = validateSecuritySettings(updates)
+ if (!validationResult.valid) {
+ return {
+ success: false,
+ error: validationResult.error
+ }
+ }
+
+ // 현재 설정 조회
+ const currentSettings = await db.select().from(securitySettings).limit(1)
+
+ if (currentSettings.length === 0) {
+ return {
+ success: false,
+ error: '기존 설정을 찾을 수 없습니다.'
+ }
+ }
+
+ // 업데이트 실행
+ await db
+ .update(securitySettings)
+ .set({
+ ...updates,
+ updatedAt: new Date()
+ })
+ .where(eq(securitySettings.id, currentSettings[0].id))
+
+ // 캐시 무효화
+ revalidatePath('/admin/password-policy')
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update security settings:', error)
+ return {
+ success: false,
+ error: '설정 업데이트 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+// 설정 유효성 검사
+function validateSecuritySettings(
+ updates: Partial<Omit<SecuritySettings, 'id' | 'createdAt' | 'updatedAt'>>
+): { valid: boolean; error?: string } {
+ // 패스워드 길이 검사
+ if (updates.minPasswordLength !== undefined) {
+ if (updates.minPasswordLength < 4 || updates.minPasswordLength > 128) {
+ return {
+ valid: false,
+ error: '패스워드 최소 길이는 4자 이상 128자 이하여야 합니다.'
+ }
+ }
+ }
+
+ // 패스워드 만료 기간 검사
+ if (updates.passwordExpiryDays !== undefined && updates.passwordExpiryDays !== null) {
+ if (updates.passwordExpiryDays < 0 || updates.passwordExpiryDays > 365) {
+ return {
+ valid: false,
+ error: '패스워드 만료 기간은 0일 이상 365일 이하여야 합니다.'
+ }
+ }
+ }
+
+ // 패스워드 히스토리 개수 검사
+ if (updates.passwordHistoryCount !== undefined) {
+ if (updates.passwordHistoryCount < 0 || updates.passwordHistoryCount > 20) {
+ return {
+ valid: false,
+ error: '패스워드 히스토리 개수는 0개 이상 20개 이하여야 합니다.'
+ }
+ }
+ }
+
+ // 최대 실패 횟수 검사
+ if (updates.maxFailedAttempts !== undefined) {
+ if (updates.maxFailedAttempts < 1 || updates.maxFailedAttempts > 20) {
+ return {
+ valid: false,
+ error: '최대 실패 횟수는 1회 이상 20회 이하여야 합니다.'
+ }
+ }
+ }
+
+ // 잠금 시간 검사
+ if (updates.lockoutDurationMinutes !== undefined) {
+ if (updates.lockoutDurationMinutes < 1 || updates.lockoutDurationMinutes > 1440) {
+ return {
+ valid: false,
+ error: '잠금 시간은 1분 이상 1440분(24시간) 이하여야 합니다.'
+ }
+ }
+ }
+
+ // SMS 토큰 만료 시간 검사
+ if (updates.smsTokenExpiryMinutes !== undefined) {
+ if (updates.smsTokenExpiryMinutes < 1 || updates.smsTokenExpiryMinutes > 30) {
+ return {
+ valid: false,
+ error: 'SMS 토큰 만료 시간은 1분 이상 30분 이하여야 합니다.'
+ }
+ }
+ }
+
+ // SMS 일일 한도 검사
+ if (updates.maxSmsAttemptsPerDay !== undefined) {
+ if (updates.maxSmsAttemptsPerDay < 1 || updates.maxSmsAttemptsPerDay > 100) {
+ return {
+ valid: false,
+ error: 'SMS 일일 한도는 1회 이상 100회 이하여야 합니다.'
+ }
+ }
+ }
+
+ // 세션 타임아웃 검사
+ if (updates.sessionTimeoutMinutes !== undefined) {
+ if (updates.sessionTimeoutMinutes < 5 || updates.sessionTimeoutMinutes > 1440) {
+ return {
+ valid: false,
+ error: '세션 타임아웃은 5분 이상 1440분(24시간) 이하여야 합니다.'
+ }
+ }
+ }
+
+ return { valid: true }
+}
+
+// 설정 리셋 (기본값으로 복원)
+export async function resetSecuritySettings(): Promise<{ success: boolean; error?: string }> {
+ try {
+ const defaultSettings = {
+ minPasswordLength: 8,
+ requireUppercase: true,
+ requireLowercase: true,
+ requireNumbers: true,
+ requireSymbols: true,
+ passwordExpiryDays: 90,
+ passwordHistoryCount: 5,
+ maxFailedAttempts: 5,
+ lockoutDurationMinutes: 30,
+ requireMfaForPartners: true,
+ smsTokenExpiryMinutes: 5,
+ maxSmsAttemptsPerDay: 10,
+ sessionTimeoutMinutes: 480,
+ updatedAt: new Date()
+ }
+
+ // 현재 설정 조회
+ const currentSettings = await db.select().from(securitySettings).limit(1)
+
+ if (currentSettings.length === 0) {
+ // 새로 생성
+ await db.insert(securitySettings).values(defaultSettings)
+ } else {
+ // 업데이트
+ await db
+ .update(securitySettings)
+ .set(defaultSettings)
+ .where(eq(securitySettings.id, currentSettings[0].id))
+ }
+
+ // 캐시 무효화
+ revalidatePath('/admin/password-policy')
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to reset security settings:', error)
+ return {
+ success: false,
+ error: '설정 초기화 중 오류가 발생했습니다.'
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index ffa29acd..e658747b 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -531,7 +531,7 @@ export async function sendTechSalesRfqToVendors(input: {
const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({
where: and(
eq(techSalesVendorQuotations.rfqId, input.rfqId),
- sql`${techSalesVendorQuotations.vendorId} IN (${input.vendorIds.join(',')})`
+ inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
),
columns: {
id: true,
diff --git a/lib/users/auth/partners-auth.ts b/lib/users/auth/partners-auth.ts
new file mode 100644
index 00000000..5418e2a8
--- /dev/null
+++ b/lib/users/auth/partners-auth.ts
@@ -0,0 +1,374 @@
+'use server';
+
+import { z } from 'zod';
+import { eq ,and} from 'drizzle-orm';
+import db from '@/db/db';
+import { users, mfaTokens } from '@/db/schema';
+import crypto from 'crypto';
+import { PasswordStrength, passwordResetRequestSchema, passwordResetSchema } from './validataions-password';
+import { sendEmail } from '@/lib/mail/sendEmail';
+import { analyzePasswordStrength, checkPasswordHistory, validatePasswordPolicy } from '@/lib/users/auth/passwordUtil';
+
+
+export interface PasswordValidationResult {
+ strength: PasswordStrength;
+ policyValid: boolean;
+ policyErrors: string[];
+ historyValid?: boolean;
+}
+
+// 비밀번호 재설정 요청 (서버 액션)
+export async function requestPasswordResetAction(
+ prevState: any,
+ formData: FormData
+): Promise<{ success: boolean; error?: string; message?: string }> {
+ try {
+ const rawData = {
+ email: formData.get('email') as string,
+ };
+
+ const validatedData = passwordResetRequestSchema.parse(rawData);
+
+ // 사용자 존재 확인
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ language: users.language
+ })
+ .from(users)
+ .where(eq(users.email, validatedData.email))
+ .limit(1);
+
+ if (!user[0]) {
+ // 보안상 사용자가 없어도 성공한 것처럼 응답
+ return {
+ success: true,
+ message: '해당 이메일로 재설정 링크를 전송했습니다. (가입된 이메일인 경우)'
+ };
+ }
+
+ // 기존 재설정 토큰 비활성화
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(and(
+ eq(mfaTokens.userId, user[0].id) ,
+ eq(mfaTokens.type, 'password_reset') ,
+ eq(mfaTokens.isActive, true))
+ );
+
+ // 새 토큰 생성 (32바이트 랜덤)
+ const resetToken = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date();
+ expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료
+
+ await db.insert(mfaTokens).values({
+ userId: user[0].id,
+ token: resetToken,
+ type: 'password_reset',
+ expiresAt,
+ isActive: true,
+ });
+
+ // 재설정 링크 생성
+ const resetLink = `${process.env.NEXTAUTH_URL}/auth/reset-password?token=${resetToken}`;
+
+ // 이메일 전송
+ await sendEmail({
+ to: user[0].email,
+ subject: user[0].language === 'ko' ? '비밀번호 재설정 요청' : 'Password Reset Request',
+ template: 'password-reset',
+ context: {
+ language: user[0].language || 'ko',
+ userName: user[0].name,
+ resetLink: resetLink,
+ expiryTime: '1시간',
+ supportEmail: process.env.SUPPORT_EMAIL || 'support@evcp.com',
+ },
+ });
+
+ return {
+ success: true,
+ message: '비밀번호 재설정 링크를 이메일로 전송했습니다.'
+ };
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: error.errors[0]?.message || '입력값이 올바르지 않습니다'
+ };
+ }
+
+ console.error('Password reset request error:', error);
+ return {
+ success: false,
+ error: '재설정 요청 처리 중 오류가 발생했습니다'
+ };
+ }
+}
+
+// 비밀번호 재설정 실행 (서버 액션)
+export async function resetPasswordAction(
+ prevState: any,
+ formData: FormData
+): Promise<{ success: boolean; error?: string; message?: string }> {
+ try {
+ const rawData = {
+ token: formData.get('token') as string,
+ newPassword: formData.get('newPassword') as string,
+ confirmPassword: formData.get('confirmPassword') as string,
+ };
+
+ const validatedData = passwordResetSchema.parse(rawData);
+
+ // 토큰 검증
+ const resetToken = await db
+ .select({
+ id: mfaTokens.id,
+ userId: mfaTokens.userId,
+ expiresAt: mfaTokens.expiresAt,
+ })
+ .from(mfaTokens)
+ .where(and(
+ eq(mfaTokens.token, validatedData.token) ,
+ eq(mfaTokens.type, 'password_reset') ,
+ eq(mfaTokens.isActive, true)
+ )
+ )
+ .limit(1);
+
+ if (!resetToken[0]) {
+ return {
+ success: false,
+ error: '유효하지 않은 재설정 링크입니다'
+ };
+ }
+
+ if (resetToken[0].expiresAt < new Date()) {
+ // 만료된 토큰 비활성화
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(eq(mfaTokens.id, resetToken[0].id));
+
+ return {
+ success: false,
+ error: '재설정 링크가 만료되었습니다. 다시 요청해주세요'
+ };
+ }
+
+ // 패스워드 변경 (기존 setPassword 함수 사용)
+ const { setPassword } = await import('@/lib/users/auth/passwordUtil');
+ const result = await setPassword(resetToken[0].userId, validatedData.newPassword);
+
+ if (result.success) {
+ // 토큰 비활성화
+ await db
+ .update(mfaTokens)
+ .set({
+ isActive: false,
+ usedAt: new Date(),
+ })
+ .where(eq(mfaTokens.id, resetToken[0].id));
+
+ // 계정 잠금 해제 (패스워드 재설정 시)
+ await db
+ .update(users)
+ .set({
+ isLocked: false,
+ lockoutUntil: null,
+ failedLoginAttempts: 0,
+ })
+ .where(eq(users.id, resetToken[0].userId));
+
+ return {
+ success: true,
+ message: '비밀번호가 성공적으로 변경되었습니다. 새 비밀번호로 로그인해주세요.'
+ };
+ }
+
+ return {
+ success: false,
+ error: result.errors.join(', ')
+ };
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: error.errors[0]?.message || '입력값이 올바르지 않습니다'
+ };
+ }
+
+ console.error('Password reset error:', error);
+ return {
+ success: false,
+ error: '비밀번호 재설정 중 오류가 발생했습니다'
+ };
+ }
+}
+
+// 토큰 유효성 검증 (페이지 접근 시)
+export async function validateResetTokenAction(
+ token: string
+): Promise<{ valid: boolean; error?: string; expired?: boolean, userId?: number }> {
+ try {
+ if (!token) {
+ return { valid: false, error: '토큰이 제공되지 않았습니다' };
+ }
+
+ const resetToken = await db
+ .select({
+ id: mfaTokens.id,
+ expiresAt: mfaTokens.expiresAt,
+ token: mfaTokens.token,
+ isActive: mfaTokens.isActive,
+ userId: mfaTokens.userId,
+ })
+ .from(mfaTokens)
+ .where(
+ and(eq(mfaTokens.token, token),
+ eq(mfaTokens.type, 'password_reset')
+ )
+ )
+ .limit(1);
+
+
+ console.log(token)
+ console.log(resetToken[0], "resetToken")
+
+ if (!resetToken[0]) {
+ return { valid: false, error: '유효하지 않은 토큰입니다' };
+ }
+
+ if (!resetToken[0].isActive) {
+ return { valid: false, error: '이미 사용된 토큰입니다' };
+ }
+
+ if (resetToken[0].expiresAt < new Date()) {
+ return { valid: false, expired: true, error: '토큰이 만료되었습니다' };
+ }
+
+ return { valid: true, userId: resetToken[0].userId };
+
+ } catch (error) {
+ console.error('Token validation error:', error);
+ return { valid: false, error: '토큰 검증 중 오류가 발생했습니다' };
+ }
+}
+
+
+export async function validatePasswordAction(
+ password: string,
+ userId?: number
+): Promise<PasswordValidationResult> {
+ try {
+ // 패스워드 강도 분석
+ const strength = analyzePasswordStrength(password);
+
+ // 정책 검증
+ const policyResult = await validatePasswordPolicy(password);
+
+ // 히스토리 검증 (userId가 있는 경우에만)
+ let historyValid: boolean | undefined = undefined;
+ if (userId) {
+ historyValid = await checkPasswordHistory(userId, password);
+ }
+
+ return {
+ strength,
+ policyValid: policyResult.valid,
+ policyErrors: policyResult.errors,
+ historyValid,
+ };
+ } catch (error) {
+ console.error('Password validation error:', error);
+
+ // 에러 발생 시 기본값 반환
+ return {
+ strength: {
+ score: 1,
+ hasUppercase: false,
+ hasLowercase: false,
+ hasNumbers: false,
+ hasSymbols: false,
+ length: 0,
+ feedback: ['검증 중 오류가 발생했습니다'],
+ },
+ policyValid: false,
+ policyErrors: ['검증 중 오류가 발생했습니다'],
+ };
+ }
+}
+
+
+// 비활성 유저 탐지
+export async function findInactiveUsers(inactiveDays: number = 90) {
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - inactiveDays);
+
+ return await db
+ .select({
+ id: users.id,
+ email: users.email,
+ name: users.name,
+ lastLoginAt: users.lastLoginAt,
+ })
+ .from(users)
+ .where(
+ and(
+ eq(users.isActive, true),
+ or(
+ lt(users.lastLoginAt, cutoffDate),
+ isNull(users.lastLoginAt) // 한번도 로그인하지 않은 유저
+ )
+ )
+ );
+}
+
+// 유저 비활성화 (Soft Delete)
+export async function deactivateUser(
+ userId: number,
+ reason: 'INACTIVE' | 'ADMIN' | 'GDPR' = 'INACTIVE'
+) {
+ return await db
+ .update(users)
+ .set({
+ isActive: false,
+ deactivatedAt: new Date(),
+ deactivationReason: reason,
+ })
+ .where(eq(users.id, userId));
+}
+
+// 배치 비활성화
+export async function deactivateInactiveUsers(inactiveDays: number = 90) {
+ const inactiveUsers = await findInactiveUsers(inactiveDays);
+
+ if (inactiveUsers.length === 0) {
+ return { deactivatedCount: 0, users: [] };
+ }
+
+ // 로그 기록
+ console.log(`Deactivating ${inactiveUsers.length} inactive users`);
+
+ await db
+ .update(users)
+ .set({
+ isActive: false,
+ deactivatedAt: new Date(),
+ deactivationReason: 'INACTIVE',
+ })
+ .where(
+ inArray(users.id, inactiveUsers.map(u => u.id))
+ );
+
+ return {
+ deactivatedCount: inactiveUsers.length,
+ users: inactiveUsers
+ };
+}
+
diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts
new file mode 100644
index 00000000..ee4e13c2
--- /dev/null
+++ b/lib/users/auth/passwordUtil.ts
@@ -0,0 +1,608 @@
+// lib/auth/passwordUtils.ts
+
+import bcrypt from 'bcryptjs';
+import crypto from 'crypto';
+import { eq, and, desc, count, sql, gte, inArray } from 'drizzle-orm';
+import db from '@/db/db';
+import {
+ users,
+ passwords,
+ passwordHistory,
+ securitySettings,
+ mfaTokens
+} from '@/db/schema';
+
+export interface PasswordStrength {
+ score: number; // 1-5
+ hasUppercase: boolean;
+ hasLowercase: boolean;
+ hasNumbers: boolean;
+ hasSymbols: boolean;
+ length: number;
+ feedback: string[];
+}
+
+export interface PasswordPolicy {
+ minLength: number;
+ requireUppercase: boolean;
+ requireLowercase: boolean;
+ requireNumbers: boolean;
+ requireSymbols: boolean;
+ historyCount: number;
+}
+
+// 패스워드 강도 분석
+export function analyzePasswordStrength(password: string): PasswordStrength {
+ const hasUppercase = /[A-Z]/.test(password);
+ const hasLowercase = /[a-z]/.test(password);
+ const hasNumbers = /\d/.test(password);
+ const hasSymbols = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
+ const length = password.length;
+
+ const feedback: string[] = [];
+ let score = 1;
+
+ // 길이 체크
+ if (length >= 12) {
+ score += 1;
+ } else if (length < 8) {
+ feedback.push('최소 8자 이상 사용하세요');
+ }
+
+ // 문자 종류 체크
+ const typeCount = [hasUppercase, hasLowercase, hasNumbers, hasSymbols]
+ .filter(Boolean).length;
+
+ if (typeCount >= 4) {
+ score += 2;
+ } else if (typeCount >= 3) {
+ score += 1;
+ } else {
+ if (!hasUppercase) feedback.push('대문자를 포함하세요');
+ if (!hasLowercase) feedback.push('소문자를 포함하세요');
+ if (!hasNumbers) feedback.push('숫자를 포함하세요');
+ if (!hasSymbols) feedback.push('특수문자를 포함하세요');
+ }
+
+ // 일반적인 패턴 체크
+ if (/(.)\1{2,}/.test(password)) {
+ feedback.push('같은 문자가 3번 이상 반복되지 않도록 하세요');
+ score = Math.max(1, score - 1);
+ }
+
+ if (/123|abc|qwe|password|admin/i.test(password)) {
+ feedback.push('일반적인 패턴이나 단어는 피하세요');
+ score = Math.max(1, score - 1);
+ }
+
+ return {
+ score: Math.min(5, score),
+ hasUppercase,
+ hasLowercase,
+ hasNumbers,
+ hasSymbols,
+ length,
+ feedback,
+ };
+}
+
+// 패스워드 정책 가져오기
+export async function getPasswordPolicy(): Promise<PasswordPolicy> {
+ const settings = await db.select().from(securitySettings).limit(1);
+ const setting = settings[0];
+
+ return {
+ minLength: setting?.minPasswordLength || 8,
+ requireUppercase: setting?.requireUppercase || true,
+ requireLowercase: setting?.requireLowercase || true,
+ requireNumbers: setting?.requireNumbers || true,
+ requireSymbols: setting?.requireSymbols || true,
+ historyCount: setting?.passwordHistoryCount || 5,
+ };
+}
+
+// 패스워드 정책 검증
+export async function validatePasswordPolicy(
+ password: string,
+ policy?: PasswordPolicy
+): Promise<{ valid: boolean; errors: string[] }> {
+ const passwordPolicy = policy || await getPasswordPolicy();
+ const strength = analyzePasswordStrength(password);
+ const errors: string[] = [];
+
+ if (strength.length < passwordPolicy.minLength) {
+ errors.push(`최소 ${passwordPolicy.minLength}자 이상이어야 합니다`);
+ }
+
+ if (passwordPolicy.requireUppercase && !strength.hasUppercase) {
+ errors.push('대문자를 포함해야 합니다');
+ }
+
+ if (passwordPolicy.requireLowercase && !strength.hasLowercase) {
+ errors.push('소문자를 포함해야 합니다');
+ }
+
+ if (passwordPolicy.requireNumbers && !strength.hasNumbers) {
+ errors.push('숫자를 포함해야 합니다');
+ }
+
+ if (passwordPolicy.requireSymbols && !strength.hasSymbols) {
+ errors.push('특수문자를 포함해야 합니다');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ };
+}
+
+// 이전 패스워드와 중복 체크
+export async function checkPasswordHistory(
+ userId: number,
+ newPassword: string,
+ historyCount: number = 5
+): Promise<boolean> {
+ const histories = await db
+ .select({
+ passwordHash: passwordHistory.passwordHash,
+ salt: passwordHistory.salt,
+ })
+ .from(passwordHistory)
+ .where(eq(passwordHistory.userId, userId))
+ .orderBy(desc(passwordHistory.createdAt))
+ .limit(historyCount);
+
+ // 현재 활성 패스워드도 체크
+ const currentPassword = await db
+ .select({
+ passwordHash: passwords.passwordHash,
+ salt: passwords.salt,
+ })
+ .from(passwords)
+ .where(
+ and(
+ eq(passwords.userId, userId),
+ eq(passwords.isActive, true)
+ )
+ )
+ .limit(1);
+
+ const allPasswords = [...histories];
+ if (currentPassword[0]) {
+ allPasswords.unshift(currentPassword[0]);
+ }
+
+ // 각 이전 패스워드와 비교
+ for (const pwd of allPasswords) {
+ const isMatch = await bcrypt.compare(
+ newPassword + pwd.salt,
+ pwd.passwordHash
+ );
+ if (isMatch) {
+ return false; // 중복됨
+ }
+ }
+
+ return true; // 중복 없음
+}
+
+// Salt 생성
+function generateSalt(): string {
+ return crypto.randomBytes(16).toString('hex');
+}
+
+// 패스워드 해싱
+async function hashPassword(password: string, salt: string): Promise<string> {
+ const saltRounds = 12;
+ return bcrypt.hash(password + salt, saltRounds);
+}
+
+// 새 패스워드 설정
+export async function setPassword(
+ userId: number,
+ newPassword: string
+): Promise<{ success: boolean; errors: string[] }> {
+ try {
+ // 1. 정책 검증
+ const policyResult = await validatePasswordPolicy(newPassword);
+ if (!policyResult.valid) {
+ return { success: false, errors: policyResult.errors };
+ }
+
+ // 2. 히스토리 검증
+ const policy = await getPasswordPolicy();
+ const isUnique = await checkPasswordHistory(userId, newPassword, policy.historyCount);
+ if (!isUnique) {
+ return {
+ success: false,
+ errors: [`최근 ${policy.historyCount}개 패스워드와 달라야 합니다`]
+ };
+ }
+
+ // 3. 현재 패스워드를 히스토리로 이동
+ const currentPassword = await db
+ .select()
+ .from(passwords)
+ .where(
+ and(
+ eq(passwords.userId, userId),
+ eq(passwords.isActive, true)
+ )
+ )
+ .limit(1);
+
+ if (currentPassword[0]) {
+ await db.insert(passwordHistory).values({
+ userId,
+ passwordHash: currentPassword[0].passwordHash,
+ salt: currentPassword[0].salt,
+ createdAt: currentPassword[0].createdAt,
+ replacedAt: new Date(),
+ });
+
+ // 현재 패스워드 비활성화
+ await db
+ .update(passwords)
+ .set({ isActive: false })
+ .where(eq(passwords.id, currentPassword[0].id));
+ }
+
+ // 4. 새 패스워드 생성
+ const salt = generateSalt();
+ const hashedPassword = await hashPassword(newPassword, salt);
+ const strength = analyzePasswordStrength(newPassword);
+
+ // 패스워드 만료일 계산
+ let expiresAt: Date | null = null;
+ const settings = await db.select().from(securitySettings).limit(1);
+ const expiryDays = settings[0]?.passwordExpiryDays;
+ if (expiryDays) {
+ expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + expiryDays);
+ }
+
+ await db.insert(passwords).values({
+ userId,
+ passwordHash: hashedPassword,
+ salt,
+ strength: strength.score,
+ hasUppercase: strength.hasUppercase,
+ hasLowercase: strength.hasLowercase,
+ hasNumbers: strength.hasNumbers,
+ hasSymbols: strength.hasSymbols,
+ length: strength.length,
+ expiresAt,
+ isActive: true,
+ });
+
+ // 5. 패스워드 변경 필수 플래그 해제
+ await db
+ .update(users)
+ .set({ passwordChangeRequired: false })
+ .where(eq(users.id, userId));
+
+ // 6. 오래된 히스토리 정리
+ await cleanupPasswordHistory(userId, policy.historyCount);
+
+ return { success: true, errors: [] };
+
+ } catch (error) {
+ console.error('Password setting error:', error);
+ return { success: false, errors: ['패스워드 설정 중 오류가 발생했습니다'] };
+ }
+}
+
+// 패스워드 히스토리 정리
+async function cleanupPasswordHistory(userId: number, keepCount: number) {
+ const histories = await db
+ .select({ id: passwordHistory.id })
+ .from(passwordHistory)
+ .where(eq(passwordHistory.userId, userId))
+ .orderBy(desc(passwordHistory.createdAt))
+ .offset(keepCount);
+
+ if (histories.length > 0) {
+ const idsToDelete = histories.map(h => h.id);
+ await db
+ .delete(passwordHistory)
+ .where(and(eq(passwordHistory.userId, userId), inArray(passwordHistory.id, idsToDelete)))
+ }
+}
+
+// MFA SMS 토큰 생성 및 전송
+// Bizppurio API 토큰 발급
+async function getBizppurioToken(): Promise<string> {
+ const account = process.env.BIZPPURIO_ACCOUNT;
+ const password = process.env.BIZPPURIO_PASSWORD;
+
+ if (!account || !password) {
+ throw new Error('Bizppurio credentials not configured');
+ }
+
+ const credentials = Buffer.from(`${account}:${password}`).toString('base64');
+
+ const response = await fetch('https://api.bizppurio.com/v1/token', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Basic ${credentials}`,
+ 'Content-Type': 'application/json; charset=utf-8'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Token request failed: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.accesstoken;
+}
+
+// SMS 메시지 전송
+async function sendSmsMessage(phoneNumber: string, message: string): Promise<boolean> {
+ try {
+ const accessToken = await getBizppurioToken();
+ const account = process.env.BIZPPURIO_ACCOUNT;
+ const fromNumber = process.env.BIZPPURIO_FROM_NUMBER;
+
+ if (!account || !fromNumber) {
+ throw new Error('Bizppurio configuration missing');
+ }
+
+ // phoneNumber에서 국가코드와 번호 분리
+ let country = '';
+ let to = phoneNumber;
+
+ if (phoneNumber.startsWith('+82')) {
+ country = '82';
+ to = phoneNumber.substring(3);
+ // 한국 번호는 0으로 시작하지 않는 경우 0 추가
+ if (!to.startsWith('0')) {
+ to = '0' + to;
+ }
+ } else if (phoneNumber.startsWith('+1')) {
+ country = '1';
+ to = phoneNumber.substring(2);
+ } else if (phoneNumber.startsWith('+81')) {
+ country = '81';
+ to = phoneNumber.substring(3);
+ } else if (phoneNumber.startsWith('+86')) {
+ country = '86';
+ to = phoneNumber.substring(3);
+ }
+ // 국가코드가 없는 경우 한국으로 가정
+ else if (!phoneNumber.startsWith('+')) {
+ country = '82';
+ to = phoneNumber.replace(/-/g, ''); // 하이픈 제거
+ if (!to.startsWith('0')) {
+ to = '0' + to;
+ }
+ }
+
+ const requestBody = {
+ account: account,
+ type: 'SMS',
+ from: fromNumber,
+ to: to,
+ country: country,
+ content: {
+ sms: {
+ message: message
+ }
+ },
+ refkey: `sms_${Date.now()}_${Math.random().toString(36).substring(7)}` // 고객사에서 부여한 키
+ };
+
+ const response = await fetch('https://api.bizppurio.com/v3/message', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: JSON.stringify(requestBody)
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SMS send failed: ${response.status} - ${errorText}`);
+ }
+
+ const result = await response.json();
+
+ if (result.code === 1000) {
+ console.log(`SMS sent successfully. MessageKey: ${result.messagekey}`);
+ return true;
+ } else {
+ throw new Error(`SMS send failed: ${result.description} (Code: ${result.code})`);
+ }
+ } catch (error) {
+ console.error('SMS send error:', error);
+ return false;
+ }
+}
+
+
+const SMS_TEMPLATES = {
+ '82': '[인증번호] {token}', // 한국
+ '1': '[Verification Code] {token}', // 미국
+ '81': '[認証コード] {token}', // 일본
+ '86': '[验证码] {token}', // 중국
+ 'default': '[Verification Code] {token}' // 기본값 (영어)
+} as const;
+
+function getSmsMessage(country: string, token: string): string {
+ const template = SMS_TEMPLATES[country as keyof typeof SMS_TEMPLATES] || SMS_TEMPLATES.default;
+ return template.replace('{token}', token);
+}
+
+// 업데이트된 메인 함수
+export async function generateAndSendSmsToken(
+ userId: number,
+ phoneNumber: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ // 1. 일일 SMS 한도 체크
+ const settings = await db.select().from(securitySettings).limit(1);
+ const maxSmsPerDay = settings[0]?.maxSmsAttemptsPerDay || 10;
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+
+ const todayCount = await db
+ .select({ count: count() })
+ .from(mfaTokens)
+ .where(
+ and(
+ eq(mfaTokens.userId, userId),
+ eq(mfaTokens.type, 'sms'),
+ gte(mfaTokens.createdAt, today)
+ )
+ );
+
+ if (todayCount[0]?.count >= maxSmsPerDay) {
+ return { success: false, error: '일일 SMS 한도를 초과했습니다' };
+ }
+
+ // 2. 이전 SMS 토큰 비활성화
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(
+ and(
+ eq(mfaTokens.userId, userId),
+ eq(mfaTokens.type, 'sms'),
+ eq(mfaTokens.isActive, true)
+ )
+ );
+
+ // 3. 새 토큰 생성
+ const token = Math.random().toString().slice(2, 8).padStart(6, '0');
+ const expiryMinutes = settings[0]?.smsTokenExpiryMinutes || 5;
+ const expiresAt = new Date();
+ expiresAt.setMinutes(expiresAt.getMinutes() + expiryMinutes);
+
+ await db.insert(mfaTokens).values({
+ userId,
+ token,
+ type: 'sms',
+ phoneNumber,
+ expiresAt,
+ isActive: true,
+ });
+
+ let country = '';
+
+ if (phoneNumber.startsWith('+82')) {
+ country = '82';
+ } else if (phoneNumber.startsWith('+1')) {
+ country = '1';
+ } else if (phoneNumber.startsWith('+81')) {
+ country = '81';
+ } else if (phoneNumber.startsWith('+86')) {
+ country = '86';
+ }
+ // 국가코드가 없는 경우 한국으로 가정
+ else if (!phoneNumber.startsWith('+')) {
+ country = '82';
+ }
+
+ // 4. SMS 전송 (Bizppurio API 사용)
+ const message = getSmsMessage(country, token);
+ const smsResult = await sendSmsMessage(phoneNumber, message);
+
+ if (!smsResult) {
+ // SMS 전송 실패 시 토큰 비활성화
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(
+ and(
+ eq(mfaTokens.userId, userId),
+ eq(mfaTokens.token, token)
+ )
+ );
+
+ return { success: false, error: 'SMS 전송에 실패했습니다' };
+ }
+
+ console.log(`SMS 토큰 ${token}을 ${phoneNumber}로 전송했습니다`);
+ return { success: true };
+
+ } catch (error) {
+ console.error('SMS token generation error:', error);
+ return { success: false, error: 'SMS 전송 중 오류가 발생했습니다' };
+ }
+}
+// SMS 토큰 검증
+export async function verifySmsToken(
+ userId: number,
+ token: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const mfaToken = await db
+ .select()
+ .from(mfaTokens)
+ .where(
+ and(
+ eq(mfaTokens.userId, userId),
+ eq(mfaTokens.token, token),
+ eq(mfaTokens.type, 'sms'),
+ eq(mfaTokens.isActive, true)
+ )
+ )
+ .limit(1);
+
+ if (!mfaToken[0]) {
+ return { success: false, error: '잘못된 인증번호입니다' };
+ }
+
+ // 만료 체크
+ if (mfaToken[0].expiresAt < new Date()) {
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(eq(mfaTokens.id, mfaToken[0].id));
+
+ return { success: false, error: '인증번호가 만료되었습니다' };
+ }
+
+ // 시도 횟수 증가
+ const newAttempts = mfaToken[0].attempts + 1;
+ if (newAttempts > 3) {
+ await db
+ .update(mfaTokens)
+ .set({ isActive: false })
+ .where(eq(mfaTokens.id, mfaToken[0].id));
+
+ return { success: false, error: '시도 횟수를 초과했습니다' };
+ }
+
+ // 토큰 사용 처리
+ await db
+ .update(mfaTokens)
+ .set({
+ usedAt: new Date(),
+ isActive: false,
+ attempts: newAttempts,
+ })
+ .where(eq(mfaTokens.id, mfaToken[0].id));
+
+ return { success: true };
+
+ } catch (error) {
+ console.error('SMS token verification error:', error);
+ return { success: false, error: '인증 중 오류가 발생했습니다' };
+ }
+}
+
+// 패스워드 강제 변경 필요 체크
+export async function checkPasswordChangeRequired(userId: number): Promise<boolean> {
+ const user = await db
+ .select({ passwordChangeRequired: users.passwordChangeRequired })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ return user[0]?.passwordChangeRequired || false;
+} \ No newline at end of file
diff --git a/lib/users/auth/validataions-password.ts b/lib/users/auth/validataions-password.ts
new file mode 100644
index 00000000..ab73751c
--- /dev/null
+++ b/lib/users/auth/validataions-password.ts
@@ -0,0 +1,230 @@
+// lib/validations/password.ts
+
+import { z } from 'zod';
+import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
+import { passwords, passwordHistory, loginAttempts, mfaTokens, users } from '@/db/schema';
+
+// Drizzle 테이블에서 자동 생성된 Zod 스키마
+export const insertPasswordSchema = createInsertSchema(passwords);
+export const selectPasswordSchema = createSelectSchema(passwords);
+
+export const insertPasswordHistorySchema = createInsertSchema(passwordHistory);
+export const selectPasswordHistorySchema = createSelectSchema(passwordHistory);
+
+export const insertLoginAttemptSchema = createInsertSchema(loginAttempts);
+export const selectLoginAttemptSchema = createSelectSchema(loginAttempts);
+
+export const insertMfaTokenSchema = createInsertSchema(mfaTokens);
+export const selectMfaTokenSchema = createSelectSchema(mfaTokens);
+
+// 커스텀 검증 스키마들
+
+// 패스워드 생성 시 검증
+export const createPasswordSchema = z.object({
+ userId: z.number().int().positive(),
+ password: z.string()
+ .min(8, "패스워드는 최소 8자 이상이어야 합니다")
+ .max(128, "패스워드는 최대 128자까지 가능합니다")
+ .regex(/[A-Z]/, "대문자를 포함해야 합니다")
+ .regex(/[a-z]/, "소문자를 포함해야 합니다")
+ .regex(/\d/, "숫자를 포함해야 합니다")
+ .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다")
+ .refine(
+ (password) => !/(.)\\1{2,}/.test(password),
+ "같은 문자가 3번 이상 연속될 수 없습니다"
+ )
+ .refine(
+ (password) => !/123|abc|qwe|password|admin|login/i.test(password),
+ "일반적인 패턴이나 단어는 사용할 수 없습니다"
+ ),
+ expiresAt: z.date().optional(),
+});
+
+// 로그인 검증
+export const loginCredentialsSchema = z.object({
+ email: z.string()
+ .email("올바른 이메일 형식이 아닙니다")
+ .max(255, "이메일이 너무 깁니다"),
+ password: z.string()
+ .min(1, "패스워드를 입력해주세요")
+ .max(128, "패스워드가 너무 깁니다"),
+});
+
+// MFA SMS 토큰 검증
+export const smsTokenSchema = z.object({
+ userId: z.string().or(z.number()).transform(val => Number(val)),
+ token: z.string()
+ .length(6, "인증번호는 6자리여야 합니다")
+ .regex(/^\d{6}$/, "인증번호는 숫자 6자리여야 합니다"),
+});
+
+// 전화번호 등록
+export const phoneRegistrationSchema = z.object({
+ phoneNumber: z.string()
+ .regex(/^\+?[1-9]\d{1,14}$/, "올바른 전화번호 형식이 아닙니다")
+ .transform(phone => phone.replace(/[\s-]/g, '')), // 공백, 하이픈 제거
+});
+
+// 패스워드 재설정 요청
+export const passwordResetRequestSchema = z.object({
+ email: z.string()
+ .email("올바른 이메일 형식이 아닙니다")
+ .max(255, "이메일이 너무 깁니다"),
+});
+
+// 패스워드 재설정 실행
+export const passwordResetSchema = z.object({
+ token: z.string()
+ .min(1, "토큰이 필요합니다")
+ .max(255, "토큰이 너무 깁니다"),
+ newPassword: z.string()
+ .min(8, "패스워드는 최소 8자 이상이어야 합니다")
+ .max(128, "패스워드는 최대 128자까지 가능합니다")
+ .regex(/[A-Z]/, "대문자를 포함해야 합니다")
+ .regex(/[a-z]/, "소문자를 포함해야 합니다")
+ .regex(/\d/, "숫자를 포함해야 합니다")
+ .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"),
+ confirmPassword: z.string(),
+}).refine(
+ (data) => data.newPassword === data.confirmPassword,
+ {
+ message: "패스워드가 일치하지 않습니다",
+ path: ["confirmPassword"],
+ }
+);
+
+// 보안 설정 업데이트
+export const securitySettingsSchema = z.object({
+ minPasswordLength: z.number().int().min(6).max(32).default(8),
+ requireUppercase: z.boolean().default(true),
+ requireLowercase: z.boolean().default(true),
+ requireNumbers: z.boolean().default(true),
+ requireSymbols: z.boolean().default(true),
+ passwordExpiryDays: z.number().int().min(0).max(365).nullable().default(90),
+ passwordHistoryCount: z.number().int().min(1).max(24).default(5),
+ maxFailedAttempts: z.number().int().min(3).max(20).default(5),
+ lockoutDurationMinutes: z.number().int().min(5).max(1440).default(30), // 최대 24시간
+ requireMfaForPartners: z.boolean().default(true),
+ smsTokenExpiryMinutes: z.number().int().min(1).max(60).default(5),
+ maxSmsAttemptsPerDay: z.number().int().min(5).max(50).default(10),
+ sessionTimeoutMinutes: z.number().int().min(30).max(1440).default(480), // 30분 ~ 24시간
+});
+
+// 사용자 등록 (partners용 - 패스워드 포함)
+export const userRegistrationSchema = z.object({
+ name: z.string()
+ .min(1, "이름을 입력해주세요")
+ .max(255, "이름이 너무 깁니다"),
+ email: z.string()
+ .email("올바른 이메일 형식이 아닙니다")
+ .max(255, "이메일이 너무 깁니다"),
+ password: z.string()
+ .min(8, "패스워드는 최소 8자 이상이어야 합니다")
+ .max(128, "패스워드는 최대 128자까지 가능합니다")
+ .regex(/[A-Z]/, "대문자를 포함해야 합니다")
+ .regex(/[a-z]/, "소문자를 포함해야 합니다")
+ .regex(/\d/, "숫자를 포함해야 합니다")
+ .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"),
+ confirmPassword: z.string(),
+ phone: z.string()
+ .regex(/^\+?[1-9]\d{1,14}$/, "올바른 전화번호 형식이 아닙니다")
+ .optional(),
+ domain: z.enum(['partners', 'clients', 'internal']).default('partners'),
+ companyId: z.number().int().positive().optional(),
+ techCompanyId: z.number().int().positive().optional(),
+}).refine(
+ (data) => data.password === data.confirmPassword,
+ {
+ message: "패스워드가 일치하지 않습니다",
+ path: ["confirmPassword"],
+ }
+);
+
+// 패스워드 변경 (기존 패스워드 필요)
+export const changePasswordSchema = z.object({
+ currentPassword: z.string()
+ .min(1, "현재 패스워드를 입력해주세요"),
+ newPassword: z.string()
+ .min(8, "패스워드는 최소 8자 이상이어야 합니다")
+ .max(128, "패스워드는 최대 128자까지 가능합니다")
+ .regex(/[A-Z]/, "대문자를 포함해야 합니다")
+ .regex(/[a-z]/, "소문자를 포함해야 합니다")
+ .regex(/\d/, "숫자를 포함해야 합니다")
+ .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"),
+ confirmPassword: z.string(),
+}).refine(
+ (data) => data.newPassword === data.confirmPassword,
+ {
+ message: "새 패스워드가 일치하지 않습니다",
+ path: ["confirmPassword"],
+ }
+).refine(
+ (data) => data.currentPassword !== data.newPassword,
+ {
+ message: "새 패스워드는 현재 패스워드와 달라야 합니다",
+ path: ["newPassword"],
+ }
+);
+
+// 로그인 이력 조회 필터
+export const loginHistoryFilterSchema = z.object({
+ userId: z.string().or(z.number()).transform(val => Number(val)),
+ limit: z.number().int().min(1).max(100).default(10),
+ offset: z.number().int().min(0).default(0),
+ success: z.boolean().optional(), // 성공/실패 필터
+ dateFrom: z.date().optional(),
+ dateTo: z.date().optional(),
+ ipAddress: z.string().optional(),
+});
+
+// API 응답 타입들
+export type LoginCredentials = z.infer<typeof loginCredentialsSchema>;
+export type SmsToken = z.infer<typeof smsTokenSchema>;
+export type PhoneRegistration = z.infer<typeof phoneRegistrationSchema>;
+export type PasswordResetRequest = z.infer<typeof passwordResetRequestSchema>;
+export type PasswordReset = z.infer<typeof passwordResetSchema>;
+export type UserRegistration = z.infer<typeof userRegistrationSchema>;
+export type ChangePassword = z.infer<typeof changePasswordSchema>;
+export type SecuritySettings = z.infer<typeof securitySettingsSchema>;
+export type LoginHistoryFilter = z.infer<typeof loginHistoryFilterSchema>;
+export type CreatePassword = z.infer<typeof createPasswordSchema>;
+
+// 패스워드 강도 결과 타입
+export const passwordStrengthSchema = z.object({
+ score: z.number().int().min(1).max(5),
+ hasUppercase: z.boolean(),
+ hasLowercase: z.boolean(),
+ hasNumbers: z.boolean(),
+ hasSymbols: z.boolean(),
+ length: z.number().int(),
+ feedback: z.array(z.string()),
+});
+
+export type PasswordStrength = z.infer<typeof passwordStrengthSchema>;
+
+// 인증 결과 타입
+export const authResultSchema = z.object({
+ success: z.boolean(),
+ user: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ imageUrl: z.string().nullable().optional(),
+ companyId: z.number().nullable().optional(),
+ techCompanyId: z.number().nullable().optional(),
+ domain: z.string().optional(),
+ }).optional(),
+ error: z.enum([
+ 'INVALID_CREDENTIALS',
+ 'ACCOUNT_LOCKED',
+ 'PASSWORD_EXPIRED',
+ 'ACCOUNT_DISABLED',
+ 'RATE_LIMITED',
+ 'MFA_REQUIRED',
+ 'SYSTEM_ERROR'
+ ]).optional(),
+ requiresMfa: z.boolean().optional(),
+ mfaToken: z.string().optional(),
+});
+
+export type AuthResult = z.infer<typeof authResultSchema>; \ No newline at end of file
diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts
new file mode 100644
index 00000000..ec3159a8
--- /dev/null
+++ b/lib/users/auth/verifyCredentails.ts
@@ -0,0 +1,620 @@
+// lib/auth/verifyCredentials.ts
+
+import bcrypt from 'bcryptjs';
+import { eq, and, desc, gte, count } from 'drizzle-orm';
+import db from '@/db/db';
+import {
+ users,
+ passwords,
+ passwordHistory,
+ loginAttempts,
+ securitySettings,
+ mfaTokens,
+ vendors
+} from '@/db/schema';
+import { headers } from 'next/headers';
+import { generateAndSendSmsToken, verifySmsToken } from './passwordUtil';
+
+// 에러 타입 정의
+export type AuthError =
+ | 'INVALID_CREDENTIALS'
+ | 'ACCOUNT_LOCKED'
+ | 'PASSWORD_EXPIRED'
+ | 'ACCOUNT_DISABLED'
+ | 'RATE_LIMITED'
+ | 'MFA_REQUIRED'
+ | 'SYSTEM_ERROR';
+
+export interface AuthResult {
+ success: boolean;
+ user?: {
+ id: string;
+ name: string;
+ email: string;
+ imageUrl?: string | null;
+ companyId?: number | null;
+ techCompanyId?: number | null;
+ domain?: string | null;
+ };
+ error?: AuthError;
+ requiresMfa?: boolean;
+ mfaToken?: string; // MFA가 필요한 경우 임시 토큰
+}
+
+// 클라이언트 IP 가져오기
+export async function getClientIP(): Promise<string> {
+ const headersList = await headers(); // ✨ await!
+ const forwarded = headersList.get('x-forwarded-for');
+ const realIP = headersList.get('x-real-ip');
+
+ if (forwarded) return forwarded.split(',')[0].trim();
+ if (realIP) return realIP;
+ return 'unknown';
+}
+
+// User-Agent 가져오기
+export async function getUserAgent(): Promise<string> {
+ const headersList = await headers(); // ✨ await!
+ return headersList.get('user-agent') ?? 'unknown';
+}
+
+
+// 보안 설정 가져오기 (캐시 고려)
+async function getSecuritySettings() {
+ const settings = await db.select().from(securitySettings).limit(1);
+ return settings[0] || {
+ maxFailedAttempts: 5,
+ lockoutDurationMinutes: 30,
+ requireMfaForPartners: true,
+ smsTokenExpiryMinutes: 5,
+ maxSmsAttemptsPerDay: 10,
+ passwordExpiryDays: 90,
+ };
+}
+
+// Rate limiting 체크
+async function checkRateLimit(email: string, ipAddress: string): Promise<boolean> {
+ const now = new Date();
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
+
+ // 이메일별 시도 횟수 체크 (1시간 내 20회 제한)
+ const emailAttempts = await db
+ .select({ count: count() })
+ .from(loginAttempts)
+ .where(
+ and(
+ eq(loginAttempts.email, email),
+ gte(loginAttempts.attemptedAt, oneHourAgo)
+ )
+ );
+
+ if (emailAttempts[0]?.count >= 20) {
+ return false;
+ }
+
+ // IP별 시도 횟수 체크 (1시간 내 50회 제한)
+ const ipAttempts = await db
+ .select({ count: count() })
+ .from(loginAttempts)
+ .where(
+ and(
+ eq(loginAttempts.ipAddress, ipAddress),
+ gte(loginAttempts.attemptedAt, oneHourAgo)
+ )
+ );
+
+ if (ipAttempts[0]?.count >= 50) {
+ return false;
+ }
+
+ return true;
+}
+
+// 로그인 시도 기록
+async function logLoginAttempt(
+ username: string,
+ userId: number | null,
+ success: boolean,
+ failureReason?: string
+) {
+ const ipAddress = getClientIP();
+ const userAgent = getUserAgent();
+
+ await db.insert(loginAttempts).values({
+ email: username,
+ userId,
+ success,
+ ipAddress,
+ userAgent,
+ failureReason,
+ attemptedAt: new Date(),
+
+ });
+}
+
+// 계정 잠금 체크 및 업데이트
+async function checkAndUpdateLockout(userId: number, settings: any): Promise<boolean> {
+ const user = await db
+ .select({
+ isLocked: users.isLocked,
+ lockoutUntil: users.lockoutUntil,
+ failedAttempts: users.failedLoginAttempts,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!user[0]) return true; // 사용자가 없으면 잠금 처리
+
+ const now = new Date();
+
+ // 잠금 해제 시간이 지났는지 확인
+ if (user[0].lockoutUntil && user[0].lockoutUntil < now) {
+ await db
+ .update(users)
+ .set({
+ isLocked: false,
+ lockoutUntil: null,
+ failedLoginAttempts: 0,
+ })
+ .where(eq(users.id, userId));
+ return false;
+ }
+
+ return user[0].isLocked;
+}
+
+// 실패한 로그인 시도 처리
+async function handleFailedLogin(userId: number, settings: any) {
+ const user = await db
+ .select({
+ failedAttempts: users.failedLoginAttempts,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!user[0]) return;
+
+ const newFailedAttempts = user[0].failedAttempts + 1;
+ const shouldLock = newFailedAttempts >= settings.maxFailedAttempts;
+
+ const updateData: any = {
+ failedLoginAttempts: newFailedAttempts,
+ };
+
+ if (shouldLock) {
+ const lockoutUntil = new Date();
+ lockoutUntil.setMinutes(lockoutUntil.getMinutes() + settings.lockoutDurationMinutes);
+
+ updateData.isLocked = true;
+ updateData.lockoutUntil = lockoutUntil;
+ }
+
+ await db
+ .update(users)
+ .set(updateData)
+ .where(eq(users.id, userId));
+}
+
+// 성공한 로그인 처리
+async function handleSuccessfulLogin(userId: number) {
+ await db
+ .update(users)
+ .set({
+ failedLoginAttempts: 0,
+ lastLoginAt: new Date(),
+ isLocked: false,
+ lockoutUntil: null,
+ })
+ .where(eq(users.id, userId));
+}
+
+// 패스워드 만료 체크
+async function checkPasswordExpiry(userId: number, settings: any): Promise<boolean> {
+ if (!settings.passwordExpiryDays) return false;
+
+ const password = await db
+ .select({
+ createdAt: passwords.createdAt,
+ expiresAt: passwords.expiresAt,
+ })
+ .from(passwords)
+ .where(
+ and(
+ eq(passwords.userId, userId),
+ eq(passwords.isActive, true)
+ )
+ )
+ .limit(1);
+
+ if (!password[0]) return true; // 패스워드가 없으면 만료 처리
+
+ const now = new Date();
+
+ // 명시적 만료일이 있는 경우
+ if (password[0].expiresAt && password[0].expiresAt < now) {
+ return true;
+ }
+
+ // 생성일 기준 만료 체크
+ const expiryDate = new Date(password[0].createdAt);
+ expiryDate.setDate(expiryDate.getDate() + settings.passwordExpiryDays);
+
+ return expiryDate < now;
+}
+
+// MFA 필요 여부 확인
+function requiresMfa(domain: string, settings: any): boolean {
+ return domain === 'partners' && settings.requireMfaForPartners;
+}
+
+
+
+// 메인 인증 함수
+export async function verifyExternalCredentials(
+ username: string,
+ password: string
+): Promise<AuthResult> {
+ const ipAddress = getClientIP();
+
+ try {
+ // 1. Rate limiting 체크
+ const rateLimitOk = await checkRateLimit(username, ipAddress);
+ if (!rateLimitOk) {
+ await logLoginAttempt(username, null, false, 'RATE_LIMITED');
+ return { success: false, error: 'RATE_LIMITED' };
+ }
+
+ // 2. 보안 설정 가져오기
+ const settings = await getSecuritySettings();
+
+ // 3. 사용자 조회
+ const userResult = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ imageUrl: users.imageUrl,
+ companyId: users.companyId,
+ techCompanyId: users.techCompanyId,
+ domain: users.domain,
+ mfaEnabled: users.mfaEnabled,
+ isActive: users.isActive, // 추가
+
+ })
+ .from(users)
+ .where(
+ and(
+ eq(users.email, username),
+ eq(users.isActive, true) // 활성 유저만
+ )
+ )
+ .limit(1);
+
+
+
+ if (!userResult[0]) {
+
+ const deactivatedUser = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, username))
+ .limit(1);
+
+ if (deactivatedUser[0]) {
+ await logLoginAttempt(username, deactivatedUser[0].id, false, 'ACCOUNT_DEACTIVATED');
+ return { success: false, error: 'ACCOUNT_DEACTIVATED' };
+ }
+
+ // 타이밍 공격 방지를 위해 가짜 해시 연산
+ await bcrypt.compare(password, '$2a$12$fake.hash.to.prevent.timing.attacks');
+ await logLoginAttempt(username, null, false, 'INVALID_CREDENTIALS');
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+ }
+
+ const user = userResult[0];
+
+ // 4. 계정 잠금 체크
+ const isLocked = await checkAndUpdateLockout(user.id, settings);
+ if (isLocked) {
+ await logLoginAttempt(username, user.id, false, 'ACCOUNT_LOCKED');
+ return { success: false, error: 'ACCOUNT_LOCKED' };
+ }
+
+ // 5. 패스워드 조회 및 검증
+ const passwordResult = await db
+ .select({
+ passwordHash: passwords.passwordHash,
+ salt: passwords.salt,
+ })
+ .from(passwords)
+ .where(
+ and(
+ eq(passwords.userId, user.id),
+ eq(passwords.isActive, true)
+ )
+ )
+ .limit(1);
+
+ if (!passwordResult[0]) {
+ await logLoginAttempt(username, user.id, false, 'INVALID_CREDENTIALS');
+ await handleFailedLogin(user.id, settings);
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+ }
+
+ // 6. 패스워드 검증
+ const isValidPassword = await bcrypt.compare(
+ password + passwordResult[0].salt,
+ passwordResult[0].passwordHash
+ );
+
+ if (!isValidPassword) {
+ await logLoginAttempt(username, user.id, false, 'INVALID_CREDENTIALS');
+ await handleFailedLogin(user.id, settings);
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+ }
+
+ // 7. 패스워드 만료 체크
+ const isPasswordExpired = await checkPasswordExpiry(user.id, settings);
+ if (isPasswordExpired) {
+ await logLoginAttempt(username, user.id, false, 'PASSWORD_EXPIRED');
+ return { success: false, error: 'PASSWORD_EXPIRED' };
+ }
+
+ // 9. 성공 처리
+ await handleSuccessfulLogin(user.id);
+ await logLoginAttempt(username, user.id, true);
+
+ return {
+ success: true,
+ user: {
+ id: user.id.toString(),
+ name: user.name,
+ email: user.email,
+ imageUrl: user.imageUrl,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId,
+ domain: user.domain,
+ },
+ };
+
+ } catch (error) {
+ console.error('Authentication error:', error);
+ await logLoginAttempt(username, null, false, 'SYSTEM_ERROR');
+ return { success: false, error: 'SYSTEM_ERROR' };
+ }
+}
+
+
+export async function completeMfaAuthentication(
+ userId: string,
+ smsToken: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ // SMS 토큰 검증
+ const result = await verifySmsToken(parseInt(userId), smsToken);
+
+ if (result.success) {
+ // MFA 성공 시 사용자의 마지막 로그인 시간 업데이트
+ await db
+ .update(users)
+ .set({
+ lastLoginAt: new Date(),
+ failedLoginAttempts: 0,
+ })
+ .where(eq(users.id, parseInt(userId)));
+
+ // 성공한 로그인 기록
+ await logLoginAttempt(
+ '', // 이미 1차 인증에서 기록되었으므로 빈 문자열
+ parseInt(userId),
+ true,
+ 'MFA_COMPLETED'
+ );
+ }
+
+ return result;
+ } catch (error) {
+ console.error('MFA completion error:', error);
+ return { success: false, error: '인증 처리 중 오류가 발생했습니다' };
+ }
+}
+
+
+
+
+
+// 서버 액션: 벤더 정보 조회
+export async function getVendorByCode(vendorCode: string) {
+ try {
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.vendorCode, vendorCode))
+ .limit(1);
+
+ return vendor[0] || null;
+ } catch (error) {
+ console.error('Database error:', error);
+ return null;
+ }
+}
+
+// 수정된 S-Gips 인증 함수
+export async function verifySGipsCredentials(
+ username: string,
+ password: string
+): Promise<{
+ success: boolean;
+ user?: {
+ id: string;
+ name: string;
+ email: string;
+ phone: string;
+ companyId?: number;
+ vendorInfo?: any; // 벤더 추가 정보
+ };
+ error?: string;
+}> {
+ try {
+ // 1. S-Gips API 호출로 인증 확인
+ const response = await fetch(process.env.S_GIPS_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `${process.env.S_GIPS_TOKEN}`,
+ },
+ body: JSON.stringify({
+ username,
+ password,
+ }),
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+ }
+ throw new Error(`API Error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // 2. S-Gips API 응답 확인
+ if (data.message === "success" && data.code === "0") {
+ // 3. username의 앞 8자리로 vendorCode 추출
+ const vendorCode = username.substring(0, 8);
+
+ // 4. 데이터베이스에서 벤더 정보 조회
+ const vendorInfo = await getVendorByCode(vendorCode);
+
+ if (!vendorInfo) {
+ return {
+ success: false,
+ error: 'VENDOR_NOT_FOUND'
+ };
+ }
+
+ // 5. 사용자 정보 구성
+ return {
+ success: true,
+ user: {
+ id: username, // 또는 vendorInfo.id를 사용
+ name: vendorInfo.representativeName || vendorInfo.vendorName,
+ email: vendorInfo.representativeEmail || vendorInfo.email || '',
+ phone: vendorInfo.representativePhone || vendorInfo.phone || '',
+ companyId: vendorInfo.id,
+ vendorInfo: {
+ vendorName: vendorInfo.vendorName,
+ vendorCode: vendorInfo.vendorCode,
+ status: vendorInfo.status,
+ taxId: vendorInfo.taxId,
+ address: vendorInfo.address,
+ country: vendorInfo.country,
+ website: vendorInfo.website,
+ vendorTypeId: vendorInfo.vendorTypeId,
+ businessSize: vendorInfo.businessSize,
+ creditRating: vendorInfo.creditRating,
+ cashFlowRating: vendorInfo.cashFlowRating,
+ }
+ },
+ };
+ }
+
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+
+ } catch (error) {
+ console.error('S-Gips API error:', error);
+ return { success: false, error: 'SYSTEM_ERROR' };
+ }
+}
+
+
+// S-Gips 사용자를 위한 통합 인증 함수
+export async function authenticateWithSGips(
+ username: string,
+ password: string
+): Promise<{
+ success: boolean;
+ user?: {
+ id: string;
+ name: string;
+ email: string;
+ imageUrl?: string | null;
+ companyId?: number | null;
+ techCompanyId?: number | null;
+ domain?: string | null;
+ };
+ requiresMfa: boolean;
+ mfaToken?: string;
+ error?: string;
+}> {
+ try {
+ // 1. S-Gips API로 인증
+ const sgipsResult = await verifySGipsCredentials(username, password);
+
+ if (!sgipsResult.success || !sgipsResult.user) {
+ return {
+ success: false,
+ requiresMfa: false,
+ error: sgipsResult.error || 'INVALID_CREDENTIALS',
+ };
+ }
+
+ // 2. 로컬 DB에서 사용자 확인 또는 생성
+ let localUser = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, sgipsResult.user.email))
+ .limit(1);
+
+ if (!localUser[0]) {
+ // 사용자가 없으면 새로 생성 (S-Gips 사용자는 자동 생성)
+ const newUser = await db
+ .insert(users)
+ .values({
+ name: sgipsResult.user.name,
+ email: sgipsResult.user.email,
+ phone: sgipsResult.user.phone,
+ companyId: sgipsResult.user.companyId,
+ domain: 'partners', // S-Gips 사용자는 partners 도메인
+ mfaEnabled: true, // S-Gips 사용자는 MFA 필수
+ })
+ .returning();
+
+ localUser = newUser;
+ }
+
+ const user = localUser[0];
+
+ // 3. MFA 토큰 생성 (S-Gips 사용자는 항상 MFA 필요)
+ // const mfaToken = await generateMfaToken(user.id);
+
+ // 4. SMS 전송
+ if (user.phone) {
+ await generateAndSendSmsToken(user.id, user.phone);
+ }
+
+ return {
+ success: true,
+ user: {
+ id: user.id.toString(),
+ name: user.name,
+ email: user.email,
+ imageUrl: user.imageUrl,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId,
+ domain: user.domain,
+ },
+ requiresMfa: true,
+ // mfaToken,
+ };
+ } catch (error) {
+ console.error('S-Gips authentication error:', error);
+ return {
+ success: false,
+ requiresMfa: false,
+ error: 'SYSTEM_ERROR',
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/users/service.ts b/lib/users/service.ts
index 0d0121b3..ad01c22a 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -9,10 +9,12 @@ import { saveDocument } from '../storage';
import { GetUsersSchema } from '../admin-users/validations';
import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache';
import { filterColumns } from '../filter-columns';
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository';
import db from "@/db/db";
import { getErrorMessage } from "@/lib/handle-error";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm";
interface AssignUsersArgs {
roleId: number
@@ -415,6 +417,72 @@ export async function getUsersAll(input: GetUsersSchema, domain: string) {
}
+export async function getUsersAllbyVendor(input: GetUsersSchema, domain: string) {
+
+ try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session?.user.companyId
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: userView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(userView.user_name, s),
+ ilike(userView.user_email, s),
+ );
+ }
+
+ // (3) domainWhere - 무조건 들어가야 하는 domain 조건
+ const domainWhere = eq(userView.user_domain, domain);
+
+ // (4) 최종 where
+ // domainWhere과 advancedWhere, globalWhere를 모두 and로 묶는다.
+ // (globalWhere가 존재하지 않을 수 있으니, and() 호출 시 undefined를 자동 무시할 수도 있음)
+ const finalWhere = and(domainWhere, advancedWhere, globalWhere, eq(userView.company_id, companyId));
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(userView[item.id]) : asc(userView[item.id])
+ )
+ : [desc(users.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectUsersWithCompanyAndRoles(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countUsers(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ return { data: [], pageCount: 0 };
+ }
+
+}
+
export async function assignUsersToRole(roleId: number, userIds: number[]) {
unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
try{
diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts
index 7b25ed49..694665bf 100644
--- a/lib/users/verifyOtp.ts
+++ b/lib/users/verifyOtp.ts
@@ -51,31 +51,5 @@ export async function verifyOtpTemp(email: string) {
}
-export async function verifyExternalCredentials(username: string, password: string) {
- // DB에서 email과 code가 맞는지, 만료 안됐는지 검증
- const otpRecord = await findEmailandOtp(username, password)
- if (!otpRecord) {
- return null
- }
-
- // 만료 체크
- if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) {
- return null
- }
-
- // 여기서 otpRecord에 유저 정보가 있다고 가정
- // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등
- // 실제 DB 설계에 맞춰 필드명을 조정하세요.
- return {
- email: otpRecord.email,
- name: otpRecord.name,
- id: otpRecord.id,
- imageUrl: otpRecord.imageUrl,
- companyId: otpRecord.companyId,
- techCompanyId: otpRecord.techCompanyId,
- domain: otpRecord.domain,
- }
-}
-
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
index 627e0eba..8e9d386b 100644
--- a/lib/vendor-document-list/dolce-upload-service.ts
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -98,7 +98,6 @@ class DOLCEUploadService {
}
// 3. 각 issueStageId별로 첫 번째 revision 정보를 미리 조회 (Mode 결정용)
- const firstRevisionMap = await this.getFirstRevisionMap(revisionsToUpload.map(r => r.issueStageId))
let uploadedDocuments = 0
let uploadedFiles = 0
@@ -134,7 +133,6 @@ class DOLCEUploadService {
contractInfo,
uploadId,
contractInfo.vendorCode,
- firstRevisionMap
)
const docResult = await this.uploadDocument([dolceDoc], userId)
@@ -237,6 +235,7 @@ class DOLCEUploadService {
.select({
// revision 테이블 정보
id: revisions.id,
+ registerId:revisions.registerId,
revision: revisions.revision, // revisionNo가 아니라 revision
revisionStatus: revisions.revisionStatus,
uploaderId: revisions.uploaderId,
@@ -293,6 +292,8 @@ class DOLCEUploadService {
const attachments = await db
.select({
id: documentAttachments.id,
+ uploadId: documentAttachments.uploadId,
+ fileId: documentAttachments.fileId,
fileName: documentAttachments.fileName,
filePath: documentAttachments.filePath,
fileType: documentAttachments.fileType,
@@ -472,16 +473,15 @@ class DOLCEUploadService {
contractInfo: any,
uploadId?: string,
vendorCode?: string,
- firstRevisionMap?: Map<number, string>
): DOLCEDocument {
// Mode 결정: 해당 issueStageId의 첫 번째 revision인지 확인
- let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD
+ let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD\
+
- if (firstRevisionMap && firstRevisionMap.has(revision.issueStageId)) {
- const firstRevision = firstRevisionMap.get(revision.issueStageId)
- if (revision.revision === firstRevision) {
- mode = "ADD"
- }
+ if(revision.registerId){
+ mode = "MOD"
+ } else{
+ mode = "ADD"
}
// RegisterKind 결정: stageName에 따라 설정
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index 344597fa..9a4e44db 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -1,14 +1,24 @@
-// lib/vendor-document-list/import-service.ts - DOLCE API 연동 버전
+// lib/vendor-document-list/import-service.ts - DOLCE API 연동 버전 (파일 다운로드 포함)
import db from "@/db/db"
-import { documents, issueStages, contracts, projects, vendors } from "@/db/schema"
+import { documents, issueStages, contracts, projects, vendors, revisions, documentAttachments } from "@/db/schema"
import { eq, and, desc, sql } from "drizzle-orm"
+import { writeFile, mkdir } from "fs/promises"
+import { join } from "path"
+import { v4 as uuidv4 } from "uuid"
+import { extname } from "path"
+import * as crypto from "crypto"
export interface ImportResult {
success: boolean
newCount: number
updatedCount: number
skippedCount: number
+ newRevisionsCount: number
+ updatedRevisionsCount: number
+ newAttachmentsCount: number
+ updatedAttachmentsCount: number
+ downloadedFilesCount: number
errors?: string[]
message?: string
}
@@ -18,6 +28,12 @@ export interface ImportStatus {
availableDocuments: number
newDocuments: number
updatedDocuments: number
+ availableRevisions: number
+ newRevisions: number
+ updatedRevisions: number
+ availableAttachments: number
+ newAttachments: number
+ updatedAttachments: number
importEnabled: boolean
}
@@ -41,19 +57,14 @@ interface DOLCEDocument {
AppDwg_ResultDate?: string
WorDwg_PlanDate?: string
WorDwg_ResultDate?: string
-
-
GTTPreDwg_PlanDate?: string
GTTPreDwg_ResultDate?: string
GTTWorkingDwg_PlanDate?: string
GTTWorkingDwg_ResultDate?: string
-
FMEAFirst_PlanDate?: string
FMEAFirst_ResultDate?: string
FMEASecond_PlanDate?: string
FMEASecond_ResultDate?: string
-
-
JGbn?: string
Manager: string
ManagerENM: string
@@ -65,7 +76,67 @@ interface DOLCEDocument {
SHIDrawingNo?: string
}
+interface DOLCEDetailDocument {
+ Status: string
+ Category: string // TS, FS
+ CategoryNM: string
+ CategoryENM: string
+ RegisterId: string
+ ProjectNo: string
+ DrawingNo: string
+ RegisterGroupId: number
+ RegisterGroup: number
+ DrawingName: string
+ RegisterSerialNoMax: number
+ RegisterSerialNo: number
+ DrawingUsage: string
+ DrawingUsageNM: string
+ DrawingUsageENM: string
+ RegisterKind: string
+ RegisterKindNM: string
+ RegisterKindENM: string
+ DrawingRevNo: string
+ RegisterDesc: string
+ UploadId: string
+ ManagerNM: string
+ Manager: string
+ UseYn: string
+ RegCompanyCode: string
+ RegCompanyNM: string
+ RegCompanyENM: string
+ CreateUserENM: string
+ CreateUserNM: string
+ CreateUserId: string
+ CreateDt: string
+ ModifyUserId: string
+ ModifyDt: string
+ Discipline: string
+ DrawingKind: string
+ DrawingMoveGbn: string
+ SHIDrawingNo: string
+ Receiver: string
+ SHINote: string
+}
+
+interface DOLCEFileInfo {
+ FileId: string
+ UploadId: string
+ FileSeq: number
+ FileServerId: string
+ FileTitle: string
+ FileDescription: string
+ FileName: string
+ FileRelativePath: string
+ FileSize: number
+ FileCreateDT: string
+ FileWriteDT: string
+ OwnerUserId: string
+ UseYn: string
+}
+
class ImportService {
+ private readonly DES_KEY = Buffer.from("4fkkdijg", "ascii")
+
/**
* DOLCE 시스템에서 문서 목록 가져오기
*/
@@ -107,6 +178,11 @@ class ImportService {
newCount: 0,
updatedCount: 0,
skippedCount: 0,
+ newRevisionsCount: 0,
+ updatedRevisionsCount: 0,
+ newAttachmentsCount: 0,
+ updatedAttachmentsCount: 0,
+ downloadedFilesCount: 0,
message: '가져올 새로운 데이터가 없습니다.'
}
}
@@ -114,6 +190,11 @@ class ImportService {
let newCount = 0
let updatedCount = 0
let skippedCount = 0
+ let newRevisionsCount = 0
+ let updatedRevisionsCount = 0
+ let newAttachmentsCount = 0
+ let updatedAttachmentsCount = 0
+ let downloadedFilesCount = 0
const errors: string[] = []
// 3. 각 문서 동기화 처리
@@ -127,12 +208,9 @@ class ImportService {
if (dolceDoc.DrawingKind === 'B4') {
await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, contractId, dolceDoc)
}
-
if (dolceDoc.DrawingKind === 'B3') {
await this.createIssueStagesForB3Document(dolceDoc.DrawingNo, contractId, dolceDoc)
}
-
-
if (dolceDoc.DrawingKind === 'B5') {
await this.createIssueStagesForB5Document(dolceDoc.DrawingNo, contractId, dolceDoc)
}
@@ -142,6 +220,30 @@ class ImportService {
skippedCount++
}
+ // 4. revisions 동기화 처리
+ try {
+ const revisionResult = await this.syncDocumentRevisions(
+ contractId,
+ dolceDoc,
+ sourceSystem
+ )
+ newRevisionsCount += revisionResult.newCount
+ updatedRevisionsCount += revisionResult.updatedCount
+
+ // 5. 파일 첨부 동기화 처리 (Category가 FS인 것만)
+ const attachmentResult = await this.syncDocumentAttachments(
+ dolceDoc,
+ sourceSystem
+ )
+ newAttachmentsCount += attachmentResult.newCount
+ updatedAttachmentsCount += attachmentResult.updatedCount
+ downloadedFilesCount += attachmentResult.downloadedCount
+
+ } catch (revisionError) {
+ console.warn(`Failed to sync revisions for ${dolceDoc.DrawingNo}:`, revisionError)
+ // revisions 동기화 실패는 에러 로그만 남기고 계속 진행
+ }
+
} catch (error) {
errors.push(`Document ${dolceDoc.DrawingNo}: ${error instanceof Error ? error.message : 'Unknown error'}`)
skippedCount++
@@ -149,14 +251,21 @@ class ImportService {
}
console.log(`Import completed: ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`)
+ console.log(`Revisions: ${newRevisionsCount} new, ${updatedRevisionsCount} updated`)
+ console.log(`Attachments: ${newAttachmentsCount} new, ${updatedAttachmentsCount} updated, ${downloadedFilesCount} downloaded`)
return {
success: errors.length === 0,
newCount,
updatedCount,
skippedCount,
+ newRevisionsCount,
+ updatedRevisionsCount,
+ newAttachmentsCount,
+ updatedAttachmentsCount,
+ downloadedFilesCount,
errors: errors.length > 0 ? errors : undefined,
- message: `가져오기 완료: 신규 ${newCount}건, 업데이트 ${updatedCount}건`
+ message: `가져오기 완료: 신규 ${newCount}건, 업데이트 ${updatedCount}건, 리비전 신규 ${newRevisionsCount}건, 리비전 업데이트 ${updatedRevisionsCount}건, 파일 다운로드 ${downloadedFilesCount}건`
}
} catch (error) {
@@ -189,7 +298,7 @@ class ImportService {
}
/**
- * DOLCE API에서 데이터 조회
+ * DOLCE API에서 문서 목록 데이터 조회
*/
private async fetchFromDOLCE(
projectCode: string,
@@ -214,7 +323,6 @@ class ImportService {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- // DOLCE API에 특별한 인증이 필요하다면 여기에 추가
},
body: JSON.stringify(requestBody)
})
@@ -248,7 +356,7 @@ class ImportService {
documents = []
}
- console.log(`Found ${documents.length} documents for ${drawingKind} in ${drawingKind === 'B3' ? 'VendorDwgList' : drawingKind === 'B4' ? 'GTTDwgList' : 'FMEADwgList'}`)
+ console.log(`Found ${documents.length} documents for ${drawingKind}`)
return documents as DOLCEDocument[]
} else {
@@ -261,6 +369,193 @@ class ImportService {
throw error
}
}
+
+ /**
+ * DOLCE API에서 문서 상세 정보 조회 (revisions 데이터)
+ */
+ private async fetchDetailFromDOLCE(
+ projectCode: string,
+ drawingNo: string,
+ discipline: string,
+ drawingKind: string
+ ): Promise<DOLCEDetailDocument[]> {
+ const endpoint = process.env.DOLCE_DOC_DETAIL_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/DetailDwgReceiptMgmt'
+
+ const requestBody = {
+ project: projectCode,
+ drawingNo: drawingNo,
+ discipline: discipline,
+ drawingKind: drawingKind
+ }
+
+ console.log(`Fetching detail from DOLCE: ${projectCode} - ${drawingNo}`)
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE Detail API failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ // DOLCE Detail API 응답 구조에 맞게 처리
+ if (data.DetailDwgReceiptMgmtResult) {
+ const documents = data.DetailDwgReceiptMgmtResult as DOLCEDetailDocument[]
+ console.log(`Found ${documents.length} detail records for ${drawingNo}`)
+ return documents
+ } else {
+ console.warn(`Unexpected DOLCE Detail response structure:`, data)
+ return []
+ }
+
+ } catch (error) {
+ console.error(`DOLCE Detail API call failed for ${drawingNo}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * DOLCE API에서 파일 정보 조회
+ */
+ private async fetchFileInfoFromDOLCE(uploadId: string): Promise<DOLCEFileInfo[]> {
+ const endpoint = process.env.DOLCE_FILE_INFO_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/FileInfoList'
+
+ const requestBody = {
+ uploadId: uploadId
+ }
+
+ console.log(`Fetching file info from DOLCE: ${uploadId}`)
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE FileInfo API failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ // DOLCE FileInfo API 응답 구조에 맞게 처리
+ if (data.FileInfoListResult) {
+ const files = data.FileInfoListResult as DOLCEFileInfo[]
+ console.log(`Found ${files.length} files for uploadId: ${uploadId}`)
+ return files
+ } else {
+ console.warn(`Unexpected DOLCE FileInfo response structure:`, data)
+ return []
+ }
+
+ } catch (error) {
+ console.error(`DOLCE FileInfo API call failed for ${uploadId}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * DES 암호화 (C# DESCryptoServiceProvider 호환)
+ */
+ private encryptDES(text: string): string {
+ try {
+ const cipher = crypto.createCipher('des-ecb', this.DES_KEY)
+ cipher.setAutoPadding(true)
+ let encrypted = cipher.update(text, 'utf8', 'base64')
+ encrypted += cipher.final('base64')
+ // + 문자를 |||로 치환
+ return encrypted.replace(/\+/g, '|||')
+ } catch (error) {
+ console.error('DES encryption failed:', error)
+ throw error
+ }
+ }
+
+ /**
+ * DOLCE에서 파일 다운로드
+ */
+ private async downloadFileFromDOLCE(
+ fileId: string,
+ userId: string,
+ fileName: string
+ ): Promise<Buffer> {
+ try {
+ // 암호화 문자열 생성: FileId↔UserId↔FileName
+ const encryptString = `${fileId}↔${userId}↔${fileName}`
+ const encryptedKey = this.encryptDES(encryptString)
+
+ const downloadUrl = `${process.env.DOLCE_DOWNLOAD_URL}?key=${encryptedKey}` ||`http://60.100.99.217:1111/Download.aspx?key=${encryptedKey}`
+
+ console.log(`Downloading file: ${fileName} with key: ${encryptedKey.substring(0, 20)}...`)
+
+ const response = await fetch(downloadUrl, {
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'DOLCE-Integration-Service'
+ }
+ })
+
+ if (!response.ok) {
+ throw new Error(`File download failed: HTTP ${response.status}`)
+ }
+
+ const buffer = Buffer.from(await response.arrayBuffer())
+ console.log(`Downloaded ${buffer.length} bytes for ${fileName}`)
+
+ return buffer
+
+ } catch (error) {
+ console.error(`Failed to download file ${fileName}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * 로컬 파일 시스템에 파일 저장
+ */
+ private async saveFileToLocal(
+ buffer: Buffer,
+ originalFileName: string
+ ): Promise<{ fileName: string; filePath: string; fileSize: number }> {
+ try {
+ const baseDir = join(process.cwd(), "public", "documents")
+
+ // 디렉토리가 없으면 생성
+ await mkdir(baseDir, { recursive: true })
+
+ const ext = extname(originalFileName)
+ const fileName = uuidv4() + ext
+ const fullPath = join(baseDir, fileName)
+ const relativePath = "/documents/" + fileName
+
+ await writeFile(fullPath, buffer)
+
+ console.log(`Saved file: ${originalFileName} as ${fileName}`)
+
+ return {
+ fileName: originalFileName,
+ filePath: relativePath,
+ fileSize: buffer.length
+ }
+
+ } catch (error) {
+ console.error(`Failed to save file ${originalFileName}:`, error)
+ throw error
+ }
+ }
+
/**
* 단일 문서 동기화
*/
@@ -354,35 +649,384 @@ class ImportService {
}
}
- private convertDolceDateToDate(dolceDate: string | undefined | null): Date | null {
- if (!dolceDate || dolceDate.trim() === '') {
- return null
+ /**
+ * 문서의 revisions 동기화
+ */
+ private async syncDocumentRevisions(
+ contractId: number,
+ dolceDoc: DOLCEDocument,
+ sourceSystem: string
+ ): Promise<{ newCount: number; updatedCount: number }> {
+ try {
+ // 1. 상세 정보 조회
+ const detailDocs = await this.fetchDetailFromDOLCE(
+ dolceDoc.ProjectNo,
+ dolceDoc.DrawingNo,
+ dolceDoc.Discipline,
+ dolceDoc.DrawingKind
+ )
+
+ if (detailDocs.length === 0) {
+ console.log(`No detail data found for ${dolceDoc.DrawingNo}`)
+ return { newCount: 0, updatedCount: 0 }
+ }
+
+ // 2. 해당 문서의 issueStages 조회
+ const documentRecord = await db
+ .select({ id: documents.id })
+ .from(documents)
+ .where(and(
+ eq(documents.contractId, contractId),
+ eq(documents.docNumber, dolceDoc.DrawingNo)
+ ))
+ .limit(1)
+
+ if (documentRecord.length === 0) {
+ throw new Error(`Document not found: ${dolceDoc.DrawingNo}`)
+ }
+
+ const documentId = documentRecord[0].id
+
+ const issueStagesList = await db
+ .select()
+ .from(issueStages)
+ .where(eq(issueStages.documentId, documentId))
+
+ let newCount = 0
+ let updatedCount = 0
+
+ // 3. 각 상세 데이터에 대해 revision 동기화
+ for (const detailDoc of detailDocs) {
+ try {
+ // RegisterGroupId로 해당하는 issueStage 찾기
+ const matchingStage = issueStagesList.find(stage => {
+ // RegisterGroupId와 매칭하는 로직 (추후 개선 필요)
+ return stage.id // 임시로 첫 번째 stage 사용
+ })
+
+ if (!matchingStage) {
+ console.warn(`No matching issue stage found for RegisterGroupId: ${detailDoc.RegisterGroupId}`)
+ continue
+ }
+
+ const result = await this.syncSingleRevision(matchingStage.id, detailDoc, sourceSystem)
+ if (result === 'NEW') {
+ newCount++
+ } else if (result === 'UPDATED') {
+ updatedCount++
+ }
+
+ } catch (error) {
+ console.error(`Failed to sync revision ${detailDoc.RegisterId}:`, error)
+ }
+ }
+
+ return { newCount, updatedCount }
+
+ } catch (error) {
+ console.error(`Failed to sync revisions for ${dolceDoc.DrawingNo}:`, error)
+ throw error
+ }
}
-
- // "20250204" 형태의 문자열을 "2025-02-04" 형태로 변환
- if (dolceDate.length === 8 && /^\d{8}$/.test(dolceDate)) {
- const year = dolceDate.substring(0, 4)
- const month = dolceDate.substring(4, 6)
- const day = dolceDate.substring(6, 8)
-
+
+ /**
+ * 문서의 첨부파일 동기화 (Category가 FS인 것만)
+ */
+ private async syncDocumentAttachments(
+ dolceDoc: DOLCEDocument,
+ sourceSystem: string
+ ): Promise<{ newCount: number; updatedCount: number; downloadedCount: number }> {
try {
- const date = new Date(`${year}-${month}-${day}`)
- // 유효한 날짜인지 확인
- if (isNaN(date.getTime())) {
- console.warn(`Invalid date format: ${dolceDate}`)
- return null
+ // 1. 상세 정보 조회
+ const detailDocs = await this.fetchDetailFromDOLCE(
+ dolceDoc.ProjectNo,
+ dolceDoc.DrawingNo,
+ dolceDoc.Discipline,
+ dolceDoc.DrawingKind
+ )
+
+ // 2. Category가 'FS'인 것만 필터링
+ const fsDetailDocs = detailDocs.filter(doc => doc.Category === 'FS')
+
+ if (fsDetailDocs.length === 0) {
+ console.log(`No FS category documents found for ${dolceDoc.DrawingNo}`)
+ return { newCount: 0, updatedCount: 0, downloadedCount: 0 }
}
- return date
+
+ let newCount = 0
+ let updatedCount = 0
+ let downloadedCount = 0
+
+ // 3. 각 FS 문서에 대해 파일 첨부 동기화
+ for (const detailDoc of fsDetailDocs) {
+ try {
+ if (!detailDoc.UploadId || detailDoc.UploadId.trim() === '') {
+ console.log(`No UploadId for ${detailDoc.RegisterId}`)
+ continue
+ }
+
+ // 4. 해당 revision 조회
+ const revisionRecord = await db
+ .select({ id: revisions.id })
+ .from(revisions)
+ .where(eq(revisions.registerId, detailDoc.RegisterId))
+ .limit(1)
+
+ if (revisionRecord.length === 0) {
+ console.warn(`No revision found for RegisterId: ${detailDoc.RegisterId}`)
+ continue
+ }
+
+ const revisionId = revisionRecord[0].id
+
+ // 5. 파일 정보 조회
+ const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId)
+
+ for (const fileInfo of fileInfos) {
+ if (fileInfo.UseYn !== 'Y') {
+ console.log(`Skipping inactive file: ${fileInfo.FileName}`)
+ continue
+ }
+
+ const result = await this.syncSingleAttachment(
+ revisionId,
+ fileInfo,
+ detailDoc.CreateUserId,
+ sourceSystem
+ )
+
+ if (result === 'NEW') {
+ newCount++
+ downloadedCount++
+ } else if (result === 'UPDATED') {
+ updatedCount++
+ }
+ }
+
+ } catch (error) {
+ console.error(`Failed to sync attachments for ${detailDoc.RegisterId}:`, error)
+ }
+ }
+
+ return { newCount, updatedCount, downloadedCount }
+
} catch (error) {
- console.warn(`Failed to parse date: ${dolceDate}`, error)
- return null
+ console.error(`Failed to sync attachments for ${dolceDoc.DrawingNo}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * 단일 첨부파일 동기화
+ */
+ private async syncSingleAttachment(
+ revisionId: number,
+ fileInfo: DOLCEFileInfo,
+ userId: string,
+ sourceSystem: string
+ ): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> {
+ try {
+ // 기존 첨부파일 조회 (FileId로)
+ const existingAttachment = await db
+ .select()
+ .from(documentAttachments)
+ .where(and(
+ eq(documentAttachments.revisionId, revisionId),
+ eq(documentAttachments.fileId, fileInfo.FileId)
+ ))
+ .limit(1)
+
+ if (existingAttachment.length > 0) {
+ // 이미 존재하는 파일인 경우, 필요시 업데이트 로직 추가
+ console.log(`File already exists: ${fileInfo.FileName}`)
+ return 'SKIPPED'
+ }
+
+ // 파일 다운로드
+ console.log(`Downloading file: ${fileInfo.FileName}`)
+ const fileBuffer = await this.downloadFileFromDOLCE(
+ fileInfo.FileId,
+ userId,
+ fileInfo.FileName
+ )
+
+ // 로컬 파일 시스템에 저장
+ const savedFile = await this.saveFileToLocal(fileBuffer, fileInfo.FileName)
+
+ // DB에 첨부파일 정보 저장
+ const attachmentData = {
+ revisionId,
+ fileName: fileInfo.FileName,
+ filePath: savedFile.filePath,
+ fileType: extname(fileInfo.FileName).slice(1).toLowerCase() || undefined,
+ fileSize: fileInfo.FileSize,
+ uploadId: fileInfo.UploadId,
+ fileId: fileInfo.FileId,
+ uploadedBy: userId,
+ dolceFilePath: fileInfo.FileRelativePath,
+ uploadedAt: this.convertDolceDateToDate(fileInfo.FileCreateDT),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+
+ await db
+ .insert(documentAttachments)
+ .values(attachmentData)
+
+ console.log(`Created new attachment: ${fileInfo.FileName}`)
+ return 'NEW'
+
+ } catch (error) {
+ console.error(`Failed to sync attachment ${fileInfo.FileName}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * 단일 revision 동기화
+ */
+ private async syncSingleRevision(
+ issueStageId: number,
+ detailDoc: DOLCEDetailDocument,
+ sourceSystem: string
+ ): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> {
+ // 기존 revision 조회 (registerId로)
+ const existingRevision = await db
+ .select()
+ .from(revisions)
+ .where(and(
+ eq(revisions.issueStageId, issueStageId),
+ eq(revisions.registerId, detailDoc.RegisterId)
+ ))
+ .limit(1)
+
+ // Category에 따른 uploaderType 매핑
+ const uploaderType = this.mapCategoryToUploaderType(detailDoc.Category)
+
+ // RegisterKind에 따른 usage, usageType 매핑 (기본 로직, 추후 개선)
+ const { usage, usageType } = this.mapRegisterKindToUsage(detailDoc.RegisterKind)
+
+ // DOLCE 상세 데이터를 revisions 스키마에 맞게 변환
+ const revisionData = {
+ issueStageId,
+ revision: detailDoc.DrawingRevNo,
+ uploaderType,
+ uploaderName: detailDoc.CreateUserNM,
+ usage,
+ usageType,
+ revisionStatus: detailDoc.Status,
+ externalUploadId: detailDoc.UploadId,
+ registerId: detailDoc.RegisterId,
+ comment: detailDoc.RegisterDesc,
+ submittedDate: this.convertDolceDateToDate(detailDoc.CreateDt),
+ updatedAt: new Date()
+ }
+
+ if (existingRevision.length > 0) {
+ // 업데이트 필요 여부 확인
+ const existing = existingRevision[0]
+ const hasChanges =
+ existing.revision !== revisionData.revision ||
+ existing.revisionStatus !== revisionData.revisionStatus ||
+ existing.uploaderName !== revisionData.uploaderName
+
+ if (hasChanges) {
+ await db
+ .update(revisions)
+ .set(revisionData)
+ .where(eq(revisions.id, existing.id))
+
+ console.log(`Updated revision: ${detailDoc.RegisterId}`)
+ return 'UPDATED'
+ } else {
+ return 'SKIPPED'
+ }
+ } else {
+ // 새 revision 생성
+ await db
+ .insert(revisions)
+ .values({
+ ...revisionData,
+ createdAt: new Date()
+ })
+
+ console.log(`Created new revision: ${detailDoc.RegisterId}`)
+ return 'NEW'
+ }
+ }
+
+ /**
+ * Category를 uploaderType으로 매핑
+ */
+ private mapCategoryToUploaderType(category: string): string {
+ switch (category) {
+ case 'TS':
+ return 'vendor'
+ case 'FS':
+ return 'shi'
+ default:
+ return 'vendor' // 기본값
}
}
-
- console.warn(`Unexpected date format: ${dolceDate}`)
- return null
-}
+ /**
+ * RegisterKind를 usage/usageType으로 매핑
+ */
+ private mapRegisterKindToUsage(registerKind: string): { usage: string; usageType: string } {
+ // TODO: 추후 비즈니스 로직에 맞게 구현
+ // 현재는 기본 매핑만 제공
+ return {
+ usage: registerKind || 'DEFAULT',
+ usageType: registerKind || 'DEFAULT'
+ }
+ }
+
+ /**
+ * Status를 revisionStatus로 매핑
+ */
+ private mapStatusToRevisionStatus(status: string): string {
+ // TODO: DOLCE의 Status 값에 맞게 매핑 로직 구현
+ // 현재는 기본 매핑만 제공
+ switch (status?.toUpperCase()) {
+ case 'SUBMITTED':
+ return 'SUBMITTED'
+ case 'APPROVED':
+ return 'APPROVED'
+ case 'REJECTED':
+ return 'REJECTED'
+ default:
+ return 'SUBMITTED' // 기본값
+ }
+ }
+
+ private convertDolceDateToDate(dolceDate: string | undefined | null): Date | null {
+ if (!dolceDate || dolceDate.trim() === '') {
+ return null
+ }
+
+ // "20250204" 형태의 문자열을 "2025-02-04" 형태로 변환
+ if (dolceDate.length === 8 && /^\d{8}$/.test(dolceDate)) {
+ const year = dolceDate.substring(0, 4)
+ const month = dolceDate.substring(4, 6)
+ const day = dolceDate.substring(6, 8)
+
+ try {
+ const date = new Date(`${year}-${month}-${day}`)
+ // 유효한 날짜인지 확인
+ if (isNaN(date.getTime())) {
+ console.warn(`Invalid date format: ${dolceDate}`)
+ return null
+ }
+ return date
+ } catch (error) {
+ console.warn(`Failed to parse date: ${dolceDate}`, error)
+ return null
+ }
+ }
+
+ console.warn(`Unexpected date format: ${dolceDate}`)
+ return null
+ }
/**
* B4 문서용 이슈 스테이지 자동 생성
@@ -426,8 +1070,8 @@ class ImportService {
actualDate: this.convertDolceDateToDate(dolceDoc.GTTPreDwg_ResultDate),
stageStatus: 'PLANNED',
stageOrder: 1,
- priority: 'MEDIUM', // 기본값
- reminderDays: 3, // 기본값
+ priority: 'MEDIUM',
+ reminderDays: 3,
description: 'GTT 예비 도면 단계'
})
}
@@ -449,7 +1093,6 @@ class ImportService {
} catch (error) {
console.error(`Failed to create issue stages for ${drawingNo}:`, error)
- // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음
}
}
@@ -483,41 +1126,36 @@ class ImportService {
const existingStageNames = existingStages.map(stage => stage.stageName)
- // For Pre 스테이지 생성 (GTTPreDwg)
+ // Approval 스테이지 생성
if (!existingStageNames.includes('Approval')) {
await db.insert(issueStages).values({
documentId: documentId,
stageName: 'Vendor → SHI (For Approval)',
-
planDate: this.convertDolceDateToDate(dolceDoc.AppDwg_PlanDate),
actualDate: this.convertDolceDateToDate(dolceDoc.AppDwg_ResultDate),
-
stageStatus: 'PLANNED',
stageOrder: 1,
description: 'Vendor 승인 도면 단계'
})
}
- // For Working 스테이지 생성 (GTTWorkingDwg)
+ // Working 스테이지 생성
if (!existingStageNames.includes('Working')) {
await db.insert(issueStages).values({
documentId: documentId,
stageName: 'Vendor → SHI (For Working)',
-
planDate: this.convertDolceDateToDate(dolceDoc.WorDwg_PlanDate),
actualDate: this.convertDolceDateToDate(dolceDoc.WorDwg_ResultDate),
-
stageStatus: 'PLANNED',
stageOrder: 2,
description: 'Vendor 작업 도면 단계'
})
}
- console.log(`Created issue stages for B4 document: ${drawingNo}`)
+ console.log(`Created issue stages for B3 document: ${drawingNo}`)
} catch (error) {
console.error(`Failed to create issue stages for ${drawingNo}:`, error)
- // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음
}
}
@@ -551,43 +1189,39 @@ class ImportService {
const existingStageNames = existingStages.map(stage => stage.stageName)
- // For Pre 스테이지 생성 (GTTPreDwg)
+ // Approval 스테이지 생성
if (!existingStageNames.includes('Approval')) {
await db.insert(issueStages).values({
documentId: documentId,
stageName: 'Vendor → SHI (For Approval)',
-
planDate: this.convertDolceDateToDate(dolceDoc.FMEAFirst_PlanDate),
actualDate: this.convertDolceDateToDate(dolceDoc.FMEAFirst_ResultDate),
-
stageStatus: 'PLANNED',
stageOrder: 1,
description: 'FMEA 예비 도면 단계'
})
}
- // For Working 스테이지 생성 (GTTWorkingDwg)
+ // Working 스테이지 생성
if (!existingStageNames.includes('Working')) {
await db.insert(issueStages).values({
documentId: documentId,
stageName: 'Vendor → SHI (For Working)',
- planDate: dolceDoc.FMEASecond_PlanDate ? dolceDoc.FMEASecond_PlanDate : null,
- actualDate: dolceDoc.FMEASecond_ResultDate ? dolceDoc.FMEASecond_ResultDate : null,
+ planDate: this.convertDolceDateToDate(dolceDoc.FMEASecond_PlanDate),
+ actualDate: this.convertDolceDateToDate(dolceDoc.FMEASecond_ResultDate),
stageStatus: 'PLANNED',
stageOrder: 2,
description: 'FMEA 작업 도면 단계'
})
}
- console.log(`Created issue stages for B4 document: ${drawingNo}`)
+ console.log(`Created issue stages for B5 document: ${drawingNo}`)
} catch (error) {
console.error(`Failed to create issue stages for ${drawingNo}:`, error)
- // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음
}
}
-
/**
* 가져오기 상태 조회
*/
@@ -607,14 +1241,9 @@ class ImportService {
eq(documents.externalSystemType, sourceSystem)
))
- console.log(contractId, "contractId")
-
-
// 프로젝트 코드와 벤더 코드 조회
const contractInfo = await this.getContractInfoById(contractId)
- console.log(contractInfo, "contractInfo")
-
if (!contractInfo?.projectCode || !contractInfo?.vendorCode) {
throw new Error(`Project code or vendor code not found for contract ${contractId}`)
}
@@ -622,6 +1251,12 @@ class ImportService {
let availableDocuments = 0
let newDocuments = 0
let updatedDocuments = 0
+ let availableRevisions = 0
+ let newRevisions = 0
+ let updatedRevisions = 0
+ let availableAttachments = 0
+ let newAttachments = 0
+ let updatedAttachments = 0
try {
// 각 drawingKind별로 확인
@@ -659,6 +1294,59 @@ class ImportService {
}
}
}
+
+ // revisions 및 attachments 상태도 확인
+ try {
+ const detailDocs = await this.fetchDetailFromDOLCE(
+ externalDoc.ProjectNo,
+ externalDoc.DrawingNo,
+ externalDoc.Discipline,
+ externalDoc.DrawingKind
+ )
+ availableRevisions += detailDocs.length
+
+ for (const detailDoc of detailDocs) {
+ const existingRevision = await db
+ .select({ id: revisions.id })
+ .from(revisions)
+ .where(eq(revisions.registerId, detailDoc.RegisterId))
+ .limit(1)
+
+ if (existingRevision.length === 0) {
+ newRevisions++
+ } else {
+ updatedRevisions++
+ }
+
+ // FS Category 문서의 첨부파일 확인
+ if (detailDoc.Category === 'FS' && detailDoc.UploadId) {
+ try {
+ const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId)
+ availableAttachments += fileInfos.filter(f => f.UseYn === 'Y').length
+
+ for (const fileInfo of fileInfos) {
+ if (fileInfo.UseYn !== 'Y') continue
+
+ const existingAttachment = await db
+ .select({ id: documentAttachments.id })
+ .from(documentAttachments)
+ .where(eq(documentAttachments.fileId, fileInfo.FileId))
+ .limit(1)
+
+ if (existingAttachment.length === 0) {
+ newAttachments++
+ } else {
+ updatedAttachments++
+ }
+ }
+ } catch (error) {
+ console.warn(`Failed to check files for ${detailDoc.UploadId}:`, error)
+ }
+ }
+ }
+ } catch (error) {
+ console.warn(`Failed to check revisions for ${externalDoc.DrawingNo}:`, error)
+ }
}
} catch (error) {
console.warn(`Failed to check ${drawingKind} for status:`, error)
@@ -673,6 +1361,12 @@ class ImportService {
availableDocuments,
newDocuments,
updatedDocuments,
+ availableRevisions,
+ newRevisions,
+ updatedRevisions,
+ availableAttachments,
+ newAttachments,
+ updatedAttachments,
importEnabled: this.isImportEnabled(sourceSystem)
}
diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts
index f3b2b633..de6f0488 100644
--- a/lib/vendor-document-list/service.ts
+++ b/lib/vendor-document-list/service.ts
@@ -3,7 +3,7 @@
import { eq, SQL } from "drizzle-orm"
import db from "@/db/db"
import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu"
-import { contracts } from "@/db/schema/vendorData"
+import { contracts } from "@/db/schema"
import { GetVendorDcoumentsSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
@@ -293,3 +293,19 @@ export async function modifyDocument(input: ModifyDocumentInputType) {
};
}
}
+
+
+export async function getContractIdsByVendor(vendorId: number): Promise<number[]> {
+ try {
+ const contractsData = await db
+ .select({ id: contracts.id })
+ .from(contracts)
+ .where(eq(contracts.vendorId, vendorId))
+ .orderBy(contracts.id)
+
+ return contractsData.map(contract => contract.id)
+ } catch (error) {
+ console.error('Error fetching contract IDs by vendor:', error)
+ return []
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
index ad184378..9c13573c 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
@@ -108,6 +108,7 @@ export function getSimplifiedDocumentColumns({
)
},
enableResizing: true,
+ maxSize:300,
meta: {
excelHeader: "문서명"
},
@@ -127,6 +128,8 @@ export function getSimplifiedDocumentColumns({
)
},
enableResizing: true,
+ maxSize:100,
+
meta: {
excelHeader: "프로젝트"
},
diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
index 23d80981..d4728d22 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -22,6 +22,8 @@ import { Progress } from "@/components/ui/progress"
import { Separator } from "@/components/ui/separator"
import { SimplifiedDocumentsView } from "@/db/schema"
import { ImportStatus } from "../import-service"
+import { useSession } from "next-auth/react"
+import { getContractIdsByVendor } from "../service" // 서버 액션 import
interface ImportFromDOLCEButtonProps {
allDocuments: SimplifiedDocumentsView[] // contractId 대신 문서 배열
@@ -37,31 +39,71 @@ export function ImportFromDOLCEButton({
const [isImporting, setIsImporting] = React.useState(false)
const [importStatusMap, setImportStatusMap] = React.useState<Map<number, ImportStatus>>(new Map())
const [statusLoading, setStatusLoading] = React.useState(false)
+ const [vendorContractIds, setVendorContractIds] = React.useState<number[]>([]) // 서버에서 가져온 contractIds
+ const [loadingVendorContracts, setLoadingVendorContracts] = React.useState(false)
+ const { data: session } = useSession()
- // 문서들에서 contractId들 추출
- const contractIds = React.useMemo(() => {
+ const vendorId = session?.user.companyId;
+
+ // allDocuments에서 추출한 contractIds
+ const documentsContractIds = React.useMemo(() => {
const uniqueIds = [...new Set(allDocuments.map(doc => doc.contractId).filter(Boolean))]
return uniqueIds.sort()
}, [allDocuments])
+ // 최종 사용할 contractIds (allDocuments가 있으면 문서에서, 없으면 vendor의 모든 contracts)
+ const contractIds = React.useMemo(() => {
+ if (documentsContractIds.length > 0) {
+ return documentsContractIds
+ }
+ return vendorContractIds
+ }, [documentsContractIds, vendorContractIds])
+
console.log(contractIds, "contractIds")
+ // vendorId로 contracts 가져오기
+ React.useEffect(() => {
+ const fetchVendorContracts = async () => {
+ // allDocuments가 비어있고 vendorId가 있을 때만 실행
+ if (allDocuments.length === 0 && vendorId) {
+ setLoadingVendorContracts(true)
+ try {
+ const contractIds = await getContractIdsByVendor(vendorId)
+ setVendorContractIds(contractIds)
+ } catch (error) {
+ console.error('Failed to fetch vendor contracts:', error)
+ toast.error('계약 정보를 가져오는데 실패했습니다.')
+ } finally {
+ setLoadingVendorContracts(false)
+ }
+ }
+ }
+
+ fetchVendorContracts()
+ }, [allDocuments.length, vendorId])
+
// 주요 contractId (가장 많이 나타나는 것)
const primaryContractId = React.useMemo(() => {
if (contractIds.length === 1) return contractIds[0]
- const counts = allDocuments.reduce((acc, doc) => {
- const id = doc.contractId || 0
- acc[id] = (acc[id] || 0) + 1
- return acc
- }, {} as Record<number, number>)
+ if (allDocuments.length > 0) {
+ const counts = allDocuments.reduce((acc, doc) => {
+ const id = doc.contractId || 0
+ acc[id] = (acc[id] || 0) + 1
+ return acc
+ }, {} as Record<number, number>)
+
+ return Number(Object.entries(counts)
+ .sort(([,a], [,b]) => b - a)[0]?.[0] || contractIds[0] || 0)
+ }
- return Number(Object.entries(counts)
- .sort(([,a], [,b]) => b - a)[0]?.[0] || contractIds[0] || 0)
+ return contractIds[0] || 0
}, [contractIds, allDocuments])
// 모든 contractId에 대한 상태 조회
const fetchAllImportStatus = async () => {
+ if (contractIds.length === 0) return
+
setStatusLoading(true)
const statusMap = new Map<number, ImportStatus>()
@@ -217,6 +259,10 @@ export function ImportFromDOLCEButton({
}
const getStatusBadge = () => {
+ if (loadingVendorContracts) {
+ return <Badge variant="secondary">계약 정보 로딩 중...</Badge>
+ }
+
if (statusLoading) {
return <Badge variant="secondary">DOLCE 연결 확인 중...</Badge>
}
@@ -231,7 +277,7 @@ export function ImportFromDOLCEButton({
if (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) {
return (
- <Badge variant="default" className="gap-1 bg-blue-500 hover:bg-blue-600">
+ <Badge variant="samsung" className="gap-1">
<AlertTriangle className="w-3 h-3" />
업데이트 가능 ({contractIds.length}개 계약)
</Badge>
@@ -249,8 +295,9 @@ export function ImportFromDOLCEButton({
const canImport = totalStats.importEnabled &&
(totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0)
- if (contractIds.length === 0) {
- return null // 계약이 없으면 버튼을 표시하지 않음
+ // 로딩 중이거나 contractIds가 없으면 버튼을 표시하지 않음
+ if (loadingVendorContracts || contractIds.length === 0) {
+ return null
}
return (
@@ -272,8 +319,8 @@ export function ImportFromDOLCEButton({
<span className="hidden sm:inline">DOLCE에서 가져오기</span>
{totalStats.newDocuments + totalStats.updatedDocuments > 0 && (
<Badge
- variant="default"
- className="h-5 w-5 p-0 text-xs flex items-center justify-center bg-blue-500"
+ variant="samsung"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center"
>
{totalStats.newDocuments + totalStats.updatedDocuments}
</Badge>
@@ -292,6 +339,13 @@ export function ImportFromDOLCEButton({
</div>
</div>
+ {/* 계약 소스 표시 */}
+ {allDocuments.length === 0 && vendorContractIds.length > 0 && (
+ <div className="text-xs text-blue-600 bg-blue-50 p-2 rounded">
+ 문서가 없어서 전체 계약에서 가져오기를 진행합니다.
+ </div>
+ )}
+
{/* 다중 계약 정보 표시 */}
{contractIds.length > 1 && (
<div className="text-sm">
diff --git a/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx
index 869839cb..aa6255bc 100644
--- a/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx
+++ b/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx
@@ -140,54 +140,43 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Evaluat
// minSize: 400,
// },
- {
- id: "vendorInfo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="협력업체" />
- ),
- cell: ({ row }) => {
- const vendor = row.original.vendor;
- return (
- <div className="space-y-1">
- <div className="font-medium">{vendor.vendorName}</div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode} • {vendor.countryCode}
- </div>
- </div>
- );
- },
- enableSorting: false,
- size: 200,
- },
+ // {
+ // id: "vendorInfo",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="협력업체" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendor = row.original.vendor;
+ // return (
+ // <div className="space-y-1">
+ // <div className="font-medium">{vendor.vendorName}</div>
+ // <div className="text-sm text-muted-foreground">
+ // {vendor.vendorCode} • {vendor.countryCode}
+ // </div>
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // size: 200,
+ // },
- {
- accessorKey: "evaluationYear",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="평가연도" />
- ),
- cell: ({ row }) => (
- <Badge variant="outline">
- {row.getValue("evaluationYear")}년
- </Badge>
- ),
- size: 60,
- },
+
- {
- accessorKey: "evaluationRound",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="평가회차" />
- ),
- cell: ({ row }) => {
- const round = row.getValue("evaluationRound") as string;
- return round ? (
- <Badge variant="secondary">{round}</Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 60,
- },
+ // {
+ // accessorKey: "evaluationRound",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="평가회차" />
+ // ),
+ // cell: ({ row }) => {
+ // const round = row.getValue("evaluationRound") as string;
+ // return round ? (
+ // <Badge variant="secondary">{round}</Badge>
+ // ) : (
+ // <span className="text-muted-foreground">-</span>
+ // );
+ // },
+ // size: 60,
+ // },
]
// ----------------------------------------------------------------
@@ -520,9 +509,16 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Evaluat
return [
selectColumn,
{
- id: "basicInfo",
- header: "기본 정보",
- columns: basicColumns,
+ accessorKey: "evaluationYear",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가연도" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {row.getValue("evaluationYear")}년
+ </Badge>
+ ),
+ size: 60,
},
{
id: "statusInfo",
diff --git a/lib/vendor-users/repository.ts b/lib/vendor-users/repository.ts
new file mode 100644
index 00000000..3719a3bf
--- /dev/null
+++ b/lib/vendor-users/repository.ts
@@ -0,0 +1,172 @@
+import db from "@/db/db";
+import { users, userRoles,userView,roles, type User, type UserRole, type UserView, Role } from "@/db/schema/users";
+import { companies, type Company } from "@/db/schema/companies";
+import {
+ eq,
+ inArray,
+ asc,
+ desc,
+ and,
+ count,
+ gt,
+ sql,
+ SQL,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { Vendor, vendors } from "@/db/schema/vendors";
+
+// ============================================================
+// 타입
+// ============================================================
+
+export type NewUser = typeof users.$inferInsert; // User insert 시 필요한 타입
+export type NewUserRole = typeof userRoles.$inferInsert; // UserRole insert 시 필요한 타입
+export type NewCompany = typeof companies.$inferInsert; // Company insert 시 필요한 타입
+
+
+
+export async function selectUsersWithCompanyAndRoles(
+ 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
+
+ // 1) 쿼리 빌더 생성
+ const queryBuilder = tx
+ .select()
+ .from(userView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit)
+
+ const rows = await queryBuilder
+ return rows
+}
+
+
+/** 총 개수 count */
+export async function countUsers(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(userView).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function groupByCompany(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ companyId: users.companyId,
+ count: count(),
+ })
+ .from(users)
+ .groupBy(users.companyId)
+ .having(gt(count(), 0));
+}
+
+export async function groupByRole(tx: PgTransaction<any, any, any>, companyId: number) {
+ return tx
+ .select({
+ roleId: userRoles.roleId,
+ count: sql<number>`COUNT(*)`.as("count"),
+ })
+ .from(users)
+ .where(eq(users.companyId, companyId))
+ .leftJoin(userRoles, eq(userRoles.userId, users.id))
+ .leftJoin(roles, eq(roles.id, userRoles.roleId))
+ .groupBy(userRoles.roleId, roles.id, roles.name)
+ .having(gt(sql<number>`COUNT(*)` /* 또는 count()와 동일 */, 0));
+}
+
+export async function insertUser(
+ tx: PgTransaction<any, any, any>,
+ data: NewUser
+) {
+ return tx.insert(users).values(data).returning();
+}
+
+export async function insertUserRole(
+ tx: PgTransaction<any, any, any>,
+ data: NewUserRole
+) {
+ return tx.insert(userRoles).values(data).returning();
+}
+
+export async function updateUser(
+ tx: PgTransaction<any, any, any>,
+ userId: number,
+ data: Partial<User>
+) {
+ return tx
+ .update(users)
+ .set(data)
+ .where(eq(users.id, userId))
+ .returning();
+}
+
+/** 복수 업데이트 */
+export async function updateUsers(
+ tx: PgTransaction<any, any, any>,
+ids: number[],
+data: Partial<User>
+) {
+return tx
+ .update(users)
+ .set(data)
+ .where(inArray(users.id, ids))
+ .returning({ companyId: users.companyId });
+}
+
+export async function deleteRolesByUserId(
+ tx: PgTransaction<any, any, any>,
+ userId: number
+) {
+ return tx.delete(userRoles).where(eq(userRoles.userId, userId));
+}
+
+
+export async function deleteRolesByUserIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(userRoles).where(inArray(userRoles.userId, ids));
+}
+
+export async function deleteUserById(
+ tx: PgTransaction<any, any, any>,
+ userId: number
+) {
+ return tx.delete(users).where(eq(users.id, userId));
+}
+
+
+export async function deleteUsersByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(users).where(inArray(users.id, ids));
+}
+
+export async function findAllCompanies(): Promise<Vendor[]> {
+ return db.select().from(vendors).orderBy(asc(vendors.vendorName));
+}
+
+export async function findAllRoles(companyId:number): Promise<Role[]> {
+ return db.select().from(roles).where(and(eq(roles.domain ,'partners'),eq(roles.companyId, companyId))).orderBy(asc(roles.name));
+}
+
+export const getUserById = async (id: number): Promise<UserView | null> => {
+ const userFouned = await db.select().from(userView).where(eq(userView.user_id, id)).execute();
+ if (userFouned.length === 0) return null;
+
+ const user = userFouned[0];
+ return user
+};
diff --git a/lib/vendor-users/service.ts b/lib/vendor-users/service.ts
new file mode 100644
index 00000000..428e8b73
--- /dev/null
+++ b/lib/vendor-users/service.ts
@@ -0,0 +1,491 @@
+"use server";
+
+import { unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import logger from '@/lib/logger';
+
+import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { headers } from 'next/headers';
+
+// 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정
+import {
+ selectUsersWithCompanyAndRoles,
+ countUsers,
+ insertUser,
+ insertUserRole,
+ updateUser, deleteRolesByUserId, deleteRolesByUserIds,
+ deleteUserById,
+ deleteUsersByIds,
+ groupByRole,
+ findAllCompanies, getUserById, updateUsers,
+ findAllRoles
+} from "./repository";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+
+// types
+import type { CreateVendorUserSchema, UpdateVendorUserSchema, GetVendorUsersSchema } from "./validations";
+
+import { sendEmail } from "@/lib//mail/sendEmail";
+import { Vendor } from "@/db/schema/vendors";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+/**
+ * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+
+
+export async function getVendorUsers(input: GetVendorUsersSchema) {
+ try {
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: userView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(userView.user_name, s),
+ ilike(userView.user_email, s),
+ ilike(userView.company_name, s)
+ );
+ }
+
+ // (3) 디폴트 domainWhere = eq(userView.domain, "partners")
+ // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X
+ let domainWhere;
+ const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain");
+ if (!hasDomainFilter) {
+ domainWhere = eq(userView.user_domain, "partners");
+ }
+
+ // (4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere, domainWhere, eq(userView.company_id , companyId));
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(userView[item.id]) : asc(userView[item.id])
+ )
+ : [desc(users.createdAt)];
+
+ // ...
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectUsersWithCompanyAndRoles(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countUsers(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ return { data: [], pageCount: 0 };
+ }
+}
+
+export async function findUserById(id: number) {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const user = await getUserById(id);
+ if (!user) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ user }, 'User fetched successfully');
+ }
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function createVendorUser(input: CreateVendorUserSchema & { language?: string }) {
+ unstable_noStore(); // Next.js 캐싱 방지
+
+ try {
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if(!companyId){
+ throw new Error("인증이 필요합니다.")
+ }
+
+
+ // 예) 관리자 메일 알림 로직
+ // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신
+ // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다.
+ const userLang = input.language || "en";
+ const subject = userLang === "ko"
+ ? "[eVCP] 계정이 생성되었습니다."
+ : "[eVCP] Account Created";
+
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ const baseUrl = `http://${host}`
+
+ const loginUrl = userLang === "ko"
+ ? `${baseUrl}/ko/partners`
+ : `${baseUrl}/en/partners`;
+
+ await sendEmail({
+ to: input.email,
+ subject,
+ template: "vendor-user-created", // 예: nodemailer + handlebars 등
+ context: {
+ name: input.name,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ if(!companyId){
+ throw new Error("인증이 필요합니다.")
+ }
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+
+ const [newUser] = await insertUser(tx, {
+ name: input.name,
+ email: input.email,
+ domain: input.domain,
+ phone: input.phone,
+ companyId:companyId,
+ // 기타 필요한 필드 추가
+ });
+
+ });
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+/**
+ * 롤별 유저 개수 groupBy
+ */
+export async function getUserCountGroupByRoleAndVendor() {
+ try {
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if (!companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByRole(tx, companyId);
+
+ const obj: Record<number, number> = {};
+ for (const row of rows) {
+ if (row.roleId !== null) {
+ obj[row.roleId] = row.count;
+ } else {
+ // roleId가 null인 유저 수
+ obj[-1] = (obj[-1] ?? 0) + row.count;
+ }
+ }
+ return obj;
+ });
+
+ // 여기서 result를 반환해 줘야 함!
+ return result;
+ } catch (err) {
+ console.error("getUserCountGroupByRole error:", err);
+ return {};
+ }
+}
+/**
+ * 단건 업데이트
+ */
+export async function modifiVendorUser(input: UpdateVendorUserSchema & { id: number } & { language?: string }) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ // 🔒 1. 수정 대상이 같은 회사 사용자인지 검증
+ const oldUser = await getUserById(input.id);
+ if (!oldUser) {
+ throw new Error("사용자를 찾을 수 없습니다.");
+ }
+
+ if (oldUser.company_id !== companyId) {
+ throw new Error("수정 권한이 없는 사용자입니다.");
+ }
+
+
+ const oldEmail = oldUser?.user_email ?? null;
+
+ const data = await db.transaction(async (tx) => {
+ // 1) User 테이블 업데이트
+ const [res] = await updateUser(tx, input.id, {
+ name: input.name,
+ email: input.email,
+ });
+
+ // 2) roles 업데이트 (같은 회사 내에서만)
+ if (input.roles) {
+
+ // 기존 roles 삭제
+ await deleteRolesByUserId(tx, input.id);
+
+ // 새 roles 삽입
+ for (const r of input.roles) {
+ await insertUserRole(tx, {
+ userId: input.id,
+ roleId: Number(r),
+ });
+ }
+ }
+
+ return res;
+ });
+
+ // 4) 이메일 변경 알림 (기존 로직 유지)
+ const isEmailChanged = oldEmail && input.email && oldEmail !== input.email;
+ const hasAdminRole = input.roles?.includes("admin") ?? false;
+
+ if (isEmailChanged && hasAdminRole && input.email) {
+ await sendEmail({
+ to: input.email,
+ subject: "[EVCP] Admin Email Changed",
+ template: "admin-email-changed",
+ context: {
+ name: input.name,
+ oldEmail,
+ newEmail: input.email,
+ language: input.language ?? "en",
+ },
+ });
+ }
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifiVendorUsers(input: {
+ ids: number[];
+ companyId?: User["companyId"];
+ roles?: UserView["roles"];
+}) {
+ unstable_noStore()
+
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if(!companyId){
+ throw new Error("인증이 필요합니다.")
+ }
+
+ await db.transaction(async (tx) => {
+ // 🔒 핵심: 대상 사용자들이 같은 회사인지 검증
+ const targetUsers = await tx.select()
+ .from(users)
+ .where(and(
+ inArray(users.id, input.ids),
+ eq(users.companyId, companyId) // 같은 회사 사용자만
+ ))
+
+ // 요청한 ID 수와 실제 같은 회사 사용자 수가 다르면 에러
+ if (targetUsers.length !== input.ids.length) {
+ throw new Error("권한이 없는 사용자가 포함되어 있습니다.")
+ }
+
+ if (Array.isArray(input.roles)) {
+ await deleteRolesByUserIds(tx, input.ids)
+
+ for (const userId of input.ids) {
+ for (const r of input.roles) {
+ await insertUserRole(tx, {
+ userId,
+ roleId: Number(r),
+ })
+ }
+ }
+ }
+ })
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+/**
+ * 단건 삭제
+ */
+export async function removeVendorUser(input: { id: number }) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if(!companyId){
+ throw new Error("인증이 필요합니다.")
+ }
+
+ await db.transaction(async (tx) => {
+ // 🔒 핵심: 삭제 대상이 같은 회사 사용자인지 검증
+ const targetUser = await tx.select()
+ .from(users)
+ .where(and(
+ eq(users.id, input.id),
+ eq(users.companyId, companyId)
+ ))
+ .limit(1);
+
+ if (targetUser.length === 0) {
+ throw new Error("삭제 권한이 없는 사용자입니다.");
+ }
+
+ // 유저 삭제
+ await deleteRolesByUserId(tx, input.id);
+ await deleteUserById(tx, input.id);
+ });
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 복수 삭제
+ */
+export async function removeVendorUsers(input: { ids: number[] }) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if(!companyId){
+ throw new Error("인증이 필요합니다.")
+ }
+
+ await db.transaction(async (tx) => {
+ // 🔒 핵심: 삭제 대상들이 모두 같은 회사 사용자인지 검증
+ const targetUsers = await tx.select()
+ .from(users)
+ .where(and(
+ inArray(users.id, input.ids),
+ eq(users.companyId, companyId)
+ ));
+
+ // 요청한 ID 수와 실제 같은 회사 사용자 수가 다르면 에러
+ if (targetUsers.length !== input.ids.length) {
+ throw new Error("삭제 권한이 없는 사용자가 포함되어 있습니다.");
+ }
+
+ // user_roles도 있으면 먼저 삭제해야 할 수 있음
+ await deleteRolesByUserIds(tx, input.ids);
+ await deleteUsersByIds(tx, input.ids);
+ });
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getAllRolesbyVendor(): Promise<Role[]> {
+ try {
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session.user.companyId;
+
+ if (!companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ return await findAllRoles(companyId);
+ } catch (err) {
+ throw new Error("Failed to get roles");
+ }
+}
+
+/**
+ * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수
+ * @param email 확인할 이메일
+ * @returns boolean - 존재하면 true, 없으면 false
+ */
+export async function checkEmailExists(email: string): Promise<boolean> {
+ const result = await db
+ .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만
+ .from(users)
+ .where(eq(users.email, email))
+ .limit(1);
+
+ return result.length > 0; // 1건 이상 있으면 true
+}
diff --git a/lib/vendor-users/table/add-ausers-dialog.tsx b/lib/vendor-users/table/add-ausers-dialog.tsx
new file mode 100644
index 00000000..632adb7f
--- /dev/null
+++ b/lib/vendor-users/table/add-ausers-dialog.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Role, userRoles } from "@/db/schema/users"
+import { type Company } from "@/db/schema/companies"
+import { MultiSelect } from "@/components/ui/multi-select"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { Vendor } from "@/db/schema/vendors"
+import { CreateVendorUserSchema, createVendorUserSchema } from "../validations"
+import { createVendorUser, getAllRolesbyVendor } from "../service"
+
+const languageOptions = [
+ { value: "ko", label: "한국어" },
+ { value: "en", label: "English" },
+]
+
+
+// Phone validation helper
+const validatePhoneNumber = (phone: string): boolean => {
+ if (!phone) return false;
+ // Basic international phone number validation
+ const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
+ return /^\+\d{10,15}$/.test(cleanPhone);
+};
+
+// Get phone placeholder based on common patterns
+const getPhonePlaceholder = (): string => {
+ return "+82 010-1234-5678"; // Default to Korean format
+};
+
+// Get phone description
+const getPhoneDescription = (): string => {
+ return "국제 전화번호를 입력하세요. (예: +82 010-1234-5678)";
+};
+
+export function AddUserDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [roles, setRoles] = React.useState<Role[]>([])
+ const [isAddPending, startAddTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ getAllRolesbyVendor().then((res) => {
+ setRoles(res)
+ })
+ }, [])
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorUserSchema & { language?: string; phone?: string }>({
+ resolver: zodResolver(createVendorUserSchema),
+ defaultValues: {
+ name: "",
+ email: "",
+ phone: "", // Add phone field
+ companyId: null,
+ language: 'en',
+ roles: ["Vendor Admin"],
+ domain: 'partners'
+ },
+ })
+
+ async function onSubmit(data: CreateVendorUserSchema & { language?: string; phone?: string }) {
+ data.domain = "partners"
+
+ // Validate phone number if provided
+ if (data.phone && !validatePhoneNumber(data.phone)) {
+ toast.error("올바른 국제 전화번호 형식이 아닙니다.")
+ return
+ }
+
+ startAddTransition(async () => {
+ const result = await createVendorUser(data)
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+ form.reset()
+ setOpen(false)
+ toast.success("User added")
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add User
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Create New User</DialogTitle>
+ <DialogDescription>
+ 새 User 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 사용자 이름 */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>User Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. dujin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. user@example.com"
+ type="email"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 - 새로 추가 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone Number</FormLabel>
+ <FormControl>
+ <Input
+ placeholder={getPhonePlaceholder()}
+ {...field}
+ className={cn(
+ field.value && !validatePhoneNumber(field.value) && "border-red-500"
+ )}
+ />
+ </FormControl>
+ <FormDescription className="text-xs text-muted-foreground">
+ {getPhoneDescription()}
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Role (Vendor Admin) - 읽기 전용 */}
+ <FormField
+ control={form.control}
+ name="roles"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role</FormLabel>
+ <FormControl>
+ <Input
+ readOnly
+ disabled
+ value="Vendor Admin"
+ className="bg-gray-50 text-gray-500"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* language Select */}
+ <FormField
+ control={form.control}
+ name="language"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Language</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select language" />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isAddPending}
+ >
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting || isAddPending}>
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-users/table/ausers-table-columns.tsx b/lib/vendor-users/table/ausers-table-columns.tsx
new file mode 100644
index 00000000..38281c7e
--- /dev/null
+++ b/lib/vendor-users/table/ausers-table-columns.tsx
@@ -0,0 +1,228 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { userRoles, type UserView } from "@/db/schema/users"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { userColumnsConfig } from "@/config/userColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<UserView> = {
+ 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"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<UserView> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ 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-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ {/* <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Roles</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <MultiSelect
+ defaultValue={row.original.roles}
+ options={userRoles.role.enumValues.map((role) => ({
+ value: role,
+ label: role,
+ }))}
+ value={row.original.roles}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+
+ toast.promise(
+ modifiUser({
+ id: row.original.user_id,
+ roles: value as ("admin"|"normal")[],
+ }),
+ {
+ loading: "Updating...",
+ success: "Roles updated",
+ error: (err) => getErrorMessage(err),
+ }
+ );
+ });
+ }}
+
+ />
+ </DropdownMenuSubContent>
+ </DropdownMenuSub> */}
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<UserView>[]> = {}
+
+ userColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<UserView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "roles") {
+ const roleValues = row.original.roles;
+ return (
+ <div className="flex flex-wrap gap-1">
+ {roleValues.map((v) => (
+ <Badge key={v} variant="outline">
+ {v}
+ </Badge>
+ ))}
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<UserView>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-users/table/ausers-table-floating-bar.tsx b/lib/vendor-users/table/ausers-table-floating-bar.tsx
new file mode 100644
index 00000000..8778da45
--- /dev/null
+++ b/lib/vendor-users/table/ausers-table-floating-bar.tsx
@@ -0,0 +1,302 @@
+"use client"
+
+import * as React from "react"
+import { userRoles, users, UserView, type User } from "@/db/schema/users"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X, Check
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
+import { MultiSelect } from "@/components/ui/multi-select"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { modifiVendorUsers, removeVendorUsers } from "../service"
+
+interface AusersTableFloatingBarProps {
+ table: Table<UserView>
+}
+
+
+export function AusersTableFloatingBar({ table }: AusersTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-roles" | "export" | "delete"
+ >()
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+ const [rolesPopoverOpen, setRolesPopoverOpen] = React.useState(false)
+
+
+ // 공용 confirm dialog state
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "삭제" Confirm 열기
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeVendorUsers({
+ ids: rows.map((row) => row.original.user_id),
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+
+ // 3) "역할 업데이트" MultiSelect 후 → Confirm Dialog
+ function handleSelectRoles(newRoles: string[]) {
+ setAction("update-roles")
+ setRolesPopoverOpen(false)
+
+ setConfirmProps({
+ title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} with roles: ${newRoles.join(", ")}?`,
+ description: "This action will override their current roles.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifiVendorUsers({
+ ids: rows.map((row) => row.original.user_id),
+ roles: newRoles as ("admin" | "normal")[],
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ return (
+ <Portal >
+ <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
+ <div className="w-full overflow-x-auto">
+ <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
+ <div className="flex items-center gap-1.5">
+
+ <Popover open={rolesPopoverOpen} onOpenChange={setRolesPopoverOpen}>
+
+ <Tooltip>
+ <PopoverTrigger asChild>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
+ disabled={isPending}
+ >
+ {isPending && action === "update-roles" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <ArrowUp className="size-3.5" aria-hidden="true" />
+
+ )}
+ </Button>
+ </TooltipTrigger>
+ </PopoverTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update roles</p>
+ </TooltipContent>
+ </Tooltip>
+ <PopoverContent>
+ <MultiSelect
+ defaultValue={["999999999"]}
+ options={[
+ /* ... */
+ { value: "999999999", label: "admin" }
+ ]}
+ onValueChange={(newRoles) => {
+ handleSelectRoles(newRoles)
+ }}
+ />
+ </PopoverContent>
+
+ </Popover>
+
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={() => {
+ setAction("export")
+
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }}
+ disabled={isPending}
+ >
+ {isPending && action === "export" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Download className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Export users</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleDeleteConfirm}
+ disabled={isPending}
+ >
+ {isPending && action === "delete" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Delete users</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-roles")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-roles"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/vendor-users/table/ausers-table-toolbar-actions.tsx b/lib/vendor-users/table/ausers-table-toolbar-actions.tsx
new file mode 100644
index 00000000..5472c3ac
--- /dev/null
+++ b/lib/vendor-users/table/ausers-table-toolbar-actions.tsx
@@ -0,0 +1,118 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+// 삭제, 추가 다이얼로그
+import { DeleteUsersDialog } from "./delete-ausers-dialog"
+import { AddUserDialog } from "./add-ausers-dialog"
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { type UserView } from "@/db/schema/users"
+
+interface AdmUserTableToolbarActionsProps {
+ table: Table<UserView>
+}
+
+export function AdmUserTableToolbarActions({ table }: AdmUserTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(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("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteUsersDialog
+ users={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 2) 새 Task 추가 다이얼로그 */}
+ <AddUserDialog />
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-users/table/ausers-table.tsx b/lib/vendor-users/table/ausers-table.tsx
new file mode 100644
index 00000000..06cb1e3f
--- /dev/null
+++ b/lib/vendor-users/table/ausers-table.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import * as React from "react"
+import { userRoles , type UserView} from "@/db/schema/users"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import { getColumns } from "./ausers-table-columns"
+import { AdmUserTableToolbarActions } from "./ausers-table-toolbar-actions"
+import { DeleteUsersDialog } from "./delete-ausers-dialog"
+import { AusersTableFloatingBar } from "./ausers-table-floating-bar"
+import { UpdateAuserSheet } from "./update-auser-sheet"
+import { getAllRolesbyVendor, getVendorUsers } from "../service"
+
+interface UsersTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorUsers>>,
+ Record<number, number>,
+ Awaited<ReturnType<typeof getAllRolesbyVendor>>
+ ]
+ >
+}
+type RoleCounts = Record<string, number>
+
+export function VendorUserTable({ promises }: UsersTableProps) {
+
+ const [{ data, pageCount }, roleCountsRaw, roles] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<UserView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const roleCounts = roleCountsRaw as RoleCounts
+
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<UserView>[] = [
+ {
+ id: "user_email",
+ label: "Email",
+ placeholder: "Filter email...",
+ },
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [
+ {
+ id: "user_name",
+ label: "User Name",
+ type: "text",
+ },
+ {
+ id: "user_email",
+ label: "Email",
+ type: "text",
+ },
+
+ {
+ id: "roles",
+ label: "Roles",
+ type: "multi-select",
+ options: roles.map((role) => {
+ return {
+ label: toSentenceCase(role.name),
+ value: role.id,
+ count: roleCounts[role.id], // 이 값이 undefined인지 확인
+ };
+ }),
+ },
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.user_id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<AusersTableFloatingBar table={table}/>}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <AdmUserTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ <DeleteUsersDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ users={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateAuserSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ user={rowAction?.row.original ?? null}
+ />
+
+ </>
+ )
+}
diff --git a/lib/vendor-users/table/delete-ausers-dialog.tsx b/lib/vendor-users/table/delete-ausers-dialog.tsx
new file mode 100644
index 00000000..e3259211
--- /dev/null
+++ b/lib/vendor-users/table/delete-ausers-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { type UserView } from "@/db/schema/users"
+import { removeVendorUsers } from "../service"
+
+interface DeleteUsersDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ users: Row<UserView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteUsersDialog({
+ users,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteUsersDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeVendorUsers({
+ ids: users.map((user) => Number(user.user_id)),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Users deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({users.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{users.length}</span>
+ {users.length === 1 ? " user" : " users"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({users.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{users.length}</span>
+ {users.length === 1 ? " user" : " users"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/vendor-users/table/update-auser-sheet.tsx b/lib/vendor-users/table/update-auser-sheet.tsx
new file mode 100644
index 00000000..2009e517
--- /dev/null
+++ b/lib/vendor-users/table/update-auser-sheet.tsx
@@ -0,0 +1,273 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+ SelectGroup,
+} from "@/components/ui/select"
+// import your MultiSelect or other role selection
+import { MultiSelect } from "@/components/ui/multi-select"
+import { cn } from "@/lib/utils"
+
+import { userRoles, type UserView } from "@/db/schema/users"
+import { UpdateVendorUserSchema, updateVendorUserSchema } from "../validations"
+import { modifiVendorUser } from "../service"
+
+export interface UpdateAuserSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ user: UserView | null
+}
+
+const languageOptions = [
+ { value: "ko", label: "한국어" },
+ { value: "en", label: "English" },
+]
+
+// Phone validation helper
+const validatePhoneNumber = (phone: string): boolean => {
+ if (!phone) return true; // Optional field
+ // Basic international phone number validation
+ const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
+ return /^\+\d{10,15}$/.test(cleanPhone);
+};
+
+// Get phone placeholder
+const getPhonePlaceholder = (): string => {
+ return "+82 010-1234-5678";
+};
+
+// Get phone description
+const getPhoneDescription = (): string => {
+ return "국제 전화번호를 입력하세요. (예: +82 010-1234-5678)";
+};
+
+export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ // 1) RHF 설정
+ const form = useForm<UpdateVendorUserSchema & { language?: string; phone?: string }>({
+ resolver: zodResolver(updateVendorUserSchema),
+ defaultValues: {
+ name: user?.user_name ?? "",
+ email: user?.user_email ?? "",
+ phone: user?.user_phone ?? "", // Add phone field
+ roles: user?.roles ?? [],
+ language: 'en',
+ },
+ })
+
+ // 2) user prop 바뀔 때마다 form.reset
+ React.useEffect(() => {
+ if (user) {
+ form.reset({
+ name: user.user_name,
+ email: user.user_email,
+ phone: user.user_phone || "", // Add phone field
+ roles: user.roles,
+ language: 'en', // You might want to get this from user object
+ })
+ }
+ }, [user, form])
+
+ // 3) onSubmit
+ async function onSubmit(input: UpdateVendorUserSchema & { language?: string; phone?: string }) {
+ // Validate phone number if provided
+ if (input.phone && !validatePhoneNumber(input.phone)) {
+ toast.error("올바른 국제 전화번호 형식이 아닙니다.")
+ return
+ }
+
+ startUpdateTransition(async () => {
+ if (!user) return
+
+ const { error } = await modifiVendorUser({
+ id: user.user_id,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공 시
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("User updated successfully!")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update user</SheetTitle>
+ <SheetDescription>
+ Update the user details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 4) RHF Form */}
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>User Name</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. dujin" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="user@example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 - 새로 추가 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone Number</FormLabel>
+ <FormControl>
+ <Input
+ placeholder={getPhonePlaceholder()}
+ {...field}
+ className={cn(
+ field.value && !validatePhoneNumber(field.value) && "border-red-500"
+ )}
+ />
+ </FormControl>
+ <FormDescription className="text-xs text-muted-foreground">
+ {getPhoneDescription()}
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* roles */}
+ <FormField
+ control={form.control}
+ name="roles"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Roles</FormLabel>
+ <FormControl>
+ <MultiSelect
+ defaultValue={form?.getValues().roles}
+ options={[
+ { value: "999999999", label: "admin" }
+ ]}
+ value={field.value}
+ onValueChange={(vals) => field.onChange(vals)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* language */}
+ <FormField
+ control={form.control}
+ name="language"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Language</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select language" />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 5) Footer: Cancel, Save */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-users/validations.ts b/lib/vendor-users/validations.ts
new file mode 100644
index 00000000..904bb41e
--- /dev/null
+++ b/lib/vendor-users/validations.ts
@@ -0,0 +1,83 @@
+import { userRoles, users, type UserView } from "@/db/schema/users";
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { checkEmailExists } from "./service";
+
+
+
+export const searchParamsVendorUserCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<UserView>().withDefault([
+ { id: "created_at", desc: true },
+ ]),
+ email: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+const phoneValidation = z
+ .string()
+ .optional()
+ .refine(
+ (phone) => {
+ if (!phone || phone.trim() === "") return true; // Optional field
+ // Remove spaces, hyphens, and parentheses for validation
+ const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
+ // Basic international phone number validation
+ return /^\+\d{10,15}$/.test(cleanPhone);
+ },
+ {
+ message: "올바른 국제 전화번호 형식이 아닙니다. +로 시작하는 10-15자리 번호를 입력해주세요. (예: +82 010-1234-5678)"
+ }
+ )
+
+export const createVendorUserSchema = z.object({
+ email: z
+ .string()
+ .email()
+ .refine(
+ async (email) => {
+ // 1) DB 조회해서 이미 같은 email이 있으면 false 반환
+ const isUsed = await checkEmailExists(email);
+ return !isUsed;
+ },
+ {
+ message: "This email is already in use",
+ }
+ ),
+ name: z.string().min(1), // 최소 길이 1
+ phone: phoneValidation,
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ companyId: z.number().nullable().optional(), // number | null | undefined
+ roles:z.array(z.string()).min(1, "At least one role must be selected"),
+ language: z.enum(["ko", "en"]).optional(),
+
+});
+
+export const updateVendorUserSchema = z.object({
+ name: z.string().optional(),
+ email: z.string().email().optional(),
+ phone: phoneValidation,
+ domain: z.enum(users.domain.enumValues).optional(),
+ companyId: z.number().nullable().optional(),
+ roles: z.array(z.string()).optional(),
+ language: z.enum(["ko", "en"]).optional(),
+
+});
+export type GetVendorUsersSchema = Awaited<ReturnType<typeof searchParamsVendorUserCache.parse>>
+export type CreateVendorUserSchema = z.infer<typeof createVendorUserSchema>
+export type UpdateVendorUserSchema = z.infer<typeof updateVendorUserSchema>
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts
index 04d6322a..41ac468b 100644
--- a/lib/vendors/repository.ts
+++ b/lib/vendors/repository.ts
@@ -79,7 +79,7 @@ export async function countVendorsWithTypes(
*/
export async function insertVendor(
tx: PgTransaction<any, any, any>,
- data: Omit<Vendor, "id" | "createdAt" | "updatedAt">
+ data: Omit<Vendor, "id" | "createdAt" | "updatedAt" | "businessSize">
) {
return tx.insert(vendors).values(data).returning();
}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index fb834814..7c6ac15d 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -30,6 +30,7 @@ import {
selectVendorsWithTypes,
countVendorsWithTypes,
countVendorMaterials,
+ selectVendorMaterials,
insertVendorMaterial,
} from "./repository";
@@ -56,7 +57,7 @@ import { promises as fsPromises } from 'fs';
import { sendEmail } from "../mail/sendEmail";
import { PgTransaction } from "drizzle-orm/pg-core";
import { items, materials } from "@/db/schema/items";
-import { users } from "@/db/schema/users";
+import { roles, userRoles, users } from "@/db/schema/users";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema";
@@ -306,7 +307,8 @@ export type CreateVendorData = {
creditRating?: string
cashFlowRating?: string
corporateRegistrationNumber?: string
-
+ businessSize?: string
+
country?: string
status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED"
}
@@ -1468,7 +1470,7 @@ interface ApproveVendorsInput {
*/
export async function approveVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
-
+
try {
// 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
@@ -1507,7 +1509,7 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
if (!vendor.email) return; // 이메일이 없으면 스킵
// 이미 존재하는 유저인지 확인
- const existingUser = await db.query.users.findFirst({
+ const existingUser = await tx.query.users.findFirst({
where: eq(users.email, vendor.email),
columns: {
id: true
@@ -1516,11 +1518,42 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
// 유저가 존재하지 않는 경우에만 생성
if (!existingUser) {
- await tx.insert(users).values({
+ // 유저 생성
+ const [newUser] = await tx.insert(users).values({
name: vendor.vendorName,
email: vendor.email,
companyId: vendor.id,
domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정
+ }).returning({ id: users.id });
+
+ // "Vendor Admin" 역할 찾기 또는 생성
+ let vendorAdminRole = await tx.query.roles.findFirst({
+ where: and(
+ eq(roles.name, "Vendor Admin"),
+ eq(roles.domain, "partners"),
+ eq(roles.companyId, vendor.id)
+ ),
+ columns: {
+ id: true
+ }
+ });
+
+ // "Vendor Admin" 역할이 없다면 생성
+ if (!vendorAdminRole) {
+ const [newRole] = await tx.insert(roles).values({
+ name: "Vendor Admin",
+ domain: "partners",
+ companyId: vendor.id,
+ description: "Vendor Administrator role",
+ }).returning({ id: roles.id });
+
+ vendorAdminRole = newRole;
+ }
+
+ // userRoles 테이블에 관계 생성
+ await tx.insert(userRoles).values({
+ userId: newUser.id,
+ roleId: vendorAdminRole.id,
});
}
})
@@ -1580,6 +1613,8 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
revalidateTag("users"); // 유저 캐시도 무효화
+ revalidateTag("roles"); // 역할 캐시도 무효화
+ revalidateTag("user-roles"); // 유저 역할 캐시도 무효화
return { data: result, error: null };
} catch (err) {
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index 7ba54ccf..07eaae83 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -10,6 +10,7 @@ import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
import { Vendor, VendorContact, VendorItemsView, VendorMaterialsView, vendors, VendorWithType } from "@/db/schema/vendors";
import { rfqs } from "@/db/schema/rfq"
+import { countryDialCodes } from "@/components/signup/join-form";
export const searchParamsCache = createSearchParamsCache({
@@ -155,97 +156,172 @@ const contactSchema = z.object({
const vendorStatusEnum = z.enum(vendors.status.enumValues)
// CREATE 시: 일부 필드는 필수, 일부는 optional
+// Phone validation helper function
+const validatePhoneByCountry = (phone: string, country: string): boolean => {
+ if (!phone || !country) return false;
+
+ // Remove spaces, hyphens, and parentheses for validation
+ const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
+
+ switch (country) {
+ case 'KR': // South Korea
+ // Should start with +82 and have 10-11 digits after country code
+ return /^\+82[1-9]\d{7,9}$/.test(cleanPhone) || /^0[1-9]\d{7,9}$/.test(cleanPhone);
+
+ case 'US': // United States
+ case 'CA': // Canada
+ // Should start with +1 and have exactly 10 digits after
+ return /^\+1[2-9]\d{9}$/.test(cleanPhone);
+
+ case 'JP': // Japan
+ // Should start with +81 and have 10-11 digits after country code
+ return /^\+81[1-9]\d{8,9}$/.test(cleanPhone);
+
+ case 'CN': // China
+ // Should start with +86 and have 11 digits after country code
+ return /^\+86[1-9]\d{9}$/.test(cleanPhone);
+
+ case 'GB': // United Kingdom
+ // Should start with +44 and have 10-11 digits after country code
+ return /^\+44[1-9]\d{8,9}$/.test(cleanPhone);
+
+ case 'DE': // Germany
+ // Should start with +49 and have 10-12 digits after country code
+ return /^\+49[1-9]\d{9,11}$/.test(cleanPhone);
+
+ case 'FR': // France
+ // Should start with +33 and have 9 digits after country code
+ return /^\+33[1-9]\d{8}$/.test(cleanPhone);
+
+ default:
+ // For other countries, just check if it starts with + and has reasonable length
+ return /^\+\d{10,15}$/.test(cleanPhone);
+ }
+};
+
+// Enhanced createVendorSchema with phone validation
export const createVendorSchema = z
.object({
-
vendorName: z
.string()
.min(1, "Vendor name is required")
.max(255, "Max length 255"),
- vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }),
-
+
+ vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }),
+
email: z.string().email("Invalid email").max(255),
- // 나머지 optional
+
+ // Updated phone validation - now required and must include country code
+ phone: z.string()
+ .min(1, "전화번호는 필수입니다")
+ .max(50, "Max length 50"),
+
+ // Other fields remain the same
vendorCode: z.string().max(100, "Max length 100").optional(),
address: z.string().optional(),
country: z.string()
- .min(1, "국가 선택은 필수입니다.")
- .max(100, "Max length 100"),
- phone: z.string().max(50, "Max length 50").optional(),
+ .min(1, "국가 선택은 필수입니다.")
+ .max(100, "Max length 100"),
website: z.string().url("유효하지 않은 URL입니다. https:// 혹은 http:// 로 시작하는 주소를 입력해주세요.").max(255).optional(),
-
+
attachedFiles: z.any()
- .refine(
- val => {
- // Validate that files exist and there's at least one file
- return val &&
- (Array.isArray(val) ? val.length > 0 :
- val instanceof FileList ? val.length > 0 :
- val && typeof val === 'object' && 'length' in val && val.length > 0);
- },
- { message: "첨부 파일은 필수입니다." }
- ),
+ .refine(
+ val => {
+ return val &&
+ (Array.isArray(val) ? val.length > 0 :
+ val instanceof FileList ? val.length > 0 :
+ val && typeof val === 'object' && 'length' in val && val.length > 0);
+ },
+ { message: "첨부 파일은 필수입니다." }
+ ),
+
status: vendorStatusEnum.default("PENDING_REVIEW"),
-
+
representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(),
taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
-
+
items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
-
+
contacts: z
- .array(contactSchema)
- .nonempty("At least one contact is required."),
-
- // ... (기타 필드)
+ .array(contactSchema)
+ .nonempty("At least one contact is required."),
})
.superRefine((data, ctx) => {
+ // Validate main phone number with country code
+ if (data.phone && data.country) {
+ if (!validatePhoneByCountry(data.phone, data.country)) {
+ const countryDialCode = countryDialCodes[data.country] || "+XX";
+ ctx.addIssue({
+ code: "custom",
+ path: ["phone"],
+ message: `올바른 전화번호 형식이 아닙니다. ${countryDialCode}로 시작하는 국제 전화번호를 입력해주세요. (예: ${countryDialCode}XXXXXXXXX)`,
+ });
+ }
+ }
+
+ // Validate representative phone for Korean companies
if (data.country === "KR") {
- // 1) 대표자 정보가 누락되면 각각 에러 발생
if (!data.representativeName) {
ctx.addIssue({
code: "custom",
path: ["representativeName"],
message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
- })
+ });
}
if (!data.representativeBirth) {
ctx.addIssue({
code: "custom",
path: ["representativeBirth"],
message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
- })
+ });
}
if (!data.representativeEmail) {
ctx.addIssue({
code: "custom",
path: ["representativeEmail"],
message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
- })
+ });
}
if (!data.representativePhone) {
ctx.addIssue({
code: "custom",
path: ["representativePhone"],
message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
- })
+ });
+ } else if (!validatePhoneByCountry(data.representativePhone, "KR")) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativePhone"],
+ message: "올바른 한국 전화번호 형식이 아닙니다. +82로 시작하거나 010으로 시작하는 번호를 입력해주세요.",
+ });
}
if (!data.corporateRegistrationNumber) {
ctx.addIssue({
code: "custom",
path: ["corporateRegistrationNumber"],
message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.",
- })
+ });
}
-
-
}
- }
-)
+ // Validate contact phone numbers
+ data.contacts?.forEach((contact, index) => {
+ if (contact.contactPhone && data.country) {
+ if (!validatePhoneByCountry(contact.contactPhone, data.country)) {
+ const countryDialCode = countryDialCodes[data.country] || "+XX";
+ ctx.addIssue({
+ code: "custom",
+ path: ["contacts", index, "contactPhone"],
+ message: `올바른 전화번호 형식이 아닙니다. ${countryDialCode}로 시작하는 국제 전화번호를 입력해주세요.`,
+ });
+ }
+ }
+ });
+ });
export const createVendorContactSchema = z.object({
vendorId: z.number(),
contactName: z.string()