[apps/cs] feat: 메뉴 변경 이력 보기 및 되돌리기 기능 작업

This commit is contained in:
2025-07-08 19:18:41 +09:00
parent 1e524ba9c5
commit 13ac6d638b
7 changed files with 317 additions and 20 deletions
@@ -74,7 +74,6 @@ function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-personal-information" />
권한관리
</Button>
</DialogTrigger>
@@ -0,0 +1,28 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
import type {
MenuHistory,
MenuHistorySearchParams,
MenuHistoryService,
} from "../model/menu-history-interface";
export class MenuHistoryApiService implements MenuHistoryService {
public async getMenuHistoryList(
params: MenuHistorySearchParams,
): Promise<PageableList<MenuHistory>> {
const response = await client.get<PageableList<MenuHistory>>(
`${ENDPOINT.CMS_SERVICE.MENU_HISTORY}`,
{ params },
);
return response.data;
}
public async rollbackMenu(hstrySn: number): Promise<void> {
await client.post(`${ENDPOINT.CMS_SERVICE.MENU_HISTORY}/rollback`, {
hstrySn,
});
}
}
export const MenuHistoryApi = new MenuHistoryApiService();
@@ -0,0 +1,71 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components";
import type { MenuHistory } from "./menu-history-interface";
import dayjs from "dayjs";
export const columns: ColumnDef<MenuHistory>[] = [
{
id: "select",
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "chgTypeNm",
header: "이력구분",
meta: {
width: 80,
},
},
{
accessorKey: "menuKornNm",
header: "메뉴명",
meta: {
width: 100,
},
},
{
accessorKey: "bchgData",
header: "이전 데이터",
cell: ({ row }) => {
const parsed = JSON.parse(row.original.bchgData);
const stringified = JSON.stringify(parsed, null, 2);
return <pre className="break-all text-left">{stringified}</pre>;
},
meta: {
width: 250,
},
},
{
accessorKey: "achgData",
header: "변경 데이터",
cell: ({ row }) => {
const parsed = JSON.parse(row.original.achgData);
const stringified = JSON.stringify(parsed, null, 2);
return <pre className="break-all text-left">{stringified}</pre>;
},
meta: {
width: 250,
},
},
{
accessorKey: "regDt",
header: "변경일시",
cell: ({ row }) => {
return dayjs(row.original.regDt).format("YYYY-MM-DD HH:mm:ss");
},
},
{
accessorKey: "rgtrNm",
header: "변경자",
meta: {
width: 80,
},
},
];
@@ -0,0 +1,30 @@
import type { PageableParams, PageableList } from "@/shared/model";
export interface MenuHistorySearchParams extends PageableParams {
keyword?: string;
searchType?: string;
menuCd?: number;
chgTypeCd?: string;
startDt?: string;
endDt?: string;
}
export interface MenuHistory {
hstrySn: number;
menuCd: number;
chgTypeCd: string;
chgTypeNm: string;
bchgData: string;
achgData: string;
rgtr: string;
regDt: string;
rgtrNm: string;
menuKornNm: string;
}
export interface MenuHistoryService {
getMenuHistoryList: (
params: MenuHistorySearchParams,
) => Promise<PageableList<MenuHistory>>;
rollbackMenu: (hstrySn: number) => Promise<void>;
}
+25 -19
View File
@@ -1,14 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { MenuApi, menuFormSchema, type MenuFormData } from "@/entities/menu";
import { ManagementAuthorityDialog } from "@/entities/menu-authority";
import { Form } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { BaseMenuForm } from "./base-menu-form";
import { TypeMenuForm } from "./type-menu-form";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { MenuHistoryDialog } from "./menu-history-dialog";
interface MenuFormProps {
form: UseFormReturn<MenuFormData>;
@@ -103,22 +104,7 @@ function MenuForm({
{isModifyMode ? "메뉴 수정" : "메뉴 등록"}
</h3>
<div className="flex items-center gap-2">
<Button
color="white"
type="button"
disabled={!isModifyMode}
onClick={handleLoadTemporaryData}
>
불러오기
</Button>
<Button
color="gray"
type="button"
disabled={!isModifyMode}
onClick={handleMenuTemporaryStorage}
>
임시저장
</Button>
<MenuHistoryDialog menuCd={menuCd} />
<ManagementAuthorityDialog menuCd={menuCd} />
</div>
</div>
@@ -128,6 +114,26 @@ function MenuForm({
</div>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
{isModifyMode && (
<Button
size="lg"
color="white"
type="button"
onClick={handleLoadTemporaryData}
>
불러오기
</Button>
)}
{isModifyMode && (
<Button
size="lg"
color="gray"
type="button"
onClick={handleMenuTemporaryStorage}
>
임시저장
</Button>
)}
<Button size="lg" type="submit" disabled={isSubmitDisabled}>
저장
</Button>
@@ -0,0 +1,162 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import { MenuHistoryApi } from "../api/menu-history-service";
import type { MenuHistorySearchParams } from "../model/menu-history-interface";
import { columns } from "../model/menu-history-columns";
type MenuHistoryDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function MenuHistoryDialog({ menuCd }: MenuHistoryDialogProps) {
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [searchParams, setSearchParams] = useState<MenuHistorySearchParams>({
keyword: "",
searchType: "menuKornNm",
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const selectedHistoryId = parseInt(Object.keys(rowSelection)[0] ?? "0");
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_HISTORY, pagination],
queryFn: () =>
MenuHistoryApi.getMenuHistoryList({
...searchParams,
menuCd,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: !!menuCd && open,
});
const { mutateAsync: rollbackMenu } = useMutationWrapper({
mutationFn: (hstrySn: number) => MenuHistoryApi.rollbackMenu(hstrySn),
onSuccess: () => {
alert("메뉴가 성공적으로 복원되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
});
setOpen(false);
},
});
const handleRollback = async (hstrySn: number) => {
if (!(await confirm("메뉴를 '이전 데이터'로 되돌리시겠습니까?"))) return;
await rollbackMenu(hstrySn);
};
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: { pagination, rowSelection },
manualPagination: true,
enableRowSelection: true,
enableMultiRowSelection: false,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.hstrySn.toString(),
},
dataTableOptions: {
viewPagination: true,
},
});
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
query.refetch();
};
useEffect(() => {
if (open) {
setSearchParams({
...searchParams,
keyword: "",
});
setPagination({
...pagination,
pageIndex: 0,
});
setRowSelection({});
}
}, [open]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button color="white" disabled={!menuCd}>
이력보기
</Button>
</DialogTrigger>
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>메뉴 이력 관리</DialogTitle>
</DialogHeader>
<form onSubmit={handleSearch}>
<div className="mb-4 flex items-center gap-2">
<Label>메뉴명</Label>
<Input
placeholder="검색어를 입력하세요"
value={searchParams.keyword ?? ""}
onChange={(e) =>
setSearchParams({ ...searchParams, keyword: e.target.value })
}
className="w-[36rem]"
/>
<Button type="submit">조회</Button>
</div>
</form>
<div className="max-h-[56rem] overflow-auto">{tableComponent}</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button size="lg" color="white">
닫기
</Button>
</DialogClose>
<Button
size="lg"
onClick={() => handleRollback(selectedHistoryId)}
disabled={!selectedHistoryId}
>
되돌리기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { MenuHistoryDialog };
+1
View File
@@ -33,6 +33,7 @@ export const ENDPOINT = {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",