diff options
Diffstat (limited to 'lib')
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() |
