feat: 메뉴 관리 > 사이트별 메뉴 api 작업

This commit is contained in:
2025-03-21 17:22:43 +09:00
parent b1f3de08a7
commit d8d5c16d31
19 changed files with 333 additions and 23 deletions
@@ -0,0 +1,2 @@
import { MenuPage } from "@/pages/menu";
export default MenuPage;
+2
View File
@@ -13,6 +13,7 @@
"@common/components": "workspace:*",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "^3.5.0",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.67.1",
@@ -23,6 +24,7 @@
"js-cookie": "^3.0.5",
"next": "15.1.7",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2",
+7
View File
@@ -0,0 +1,7 @@
export { SiteApi } from "./api/site-service";
export {
siteFormSchema,
type ISiteParams,
type ISite,
type SiteFormData,
} from "./model/site-interface";
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Menu, MenuFormData, MenuService } from "../model/menu-interface";
export class MenuApiService implements MenuService {
public async getMenuList(siteId: string): Promise<Menu[]> {
const response = await client.get<Menu[]>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
);
return response.data;
}
public async modifyMenu(menuCd: number, data: MenuFormData): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
data,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+1
View File
@@ -0,0 +1 @@
export { MenuPage } from "./ui/menu-page";
@@ -0,0 +1,44 @@
import { z } from "zod";
export interface Menu {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number;
sortSeq: number;
iconNm: string;
children: Menu[];
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
qrcd: string;
useYn: string;
otptYn: string;
requiredUrlPath: boolean;
}
export const menuFormSchema = z.object({
menuKornNm: z.string(),
menuSubKornNm: z.string().nullish(),
menuEngNm: z.string().nullish(),
menuSubEngNm: z.string().nullish(),
levelNo: z.number(),
urlPath: z.string().nullish(),
iconNm: z.string().nullish(),
menuLnkgTypeCd: z.string().nullish(),
menuLnkgSn: z.number().nullish(),
otptYn: z.string().nullish(),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.string().nullish(),
qrcd: z.string().nullish(),
useYn: z.string().nullish(),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export interface MenuService {
getMenuList: (siteId: string) => Promise<Menu[]>;
modifyMenu: (menuCd: number, data: MenuFormData) => Promise<void>;
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components/ui";
import { PageLayout } from "@/shared/ui/page-layout";
import { ENDPOINT } from "@/shared/config";
import { SiteApi } from "@/entities/site";
import { MenuApi } from "../api/menu-service";
import { MenuTree } from "./menu-tree";
function MenuPage() {
const [siteId, setSiteId] = useState("");
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getMenuList(siteId),
select: (data) => {
return data.map((menu) => ({
id: menu.menuCd,
parent: menu.parentId ?? 0,
text: menu.menuKornNm,
droppable: true,
data: menu,
}));
},
enabled: !!siteId,
});
const { data: options } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
select: (data) =>
data.content.map((site) => ({
label: site.siteNm,
value: site.siteId,
})),
});
return (
<PageLayout pageTitle="메뉴 관리">
<div className="flex gap-4">
<div className="card card-border">
<Select value={siteId} onValueChange={setSiteId}>
<SelectTrigger className="w-[18rem]">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{treeData && (
<>
<div className="my-4 flex gap-2">
<Button size="sm" color="primary" className="flex-1">
메뉴 추가
</Button>
<Button size="sm" color="red" className="flex-1">
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree treeData={treeData ?? []} />
</div>
</>
)}
</div>
<div className="card card-border"></div>
</div>
</PageLayout>
);
}
export { MenuPage };
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { DndProvider } from "react-dnd";
import { useMutation } from "@tanstack/react-query";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type DropOptions,
} from "@minoru/react-dnd-treeview";
import { cn } from "@common/components/lib";
import { MenuApi } from "../api/menu-service";
import {
menuFormSchema,
type Menu,
type MenuFormData,
} from "../model/menu-interface";
function MenuTree({ treeData }: { treeData: NodeModel<Menu>[] }) {
const { mutate } = useMutation({
mutationFn: ({ menuCd, data }: { menuCd: number; data: MenuFormData }) =>
MenuApi.modifyMenu(menuCd, data),
});
const [selectedNode, setSelectedNode] = useState<NodeModel<Menu> | null>(
null,
);
const handleSelect = (node: NodeModel<Menu>) => setSelectedNode(node);
const handleDrop = (
newTree: NodeModel<Menu>[],
options: DropOptions<Menu>,
) => {
let saveData: MenuFormData | null = null;
const dragSourceNode = findNode(options.dragSourceId) ?? options.dragSource;
const dropTargetNode = findNode(options.dropTargetId) ?? options.dropTarget;
if (!dropTargetNode) return;
saveData = menuFormSchema.parse({
...dragSourceNode?.data,
});
console.log(saveData);
};
const findNode = (id?: NodeModel["id"]) => {
return id ? treeData.find((node) => node.id === id) : null;
};
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={treeData}
rootId={0}
render={(node: NodeModel<Menu>, { depth, isOpen, onToggle }) => (
<MenuNode
node={node}
depth={depth}
isOpen={isOpen}
isSelected={selectedNode?.id === node.id}
onToggle={onToggle}
onSelect={handleSelect}
/>
)}
onDrop={handleDrop}
classes={{
root: "p-2",
container: "flex flex-col",
listItem: "",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
draggingSource: "",
placeholder: "",
}}
/>
</DndProvider>
);
}
type MenuNodeProps = {
node: NodeModel<Menu>;
depth: number;
isOpen: boolean;
isSelected: boolean;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<Menu>) => void;
};
function MenuNode({
node,
depth,
isOpen,
isSelected,
onToggle,
onSelect,
}: MenuNodeProps) {
const { droppable, id, text } = node;
const indent = depth * 24 + 5;
const handleToggle = () => onToggle(id);
const handleSelect = () => onSelect(node);
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!bg-[rgba(32,148,250,0.2)]",
)}
onClick={handleSelect}
>
{droppable && (
<button
className="inline-flex size-6 items-center justify-center rounded-sm text-label"
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
<i
className={cn(
"diveicon di-arrow-forward text-sm",
isOpen && "rotate-90",
)}
/>
</button>
)}
<div>
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
</div>
);
}
export { MenuTree };
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { createColumnHelper } from "@tanstack/react-table";
import type { ISite } from "./site-interface";
import type { ISite } from "@/entities/site";
const columnHelper = createColumnHelper<ISite>();
+5 -6
View File
@@ -4,11 +4,10 @@ import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { SiteApi } from "../api/site-service";
import { siteFormSchema, type SiteFormData } from "../model/site-interface";
import { SitePageLayout } from "./layout";
import { SiteForm } from "./site-form";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { PageLayout } from "@/shared/ui/page-layout";
import { SiteForm } from "./site-form";
function CreatePage() {
const router = useRouter();
@@ -39,9 +38,9 @@ function CreatePage() {
});
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<SiteForm form={form} handleSave={createSite} />
</SitePageLayout>
</PageLayout>
);
}
+5 -6
View File
@@ -2,13 +2,12 @@
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { SiteApi } from "../api/site-service";
import { siteFormSchema, type SiteFormData } from "../model/site-interface";
import { SitePageLayout } from "./layout";
import { SiteForm } from "./site-form";
function ModifyPage({ siteId }: { siteId: string }) {
@@ -63,14 +62,14 @@ function ModifyPage({ siteId }: { siteId: string }) {
});
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<SiteForm
form={form}
siteId={siteId}
handleSave={modifySite}
handleDelete={deleteSite}
/>
</SitePageLayout>
</PageLayout>
);
}
+1 -1
View File
@@ -15,6 +15,7 @@ import {
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import type { SiteFormData } from "@/entities/site";
import {
FormItem,
FormControl,
@@ -23,7 +24,6 @@ import {
FormMessage,
} from "@/shared/ui/form";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import type { SiteFormData } from "../model/site-interface";
import type { Domain } from "../model/domain-interface";
import { IPManagementDialog } from "./ip-management-dialog";
import { DomainManagementDialog } from "./domain-management-dialog";
+4 -5
View File
@@ -7,11 +7,10 @@ import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { Button, DataTable, Input } from "@common/components";
import { SiteApi, type ISite, type ISiteParams } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { SiteApi } from "../api/site-service";
import { PageLayout } from "@/shared/ui/page-layout";
import { columns } from "../model/table-columns";
import { type ISite, type ISiteParams } from "../model/site-interface";
import { SitePageLayout } from "./layout";
function SitePage() {
const router = useRouter();
@@ -46,7 +45,7 @@ function SitePage() {
}, []);
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
@@ -73,7 +72,7 @@ function SitePage() {
</div>
</div>
</div>
</SitePageLayout>
</PageLayout>
);
}
+1
View File
@@ -13,6 +13,7 @@ export const ENDPOINT = {
CODE: "/cms-service/api/v1/code/cache",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
MENUS: "/cms-service/api/v1/menus",
},
};
@@ -1,12 +1,18 @@
function SitePageLayout({ children }: { children: React.ReactNode }) {
function PageLayout({
children,
pageTitle,
}: {
children: React.ReactNode;
pageTitle: string;
}) {
return (
<div className="h-auto min-h-full bg-container">
<h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-title">
사이트 관리
{pageTitle}
</h2>
{children}
</div>
);
}
export { SitePageLayout };
export { PageLayout };
@@ -7,7 +7,7 @@ import { Footer } from "./footer";
import "./common.css";
import type { QueryClient } from "@tanstack/react-query";
export function BaseLayout({
export function VBaseLayout({
children,
queryClient,
}: {
+6
View File
@@ -51,6 +51,9 @@ importers:
'@hookform/resolvers':
specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.54.2)
'@minoru/react-dnd-treeview':
specifier: ^3.5.0
version: 3.5.0(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
@@ -81,6 +84,9 @@ importers:
react-chartjs-2:
specifier: ^5.3.0
version: 5.3.0(chart.js@4.4.8)(react@18.3.1)
react-dnd:
specifier: ^16.0.1
version: 16.0.1(@types/node@22.13.5)(@types/react@18.3.18)(react@18.3.1)
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@18.3.1)