summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/evaluation-criteria/repository.ts198
-rw-r--r--lib/evaluation-criteria/service.ts221
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx364
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx147
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx586
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx161
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table.tsx189
-rw-r--r--lib/evaluation-criteria/validations.ts41
-rw-r--r--lib/evaluation-target-list/service.ts372
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx543
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx351
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx2
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx4
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx4
-rw-r--r--lib/evaluation/service.ts125
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx35
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx2
-rw-r--r--lib/evaluation/table/evaluation-table.tsx72
-rw-r--r--lib/spread-js/fns.ts353
-rw-r--r--lib/tech-vendors/service.ts23
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx5
-rw-r--r--lib/tech-vendors/validations.ts3
22 files changed, 3143 insertions, 658 deletions
diff --git a/lib/evaluation-criteria/repository.ts b/lib/evaluation-criteria/repository.ts
new file mode 100644
index 00000000..d406f45a
--- /dev/null
+++ b/lib/evaluation-criteria/repository.ts
@@ -0,0 +1,198 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/* IMPORT */
+import {
+ asc,
+ count,
+ desc,
+ eq,
+} from 'drizzle-orm';
+import { PgTransaction } from 'drizzle-orm/pg-core';
+import {
+ regEvalCriteria,
+ regEvalCriteriaDetails,
+ regEvalCriteriaView,
+ type NewRegEvalCriteria,
+ type NewRegEvalCriteriaDetails,
+} from '@/db/schema';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* SELECT VIEW TRANSACTION */
+async function selectRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ },
+) {
+ const {
+ where,
+ orderBy,
+ offset = 0,
+ limit = 10,
+ } = params;
+ const result = await tx
+ .select()
+ .from(regEvalCriteriaView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+
+ return result;
+}
+
+/* SELECT COUNT TRANSACTION */
+async function countRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ where?: any,
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(regEvalCriteriaView)
+ .where(where);
+
+ return result[0]?.count ?? 0;
+}
+
+/* SELECT JOIN TRANSACTION */
+async function selectRegEvalCriteriaWithDetails(
+ tx: PgTransaction<any, any, any>,
+ id: number,
+) {
+ const criteria = await tx
+ .select()
+ .from(regEvalCriteria)
+ .where(eq(regEvalCriteria.id, id));
+
+ if (!criteria[0]) {
+ return null;
+ }
+
+ const details = await tx
+ .select()
+ .from(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.criteriaId, id))
+ .orderBy(regEvalCriteriaDetails.orderIndex);
+
+ return {
+ ...criteria[0],
+ criteriaDetails: details,
+ };
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* INSERT CRITERIA TRANSACTION */
+async function insertRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ data: NewRegEvalCriteria,
+) {
+ const [insertRes] = await tx
+ .insert(regEvalCriteria)
+ .values(data)
+ .returning();
+
+ return insertRes;
+}
+
+/* INSERT CRITERIA DETAILS TRANSACTION */
+async function insertRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ data: NewRegEvalCriteriaDetails,
+) {
+ const [insertRes] = await tx
+ .insert(regEvalCriteriaDetails)
+ .values(data)
+ .returning();
+
+ return insertRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* UPDATE CRITERIA TRANSACTION */
+async function updateRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ criteriaId: number,
+ data: Partial<NewRegEvalCriteria>,
+) {
+ const [updateRes] = await tx
+ .update(regEvalCriteria)
+ .set(data)
+ .where(eq(regEvalCriteria.id, criteriaId))
+ .returning();
+
+ return updateRes;
+}
+
+/* UPDATE CRITERIA DETAILS TRANSACTION */
+async function updateRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ detailId: number,
+ data: Partial<NewRegEvalCriteriaDetails>,
+) {
+ const [updateRes] = await tx
+ .update(regEvalCriteriaDetails)
+ .set(data)
+ .where(eq(regEvalCriteriaDetails.id, detailId))
+ .returning();
+
+ return updateRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* DELETE CRITERIA TRANSACTION */
+async function deleteRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ criteriaId: number,
+) {
+ const [deleteRes] = await tx
+ .delete(regEvalCriteria)
+ .where(eq(regEvalCriteria.id, criteriaId))
+ .returning();
+
+ return deleteRes;
+}
+
+/* DELETE CRITERIA TRANSACTION */
+async function deleteRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ datailId: number,
+) {
+ const [deleteRes] = await tx
+ .delete(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.id, datailId))
+ .returning();
+
+ return deleteRes;
+}
+
+/* DELETE ALL TRANSACTION */
+async function deleteAllRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+) {
+ const [deleteRes] = await tx.delete(regEvalCriteria);
+
+ return deleteRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ countRegEvalCriteria,
+ deleteAllRegEvalCriteria,
+ deleteRegEvalCriteria,
+ deleteRegEvalCriteriaDetails,
+ insertRegEvalCriteria,
+ insertRegEvalCriteriaDetails,
+ selectRegEvalCriteria,
+ selectRegEvalCriteriaWithDetails,
+ updateRegEvalCriteria,
+ updateRegEvalCriteriaDetails,
+}; \ No newline at end of file
diff --git a/lib/evaluation-criteria/service.ts b/lib/evaluation-criteria/service.ts
new file mode 100644
index 00000000..ec23c9e4
--- /dev/null
+++ b/lib/evaluation-criteria/service.ts
@@ -0,0 +1,221 @@
+'use server';
+
+/* IMPORT */
+import {
+ and,
+ asc,
+ desc,
+ ilike,
+ or,
+} from 'drizzle-orm';
+import {
+ countRegEvalCriteria,
+ deleteRegEvalCriteria,
+ deleteRegEvalCriteriaDetails,
+ insertRegEvalCriteria,
+ insertRegEvalCriteriaDetails,
+ selectRegEvalCriteria,
+ selectRegEvalCriteriaWithDetails,
+ updateRegEvalCriteria,
+ updateRegEvalCriteriaDetails,
+} from './repository';
+import db from '@/db/db';
+import { filterColumns } from '@/lib/filter-columns';
+import {
+ regEvalCriteriaView,
+ type NewRegEvalCriteria,
+ type NewRegEvalCriteriaDetails,
+ type RegEvalCriteria,
+ type RegEvalCriteriaDetails,
+} from '@/db/schema';
+import { type GetRegEvalCriteriaSchema } from './validations';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING CRITERIA */
+async function getRegEvalCriteria(input: GetRegEvalCriteriaSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ const advancedWhere = filterColumns({
+ table: regEvalCriteriaView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // Filtering
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(regEvalCriteriaView.category, s),
+ ilike(regEvalCriteriaView.item, s),
+ ilike(regEvalCriteriaView.classification, s),
+ );
+ }
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // Sorting
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) => {
+ return item.desc
+ ? desc(regEvalCriteriaView[item.id])
+ : asc(regEvalCriteriaView[item.id]);
+ })
+ : [asc(regEvalCriteriaView.id)];
+
+ // Getting Data
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectRegEvalCriteria(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countRegEvalCriteria(tx, finalWhere);
+
+ return { data, total };
+ });
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.error('Error in Getting Regular Evaluation Criteria: ', err);
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/* FUNCTION FOR GETTING CRITERIA WITH DETAILS */
+async function getRegEvalCriteriaWithDetails(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await selectRegEvalCriteriaWithDetails(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Getting Regular Evaluation Criteria with Details: ', err);
+ return null;
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR CREATING CRITERIA WITH DETAILS */
+async function createRegEvalCriteriaWithDetails(
+ criteriaData: NewRegEvalCriteria,
+ detailList: Omit<NewRegEvalCriteriaDetails, 'criteriaId'>[],
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ const criteria = await insertRegEvalCriteria(tx, criteriaData);
+ const criteriaId = criteria.id;
+ const newDetailList = detailList.map((detailItem, index) => ({
+ ...detailItem,
+ criteriaId,
+ orderIndex: index,
+ }));
+
+ const criteriaDetails: NewRegEvalCriteriaDetails[] = [];
+ for (let idx = 0; idx < newDetailList.length; idx += 1) {
+ criteriaDetails.push(await insertRegEvalCriteriaDetails(tx, newDetailList[idx]));
+ }
+
+ return { ...criteria, criteriaDetails };
+ });
+ } catch (error) {
+ console.error('Error in Creating New Regular Evaluation Criteria with Details: ', error);
+ throw new Error('Failed to Create New Regular Evaluation Criteria with Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR MODIFYING CRITERIA WITH DETAILS */
+async function modifyRegEvalCriteriaWithDetails(
+ id: number,
+ criteriaData: Partial<RegEvalCriteria>,
+ detailList: Partial<RegEvalCriteriaDetails>[],
+) {
+ 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
+ .filter(item => item.id !== undefined)
+ .map(item => item.id);
+ const toDeleteIdList = originCriteriaDetails.filter(
+ (item) => !detailIdList.includes(item.id),
+ );
+
+ for (const item of toDeleteIdList) {
+ await deleteRegEvalCriteriaDetails(tx, item.id);
+ }
+
+ const criteriaDetails = [];
+ for (let idx = 0; idx < detailList.length; idx += 1) {
+ const detailItem = detailList[idx];
+ const isUpdate = detailItem.id;
+ const isInsert = !detailItem.id && detailItem.detail;
+
+ if (isUpdate) {
+ const updatedDetail = await updateRegEvalCriteriaDetails(tx, detailItem.id!, detailItem);
+ criteriaDetails.push(updatedDetail);
+ } else if (isInsert) {
+ const newDetailItem = {
+ ...detailItem,
+ criteriaId: id,
+ detail: detailItem.detail!,
+ orderIndex: idx,
+ };
+ const insertedDetail = await insertRegEvalCriteriaDetails(tx, newDetailItem);
+ criteriaDetails.push(insertedDetail);
+ }
+ }
+
+ return { ...modifiedCriteria, criteriaDetails };
+ });
+ } catch (error) {
+ console.error('Error in Modifying Regular Evaluation Criteria with Details: ', error);
+ throw new Error('Failed to Modify Regular Evaluation Criteria with Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR REMOVING CRITERIA WITH DETAILS */
+async function removeRegEvalCriteria(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await deleteRegEvalCriteria(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Removing Regular Evaluation Criteria with Details: ', err);
+ throw new Error('Failed to Remove Regular Evaluation Criteria with Details');
+ }
+}
+
+/* FUNCTION FOR REMOVING CRITERIA DETAILS */
+async function removeRegEvalCriteriaDetails(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await deleteRegEvalCriteriaDetails(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Removing Regular Evaluation Criteria Details: ', err);
+ throw new Error('Failed to Remove Regular Evaluation Criteria Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ createRegEvalCriteriaWithDetails,
+ modifyRegEvalCriteriaWithDetails,
+ getRegEvalCriteria,
+ getRegEvalCriteriaWithDetails,
+ 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
new file mode 100644
index 00000000..7367fabb
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
@@ -0,0 +1,364 @@
+'use client';
+
+/* IMPORT */
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header';
+import { Dispatch, SetStateAction } from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { PenToolIcon, TrashIcon } from 'lucide-react';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ type RegEvalCriteriaView,
+} from '@/db/schema';
+import { type ColumnDef } from '@tanstack/react-table';
+import { type DataTableRowAction } from '@/types/table';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface GetColumnsProps {
+ setRowAction: Dispatch<SetStateAction<DataTableRowAction<RegEvalCriteriaView> | null>>,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING COLUMNS SETTING */
+function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteriaView>[] {
+
+ // [1] SELECT COLUMN - CHECKBOX
+ const selectColumn: ColumnDef<RegEvalCriteriaView> = {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="select-all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="select-row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size:40,
+ };
+
+ // [2] CRITERIA COLUMNS
+ const criteriaColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'category',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가부문" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('category');
+ const label = REG_EVAL_CRITERIA_CATEGORY.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="default">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Category',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'scoreCategory',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="점수구분" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreCategory');
+ const label = REG_EVAL_CRITERIA_CATEGORY2.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="secondary">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Score Category',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'item',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="항목" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('item');
+ const label = REG_EVAL_CRITERIA_ITEM.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="outline">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Item',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'classification',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구분" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('classification')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Classification',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'range',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="범위" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('range') || '-'}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Range',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'detail',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가내용" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-bold">
+ {row.getValue('detail')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Detail',
+ type: 'text',
+ },
+ },
+ ];
+
+ // [3] SCORE COLUMNS
+ const scoreEquipColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'scoreEquipShip',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="조선" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreEquipShip');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Equipment-Shipbuilding Score',
+ group: 'Equipment Score',
+ type: 'number',
+ },
+ },
+ {
+ accessorKey: 'scoreEquipMarine',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="해양" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreEquipMarine');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Equipment-Marine Engineering Score',
+ group: 'Equipment Score',
+ type: 'number',
+ },
+ },
+ ];
+ const scoreBulkColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'scoreBulkShip',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="조선" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreBulkShip');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Bulk-Shipbuiling Score',
+ group: 'Bulk Score',
+ type: 'number',
+ },
+ },
+ {
+ accessorKey: 'scoreBulkMarine',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="해양" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreBulkMarine');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Bulk-Marine Engineering Score',
+ group: 'Bulk Score',
+ type: 'number',
+ },
+ },
+ ];
+
+ // [4] REMARKS COLUMN
+ const remarksColumn: ColumnDef<RegEvalCriteriaView> = {
+ accessorKey: 'remarks',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('remarks') || '-'}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Remarks',
+ type: 'text',
+ },
+ };
+
+ // [5] ACTIONS COLUMN - DROPDOWN MENU
+ const actionsColumn: ColumnDef<RegEvalCriteriaView> = {
+ id: 'actions',
+ header: '작업',
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <PenToolIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: 'update' })}
+ >
+ <PenToolIcon className="mr-2 h-4 w-4" />
+ Modify Criteria
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: 'delete' })}
+ className="text-destructive"
+ >
+ <TrashIcon className="mr-2 h-4 w-4" />
+ Delete Criteria
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 80,
+ };
+
+ return [
+ selectColumn,
+ ...criteriaColumns,
+ {
+ id: 'score',
+ header: '배점',
+ columns: [
+ {
+ id: 'scoreEquip',
+ header: '기자재',
+ columns: scoreEquipColumns,
+ },
+ {
+ id: 'scoreBulk',
+ header: '벌크',
+ columns: scoreBulkColumns,
+ },
+ ],
+ },
+ remarksColumn,
+ actionsColumn,
+ ];
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default getColumns; \ 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
new file mode 100644
index 00000000..aac7db29
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ getRegEvalCriteriaWithDetails,
+ removeRegEvalCriteria,
+} from '../service';
+import { LoaderCircle } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_SCORE_CATEGORY,
+ type RegEvalCriteriaView,
+ type RegEvalCriteriaWithDetails,
+} from '@/db/schema';
+import { useEffect, useState } from 'react';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaDeleteDialogProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ criteriaViewData: RegEvalCriteriaView,
+ onSuccess: () => void,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* REGULAR EVALUATION CRITERIA DELETE DIALOG COMPONENT */
+function RegEvalCriteriaDeleteDialog(props: RegEvalCriteriaDeleteDialogProps) {
+ const { open, onOpenChange, criteriaViewData, onSuccess } = props;
+ const [isLoading, setIsLoading] = useState<boolean>(false);
+ const [isDeleting, setIsDeleting] = useState<boolean>(false);
+ const [targetData, setTargetData] = useState<RegEvalCriteriaWithDetails | null>();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (!criteriaViewData?.criteriaId) {
+ return;
+ }
+ setIsLoading(true);
+ try {
+ const result = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId);
+ setTargetData(result);
+ } catch (error) {
+ console.error('Error in Loading Target Data for Deletion: ', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchData();
+ }, [criteriaViewData.criteriaId]);
+
+ const handleDelete = async () => {
+ if (!criteriaViewData || !criteriaViewData.criteriaId) {
+ return;
+ }
+
+ try {
+ setIsDeleting(true);
+ await removeRegEvalCriteria(criteriaViewData.criteriaId);
+ toast.success('평가 기준이 삭제되었습니다.');
+ onSuccess();
+ } catch (error) {
+ console.error('Error in Deleting Regular Evaluation Criteria: ', error);
+ toast.error(
+ error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.'
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ if (!criteriaViewData) {
+ return null;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent>
+ {isLoading ? (
+ <div 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>
+ <DialogTitle>협력업체 평가 기준 삭제</DialogTitle>
+ <DialogDescription>
+ 정말로 이 협력업체 평가 기준을 삭제하시겠습니까?
+ <br />
+ <br />
+ <strong>삭제될 평가 기준:</strong>
+ <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 ?? '-'}
+ <br />
+ • 항목: {REG_EVAL_CRITERIA_ITEM.find((c) => c.value === criteriaViewData.item)?.label ?? '-'}
+ <br />
+ • 구분: {criteriaViewData.classification || '-'}
+ <br />
+ • 범위: {criteriaViewData.range || '-'}
+ <br />
+ <br />
+ <b>이 작업은 되돌릴 수 없으며</b>, 평가기준과 그에 속한 <b>{targetData?.criteriaDetails.length}개의 평가항목도 함께 삭제</b>됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting ? 'Deleting...' : 'Delete'}
+ </Button>
+ </DialogFooter>
+ </>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaDeleteDialog; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx
new file mode 100644
index 00000000..d33e7d29
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx
@@ -0,0 +1,586 @@
+/* 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,
+ 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, '평가부문은 필수 항목입니다.'),
+ scoreCategory: 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,
+ criteriaViewData: RegEvalCriteriaView | null,
+ 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 RegEvalCriteriaFormSheet({
+ open,
+ onOpenChange,
+ criteriaViewData,
+ onSuccess,
+}: RegEvalCriteriaFormSheetProps) {
+ const [isPending, startTransition] = useTransition();
+ const isUpdateMode = !!criteriaViewData;
+
+ const form = useForm<RegEvalCriteriaFormData>({
+ resolver: zodResolver(regEvalCriteriaFormSchema),
+ defaultValues: {
+ category: '',
+ scoreCategory: '',
+ 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 && isUpdateMode && criteriaViewData) {
+ startTransition(async () => {
+ try {
+ const targetData = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!);
+ if (targetData) {
+ form.reset({
+ category: targetData.category,
+ scoreCategory: targetData.scoreCategory,
+ 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 : '편집할 데이터를 불러오는 데 실패했습니다.')
+ }
+ })
+ } else if (open && !isUpdateMode) {
+ form.reset({
+ category: '',
+ scoreCategory: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ })
+ }
+ }, [open, isUpdateMode, criteriaViewData, form]);
+
+ const onSubmit = async (data: RegEvalCriteriaFormData) => {
+ startTransition(async () => {
+ try {
+ const criteriaData = {
+ category: data.category,
+ scoreCategory: data.scoreCategory,
+ 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,
+ }));
+
+ if (isUpdateMode && criteriaViewData) {
+ await modifyRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!, criteriaData, detailList);
+ toast.success('평가 기준표가 수정되었습니다.');
+ } else {
+ 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 (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[900px] sm:max-w-[900px] overflow-y-auto">
+ <SheetHeader className="mb-4">
+ <SheetTitle className="font-bold">
+ {isUpdateMode ? '협력업체 평가 기준표 수정' : '새 협력업체 평가 기준표 생성'}
+ </SheetTitle>
+ <SheetDescription>
+ {isUpdateMode ? '협력업체 평가 기준표의 정보를 수정합니다.' : '새로운 협력업체 평가 기준표를 생성합니다.'}
+ </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="scoreCategory"
+ 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...'
+ : isUpdateMode
+ ? 'Modify'
+ : 'Create'}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaFormSheet; \ No newline at end of file
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
new file mode 100644
index 00000000..95b2171e
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+/* IMPORT */
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { Download, Plus, Trash2 } from 'lucide-react';
+import { exportTableToExcel } from '@/lib/export';
+import { removeRegEvalCriteria } from '../service';
+import { type RegEvalCriteriaView } from '@/db/schema';
+import { type Table } from '@tanstack/react-table';
+import { toast } from 'sonner';
+import { useMemo, useState } from 'react';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaTableToolbarActionsProps {
+ table: Table<RegEvalCriteriaView>,
+ onCreateCriteria?: () => void,
+ onRefresh?: () => void,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* REGULAR EVALUATION CRITERIA TABLE TOOLBAR ACTIONS COMPONENT */
+function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarActionsProps) {
+ const { table, onCreateCriteria, onRefresh } = props;
+ const [isDeleting, setIsDeleting] = useState<boolean>(false);
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const hasSelection = selectedRows.length > 0;
+ const selectedIds = useMemo(() => {
+ return [...new Set(selectedRows.map(row => row.original.criteriaId))];
+ }, [selectedRows]);
+
+ // Function for Create New Criteria
+ const handleCreateNew = () => {
+ if (!onCreateCriteria) {
+ return;
+ }
+ onCreateCriteria();
+ }
+
+ const handleDeleteSelected = async () => {
+ if (!hasSelection) {
+ return;
+ }
+ try {
+ setIsDeleting(true);
+
+ for (const selectedId of selectedIds) {
+ if (selectedId) {
+ await removeRegEvalCriteria(selectedId);
+ }
+ }
+ table.resetRowSelection();
+ toast.success(`${selectedIds.length}개의 평가 기준이 삭제되었습니다.`);
+
+ if (onRefresh) {
+ onRefresh();
+ } else {
+ window.location.reload();
+ }
+ } catch (error) {
+ console.error('Error in Deleting Regular Evaluation Critria: ', error);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : '평가 기준 삭제 중 오류가 발생했습니다.'
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ // Excel Export
+ const handleExport = () => {
+ try {
+ exportTableToExcel(table, {
+ filename: 'Regular_Evaluation_Criteria',
+ excludeColumns: ['select', 'actions'],
+ });
+ toast.success('Excel 파일이 다운로드되었습니다.');
+ } catch (error) {
+ console.error('Error in Exporting to Excel: ', error);
+ toast.error('Excel 내보내기 중 오류가 발생했습니다.');
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ onClick={handleCreateNew}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">New Criteria</span>
+ </Button>
+ {hasSelection && (
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button
+ variant="destructive"
+ size="sm"
+ className="gap-2"
+ disabled={isDeleting}
+ >
+ <Trash2 className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 선택 삭제 ({selectedIds.length})
+ </span>
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>정말 삭제하시겠습니까?</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택된 {selectedIds.length}개의 협력업체 평가 기준 항목이 영구적으로 삭제됩니다.
+ 이 작업은 되돌릴 수 없으며, 연관된 평가 기준과 항목들도 함께 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteSelected}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? 'Deleting...' : 'Delete'}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ );
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaTableToolbarActions; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
new file mode 100644
index 00000000..a2242309
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+/* IMPORT */
+import { DataTable } from '@/components/data-table/data-table';
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar';
+import getColumns from './reg-eval-criteria-columns';
+import { getRegEvalCriteria } from '../service';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ type RegEvalCriteriaView
+} from '@/db/schema';
+import RegEvalCriteriaDeleteDialog from './reg-eval-criteria-delete-dialog';
+import RegEvalCriteriaFormSheet from './reg-eval-criteria-form-sheet';
+import RegEvalCriteriaTableToolbarActions from './reg-eval-criteria-table-toolbar-actions';
+import {
+ type DataTableFilterField,
+ type DataTableRowAction,
+ type DataTableAdvancedFilterField,
+} from '@/types/table';
+import { useDataTable } from '@/hooks/use-data-table';
+import { use, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getRegEvalCriteria>>,
+ ]>,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TABLE COMPONENT */
+function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
+ const router = useRouter();
+ const [rowAction, setRowAction] = useState<DataTableRowAction<RegEvalCriteriaView> | null>(null);
+ const [isCreateFormOpen, setIsCreateFormOpen] = useState<boolean>(false);
+ const [promiseData] = use(promises);
+ const tableData = promiseData;
+
+ const columns = useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction],
+ );
+
+ const filterFields: DataTableFilterField<RegEvalCriteriaView>[] = [
+ {
+ id: 'category',
+ label: '평가부문',
+ placeholder: '평가부문 선택...',
+ },
+ {
+ id: 'scoreCategory',
+ label: '점수구분',
+ placeholder: '점수구분 선택...',
+ },
+ {
+ id: 'item',
+ label: '항목',
+ placeholder: '항목 선택...',
+ },
+ ]
+ const advancedFilterFields: DataTableAdvancedFilterField<RegEvalCriteriaView>[] = [
+ {
+ id: 'category',
+ label: '평가부문',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_CATEGORY,
+ },
+ {
+ id: 'scoreCategory',
+ label: '점수구분',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_CATEGORY2,
+ },
+ {
+ id: 'item',
+ label: '항목',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_ITEM,
+ },
+ { id: 'classification', label: '구분', type: 'text' },
+ { id: 'range', label: '범위', type: 'text' },
+ { id: 'detail', label: '평가내용', type: 'text' },
+ { id: 'scoreEquipShip', label: '조선', type: 'number' },
+ { id: 'scoreEquipMarine', label: '해양', type: 'number' },
+ { id: 'scoreBulkShip', label: '조선', type: 'number' },
+ { id: 'scoreBulkMarine', label: '해양', type: 'number' },
+ { id: 'remarks', label: '비고', type: 'text' },
+ ];
+
+ // Data Table Setting
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'id', desc: false }],
+ columnPinning: { left: ['select'], right: ['actions'] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ const emptyCriteriaViewData: RegEvalCriteriaView = {
+ id: 0,
+ category: '',
+ scoreCategory: '',
+ item: '',
+ classification: '',
+ range: null,
+ remarks: null,
+ criteriaId: 0,
+ detail: '',
+ orderIndex: null,
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ };
+
+ const refreshData = useCallback(() => {
+ router.refresh();
+ }, [router]);
+ const handleCreateCriteria = () => {
+ setIsCreateFormOpen(true);
+ };
+ const handleCreateSuccess = useCallback(() => {
+ setIsCreateFormOpen(false);
+ refreshData();
+ }, [refreshData]);
+ const handleModifySuccess = useCallback(() => {
+ setRowAction(null);
+ refreshData();
+ }, [refreshData]);
+ const handleDeleteSuccess = useCallback(() => {
+ setRowAction(null);
+ refreshData();
+ }, [refreshData]);
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RegEvalCriteriaTableToolbarActions
+ table={table}
+ onCreateCriteria={handleCreateCriteria}
+ onRefresh={refreshData}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <RegEvalCriteriaFormSheet
+ open={isCreateFormOpen}
+ onOpenChange={setIsCreateFormOpen}
+ criteriaViewData={null}
+ onSuccess={handleCreateSuccess}
+ />
+ <RegEvalCriteriaFormSheet
+ open={rowAction?.type === 'update'}
+ onOpenChange={() => setRowAction(null)}
+ criteriaViewData={rowAction?.row.original ?? null}
+ onSuccess={handleModifySuccess}
+ />
+ <RegEvalCriteriaDeleteDialog
+ open={rowAction?.type === 'delete'}
+ onOpenChange={() => setRowAction(null)}
+ criteriaViewData={rowAction?.row.original ?? emptyCriteriaViewData}
+ onSuccess={handleDeleteSuccess}
+ />
+ </>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaTable;
diff --git a/lib/evaluation-criteria/validations.ts b/lib/evaluation-criteria/validations.ts
new file mode 100644
index 00000000..e9d5becc
--- /dev/null
+++ b/lib/evaluation-criteria/validations.ts
@@ -0,0 +1,41 @@
+/* IMPORT */
+import * as z from 'zod';
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from 'nuqs/server';
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { type RegEvalCriteriaView } from "@/db/schema";
+
+// ----------------------------------------------------------------------------------------------------
+
+/* QUERY PARAMETER SCHEMATA */
+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 }]),
+ tagTypeLabel: parseAsString.withDefault(''),
+ classLabel: parseAsString.withDefault(''),
+ formCode: parseAsString.withDefault(''),
+ formName: parseAsString.withDefault(''),
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'),
+ search: parseAsString.withDefault(''),
+});
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+type GetRegEvalCriteriaSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ searchParamsCache,
+ type GetRegEvalCriteriaSchema,
+}; \ No newline at end of file
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 572b468d..0da50fa2 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -17,13 +17,16 @@ import {
type DomesticForeign,
EVALUATION_DEPARTMENT_CODES,
EvaluationTargetWithDepartments,
- evaluationTargetsWithDepartments
+ evaluationTargetsWithDepartments,
+ periodicEvaluations,
+ reviewerEvaluations
} from "@/db/schema";
import { GetEvaluationTargetsSchema } from "./validation";
import { PgTransaction } from "drizzle-orm/pg-core";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { sendEmail } from "../mail/sendEmail";
+import type { SQL } from "drizzle-orm"
export async function selectEvaluationTargetsFromView(
tx: PgTransaction<any, any, any>,
@@ -60,76 +63,122 @@ export async function countEvaluationTargetsFromView(
// ============= 메인 서버 액션도 함께 수정 =============
+
export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) {
try {
const offset = (input.page - 1) * input.perPage;
- // 고급 필터링 (View 테이블 기준)
- const advancedWhere = filterColumns({
- table: evaluationTargetsWithDepartments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
+ // ✅ getRFQ 방식과 동일한 필터링 처리
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: evaluationTargetsWithDepartments,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
- // 베이직 필터링 (커스텀 필터)
- let basicWhere;
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
if (input.basicFilters && input.basicFilters.length > 0) {
basicWhere = filterColumns({
table: evaluationTargetsWithDepartments,
filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || "and",
+ joinOperator: input.basicJoinOperator || 'and',
});
}
- // 전역 검색 (View 테이블 기준)
- let globalWhere;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
if (input.search) {
const s = `%${input.search}%`;
- globalWhere = or(
- ilike(evaluationTargetsWithDepartments.vendorCode, s),
- ilike(evaluationTargetsWithDepartments.vendorName, s),
- ilike(evaluationTargetsWithDepartments.adminComment, s),
- ilike(evaluationTargetsWithDepartments.consolidatedComment, s),
- // 담당자 이름으로도 검색 가능
- ilike(evaluationTargetsWithDepartments.orderReviewerName, s),
- ilike(evaluationTargetsWithDepartments.procurementReviewerName, s),
- ilike(evaluationTargetsWithDepartments.qualityReviewerName, s),
- ilike(evaluationTargetsWithDepartments.designReviewerName, s),
- ilike(evaluationTargetsWithDepartments.csReviewerName, s)
- );
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ const vendorCodeCondition = ilike(evaluationTargetsWithDepartments.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorNameCondition = ilike(evaluationTargetsWithDepartments.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ const adminCommentCondition = ilike(evaluationTargetsWithDepartments.adminComment, s);
+ if (adminCommentCondition) validSearchConditions.push(adminCommentCondition);
+
+ const consolidatedCommentCondition = ilike(evaluationTargetsWithDepartments.consolidatedComment, s);
+ if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition);
+
+ // 담당자 이름으로도 검색
+ const orderReviewerCondition = ilike(evaluationTargetsWithDepartments.orderReviewerName, s);
+ if (orderReviewerCondition) validSearchConditions.push(orderReviewerCondition);
+
+ const procurementReviewerCondition = ilike(evaluationTargetsWithDepartments.procurementReviewerName, s);
+ if (procurementReviewerCondition) validSearchConditions.push(procurementReviewerCondition);
+
+ const qualityReviewerCondition = ilike(evaluationTargetsWithDepartments.qualityReviewerName, s);
+ if (qualityReviewerCondition) validSearchConditions.push(qualityReviewerCondition);
+
+ const designReviewerCondition = ilike(evaluationTargetsWithDepartments.designReviewerName, s);
+ if (designReviewerCondition) validSearchConditions.push(designReviewerCondition);
+
+ const csReviewerCondition = ilike(evaluationTargetsWithDepartments.csReviewerName, s);
+ if (csReviewerCondition) validSearchConditions.push(csReviewerCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
}
- const finalWhere = and(advancedWhere, basicWhere, globalWhere);
+ // ✅ getRFQ 방식과 동일한 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- // 정렬 (View 테이블 기준)
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) => {
- const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments];
- return item.desc ? desc(column) : asc(column);
- })
- : [desc(evaluationTargetsWithDepartments.createdAt)];
-
- // 데이터 조회 - View 테이블 사용
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectEvaluationTargetsFromView(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- const total = await countEvaluationTargetsFromView(tx, finalWhere);
- return { data, total };
+ // ✅ getRFQ 방식과 동일한 전체 데이터 수 조회 (Transaction 제거)
+ const totalResult = await db
+ .select({ count: count() })
+ .from(evaluationTargetsWithDepartments)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("Total evaluation targets:", total);
+
+ // ✅ getRFQ 방식과 동일한 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof evaluationTargetsWithDepartments.$inferSelect;
+ return sort.desc ? desc(evaluationTargetsWithDepartments[column]) : asc(evaluationTargetsWithDepartments[column]);
});
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(evaluationTargetsWithDepartments.createdAt));
+ }
+
+ const evaluationData = await db
+ .select()
+ .from(evaluationTargetsWithDepartments)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
const pageCount = Math.ceil(total / input.perPage);
- return { data, pageCount, total };
+
+ return { data: evaluationData, pageCount, total };
} catch (err) {
console.error("Error in getEvaluationTargets:", err);
- return { data: [], pageCount: 0 };
+ // ✅ getRFQ 방식과 동일한 에러 반환 (total 포함)
+ return { data: [], pageCount: 0, total: 0 };
}
}
-
// ============= 개별 조회 함수도 업데이트 =============
export async function getEvaluationTargetById(id: number): Promise<EvaluationTargetWithDepartments | null> {
@@ -377,7 +426,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
console.log(input, "update input")
try {
- const session = await auth()
+ const session = await getServerSession(authOptions)
+
if (!session?.user) {
throw new Error("인증이 필요합니다.")
}
@@ -462,8 +512,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
.values({
evaluationTargetId: input.id,
departmentCode: update.departmentCode,
- reviewerUserId: user[0].id,
- assignedBy: session.user.id,
+ reviewerUserId: Number(user[0].id),
+ assignedBy:Number( session.user.id),
})
}
}
@@ -553,7 +603,7 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
consensusStatus: hasConsensus,
status: newStatus,
confirmedAt: hasConsensus ? new Date() : null,
- confirmedBy: hasConsensus ? session.user.id : null,
+ confirmedBy: hasConsensus ? Number(session.user.id) : null,
updatedAt: new Date()
})
.where(eq(evaluationTargets.id, input.id))
@@ -649,20 +699,27 @@ export async function getDepartmentInfo() {
}
-export async function confirmEvaluationTargets(targetIds: number[]) {
+export async function confirmEvaluationTargets(
+ targetIds: number[],
+ evaluationPeriod?: string // "상반기", "하반기", "연간" 등
+) {
try {
const session = await getServerSession(authOptions)
-
+
if (!session?.user) {
return { success: false, error: "인증이 필요합니다." }
}
-
+
if (targetIds.length === 0) {
return { success: false, error: "선택된 평가 대상이 없습니다." }
}
+ // 평가 기간이 없으면 현재 날짜 기준으로 자동 결정
+ // const currentPeriod = evaluationPeriod || getCurrentEvaluationPeriod()
+ const currentPeriod ="연간"
+
// 트랜잭션으로 처리
- await db.transaction(async (tx) => {
+ const result = await db.transaction(async (tx) => {
// 확정 가능한 대상들 확인 (PENDING 상태이면서 consensusStatus가 true인 것들)
const eligibleTargets = await tx
.select()
@@ -674,13 +731,14 @@ export async function confirmEvaluationTargets(targetIds: number[]) {
eq(evaluationTargets.consensusStatus, true)
)
)
-
+
if (eligibleTargets.length === 0) {
throw new Error("확정 가능한 평가 대상이 없습니다. (의견 일치 상태인 대기중 항목만 확정 가능)")
}
-
- // 상태를 CONFIRMED로 변경
+
const confirmedTargetIds = eligibleTargets.map(target => target.id)
+
+ // 1. 평가 대상 상태를 CONFIRMED로 변경
await tx
.update(evaluationTargets)
.set({
@@ -690,26 +748,201 @@ export async function confirmEvaluationTargets(targetIds: number[]) {
updatedAt: new Date()
})
.where(inArray(evaluationTargets.id, confirmedTargetIds))
+
+ // 2. 각 확정된 평가 대상에 대해 periodicEvaluations 레코드 생성
+ const periodicEvaluationsToCreate = []
+
+ for (const target of eligibleTargets) {
+ // 이미 해당 기간에 평가가 존재하는지 확인
+ const existingEvaluation = await tx
+ .select({ id: periodicEvaluations.id })
+ .from(periodicEvaluations)
+ .where(
+ and(
+ eq(periodicEvaluations.evaluationTargetId, target.id),
+ // eq(periodicEvaluations.evaluationPeriod, currentPeriod)
+ )
+ )
+ .limit(1)
+
+ // 없으면 생성 목록에 추가
+ if (existingEvaluation.length === 0) {
+ periodicEvaluationsToCreate.push({
+ evaluationTargetId: target.id,
+ evaluationPeriod: currentPeriod,
+ // 평가년도에 따른 제출 마감일 설정 (예: 상반기는 7월 말, 하반기는 1월 말)
+ submissionDeadline: getSubmissionDeadline(target.evaluationYear, currentPeriod),
+ status: "PENDING_SUBMISSION" as const,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+ }
+
+ // 3. periodicEvaluations 레코드들 일괄 생성
+ let createdEvaluationsCount = 0
+ if (periodicEvaluationsToCreate.length > 0) {
+ const createdEvaluations = await tx
+ .insert(periodicEvaluations)
+ .values(periodicEvaluationsToCreate)
+ .returning({ id: periodicEvaluations.id })
+
+ createdEvaluationsCount = createdEvaluations.length
+ }
+
+ // 4. 평가 항목 수 조회 (evaluationSubmissions 생성을 위해)
+ const [generalItemsCount, esgItemsCount] = await Promise.all([
+ // 활성화된 일반평가 항목 수
+ tx.select({ count: count() })
+ .from(generalEvaluations)
+ .where(eq(generalEvaluations.isActive, true)),
+
+ // 활성화된 ESG 평가항목 수
+ tx.select({ count: count() })
+ .from(esgEvaluationItems)
+ .where(eq(esgEvaluationItems.isActive, true))
+ ])
+
+ const totalGeneralItems = generalItemsCount[0]?.count || 0
+ const totalEsgItems = esgItemsCount[0]?.count || 0
- return confirmedTargetIds
+ // 5. 각 periodicEvaluation에 대해 담당자별 reviewerEvaluations도 생성
+ if (periodicEvaluationsToCreate.length > 0) {
+ // 새로 생성된 periodicEvaluations 조회
+ const newPeriodicEvaluations = await tx
+ .select({
+ id: periodicEvaluations.id,
+ evaluationTargetId: periodicEvaluations.evaluationTargetId
+ })
+ .from(periodicEvaluations)
+ .where(
+ and(
+ inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds),
+ eq(periodicEvaluations.evaluationPeriod, currentPeriod)
+ )
+ )
+
+ // 각 평가에 대해 담당자별 reviewerEvaluations 생성
+ for (const periodicEval of newPeriodicEvaluations) {
+ // 해당 evaluationTarget의 담당자들 조회
+ const reviewers = await tx
+ .select()
+ .from(evaluationTargetReviewers)
+ .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId))
+
+ if (reviewers.length > 0) {
+ const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({
+ periodicEvaluationId: periodicEval.id,
+ evaluationTargetReviewerId: reviewer.id,
+ isCompleted: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }))
+
+ await tx
+ .insert(reviewerEvaluations)
+ .values(reviewerEvaluationsToCreate)
+ }
+ }
+ }
+
+ // 6. 벤더별 evaluationSubmissions 레코드 생성
+ const evaluationSubmissionsToCreate = []
+
+ for (const target of eligibleTargets) {
+ // 이미 해당 년도/기간에 제출 레코드가 있는지 확인
+ const existingSubmission = await tx
+ .select({ id: evaluationSubmissions.id })
+ .from(evaluationSubmissions)
+ .where(
+ and(
+ eq(evaluationSubmissions.companyId, target.vendorId),
+ eq(evaluationSubmissions.evaluationYear, target.evaluationYear),
+ // eq(evaluationSubmissions.evaluationRound, currentPeriod)
+ )
+ )
+ .limit(1)
+
+ // 없으면 생성 목록에 추가
+ if (existingSubmission.length === 0) {
+ evaluationSubmissionsToCreate.push({
+ companyId: target.vendorId,
+ evaluationYear: target.evaluationYear,
+ evaluationRound: currentPeriod,
+ submissionStatus: "draft" as const,
+ totalGeneralItems: totalGeneralItems,
+ completedGeneralItems: 0,
+ totalEsgItems: totalEsgItems,
+ completedEsgItems: 0,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+ }
+
+ // 7. evaluationSubmissions 레코드들 일괄 생성
+ let createdSubmissionsCount = 0
+ if (evaluationSubmissionsToCreate.length > 0) {
+ const createdSubmissions = await tx
+ .insert(evaluationSubmissions)
+ .values(evaluationSubmissionsToCreate)
+ .returning({ id: evaluationSubmissions.id })
+
+ createdSubmissionsCount = createdSubmissions.length
+ }
+
+ return {
+ confirmedTargetIds,
+ createdEvaluationsCount,
+ createdSubmissionsCount,
+ totalConfirmed: confirmedTargetIds.length
+ }
})
-
-
- return {
- success: true,
- message: `${targetIds.length}개 평가 대상이 확정되었습니다.`,
- confirmedCount: targetIds.length
+
+ return {
+ success: true,
+ message: `${result.totalConfirmed}개 평가 대상이 확정되었습니다. ${result.createdEvaluationsCount}개의 정기평가와 ${result.createdSubmissionsCount}개의 제출 요청이 생성되었습니다.`,
+ confirmedCount: result.totalConfirmed,
+ createdEvaluationsCount: result.createdEvaluationsCount,
+ createdSubmissionsCount: result.createdSubmissionsCount
}
-
+
} catch (error) {
console.error("Error confirming evaluation targets:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다."
}
}
}
+
+// 현재 날짜 기준으로 평가 기간 결정하는 헬퍼 함수
+function getCurrentEvaluationPeriod(): string {
+ const now = new Date()
+ const month = now.getMonth() + 1 // 0-based이므로 +1
+
+ // 1~6월: 상반기, 7~12월: 하반기
+ return month <= 6 ? "상반기" : "하반기"
+}
+
+// 평가년도와 기간에 따른 제출 마감일 설정하는 헬퍼 함수
+function getSubmissionDeadline(evaluationYear: number, period: string): Date {
+ const year = evaluationYear
+
+ if (period === "상반기") {
+ // 상반기 평가는 다음 해 6월 말까지
+ return new Date(year, 5, 31) // 7월은 6 (0-based)
+ } else if (period === "하반기") {
+ // 하반기 평가는 다음 올해 12월 말까지
+ return new Date(year, 11, 31) // 1월은 0 (0-based)
+ } else {
+ // 연간 평가는 올해 6월 말까지
+ return new Date(year, 5, 31) // 3월은 2 (0-based)
+ }
+}
+
export async function excludeEvaluationTargets(targetIds: number[]) {
try {
const session = await getServerSession(authOptions)
@@ -769,7 +1002,8 @@ export async function excludeEvaluationTargets(targetIds: number[]) {
export async function requestEvaluationReview(targetIds: number[], message?: string) {
try {
- const session = await auth()
+ const session = await getServerSession(authOptions)
+
if (!session?.user) {
return { success: false, error: "인증이 필요합니다." }
}
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index fe0b3188..87be3589 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -1,76 +1,61 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { 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"
+// ============================================================================
+// components/evaluation-targets-table.tsx (CLIENT COMPONENT)
+// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도
+// ============================================================================
+"use client";
+
+import * as React from "react";
+import { useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { 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";
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
DataTableRowAction,
-} from "@/types/table"
-
-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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service"
-import { cn } from "@/lib/utils"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
-import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"
-import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"
-import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"
-import { EvaluationTargetWithDepartments } from "@/db/schema"
-import { EditEvaluationTargetSheet } from "./update-evaluation-target"
-
-interface EvaluationTargetsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>
- evaluationYear: number
- className?: string
-}
-
-// 통계 카드 컴포넌트 (클라이언트 컴포넌트용)
+} from "@/types/table";
+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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service";
+import { cn } from "@/lib/utils";
+import { useTablePresets } from "@/components/data-table/use-table-presets";
+import { TablePresetManager } from "@/components/data-table/data-table-preset";
+import { getEvaluationTargetsColumns } from "./evaluation-targets-columns";
+import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions";
+import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet";
+import { EvaluationTargetWithDepartments } from "@/db/schema";
+import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+
+/* -------------------------------------------------------------------------- */
+/* Stats Card */
+/* -------------------------------------------------------------------------- */
function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) {
- const [stats, setStats] = React.useState<any>(null)
- const [isLoading, setIsLoading] = React.useState(true)
- const [error, setError] = React.useState<string | null>(null)
+ const [stats, setStats] = React.useState<any>(null);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
- let isMounted = true
-
- async function fetchStats() {
+ let mounted = true;
+ (async () => {
try {
- setIsLoading(true)
- setError(null)
- const statsData = await getEvaluationTargetsStats(evaluationYear)
-
- if (isMounted) {
- setStats(statsData)
- }
- } catch (err) {
- if (isMounted) {
- setError(err instanceof Error ? err.message : 'Failed to fetch stats')
- console.error('Error fetching evaluation targets stats:', err)
- }
+ setIsLoading(true);
+ const data = await getEvaluationTargetsStats(evaluationYear);
+ mounted && setStats(data);
+ } catch (e) {
+ mounted && setError(e instanceof Error ? e.message : "failed");
} finally {
- if (isMounted) {
- setIsLoading(false)
- }
+ mounted && setIsLoading(false);
}
- }
-
- fetchStats()
-
+ })();
return () => {
- isMounted = false
- }
- }, [])
+ mounted = false;
+ };
+ }, [evaluationYear]);
- if (isLoading) {
+ if (isLoading)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
@@ -84,42 +69,32 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
</Card>
))}
</div>
- )
- }
-
- if (error) {
+ );
+ if (error)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="col-span-full">
- <CardContent className="pt-6">
- <div className="text-center text-sm text-muted-foreground">
- 통계 데이터를 불러올 수 없습니다: {error}
- </div>
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터를 불러올 수 없습니다: {error}
</CardContent>
</Card>
</div>
- )
- }
-
- if (!stats) {
+ );
+ if (!stats)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="col-span-full">
- <CardContent className="pt-6">
- <div className="text-center text-sm text-muted-foreground">
- 통계 데이터가 없습니다.
- </div>
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터가 없습니다.
</CardContent>
</Card>
</div>
- )
- }
+ );
- const totalTargets = stats.total || 0
- const pendingTargets = stats.pending || 0
- const confirmedTargets = stats.confirmed || 0
- const excludedTargets = stats.excluded || 0
- const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0
+ const total = stats.total || 0;
+ const pending = stats.pending || 0;
+ const confirmed = stats.confirmed || 0;
+ const consensusRate = total ? Math.round(((stats.consensusTrue || 0) / total) * 100) : 0;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
@@ -130,7 +105,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Badge variant="outline">{evaluationYear}년</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold">{totalTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold">{total.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개
</div>
@@ -144,9 +119,9 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Badge variant="secondary">대기</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-orange-600">{pendingTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold text-orange-600">{pending.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
- {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total
+ {total ? Math.round((pending / total) * 100) : 0}% of total
</div>
</CardContent>
</Card>
@@ -155,12 +130,12 @@ 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" className="bg-green-600">완료</Badge>
+ <Badge variant="default">완료</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
- {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total
+ {total ? Math.round((confirmed / total) * 100) : 0}% of total
</div>
</CardContent>
</Card>
@@ -169,9 +144,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={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>
- {consensusRate}%
- </Badge>
+ <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{consensusRate}%</div>
@@ -181,83 +154,92 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
</CardContent>
</Card>
</div>
- )
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* EvaluationTargetsTable */
+/* -------------------------------------------------------------------------- */
+interface EvaluationTargetsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>;
+ evaluationYear: number;
+ className?: string;
}
export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
- const router = useRouter()
- const searchParams = useSearchParams()
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+ const searchParams = useSearchParams();
- const containerRef = React.useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = React.useState(0)
+ /* --------------------------- layout refs --------------------------- */
+ const containerRef = React.useRef<HTMLDivElement>(null);
+ const [containerTop, setContainerTop] = React.useState(0);
- // ✅ 스크롤 이벤트 throttling으로 성능 최적화
+ // RFQ 패턴으로 변경: State를 통한 위치 관리
const updateContainerBounds = React.useCallback(() => {
if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- const newTop = rect.top
-
- // ✅ 값이 실제로 변경될 때만 상태 업데이트
- setContainerTop(prevTop => {
- if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
+ const rect = containerRef.current.getBoundingClientRect();
+ setContainerTop(rect.top);
}
- }, [])
-
- // ✅ throttle 함수 추가
- const throttledUpdateBounds = React.useCallback(() => {
- let timeoutId: NodeJS.Timeout
- return () => {
- clearTimeout(timeoutId)
- timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps
- }
- }, [updateContainerBounds])
+ }, []);
React.useEffect(() => {
- updateContainerBounds()
-
- const throttledHandler = throttledUpdateBounds()
-
+ updateContainerBounds();
+
const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용
-
+ updateContainerBounds();
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', updateContainerBounds);
+
return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', throttledHandler)
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', updateContainerBounds);
+ };
+ }, [updateContainerBounds]);
+
+ /* ---------------------- 데이터 프리패치 ---------------------- */
+ const [promiseData] = React.use(promises);
+ const tableData = promiseData;
+
+ /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
+ const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
+ return searchParams?.get(key) ?? defaultValue ?? "";
+ }, [searchParams]);
+
+ // 제네릭 함수는 useCallback 밖에서 정의
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
}
- }, [updateContainerBounds, throttledUpdateBounds])
+ }, [getSearchParam]);
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- console.log(tableData)
+const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+};
+ /* ---------------------- 초기 설정 ---------------------------- */
const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ?
- JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: parseSearchParam("sort", [{ id: "createdAt", desc: true }]),
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ basicFilters: parseSearchParam("basicFilters", []),
+ basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
groupBy: [],
- expandedRows: []
- }), [searchParams])
+ expandedRows: [],
+ }), [getSearchParam, parseSearchParamHelper]);
+ /* --------------------- 프리셋 훅 ------------------------------ */
const {
presets,
activePresetId,
@@ -269,83 +251,59 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
deletePreset,
setDefaultPreset,
renamePreset,
- updateClientState,
getCurrentSettings,
- } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings)
-
- const columns = React.useMemo(
- () => getEvaluationTargetsColumns({ setRowAction }),
- [setRowAction]
- )
-
+ } = useTablePresets<EvaluationTargetWithDepartments>(
+ "evaluation-targets-table",
+ initialSettings
+ );
+
+ /* --------------------- 컬럼 ------------------------------ */
+ const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]);
+// const columns =[
+// { accessorKey: "vendorCode", header: "벤더 코드" },
+// { accessorKey: "vendorName", header: "벤더명" },
+// { accessorKey: "status", header: "상태" },
+// { accessorKey: "evaluationYear", header: "평가년도" },
+// { accessorKey: "division", header: "구분" }
+// ];
+
+window.addEventListener('beforeunload', () => {
+ console.trace('[beforeunload] 문서가 통째로 사라지려 합니다!');
+});
+
+ /* 기본 필터 */
const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [
{ id: "vendorCode", label: "벤더 코드" },
{ id: "vendorName", label: "벤더명" },
{ id: "status", label: "상태" },
- ]
+ ];
+ /* 고급 필터 */
const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [
{ id: "evaluationYear", label: "평가년도", type: "number" },
- {
- id: "division", label: "구분", type: "select", options: [
- { label: "해양", value: "OCEAN" },
- { label: "조선", value: "SHIPYARD" },
- ]
- },
+ { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] },
{ id: "vendorCode", label: "벤더 코드", type: "text" },
{ id: "vendorName", label: "벤더명", type: "text" },
- {
- id: "domesticForeign", label: "내외자", type: "select", options: [
- { label: "내자", value: "DOMESTIC" },
- { label: "외자", value: "FOREIGN" },
- ]
- },
- {
- id: "materialType", label: "자재구분", type: "select", options: [
- { label: "기자재", value: "EQUIPMENT" },
- { label: "벌크", value: "BULK" },
- { label: "기자재/벌크", value: "EQUIPMENT_BULK" },
- ]
- },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "검토 중", value: "PENDING" },
- { label: "확정", value: "CONFIRMED" },
- { label: "제외", value: "EXCLUDED" },
- ]
- },
- {
- id: "consensusStatus", label: "의견 일치", type: "select", options: [
- { label: "의견 일치", value: "true" },
- { label: "의견 불일치", value: "false" },
- { label: "검토 중", value: "null" },
- ]
- },
+ { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] },
+ { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] },
+ { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] },
+ { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] },
{ id: "adminComment", label: "관리자 의견", type: "text" },
{ id: "consolidatedComment", label: "종합 의견", type: "text" },
{ id: "confirmedAt", label: "확정일", type: "date" },
{ id: "createdAt", label: "생성일", type: "date" },
- ]
-
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- function getColKey<T>(c: ColumnDef<T>): string | undefined {
- if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
- if ("id" in c && c.id) return c.id as string
- return undefined
- }
-
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(s =>
- columns.some(c => getColKey(c) === s.id)),
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [columns, currentSettings, initialSettings.sort])
+ ];
+
+ /* 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]);
+ /* ----------------------- useDataTable ------------------------ */
const { table } = useDataTable({
data: tableData.data,
columns,
@@ -355,134 +313,119 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
enablePinning: true,
enableAdvancedFilter: true,
initialState,
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (row) => String(row.id),
shallow: false,
clearOnDefault: true,
- })
+ });
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- const getActiveBasicFilterCount = () => {
+ /* ---------------------- helper ------------------------------ */
+ const getActiveBasicFilterCount = React.useCallback(() => {
try {
- const basicFilters = searchParams.get('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
+ const f = getSearchParam("basicFilters");
+ return f ? JSON.parse(f).length : 0;
+ } catch {
+ return 0;
}
- }
+ }, [getSearchParam]);
const FILTER_PANEL_WIDTH = 400;
+ /* ---------------------------- JSX ---------------------------- */
return (
- <>
+ <>
{/* Filter Panel */}
<div
className={cn(
"fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
)}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
top: `${containerTop}px`,
height: `calc(100vh - ${containerTop}px)`
}}
>
- <div className="h-full">
- <EvaluationTargetFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
+ <EvaluationTargetFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={() => setIsFilterPanelOpen(false)}
+ isLoading={false}
+ />
</div>
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
+ {/* Main Container */}
+ <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}>
<div className="flex w-full h-full">
<div
className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%",
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
}}
>
- {/* Header Bar */}
+ {/* Header */}
<div className="flex items-center justify-between p-4 bg-background shrink-0">
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
)}
+ </Button>
+ <div className="text-sm text-muted-foreground">
+ 총 {tableData.total || tableData.data.length}건
</div>
</div>
- {/* 통계 카드들 */}
+ {/* Stats */}
<div className="px-4">
<EvaluationTargetsStats evaluationYear={evaluationYear} />
</div>
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}>
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<EvaluationTargetWithDepartments>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <EvaluationTargetsTableToolbarActions table={table} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <EditEvaluationTargetSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- evaluationTarget={rowAction?.row.original ?? null}
- />
-
- </div>
+ {/* Table */}
+ <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}>
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<EvaluationTargetWithDepartments>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <EvaluationTargetsTableToolbarActions table={table} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 편집 다이얼로그 */}
+ <EditEvaluationTargetSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ evaluationTarget={rowAction?.row.original ?? null}
+ />
</div>
</div>
</div>
</div>
</>
- )
+ );
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
index 93807ef9..e2163cad 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
@@ -4,16 +4,17 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Pencil, Eye, MessageSquare, Check, X } from "lucide-react";
+import { Pencil, Check, X } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { EvaluationTargetWithDepartments } from "@/db/schema";
-import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+import type { DataTableRowAction } from "@/types/table";
+import { formatDate } from "@/lib/utils";
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>;
}
-// 상태별 색상 매핑
+// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지)
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "PENDING":
@@ -27,7 +28,15 @@ const getStatusBadgeVariant = (status: string) => {
}
};
-// 의견 일치 여부 배지
+const getStatusText = (status: string) => {
+ const statusMap = {
+ PENDING: "검토 중",
+ CONFIRMED: "확정",
+ EXCLUDED: "제외"
+ };
+ return statusMap[status] || status;
+};
+
const getConsensusBadge = (consensusStatus: boolean | null) => {
if (consensusStatus === null) {
return <Badge variant="outline">검토 중</Badge>;
@@ -38,16 +47,14 @@ const getConsensusBadge = (consensusStatus: boolean | null) => {
return <Badge variant="destructive">의견 불일치</Badge>;
};
-// 구분 배지
const getDivisionBadge = (division: string) => {
return (
- <Badge variant={division === "PLANT" ? "default" : "secondary"}>
- {division === "PLANT" ? "해양" : "조선"}
+ <Badge variant={division === "OCEAN" ? "default" : "secondary"}>
+ {division === "OCEAN" ? "해양" : "조선"}
</Badge>
);
};
-// 자재구분 배지
const getMaterialTypeBadge = (materialType: string) => {
const typeMap = {
EQUIPMENT: "기자재",
@@ -57,7 +64,6 @@ const getMaterialTypeBadge = (materialType: string) => {
return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>;
};
-// 내외자 배지
const getDomesticForeignBadge = (domesticForeign: string) => {
return (
<Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}>
@@ -66,24 +72,27 @@ const getDomesticForeignBadge = (domesticForeign: string) => {
);
};
-// 평가 상태 배지
-const getApprovalBadge = (isApproved: boolean | null) => {
- if (isApproved === null) {
- return <Badge variant="outline" className="text-xs">대기중</Badge>;
- }
- if (isApproved === true) {
- return <Badge variant="default" className="bg-green-600 text-xs">승인</Badge>;
+// ✅ 평가 대상 여부 표시 함수
+const getEvaluationTargetBadge = (isTarget: boolean | null) => {
+ if (isTarget === null) {
+ return <Badge variant="outline">미정</Badge>;
}
- return <Badge variant="destructive" className="text-xs">거부</Badge>;
+ return isTarget ? (
+ <Badge variant="default" className="bg-blue-600">
+ <Check className="size-3 mr-1" />
+ 평가 대상
+ </Badge>
+ ) : (
+ <Badge variant="secondary">
+ <X className="size-3 mr-1" />
+ 평가 제외
+ </Badge>
+ );
};
-export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] {
+export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] {
return [
- // ═══════════════════════════════════════════════════════════════
- // 기본 정보
- // ═══════════════════════════════════════════════════════════════
-
- // Checkbox
+ // ✅ Checkbox
{
id: "select",
header: ({ table }) => (
@@ -107,15 +116,13 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
enableHiding: false,
},
- // ░░░ 평가년도 ░░░
+ // ✅ 기본 정보
{
accessorKey: "evaluationYear",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>,
size: 100,
},
-
- // ░░░ 구분 ░░░
{
accessorKey: "division",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
@@ -127,24 +134,25 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
cell: ({ row }) => {
const status = row.getValue<string>("status");
- const statusMap = {
- PENDING: "검토 중",
- CONFIRMED: "확정",
- EXCLUDED: "제외"
- };
return (
<Badge variant={getStatusBadgeVariant(status)}>
- {statusMap[status] || status}
+ {getStatusText(status)}
</Badge>
);
},
size: 100,
},
+ {
+ accessorKey: "consensusStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />,
+ cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")),
+ size: 100,
+ },
- // ░░░ 벤더 코드 ░░░
-
+ // ✅ 벤더 정보 그룹
{
- header: "협력업체 정보",
+ id: "vendorInfo",
+ header: "벤더 정보",
columns: [
{
accessorKey: "vendorCode",
@@ -154,8 +162,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
),
size: 120,
},
-
- // ░░░ 벤더명 ░░░
{
accessorKey: "vendorName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
@@ -166,267 +172,182 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
),
size: 200,
},
-
- // ░░░ 내외자 ░░░
{
accessorKey: "domesticForeign",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")),
size: 80,
},
-
+ {
+ accessorKey: "materialType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
+ cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
+ size: 120,
+ },
]
},
- // ░░░ 자재구분 ░░░
- {
- accessorKey: "materialType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
- cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
- size: 120,
- },
-
- // ░░░ 상태 ░░░
-
-
- // ░░░ 의견 일치 여부 ░░░
- {
- accessorKey: "consensusStatus",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />,
- cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")),
- size: 100,
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 주문 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 발주 담당자
{
- header: "발주 평가 담당자",
+ id: "orderReviewer",
+ header: "발주 담당자",
columns: [
{
- accessorKey: "orderDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("orderDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "orderReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("orderReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "orderIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("orderIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("orderIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 조달 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 조달 담당자
{
- header: "조달 평가 담당자",
+ id: "procurementReviewer",
+ header: "조달 담당자",
columns: [
{
- accessorKey: "procurementDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("procurementDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "procurementReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("procurementReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "procurementIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("procurementIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("procurementIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 품질 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 품질 담당자
{
- header: "품질 평가 담당자",
+ id: "qualityReviewer",
+ header: "품질 담당자",
columns: [
{
- accessorKey: "qualityDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("qualityDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "qualityReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("qualityReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "qualityIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("qualityIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("qualityIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 설계 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 설계 담당자
{
- header: "설계 평가 담당자",
+ id: "designReviewer",
+ header: "설계 담당자",
columns: [
{
- accessorKey: "designDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("designDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "designReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("designReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "designIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("designIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("designIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // CS 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ CS 담당자
{
- header: "CS 평가 담당자",
+ id: "csReviewer",
+ header: "CS 담당자",
columns: [
{
- accessorKey: "csDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("csDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "csReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("csReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "csIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("csIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("csIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 관리 정보
- // ═══════════════════════════════════════════════════════════════
-
- // ░░░ 관리자 의견 ░░░
+ // ✅ 의견 및 결과
{
accessorKey: "adminComment",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />,
@@ -442,8 +363,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
},
size: 150,
},
-
- // ░░░ 종합 의견 ░░░
{
accessorKey: "consolidatedComment",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />,
@@ -459,69 +378,49 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
},
size: 150,
},
-
- // ░░░ 확정일 ░░░
{
accessorKey: "confirmedAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
cell: ({ row }) => {
const confirmedAt = row.getValue<Date>("confirmedAt");
- return confirmedAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(confirmedAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ return <span className="text-sm">{formatDate(confirmedAt, "KR")}</span>;
},
size: 100,
},
-
- // ░░░ 생성일 ░░░
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />,
cell: ({ row }) => {
const createdAt = row.getValue<Date>("createdAt");
- return createdAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(createdAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ return <span className="text-sm">{formatDate(createdAt, "KR")}</span>;
},
size: 100,
},
- // ░░░ Actions ░░░
+ // ✅ Actions - 가장 안전하게 처리
{
id: "actions",
enableHiding: false,
size: 40,
minSize: 40,
cell: ({ row }) => {
- return (
+ // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리
+ const handleEdit = () => {
+ setRowAction({ row, type: "update" });
+ };
+
+ return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
- onClick={() => setRowAction({ row, type: "update" })}
+ onClick={handleEdit}
aria-label="수정"
title="수정"
>
<Pencil className="size-4" />
</Button>
-
</div>
);
},
diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
index 502ee974..c37258ae 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
@@ -365,7 +365,7 @@ export function EvaluationTargetFilterSheet({
setIsInitializing(true);
form.reset({
- evaluationYear: new Date().getFullYear().toString(),
+ evaluationYear: "",
division: "",
status: "",
domesticForeign: "",
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 9043c588..7ea2e0ec 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
@@ -239,7 +239,7 @@ export function EvaluationTargetsTableToolbarActions({
/>
{/* 선택 정보 표시 */}
- {hasSelection && (
+ {/* {hasSelection && (
<div className="text-xs text-muted-foreground">
선택된 {selectedRows.length}개 항목:
대기중 {selectedStats.pending}개,
@@ -248,7 +248,7 @@ export function EvaluationTargetsTableToolbarActions({
{selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`}
{selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`}
</div>
- )}
+ )} */}
</>
)
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx
index 0d56addb..9f9b7af4 100644
--- a/lib/evaluation-target-list/table/update-evaluation-target.tsx
+++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx
@@ -498,7 +498,7 @@ export function EditEvaluationTargetSheet({
{/* 각 부서별 평가 */}
<div className="grid grid-cols-1 gap-4">
{[
- { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail },
+ { key: "orderIsApproved", label: "발주 부서 평가", email: evaluationTarget.orderReviewerEmail },
{ key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail },
{ key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail },
{ key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail },
@@ -608,7 +608,7 @@ export function EditEvaluationTargetSheet({
</CardHeader>
<CardContent className="space-y-4">
{[
- { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail },
+ { key: "orderReviewerEmail", label: "발주 부서 담당자", current: evaluationTarget.orderReviewerEmail },
{ key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail },
{ key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail },
{ key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail },
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index e69de29b..3cc4ca7d 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -0,0 +1,125 @@
+import db from "@/db/db"
+import {
+ periodicEvaluationsView,
+ type PeriodicEvaluationView
+} from "@/db/schema"
+import {
+ and,
+ asc,
+ count,
+ desc,
+ ilike,
+ or,
+ type SQL
+} from "drizzle-orm"
+import { filterColumns } from "@/lib/filter-columns"
+import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation";
+
+export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // ✅ getEvaluationTargets 방식과 동일한 필터링 처리
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: periodicEvaluationsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: periodicEvaluationsView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 벤더 정보로 검색
+ const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ // 평가 관련 코멘트로 검색
+ const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s);
+ if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition);
+
+ const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s);
+ if (adminCommentCondition) validSearchConditions.push(adminCommentCondition);
+
+ const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s);
+ if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition);
+
+ // 최종 확정자 이름으로 검색
+ const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s);
+ if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(periodicEvaluationsView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("Total periodic evaluations:", total);
+
+ // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect;
+ return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(periodicEvaluationsView.createdAt));
+ }
+
+ const periodicEvaluationsData = await db
+ .select()
+ .from(periodicEvaluationsView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: periodicEvaluationsData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getPeriodicEvaluations:", err);
+ // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함)
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ } \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 0c207a53..821e8182 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView } from "@/db/schema";
+import { DataTableRowAction } from "@/types/table";
@@ -104,7 +105,7 @@ const getProgressBadge = (completed: number, total: number) => {
return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>;
};
-export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationWithRelations>[] {
+export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationView>[] {
return [
// ═══════════════════════════════════════════════════════════════
// 선택 및 기본 정보
@@ -136,9 +137,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
// ░░░ 평가년도 ░░░
{
- accessorKey: "evaluationTarget.evaluationYear",
+ accessorKey: "evaluationYear",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
- cell: ({ row }) => <span className="font-medium">{row.original.evaluationTarget?.evaluationYear}</span>,
+ cell: ({ row }) => <span className="font-medium">{row.original.evaluationYear}</span>,
size: 100,
},
@@ -154,9 +155,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
// ░░░ 구분 ░░░
{
- accessorKey: "evaluationTarget.division",
+ accessorKey: "division",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
- cell: ({ row }) => getDivisionBadge(row.original.evaluationTarget?.division || ""),
+ cell: ({ row }) => getDivisionBadge(row.original.division || ""),
size: 80,
},
@@ -167,36 +168,36 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: "협력업체 정보",
columns: [
{
- accessorKey: "evaluationTarget.vendorCode",
+ accessorKey: "vendorCode",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />,
cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.evaluationTarget?.vendorCode}</span>
+ <span className="font-mono text-sm">{row.original.vendorCode}</span>
),
size: 120,
},
{
- accessorKey: "evaluationTarget.vendorName",
+ accessorKey: "vendorName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.evaluationTarget?.vendorName}>
- {row.original.evaluationTarget?.vendorName}
+ <div className="truncate max-w-[200px]" title={row.original.vendorName}>
+ {row.original.vendorName}
</div>
),
size: 200,
},
{
- accessorKey: "evaluationTarget.domesticForeign",
+ accessorKey: "domesticForeign",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
- cell: ({ row }) => getDomesticForeignBadge(row.original.evaluationTarget?.domesticForeign || ""),
+ cell: ({ row }) => getDomesticForeignBadge(row.original.domesticForeign || ""),
size: 80,
},
{
- accessorKey: "evaluationTarget.materialType",
+ accessorKey: "materialType",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
- cell: ({ row }) => getMaterialTypeBadge(row.original.evaluationTarget?.materialType || ""),
+ cell: ({ row }) => getMaterialTypeBadge(row.original.materialType || ""),
size: 120,
},
]
@@ -355,10 +356,10 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
id: "reviewProgress",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
cell: ({ row }) => {
- const stats = row.original.reviewerStats;
- if (!stats) return <span className="text-muted-foreground">-</span>;
+ const totalReviewers = row.original.totalReviewers || 0;
+ const completedReviewers = row.original.completedReviewers || 0;
- return getProgressBadge(stats.completedReviewers, stats.totalReviewers);
+ return getProgressBadge(completedReviewers, totalReviewers);
},
size: 120,
},
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
index 7cda4989..7c1e93d8 100644
--- a/lib/evaluation/table/evaluation-filter-sheet.tsx
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -394,7 +394,7 @@ export function PeriodicEvaluationFilterSheet({
}
return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8">
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
{/* Filter Panel Header */}
<div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
<h3 className="text-lg font-semibold whitespace-nowrap">정기평가 검색 필터</h3>
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 16f70592..a628475d 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -23,6 +23,7 @@ import { useMemo } from "react"
import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
import { PeriodicEvaluationView } from "@/db/schema"
+import { getPeriodicEvaluations } from "../service"
interface PeriodicEvaluationsTableProps {
promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
@@ -229,16 +230,33 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const [promiseData] = React.use(promises)
const tableData = promiseData
+ const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
+ return searchParams?.get(key) ?? defaultValue ?? "";
+ }, [searchParams]);
+
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ }, [getSearchParam]);
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+ };
+
const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ?
- JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
+ page: parseInt(getSearchParam('page') || '1'),
+ perPage: parseInt(getSearchParam('perPage') || '10'),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: getSearchParam('filters') ? JSON.parse(getSearchParam('filters')!) : [],
+ joinOperator: (getSearchParam('joinOperator') as "and" | "or") || "and",
+ basicFilters: getSearchParam('basicFilters') ?
+ JSON.parse(getSearchParam('basicFilters')!) : [],
+ basicJoinOperator: (getSearchParam('basicJoinOperator') as "and" | "or") || "and",
+ search: getSearchParam('search') || '',
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
@@ -267,22 +285,22 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
)
const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [
- { id: "evaluationTarget.vendorCode", label: "벤더 코드" },
- { id: "evaluationTarget.vendorName", label: "벤더명" },
+ { id: "vendorCode", label: "벤더 코드" },
+ { id: "vendorName", label: "벤더명" },
{ id: "status", label: "진행상태" },
]
const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [
- { id: "evaluationTarget.evaluationYear", label: "평가년도", type: "number" },
+ { id: "evaluationYear", label: "평가년도", type: "number" },
{ id: "evaluationPeriod", label: "평가기간", type: "text" },
{
- id: "evaluationTarget.division", label: "구분", type: "select", options: [
+ id: "division", label: "구분", type: "select", options: [
{ label: "해양", value: "PLANT" },
{ label: "조선", value: "SHIP" },
]
},
- { id: "evaluationTarget.vendorCode", label: "벤더 코드", type: "text" },
- { id: "evaluationTarget.vendorName", label: "벤더명", type: "text" },
+ { id: "vendorCode", label: "벤더 코드", type: "text" },
+ { id: "vendorName", label: "벤더명", type: "text" },
{
id: "status", label: "진행상태", type: "select", options: [
{ label: "제출대기", value: "PENDING_SUBMISSION" },
@@ -305,24 +323,14 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
{ id: "finalizedAt", label: "최종확정일", type: "date" },
]
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
- function getColKey<T>(c: ColumnDef<T>): string | undefined {
- if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
- if ("id" in c && c.id) return c.id as string
- return undefined
- }
+ 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 = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(s =>
- columns.some(c => getColKey(c) === s.id)),
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [columns, currentSettings, initialSettings.sort])
const { table } = useDataTable({
data: tableData.data,
@@ -344,7 +352,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const getActiveBasicFilterCount = () => {
try {
- const basicFilters = searchParams.get('basicFilters')
+ const basicFilters = getSearchParam('basicFilters')
return basicFilters ? JSON.parse(basicFilters).length : 0
} catch (e) {
return 0
diff --git a/lib/spread-js/fns.ts b/lib/spread-js/fns.ts
new file mode 100644
index 00000000..8a2b23a0
--- /dev/null
+++ b/lib/spread-js/fns.ts
@@ -0,0 +1,353 @@
+'use client'
+
+import GC from "@mescius/spread-sheets";
+import { Spread } from "@/types/spread-js";
+
+//시트 추가
+export const addSheet = (spread: Spread | null): void => {
+ if (!spread) return;
+
+ const activeIndex = spread.getActiveSheetIndex();
+
+ if (activeIndex >= 0) {
+ spread.addSheet(activeIndex + 1);
+ spread.setActiveSheetIndex(activeIndex + 1);
+ } else {
+ spread.addSheet(0);
+ spread.setActiveSheetIndex(0);
+ }
+};
+
+//시트 제거
+export const removeSheet = (spread: Spread | null): void => {
+ if (!spread) return;
+
+ if (spread.getSheetCount() > 0) {
+ spread.removeSheet(spread.getActiveSheetIndex());
+ }
+};
+
+//전체 시트 제거
+export const clearSheet = (spread: Spread | null) => {
+ if (!spread) return;
+
+ spread.clearSheets();
+};
+
+//Table Sheet 추가
+export const createTableSheet = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const activeIndex = spread.getActiveSheetIndex();
+
+ if (activeIndex >= 0) {
+ spread.addSheet(
+ activeIndex + 1,
+ new GC.Spread.Sheets.Worksheet(`Table Sheet ${activeIndex + 1}`)
+ );
+ spread.setActiveSheetIndex(activeIndex + 1);
+ } else {
+ spread.addSheet(0, new GC.Spread.Sheets.Worksheet(`Table Sheet 0`));
+ spread.setActiveSheetIndex(0);
+ }
+
+ const sheet = spread.getActiveSheet();
+};
+
+/**
+ * Sample Report Form를 만드는 함수
+ * @param spread
+ * @returns
+ */
+export const createSampleReportForm = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const sheet = spread.getActiveSheet();
+
+ sheet.reset();
+
+ sheet.suspendPaint();
+
+ sheet.name("Inspection Report");
+
+ let bindingPathCellType = new BindingPathCellType();
+
+ //기본 테두리 설정
+ const defaultLineStyle = GC.Spread.Sheets.LineStyle.thin;
+ const defaultLineBorder = new GC.Spread.Sheets.LineBorder(
+ "black",
+ defaultLineStyle
+ );
+
+ //행 높이 설정
+ sheet.setRowHeight(1, 50);
+
+ //Cell Data 삽입
+ sheet
+ .getCell(1, 1)
+ .value("TEST")
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom)
+ .hAlign(GC.Spread.Sheets.HorizontalAlign.left);
+
+ //Cell Data Binding
+
+ sheet
+ .getCell(1, 2)
+ .bindingPath("test")
+ .cellType(bindingPathCellType)
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom);
+
+ sheet
+ .getCell(1, 7)
+ .bindingPath("test2")
+ .cellType(bindingPathCellType)
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom);
+
+ const bindingData = { test: "testData", test2: "testData2" };
+ const bindingDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ bindingData
+ );
+
+ sheet.setDataSource(bindingDataSource);
+
+ //Cell Merge
+ sheet.addSpan(1, 2, 1, 4);
+
+ //Cell Border Setting
+ sheet
+ .getRange(1, 1, 1, 5)
+ .setBorder(defaultLineBorder, { outline: true, inside: true });
+
+ //Table 생성
+ const tableFields = [
+ { field: "id", name: "ID" },
+ { field: "name", name: "DESCRIPTION" },
+ { field: "date", name: "Date", formatter: "dd/mm/yyyy" },
+ { field: "qty", name: "QUANTITY" },
+ { field: "amount", name: "AMOUNT" },
+ ];
+
+ const table = sheet.tables.add("sampleTable", 3, 1, 1, tableFields.length);
+
+ const tableColumns = tableFields.map((c, i) => {
+ const { field, name, formatter } = c;
+ const tableColumn = new GC.Spread.Sheets.Tables.TableColumn(i);
+
+ tableColumn.name(name);
+ tableColumn.dataField(field);
+
+ if (formatter) {
+ tableColumn.formatter(formatter);
+ }
+
+ return tableColumn;
+ });
+ table.autoGenerateColumns(false);
+ table.bindColumns(tableColumns);
+
+ table.bindingPath("tableData");
+
+ //이미 있는 속성으로 테이블 스타일을 적용 시키는 법
+ // table.style(GC.Spread.Sheets.Tables.TableThemes.light1.name());
+
+ //테이블 속성을 임의로 만들어서 적용 시키는 법 1
+ const tableBorder = new GC.Spread.Sheets.LineBorder(
+ "black",
+ defaultLineStyle
+ );
+
+ const baseTableStyleInfo = new GC.Spread.Sheets.Tables.TableStyle(
+ "#fff",
+ "#000000",
+ undefined,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder
+ );
+
+ const headerStyle = new GC.Spread.Sheets.Tables.TableStyle(
+ "#F9FAFB",
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "900",
+ "14px"
+ );
+
+ var customTableStyle = new GC.Spread.Sheets.Tables.TableTheme();
+
+ // customTableStyle.headerRowStyle(tableStyleInfo);
+ customTableStyle.wholeTableStyle(baseTableStyleInfo);
+
+ customTableStyle.headerRowStyle(headerStyle);
+ customTableStyle.footerRowStyle(headerStyle);
+
+ // customTableStyle.wholeTableStyle(new GC.Spread.Sheets.Tables.TableStyle('#e0f2f1'));
+ // customTableStyle.headerRowStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.firstRowStripStyle(new GC.Spread.Sheets.Tables.TableStyle('#b2dfdb'));
+ // customTableStyle.firstColumnStripStyle(new GC.Spread.Sheets.Tables.TableStyle('#b2dfdb'));
+ // customTableStyle.footerRowStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.highlightFirstColumnStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.highlightLastColumnStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+
+ customTableStyle.name("customTableStyle1");
+
+ spread.customTableThemes.add(customTableStyle);
+
+ table.style("customTableStyle1");
+
+ table.showFooter(true, false /*isFooterInserted*/);
+ table.useFooterDropDownList(true);
+
+ sheet.resumePaint();
+};
+
+export const setSampleReportData = (
+ spread: Spread | null,
+ reportData: { [key: string]: any } = {
+ test: "test",
+ tableData: [
+ {
+ id: "1",
+ name: "2",
+ date: new Date(),
+ qty: 3,
+ amount: 4000,
+ },
+ {
+ id: "1",
+ name: "2",
+ date: new Date(),
+ qty: 3,
+ amount: 4000,
+ },
+ ],
+ }
+) => {
+ if (!spread) return;
+
+ const sheet = spread.getActiveSheet();
+
+ const dataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ reportData
+ );
+
+ sheet.setDataSource(dataSource);
+};
+
+export const exportJSON = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const spreadJSON = spread.toJSON()
+
+ const jsonString = JSON.stringify(spreadJSON, null, 2); // 예쁘게 포맷
+ const blob = new Blob([jsonString], { type: "application/json" });
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+
+ a.href = url;
+ a.download = "spreadjs-form.json"; // 저장될 파일명
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ URL.revokeObjectURL(url); // 메모리 해제
+};
+
+export function handleFileImport(spread: Spread) {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+
+ input.onchange = async (e: any) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const text = await file.text();
+ const json = JSON.parse(text);
+
+ spread.fromJSON(json)
+
+ // const targetSheet = spread.getSheetFromName("Inspection Report");
+
+ const sheets = spread.sheets
+
+ sheets.forEach(sheet => {
+ const sheetName = sheet.name()
+ const bindingData = { test: "testData", test2: "testData2" };
+ const bindingDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ bindingData
+ );
+
+
+
+ sheet.setDataSource(bindingDataSource);
+ })
+
+ };
+
+ input.click();
+}
+
+
+class BindingPathCellType extends GC.Spread.Sheets.CellTypes.Text {
+ constructor() {
+ super();
+ }
+
+ paint(
+ ctx: any,
+ value: any,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ style: any,
+ context: any
+ ) {
+ if (value === null || value === undefined) {
+ let sheet = context.sheet,
+ row = context.row,
+ col = context.col;
+ if (sheet && (row === 0 || !!row) && (col === 0 || !!col)) {
+ let bindingPath = sheet.getBindingPath(context.row, context.col);
+ if (bindingPath) {
+ value = "[" + bindingPath + "]";
+ }
+ }
+ }
+ super.paint(ctx, value, x, y, w, h, style, context);
+ }
+}
+
+type TableStyle = {
+ backColor?:
+ | string
+ | GC.Spread.Sheets.IPatternFill
+ | GC.Spread.Sheets.IGradientFill
+ | GC.Spread.Sheets.IGradientPathFill;
+ foreColor?: string;
+ font?: string;
+ borderLeft?: GC.Spread.Sheets.LineBorder;
+ borderTop?: GC.Spread.Sheets.LineBorder;
+ borderRight?: GC.Spread.Sheets.LineBorder;
+ borderBottom?: GC.Spread.Sheets.LineBorder;
+ borderHorizontal?: GC.Spread.Sheets.LineBorder;
+ borderVertical?: GC.Spread.Sheets.LineBorder;
+ textDecoration?: GC.Spread.Sheets.TextDecorationType;
+ fontStyle?: string;
+ fontWeight?: string;
+ fontSize?: string;
+ fontFamily?: string;
+};
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index b2dec1ab..5fd5ef02 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -78,6 +78,25 @@ export async function getTechVendors(input: GetTechVendorsSchema) {
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
+ // 벤더 타입 필터링 로직 추가
+ let vendorTypeWhere;
+ if (input.vendorType) {
+ // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
+ const vendorTypeMap = {
+ "ship": "조선",
+ "top": "해양TOP",
+ "hull": "해양HULL"
+ };
+
+ const actualVendorType = input.vendorType in vendorTypeMap
+ ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap]
+ : undefined;
+ if (actualVendorType) {
+ // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
+ vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`);
+ }
+ }
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
@@ -89,8 +108,8 @@ export async function getTechVendors(input: GetTechVendorsSchema) {
: undefined
);
- // 실제 사용될 where
- const where = finalWhere;
+ // 실제 사용될 where (vendorType 필터링 추가)
+ const where = and(finalWhere, vendorTypeWhere);
// 정렬
const orderBy =
diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
index e586a667..093b5547 100644
--- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
@@ -131,11 +131,6 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
>
상세보기(새창)
</DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "log" })}
- >
- 감사 로그 보기
- </DropdownMenuItem>
<Separator />
<DropdownMenuSub>
diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts
index c45eb97d..ee076945 100644
--- a/lib/tech-vendors/validations.ts
+++ b/lib/tech-vendors/validations.ts
@@ -46,7 +46,8 @@ export const searchParamsCache = createSearchParamsCache({
// 예) 코드 검색
vendorCode: parseAsString.withDefault(""),
-
+ // 벤더 타입 필터링 (다중 선택 가능)
+ vendorType: parseAsStringEnum(["ship", "top", "hull"]),
// 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
email: parseAsString.withDefault(""),
website: parseAsString.withDefault(""),