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..bb5dd45f4 --- /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, MenuService, TreeMenu } 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(siteId: string, data: TreeMenu[]): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, + 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/lib/utils.ts b/apps/cs/src/pages/menu/lib/utils.ts new file mode 100644 index 000000000..304feac48 --- /dev/null +++ b/apps/cs/src/pages/menu/lib/utils.ts @@ -0,0 +1,61 @@ +import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview"; +import type { Menu, TreeMenu } from "../model/menu-interface"; + +export const generateTreeData = (data: Menu[]): NodeModel[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { + id: menu.menuCd ?? 0, + parent: menu.parentId ?? 0, + text: menu.menuKornNm, + droppable: true, + data: menu, + }; + + acc.push(node); + + if (menu.children?.length) { + acc.push(...generateTreeData(menu.children)); + } + + return acc; + }, []); +}; + +export const generateNewTreeData = ( + treeData: NodeModel[], + options: DropOptions, +): TreeMenu | null => { + const { dragSourceId, dragSource, dropTargetId, dropTarget } = options; + if (!dragSource?.data) return null; + + const childNodes = treeData.filter( + (node) => node.parent === dragSourceId && node.data, + ); + + const baseNode = (node: NodeModel): TreeMenu => { + if (!node.data) return {}; + return { + menuCd: node.data?.menuCd, + name: node.data?.menuKornNm, + sortSeq: node.data?.sortSeq, + icon: node.data?.iconNm, + }; + }; + + const treeMenu: TreeMenu = { + ...baseNode(dragSource), + parentId: dropTargetId === 0 ? null : (dropTargetId as number), + levelNo: (dropTarget?.data?.levelNo ?? 0) + 1, + }; + + const result: TreeMenu = { + ...treeMenu, + children: childNodes.map((child) => ({ + ...baseNode(child), + parentId: child.data?.parentId === 0 ? null : (dropTargetId as number), + levelNo: (treeMenu?.levelNo ?? 0) + 1, + })), + }; + + return result; +}; 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..09e473d07 --- /dev/null +++ b/apps/cs/src/pages/menu/model/menu-interface.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +export const MENU_TYPE = { + EMPTY: "EMPTY", + CONTENTS: "CONTENTS", + BOARD: "BOARD", + INSIDE: "INSIDE", + OUTSIDE: "OUTSIDE", + BOTTOM: "BOTTOM", +} as const; + +export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE]; + +export interface TreeMenu { + menuCd?: number; + name?: string; + sortSeq?: number; + parentId?: number | null; + levelNo?: number; + icon?: string; + children?: TreeMenu[]; +} + +export interface Menu extends Omit { + menuKornNm: string; + menuEngNm: string; + iconNm: string; + children: Menu[]; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + qrcd: string; + useYn: string; + otptYn: string; + requiredUrlPath: boolean; +} + +export const menuFormSchema = z.object({ + siteId: z.string(), + 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.enum([ + MENU_TYPE.EMPTY, + MENU_TYPE.CONTENTS, + MENU_TYPE.BOARD, + MENU_TYPE.INSIDE, + MENU_TYPE.OUTSIDE, + MENU_TYPE.BOTTOM, + ]), + menuLnkgSn: z.number().nullish(), + otptYn: z.enum(["Y", "N"]).default("Y"), + prntMenuCd: z.number().nullish(), + sortSeq: z.number(), + evlUseYn: z.enum(["Y", "N"]).nullish(), + qrcd: z.string().nullish(), + useYn: z.enum(["Y", "N"]).default("Y"), +}); + +export type MenuFormData = z.infer; + +export interface MenuService { + getMenuList: (siteId: string) => Promise; + modifyMenu: (siteId: string, data: TreeMenu[]) => Promise; +} diff --git a/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx new file mode 100644 index 000000000..84527d2da --- /dev/null +++ b/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx @@ -0,0 +1,122 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + IconFinder, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; + +export function BaseBottomMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + const handleIconClick = (icon: string) => { + form.setValue("iconNm", icon); + }; + + return ( + <> + + 메뉴 서브명 + + ( + + + + + + + )} + /> + + + + 메뉴 아이콘 + +
+
+ {form.watch("iconNm")} +
+ +
+
+
+ + 출력 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + ); +} diff --git a/apps/cs/src/pages/menu/ui/base-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-menu-form.tsx new file mode 100644 index 000000000..9af6dd6f1 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/base-menu-form.tsx @@ -0,0 +1,130 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; + +interface BaseMenuFormProps { + form: UseFormReturn; + menuId?: number; +} + +export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { + return ( + + + + + + + + 메뉴ID + +
+ {menuId} +
+
+
+ + 메뉴명 + + ( + + + + + + + )} + /> + + + + 메뉴 영문명 + + ( + + + + + + + )} + /> + + + + 메뉴 유형 + + ( + + + +
+ {Object.entries(MENU_TYPE).map(([key, value]) => ( +
+ + +
+ ))} +
+
+
+ +
+ )} + /> +
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/board-menu-form.tsx b/apps/cs/src/pages/menu/ui/board-menu-form.tsx new file mode 100644 index 000000000..a63fe24ff --- /dev/null +++ b/apps/cs/src/pages/menu/ui/board-menu-form.tsx @@ -0,0 +1,144 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function BoardMenuForm({ form }: { form: UseFormReturn }) { + return ( + + + + + + + + 게시판 + +
+ {form.watch("menuLnkgSn")} +
+
+
+ + 연결 형태 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 메뉴 평가 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + + + + QR코드 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 담당자 + +
+ {form.watch("menuLnkgSn")} +
+
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx new file mode 100644 index 000000000..88199147b --- /dev/null +++ b/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function BottomMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/content-menu-form.tsx b/apps/cs/src/pages/menu/ui/content-menu-form.tsx new file mode 100644 index 000000000..65e806f25 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/content-menu-form.tsx @@ -0,0 +1,148 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function ContentMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + 콘텐츠 + +
+ {form.watch("menuLnkgSn")} +
+
+
+ + 연결 형태 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 메뉴 평가 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + + + + QR코드 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 담당자 + +
+ {form.watch("menuLnkgSn")} +
+
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/empty-menu-form.tsx b/apps/cs/src/pages/menu/ui/empty-menu-form.tsx new file mode 100644 index 000000000..1cdaaed97 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/empty-menu-form.tsx @@ -0,0 +1,18 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function EmptyMenuForm({ form }: { form: UseFormReturn }) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/inside-menu-form.tsx b/apps/cs/src/pages/menu/ui/inside-menu-form.tsx new file mode 100644 index 000000000..8e63a2615 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/inside-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function InsideMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/menu-form.tsx b/apps/cs/src/pages/menu/ui/menu-form.tsx new file mode 100644 index 000000000..894927ba0 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-form.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { type UseFormReturn } from "react-hook-form"; +import { Button } from "@common/components"; +import { Form } from "@/shared/ui/form"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; +import { BaseMenuForm } from "./base-menu-form"; +import { EmptyMenuForm } from "./empty-menu-form"; +import { ContentMenuForm } from "./content-menu-form"; +import { BoardMenuForm } from "./board-menu-form"; +import { InsideMenuForm } from "./inside-menu-form"; +import { OutsideMenuForm } from "./outside-menu-form"; +import { BottomMenuForm } from "./bottom-menu-form"; + +interface MenuFormProps { + form: UseFormReturn; + menuId?: number; + handleSave: (data: MenuFormData) => Promise; + handleDelete?: () => Promise; +} + +export function MenuForm({ + form, + menuId, + handleSave, + handleDelete, +}: MenuFormProps) { + const isModifyMode = !!menuId; + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + + const onSubmit = async (data: MenuFormData) => { + const confirmMessage = isModifyMode + ? "수정하시겠습니까?" + : "저장하시겠습니까?"; + + setOnConfirm(true); + if (await confirm(confirmMessage)) { + setOnConfirm(false); + handleSave(data); + } + setOnConfirm(false); + }; + + const onDelete = async () => { + setOnConfirm(true); + if (await confirm("삭제하시겠습니까?")) { + setOnConfirm(false); + handleDelete?.(); + } + setOnConfirm(false); + }; + + const menuType = form.watch("menuLnkgTypeCd"); + + return ( + <> +
+ +
+
+

+ 메뉴 등록/수정 +

+
+
+ +
+ {renderMenuTypeComponent(menuType, form)} +
+
+
+
+ + {isModifyMode && ( + + )} +
+
+ + + + ); +} + +function renderMenuTypeComponent( + menuType: string, + form: UseFormReturn, +) { + switch (menuType) { + case MENU_TYPE.EMPTY: + return ; + case MENU_TYPE.CONTENTS: + return ; + case MENU_TYPE.BOARD: + return ; + case MENU_TYPE.INSIDE: + return ; + case MENU_TYPE.OUTSIDE: + return ; + case MENU_TYPE.BOTTOM: + return ; + default: + return null; + } +} 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..760f86c42 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components/ui"; +import { SiteApi } from "@/entities/site"; +import { ENDPOINT } from "@/shared/config"; +import { PageLayout } from "@/shared/ui/page-layout"; +import { MenuApi } from "../api/menu-service"; +import { + MENU_TYPE, + menuFormSchema, + type MenuFormData, +} from "../model/menu-interface"; +import { MenuTree } from "./menu-tree"; +import { MenuForm } from "./menu-form"; +import { generateTreeData } from "../lib/utils"; + +function MenuPage() { + const [siteId, setSiteId] = useState(""); + + const { data: treeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], + queryFn: () => MenuApi.getMenuList(siteId), + select: (data) => generateTreeData(data), + 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, + })), + }); + + const form = useForm({ + resolver: zodResolver(menuFormSchema), + defaultValues: { + menuLnkgTypeCd: MENU_TYPE.EMPTY, + }, + }); + + const handleSave = async (data: MenuFormData) => { + console.log(data); + }; + + const handleDelete = async () => {}; + + 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..cab0bafcc --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-tree.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { DndProvider } from "react-dnd"; +import { useMutation, useQueryClient } 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 type { Menu, TreeMenu } from "../model/menu-interface"; +import { generateNewTreeData } from "../lib/utils"; +import { ENDPOINT } from "@/shared/config"; + +type MenuTreeProps = { + treeData: NodeModel[]; + siteId: string; +}; + +function MenuTree({ treeData, siteId }: MenuTreeProps) { + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + mutationFn: ({ data }: { data: TreeMenu[] }) => + MenuApi.modifyMenu(siteId, data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS], + }); + }, + }); + + const [selectedNode, setSelectedNode] = useState | null>( + null, + ); + + const handleSelect = (node: NodeModel) => setSelectedNode(node); + + const handleDrop = ( + newTree: NodeModel[], + options: DropOptions, + ) => { + const newTreeData = generateNewTreeData(newTree, options); + if (!newTreeData) return; + mutate({ data: [newTreeData] }); + }; + + return ( + + , { depth, isOpen, onToggle }) => ( + + )} + onDrop={handleDrop} + classes={{ + root: "p-4", + 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 { id, text, data } = node; + const indent = depth * 10 + 5; + + const handleToggle = () => onToggle(id); + const handleSelect = () => onSelect(node); + + return ( +
+
+ {data?.children?.length && ( + { + e.stopPropagation(); + handleToggle(); + }} + /> + )} + {text} +
+
+ ); +} + +export { MenuTree }; diff --git a/apps/cs/src/pages/menu/ui/outside-menu-form.tsx b/apps/cs/src/pages/menu/ui/outside-menu-form.tsx new file mode 100644 index 000000000..b92977a83 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/outside-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function OutsideMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} 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/icon-finder/data.ts b/packages/common/components/src/ui/icon-finder/data.ts new file mode 100644 index 000000000..b5b2d8c2b --- /dev/null +++ b/packages/common/components/src/ui/icon-finder/data.ts @@ -0,0 +1,592 @@ +export default [ + "account-box", + "account-circle", + "add-image", + "add-Note", + "alarm-add", + "alarm-clock", + "alarm-off", + "alarm-on", + "alarm", + "alternate", + "analytics", + "android", + "announcement", + "app-indicator", + "app-list", + "app-square", + "apple", + "apps-square", + "apps", + "archive-fill", + "archive", + "arrow-90left-down", + "arrow-90left-up", + "arrow-90left", + "arrow-90right-down", + "arrow-90right-up", + "arrow-90right", + "arrow-back-square-fill", + "arrow-back", + "arrow-down-square-fill", + "arrow-down-square", + "arrow-down", + "arrow-forward-square-fill", + "arrow-forward", + "arrow-left-right", + "arrow-left-square-fill", + "arrow-left-square", + "arrow-left", + "arrow-lr-up-down", + "arrow-reflash", + "arrow-reset", + "arrow-right-square-fill", + "arrow-right-square", + "arrow-right", + "arrow-up-down", + "arrow-up-square-fill", + "arrow-up-square", + "arrow-up", + "arrows-angle-contract", + "arrows-angle-expand", + "arrows-fullscreen", + "aspect-ratio-fill", + "aspect-ratio", + "assignment-ind", + "assignment-turned-in", + "assignment", + "asterisk", + "atm", + "badge-4k-fill", + "badge-4k", + "badge-8k-fill", + "badge-8k", + "badge-ad-fill", + "badge-ad", + "badge-cc-fill", + "badge-cc", + "badge-hd-fill", + "badge-hd", + "badge-tm-fill", + "badge-tm", + "bag-check-fill", + "bag-check", + "bag-fill", + "bag", + "bags", + "bar-chart-fill", + "bar-chart", + "battery-charging", + "battery-emty", + "battery-full", + "battery-harf", + "bell-alert-box-fill", + "bell-alert-box", + "bell-alert-fill", + "bell-alert", + "bell-notification-fill", + "bell-notification-on-fill", + "bell-notification-on", + "bell-notification", + "bike", + "bluetooth", + "boat", + "book", + "bookmark-check-fill", + "bookmark-check", + "bookmark-fill", + "bookmark-new-fill", + "bookmark-new", + "bookmark-star-fill", + "bookmark-star", + "bookmark", + "boostzone", + "box-arrow-backup", + "box-arrow-download", + "box-arrow-link-blank", + "box-arrow-upload", + "box-seam", + "box", + "braces", + "briefcase-fill", + "briefcase", + "brightness-fill", + "brightness", + "broadcast-pin", + "broadcast", + "bug-fill", + "bug", + "building", + "bus", + "calculator-fill", + "calculator", + "calendar-check-fill", + "calendar-check", + "calendar-day-fill", + "calendar-day", + "calendar-event-fill", + "calendar-event", + "calendar-fill", + "calendar-week-fill", + "calendar-week", + "calendar", + "call-end", + "call", + "camera-fill", + "camera-video-fill", + "camera-video", + "camera", + "car", + "card-fill", + "card", + "caret-down-fill", + "caret-down-square-fill", + "caret-down-square", + "caret-left-fill", + "caret-left-square-fill", + "caret-left-square", + "caret-right-fill", + "caret-right-square-fill", + "caret-right-square", + "caret-up-fill", + "caret-up-square-fill", + "caret-up-square", + "cart-fill", + "cart", + "cash-stack", + "cash", + "cast", + "cctv", + "chat-box-left-dots-fill", + "chat-box-left-dots", + "chat-box-left-fill", + "chat-box-left-text-fill", + "chat-box-left-text", + "chat-box-left", + "chat-box-right-dots-fill", + "chat-box-right-dots", + "chat-box-right-fill", + "chat-box-right-text-fill", + "chat-box-right-text", + "chat-box-right", + "chat-circle-left-dot-fill", + "chat-circle-left-dot", + "chat-circle-left-fill", + "chat-circle-left-text-fill", + "chat-circle-left-text", + "chat-circle-left", + "chat-circle-right-dot-fill", + "chat-circle-right-dot", + "chat-circle-right-fill", + "chat-circle-right-text-fill", + "chat-circle-right-text", + "chat-circle-right", + "check-box", + "check-circle-fill", + "check-circle-line", + "check-circle", + "check-square-fill", + "check-square-line", + "check-square", + "check", + "chevron-bar-contract", + "chevron-bar-down", + "chevron-bar-expand", + "chevron-bar-left", + "chevron-bar-right", + "chevron-bar-up", + "chevron-contract", + "chevron-double-down", + "chevron-double-left", + "chevron-double-right", + "chevron-double-up", + "chevron-down", + "chevron-expand", + "chevron-left", + "chevron-right", + "chevron-up", + "clipboard-check", + "clipboard-data", + "clipboard-text", + "clipboard", + "clock-fill", + "clock-history", + "clock", + "cloud-arrow-down-fill", + "cloud-arrow-down", + "cloud-arrow-up-fill", + "cloud-arrow-up", + "cloud-check-fill", + "cloud-check", + "cloud-fill", + "cloud", + "code-square", + "code", + "collection-fill", + "collection", + "compass-fill", + "compass", + "cone", + "controller-fill", + "controller", + "copyright-sign", + "copyright-symbol", + "COVID-19-fill", + "COVID-19", + "cpu-fill", + "cpu", + "crop", + "cup-fill", + "cup-straw", + "cup", + "cursor-fill", + "cursor-text", + "cursor", + "dash-circle-fill", + "dash-circle", + "dash-square-fill", + "dash-square", + "dash", + "dashboard", + "diagram", + "diamond-fill", + "diamond", + "diamond2", + "display-fill", + "display", + "dns", + "door-closed-fill", + "door-closed", + "download_app", + "download", + "email-fill-notification", + "email-fill-open", + "email-fill", + "email-notification", + "email-open", + "email", + "emergenc_call", + "emergenc", + "exclamation-chat-fill", + "exclamation-chat", + "exclamation-circle-fill", + "exclamation-circle", + "exclamation-diamond-fill", + "exclamation-diamond", + "exclamation-square-circle", + "exclamation-square-fill", + "exclamation-square", + "exclamation-triangle-fill", + "exclamation-triangle", + "exclamation", + "exclude", + "face-id", + "facebook-circle", + "facebook-square", + "favorite-hart", + "favorite-star", + "file-copy", + "file-excel", + "file-image", + "file-keynote", + "file-numbers", + "file-powerpoint", + "file-text", + "file-word", + "file", + "filter-circle-fill", + "filter-circle", + "filter-left", + "filter-right", + "filter-square-fill", + "filter-square", + "filter", + "finder-folder", + "finder", + "fingerprint", + "flag-fill", + "flag", + "flashlight", + "folder-check", + "folder-delet", + "folder-fill", + "folder-open-fill", + "folder-open", + "folder-plus", + "folder-share", + "folder-x", + "folder", + "font", + "fullscreen-exit", + "fullscreen", + "gear-fill", + "gear-square-fill", + "gear-system-update", + "gear", + "gem", + "geo-alt", + "geo-location", + "geo-my-location", + "gift-fill", + "gift", + "graph-down", + "graph-up", + "grid-2-fill", + "grid-3-fill", + "grid", + "grip-horizontal", + "grip-vertical", + "group", + "group2", + "hand-index", + "harf", + "hart-fill", + "hart-half", + "hash", + "hdd-network", + "hdd", + "hexagon-fill", + "hexagon-harf", + "hexagon", + "history", + "home-fill", + "home", + "hotel_bed", + "hotel", + "image-fill", + "image", + "images", + "info-box", + "info-circle", + "info-fill", + "info", + "instagram-circle", + "instagram-fill", + "instagram", + "intersect", + "justify-left", + "justify-right", + "justify", + "kakao-alim-talk", + "kakao-circle", + "kakao-firend-talk", + "kakao-story-circle", + "kakao-story", + "kakao-talk", + "key", + "keyboard", + "label", + "language", + "laptop-fill", + "laptop", + "layer-back", + "layer-front", + "layer", + "lightning-fill", + "lightning", + "list-check", + "list", + "local-airport-land", + "local-airport", + "local-atm", + "local-bar", + "local-cafe", + "local-dining", + "local-ev-station", + "local-fastfood", + "local-gas-station", + "local-hospital", + "local-laundry", + "local-library", + "local-museum", + "local-restaurant", + "local-store", + "lock-open", + "lock", + "map-fill", + "map", + "menu-close-fill", + "menu-close", + "menu-open-fill", + "menu-open", + "mic-fill", + "mic-mute-fill", + "mic-mute", + "mic", + "minus", + "mobile-android", + "mobile-flip", + "mobile-fold", + "mobile-ios", + "mobile-pad", + "mobile-store", + "mobile", + "moon-crescent", + "moon-half", + "moon", + "more-horiz", + "more-vert", + "movie-fill", + "movie", + "music-backward-fill", + "music-backward", + "music-forward-fill", + "music-forward", + "music-list", + "music-pause-fill", + "music-pause", + "music-play-fill", + "music-play", + "music-stop-fill", + "music-stop", + "music", + "naver-blog", + "naver-circle", + "naver", + "news", + "option", + "paperclip", + "payment", + "pen-fill", + "pen", + "pencil-fill", + "pencil-square", + "pencil", + "person-fill", + "person", + "personal-information", + "pet", + "power", + "print", + "problem", + "profile-smile", + "profile", + "push-assemble", + "push-fill", + "push-notification", + "push", + "question-answer", + "question-circle-fill", + "question-circle", + "question-diamond-fill", + "question-diamond", + "question-square-fill", + "question-square", + "question", + "radio", + "receipt", + "refresh-fill", + "refresh", + "reset-fill", + "reset", + "rotation-lock", + "rotation", + "rwis", + "search", + "server", + "share", + "shield", + "shift-fill", + "shift", + "shuffle", + "silent", + "sim", + "slash", + "sliders", + "smart-watch", + "sort-alpha-down-alt", + "sort-alpha-down", + "sort-alpha-up-alt", + "sort-alpha-up", + "sort-down-alt", + "sort-down", + "sort-numeric-down-alt", + "sort-numeric-down", + "sort-numeric-up-alt", + "sort-numeric-up", + "sort-up-alt", + "sort-up", + "sound", + "soundwave", + "square-fill", + "square", + "star-circle", + "star-fill", + "star", + "statistics-circle", + "statistics", + "sticky-fill", + "sticky", + "stoplights-fill", + "stoplights", + "sunny", + "tab", + "table", + "terminal-fill", + "terminal", + "ternnel", + "textarea-t", + "textarea", + "thermometer-full", + "thermometer-half", + "thermometer", + "time-end", + "time-full", + "time-start", + "toggle-off", + "toggle-on", + "toggle", + "train", + "trash-fill", + "trash", + "truck-fill", + "truck", + "ui-checks-grid", + "ui-radios-grid", + "upload", + "vibration", + "vms", + "volume-down-fill", + "volume-down", + "volume-mute-fill", + "volume-mute", + "volume-off-fill", + "volume-off", + "volume-up-fill", + "volume-up", + "weather-cloud-fill", + "weather-cloud", + "weather-cloudy-gusts", + "weather-cloudy-windy", + "weather-cloudy", + "weather-fahrenheit", + "weather-fog", + "weather-hail", + "weather-humidity", + "weather-hurricane", + "weather-lightning", + "weather-rain", + "weather-rainny", + "weather-snow", + "weather-snowflake", + "weather-sunny-overcast", + "weather-sunny", + "weather-thunderstorm", + "weather-umbrella", + "weather-windy", + "whater", + "whather-celsius", + "wifi-1", + "wifi-2", + "wifi-off", + "wifi", + "window", + "x-circle-fill", + "x-circle", + "x-square-fill", + "x-square", + "x", + "zoom-in", + "zoom-out", + "server-line", +]; diff --git a/packages/common/components/src/ui/icon-finder/icon-finder.stories.tsx b/packages/common/components/src/ui/icon-finder/icon-finder.stories.tsx new file mode 100644 index 000000000..59a37c8c7 --- /dev/null +++ b/packages/common/components/src/ui/icon-finder/icon-finder.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { IconFinder } from "./index"; + +const meta: Meta = { + title: "UI/IconFinder", + component: IconFinder, + tags: ["autodocs"], + argTypes: {}, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/packages/common/components/src/ui/icon-finder/index.tsx b/packages/common/components/src/ui/icon-finder/index.tsx new file mode 100644 index 000000000..62e6e195a --- /dev/null +++ b/packages/common/components/src/ui/icon-finder/index.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { Button } from "../button"; +import { Dialog, DialogContent, DialogTrigger } from "../dialog"; +import { cn } from "../../lib"; +import iconNames from "./data"; + +type IconFinderProps = { + handleClick: (icon: string) => void; +}; + +const IconFinder = ({ handleClick }: IconFinderProps) => { + const [selectedIcon, setSelectedIcon] = useState(""); + + const handleIconClick = (icon: string) => { + setSelectedIcon(icon); + handleClick(icon); + }; + + return ( + + + + + +
+
+ {iconNames.map((icon) => ( + + ))} +
+
+
+
+ ); +}; + +const IconItem = ({ + icon, + selectedIcon, + handleClick, +}: { + icon: string; + selectedIcon: string; + handleClick: (icon: string) => void; +}) => { + return ( +
handleClick(icon)} + > + + + {icon} +
+ ); +}; + +export { IconFinder }; diff --git a/packages/common/components/src/ui/index.ts b/packages/common/components/src/ui/index.ts index 438c18dbd..650eb30e1 100644 --- a/packages/common/components/src/ui/index.ts +++ b/packages/common/components/src/ui/index.ts @@ -7,8 +7,8 @@ export * from "./data-table"; export * from "./date-picker"; export * from "./dialog"; export * from "./dropdown-menu"; -export * from "./input"; export * from "./file-search"; +export * from "./icon-finder"; export * from "./input"; export * from "./label"; export * from "./layout"; 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)