diff --git a/apps/cs/app/(with-base-layout)/menu/page.tsx b/apps/cs/app/(with-base-layout)/menu/page.tsx new file mode 100644 index 000000000..bbac0670c --- /dev/null +++ b/apps/cs/app/(with-base-layout)/menu/page.tsx @@ -0,0 +1,2 @@ +import { MenuPage } from "@/pages/menu"; +export default MenuPage; diff --git a/apps/cs/package.json b/apps/cs/package.json index 4b400a285..6e774acba 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -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", diff --git a/apps/cs/src/pages/site/api/site-service.ts b/apps/cs/src/entities/site/api/site-service.ts similarity index 100% rename from apps/cs/src/pages/site/api/site-service.ts rename to apps/cs/src/entities/site/api/site-service.ts diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts new file mode 100644 index 000000000..985b9346a --- /dev/null +++ b/apps/cs/src/entities/site/index.ts @@ -0,0 +1,7 @@ +export { SiteApi } from "./api/site-service"; +export { + siteFormSchema, + type ISiteParams, + type ISite, + type SiteFormData, +} from "./model/site-interface"; diff --git a/apps/cs/src/pages/site/model/site-interface.ts b/apps/cs/src/entities/site/model/site-interface.ts similarity index 100% rename from apps/cs/src/pages/site/model/site-interface.ts rename to apps/cs/src/entities/site/model/site-interface.ts diff --git a/apps/cs/src/pages/menu/api/menu-service.ts b/apps/cs/src/pages/menu/api/menu-service.ts new file mode 100644 index 000000000..6e983a197 --- /dev/null +++ b/apps/cs/src/pages/menu/api/menu-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, + ); + return response.data; + } + + public async modifyMenu(menuCd: number, data: MenuFormData): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + data, + ); + return response.data; + } +} + +export const MenuApi = new MenuApiService(); diff --git a/apps/cs/src/pages/menu/index.ts b/apps/cs/src/pages/menu/index.ts new file mode 100644 index 000000000..fa0eda54f --- /dev/null +++ b/apps/cs/src/pages/menu/index.ts @@ -0,0 +1 @@ +export { MenuPage } from "./ui/menu-page"; diff --git a/apps/cs/src/pages/menu/model/menu-interface.ts b/apps/cs/src/pages/menu/model/menu-interface.ts new file mode 100644 index 000000000..2463b511a --- /dev/null +++ b/apps/cs/src/pages/menu/model/menu-interface.ts @@ -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; + +export interface MenuService { + getMenuList: (siteId: string) => Promise; + modifyMenu: (menuCd: number, data: MenuFormData) => Promise; +} diff --git a/apps/cs/src/pages/menu/ui/menu-page.tsx b/apps/cs/src/pages/menu/ui/menu-page.tsx new file mode 100644 index 000000000..f7236616c --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-page.tsx @@ -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 ( + +
+
+ + {treeData && ( + <> +
+ + +
+
+ +
+ + )} +
+
+
+
+ ); +} + +export { MenuPage }; diff --git a/apps/cs/src/pages/menu/ui/menu-tree.tsx b/apps/cs/src/pages/menu/ui/menu-tree.tsx new file mode 100644 index 000000000..e0ab4b596 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-tree.tsx @@ -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[] }) { + const { mutate } = useMutation({ + mutationFn: ({ menuCd, data }: { menuCd: number; data: MenuFormData }) => + MenuApi.modifyMenu(menuCd, data), + }); + + const [selectedNode, setSelectedNode] = useState | null>( + null, + ); + + const handleSelect = (node: NodeModel) => setSelectedNode(node); + + const handleDrop = ( + newTree: NodeModel[], + options: DropOptions, + ) => { + 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 ( + + , { depth, isOpen, onToggle }) => ( + + )} + onDrop={handleDrop} + classes={{ + root: "p-2", + container: "flex flex-col", + listItem: "", + dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm", + draggingSource: "", + placeholder: "", + }} + /> + + ); +} + +type MenuNodeProps = { + node: NodeModel; + depth: number; + isOpen: boolean; + isSelected: boolean; + onToggle: (id: NodeModel["id"]) => void; + onSelect: (node: NodeModel) => 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 ( +
+ {droppable && ( + + )} +
+ {text} +
+
+ ); +} + +export { MenuTree }; diff --git a/apps/cs/src/pages/site/model/table-columns.tsx b/apps/cs/src/pages/site/model/table-columns.tsx index 35c084896..ce68b93ee 100644 --- a/apps/cs/src/pages/site/model/table-columns.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -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(); diff --git a/apps/cs/src/pages/site/ui/create-page.tsx b/apps/cs/src/pages/site/ui/create-page.tsx index 9e591d225..66a9c63c4 100644 --- a/apps/cs/src/pages/site/ui/create-page.tsx +++ b/apps/cs/src/pages/site/ui/create-page.tsx @@ -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 ( - + - + ); } diff --git a/apps/cs/src/pages/site/ui/modify-page.tsx b/apps/cs/src/pages/site/ui/modify-page.tsx index 6cb394eae..dd9bc2dcf 100644 --- a/apps/cs/src/pages/site/ui/modify-page.tsx +++ b/apps/cs/src/pages/site/ui/modify-page.tsx @@ -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 ( - + - + ); } diff --git a/apps/cs/src/pages/site/ui/site-form.tsx b/apps/cs/src/pages/site/ui/site-form.tsx index 65d50159f..50feb8ec6 100644 --- a/apps/cs/src/pages/site/ui/site-form.tsx +++ b/apps/cs/src/pages/site/ui/site-form.tsx @@ -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"; diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index 8a2bd5264..6f9626761 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -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 ( - +
@@ -73,7 +72,7 @@ function SitePage() {
-
+ ); } diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index b7164229a..e07b6c9ff 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -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", }, }; diff --git a/apps/cs/src/pages/site/ui/layout.tsx b/apps/cs/src/shared/ui/page-layout.tsx similarity index 56% rename from apps/cs/src/pages/site/ui/layout.tsx rename to apps/cs/src/shared/ui/page-layout.tsx index 3fec47e17..e4f11141b 100644 --- a/apps/cs/src/pages/site/ui/layout.tsx +++ b/apps/cs/src/shared/ui/page-layout.tsx @@ -1,12 +1,18 @@ -function SitePageLayout({ children }: { children: React.ReactNode }) { +function PageLayout({ + children, + pageTitle, +}: { + children: React.ReactNode; + pageTitle: string; +}) { return (

- 사이트 관리 + {pageTitle}

{children}
); } -export { SitePageLayout }; +export { PageLayout }; diff --git a/packages/common/components/src/ui/layout/vbase.tsx b/packages/common/components/src/ui/layout/vbase.tsx index 63c4e6d27..d73f14254 100644 --- a/packages/common/components/src/ui/layout/vbase.tsx +++ b/packages/common/components/src/ui/layout/vbase.tsx @@ -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, }: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70c256d33..82f59a6cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)