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