feat: 메뉴 관리 > 사이트별 메뉴 api 작업
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
import { MenuPage } from "@/pages/menu";
|
||||
export default MenuPage;
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
Generated
+6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user