feat: 트리 메뉴 추가 기능 작업

This commit is contained in:
2025-03-27 14:40:35 +09:00
parent c5f1718441
commit 5c7ab94922
5 changed files with 145 additions and 34 deletions
@@ -23,16 +23,16 @@ export interface TreeNode {
export interface TreeMenu extends Omit<TreeNode, "name" | "icon" | "children"> {
menuKornNm: string;
menuEngNm: string;
iconNm: string;
children: TreeMenu[];
urlPath: string;
menuEngNm?: string;
iconNm?: string;
children?: TreeMenu[];
urlPath?: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
qrcd: string;
menuLnkgSn?: number;
qrcd?: string;
useYn: string;
otptYn: string;
requiredUrlPath: boolean;
requiredUrlPath?: boolean;
}
export const menuFormSchema = z.object({
+13 -4
View File
@@ -1,14 +1,23 @@
import { Button, Input } from "@common/components/ui";
import type { ComponentProps } from "react";
import { Button, Input } from "@common/components/ui";
type EditableNodeProps = {
depth?: number;
onSave: () => void;
onCancel: () => void;
} & ComponentProps<typeof Input>;
function EditableNode({ onSave, onCancel, ...props }: EditableNodeProps) {
function EditableNode({
depth,
onSave,
onCancel,
...props
}: EditableNodeProps) {
return (
<>
<div
className="flex items-center gap-2 rounded-sm bg-accent p-2"
style={{ marginInlineStart: !depth ? 0 : depth * 5 + 5 }}
>
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
<div className="flex items-center gap-1">
<Button
@@ -28,7 +37,7 @@ function EditableNode({ onSave, onCancel, ...props }: EditableNodeProps) {
<i className="diveicon di-x" />
</Button>
</div>
</>
</div>
);
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
import { Button } from "@common/components/ui";
import { cn } from "@common/components/lib";
import { ENDPOINT } from "@/shared/config";
@@ -13,6 +13,8 @@ type MenuNodeProps = {
depth: number;
isOpen: boolean;
isSelected: boolean;
isAdditionalParent: boolean;
treeMethods: TreeMethods | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<TreeMenu>) => void;
};
@@ -22,6 +24,8 @@ function MenuNode({
depth,
isOpen,
isSelected,
isAdditionalParent,
treeMethods,
onToggle,
onSelect,
}: MenuNodeProps) {
@@ -58,6 +62,10 @@ function MenuNode({
setNodeName(text);
};
useEffect(() => {
if (isAdditionalParent) treeMethods?.open(id);
}, [isAdditionalParent]);
return (
<div
style={{ paddingInlineStart: indent }}
+65 -7
View File
@@ -34,6 +34,8 @@ function MenuPage() {
const [siteId, setSiteId] = useState("");
const [selectedMenu, setSelectedMenu] = useState<TreeMenu | null>(null);
const [additionalNode, setAdditionalNode] =
useState<NodeModel<TreeMenu> | null>(null);
const { data: menuInfo } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, selectedMenu?.menuCd],
@@ -65,7 +67,7 @@ function MenuPage() {
})),
});
const { mutate: createMenu } = useMutation({
const { mutateAsync: createMenu } = useMutation({
mutationFn: (data: MenuFormData) => MenuApi.createMenu(data),
onSuccess: () => {
alert("저장되었습니다.");
@@ -99,8 +101,8 @@ function MenuPage() {
values: {
...menuInfo,
siteId,
sortSeq: menuInfo?.sortSeq ?? 1,
levelNo: menuInfo?.levelNo ?? 1,
sortSeq: menuInfo?.sortSeq ?? 1,
menuKornNm: menuInfo?.menuKornNm ?? "",
menuLnkgTypeCd: menuInfo?.menuLnkgTypeCd ?? MENU_TYPE.EMPTY,
otptYn: menuInfo?.otptYn ?? "Y",
@@ -109,21 +111,64 @@ function MenuPage() {
},
});
const additionalNodeForm = useForm<MenuFormData>({
resolver: zodResolver(menuFormSchema),
defaultValues: {
menuLnkgTypeCd: MENU_TYPE.EMPTY,
levelNo: 1,
sortSeq: 1,
otptYn: "Y",
evlUseYn: "Y",
useYn: "Y",
},
});
const handleAdditionalNodeSave = async () => {
await createMenu({
...additionalNodeForm.getValues(),
prntMenuCd: additionalNode?.parent as number,
});
setAdditionalNode(null);
};
const handleAdditionalNodeCancel = () => {
additionalNodeForm.setValue("menuKornNm", "");
setAdditionalNode(null);
};
const handleDelete = async () => {};
const handleSiteIdChange = (value: string) => {
setSiteId(value);
form.setValue("siteId", value);
additionalNodeForm.setValue("siteId", value);
setSiteId(value);
setSelectedMenu(null);
};
const handleNodeSelect = (node: NodeModel<TreeMenu>) => {
if (!node.data) return;
if (node.data.menuCd === selectedMenu?.menuCd) {
setSelectedMenu(null);
additionalNodeForm.setValue("levelNo", 1);
return;
}
setSelectedMenu(node.data);
additionalNodeForm.setValue("levelNo", (node.data.levelNo ?? 0) + 1);
};
const handleDelete = async () => {};
const handleAddMenu = () => {
if (additionalNode) return;
setAdditionalNode({
id: -1,
parent: selectedMenu?.menuCd ?? 0,
text: "",
});
};
const tree = additionalNode
? [...(treeData ?? []), ...(additionalNode ? [additionalNode] : [])]
: (treeData ?? []);
return (
<PageLayout pageTitle="메뉴 관리">
@@ -144,19 +189,32 @@ function MenuPage() {
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button size="sm" color="primary" className="flex-1">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button size="sm" color="red" className="flex-1">
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleDelete}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
treeData={treeData ?? []}
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={handleAdditionalNodeCancel}
/>
</div>
</>
+50 -14
View File
@@ -1,33 +1,44 @@
"use client";
import { useRef } from "react";
import { DndProvider } from "react-dnd";
import type { UseFormReturn } from "react-hook-form";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type TreeMethods,
} from "@minoru/react-dnd-treeview";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { TreeMenu, TreeNode } from "../model/menu-interface";
import type { MenuFormData, TreeMenu, TreeNode } from "../model/menu-interface";
import { handleDropNode, handleSelectNode } from "../lib/utils";
import { MenuNode } from "./menu-node";
import { EditableNode } from "./editable-node";
type MenuTreeProps = {
treeData: NodeModel<TreeMenu>[];
tree: NodeModel<TreeMenu>[];
siteId: string;
selectedMenu: TreeMenu | null;
additionalNodeForm: UseFormReturn<MenuFormData>;
onSelectNode: (node: NodeModel<TreeMenu>) => void;
onAdditionalNodeSave: () => void;
onAdditionalNodeCancel: () => void;
};
function MenuTree({
treeData,
tree,
siteId,
selectedMenu,
additionalNodeForm,
onSelectNode,
onAdditionalNodeSave,
onAdditionalNodeCancel,
}: MenuTreeProps) {
const queryClient = useQueryClient();
const treeMethods = useRef<TreeMethods>(null);
const { mutate: modifyTreeMenu } = useMutation({
mutationFn: (newTree: TreeNode) =>
@@ -39,21 +50,46 @@ function MenuTree({
},
});
const additionalNode = tree.find((n) => n.id === -1);
const isAdditionalParent = (node: NodeModel<TreeMenu>): boolean => {
if (!additionalNode) return false;
return additionalNode?.parent === node.id;
};
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={treeData}
tree={tree}
rootId={0}
render={(node: NodeModel<TreeMenu>, { depth, isOpen, onToggle }) => (
<MenuNode
node={node}
depth={depth}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
/>
)}
ref={treeMethods}
render={(node: NodeModel<TreeMenu>, { depth, isOpen, onToggle }) => {
if (node.id === -1) {
return (
<EditableNode
value={additionalNodeForm.watch("menuKornNm") ?? ""}
onChange={(e) =>
additionalNodeForm.setValue("menuKornNm", e.target.value)
}
depth={depth}
onSave={onAdditionalNodeSave}
onCancel={onAdditionalNodeCancel}
/>
);
}
return (
<MenuNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
/>
);
}}
onDrop={handleDropNode(modifyTreeMenu)}
classes={{
root: "p-4",