diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /lib/evaluation-criteria | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'lib/evaluation-criteria')
8 files changed, 1635 insertions, 48 deletions
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(''),
|
