feat: 트리 메뉴명 수정 작업

This commit is contained in:
2025-03-25 14:23:16 +09:00
parent 2aee109912
commit 92f67ce1a3
7 changed files with 179 additions and 83 deletions
+5
View File
@@ -11,6 +11,11 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"react-hooks/exhaustive-deps": "off",
},
},
];
export default eslintConfig;
+9 -2
View File
@@ -3,20 +3,27 @@ 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<Menu[]> {
public async getTreeMenuList(siteId: string): Promise<Menu[]> {
const response = await client.get<Menu[]>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
);
return response.data;
}
public async modifyMenu(siteId: string, data: TreeMenu[]): Promise<void> {
public async modifyTreeMenu(siteId: string, data: TreeMenu[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
);
return response.data;
}
public async modifyMenuName(menuCd: number, name: string): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}/${name}`,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+33 -3
View File
@@ -44,8 +44,8 @@ export const generateNewTreeData = (
const treeMenu: TreeMenu = {
...baseNode(dragSource),
parentId: dropTargetId === 0 ? null : (dropTargetId as number),
sortSeq: dropTarget?.data?.children?.length ?? 0 + 1,
levelNo: dropTarget?.data?.levelNo ?? 0 + 1,
sortSeq: (dropTarget?.data?.children?.length ?? 0) + 1,
levelNo: (dropTarget?.data?.levelNo ?? 0) + 1,
};
const result: TreeMenu = {
@@ -54,9 +54,39 @@ export const generateNewTreeData = (
...baseNode(child),
parentId: child.data?.parentId === 0 ? null : (dropTargetId as number),
sortSeq: child.data?.sortSeq,
levelNo: treeMenu?.levelNo ?? 0 + 1,
levelNo: (treeMenu?.levelNo ?? 0) + 1,
})),
};
return result;
};
export const handleSelectNode =
(callback: (node: NodeModel<Menu>) => void) => (node: NodeModel<Menu>) => {
callback(node);
};
export const handleDropNode =
(callback: (newTree: TreeMenu) => void) =>
(newTree: NodeModel<Menu>[], options: DropOptions<Menu>) => {
const newTreeData = generateNewTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
export const handleNodeTextChange =
(treeData: NodeModel<Menu>[]) =>
(callback: (newTree: NodeModel<Menu>[]) => void) =>
(id: NodeModel["id"], value: string) => {
const newTree = treeData.map((node) => {
if (node.id === id) {
return {
...node,
text: value,
};
}
return node;
});
callback(newTree);
};
@@ -53,17 +53,18 @@ export const menuFormSchema = z.object({
MENU_TYPE.BOTTOM,
]),
menuLnkgSn: z.number().nullish(),
otptYn: z.enum(["Y", "N"]).default("Y"),
otptYn: z.enum(["Y", "N"]).default("Y").nullish(),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.enum(["Y", "N"]).nullish(),
qrcd: z.string().nullish(),
useYn: z.enum(["Y", "N"]).default("Y"),
useYn: z.enum(["Y", "N"]).default("Y").nullish(),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export interface MenuService {
getMenuList: (siteId: string) => Promise<Menu[]>;
modifyMenu: (siteId: string, data: TreeMenu[]) => Promise<void>;
getTreeMenuList: (siteId: string) => Promise<Menu[]>;
modifyTreeMenu: (siteId: string, data: TreeMenu[]) => Promise<void>;
modifyMenuName: (menuCd: number, name: string) => Promise<void>;
}
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import { cn } from "@common/components/lib";
import { Button, Input } from "@common/components/ui";
import type { Menu } from "../model/menu-interface";
import { MenuApi } from "../api/menu-service";
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 { id, text, data } = node;
const indent = depth * 10 + 5;
const { mutateAsync: modifyMenuName } = useMutation({
mutationFn: (nodeName: string) => {
const encodedName = encodeURIComponent(nodeName);
return MenuApi.modifyMenuName(id as number, encodedName);
},
});
const [editMode, setEditMode] = useState(false);
const [nodeName, setNodeName] = useState(text);
const handleToggle = () => onToggle(id);
const handleSelect = () => onSelect(node);
const handleSave = async () => {
await modifyMenuName(nodeName);
setEditMode(false);
};
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!bg-[rgba(32,148,250,0.2)]",
)}
onClick={handleSelect}
>
<div className="flex items-center">
{editMode ? (
<>
<Input
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
className="h-[2.5rem] !text-[1.3rem] text-foreground"
/>
</>
) : (
<>
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
onClick={handleToggle}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</>
)}
</div>
{editMode ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={handleSave}
>
<i className="diveicon di-check text-2xl" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={() => {
setEditMode(false);
setNodeName(text);
}}
>
<i className="diveicon di-x" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-pencil" />
</Button>
)}
</div>
);
}
export { MenuNode };
+3 -3
View File
@@ -30,7 +30,7 @@ function MenuPage() {
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getMenuList(siteId),
queryFn: () => MenuApi.getTreeMenuList(siteId),
select: (data) => generateTreeData(data),
enabled: !!siteId,
});
@@ -61,9 +61,9 @@ function MenuPage() {
return (
<PageLayout pageTitle="메뉴 관리">
<div className="card flex gap-2">
<div className="card card-border">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={setSiteId}>
<SelectTrigger className="w-[18rem]">
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
+8 -71
View File
@@ -8,13 +8,12 @@ import {
MultiBackend,
getBackendOptions,
type NodeModel,
type DropOptions,
} from "@minoru/react-dnd-treeview";
import { cn } from "@common/components/lib";
import { ENDPOINT } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import type { Menu, TreeMenu } from "../model/menu-interface";
import { generateNewTreeData } from "../lib/utils";
import { ENDPOINT } from "@/shared/config";
import { handleDropNode, handleSelectNode } from "../lib/utils";
import { MenuNode } from "./menu-node";
type MenuTreeProps = {
treeData: NodeModel<Menu>[];
@@ -24,9 +23,9 @@ type MenuTreeProps = {
function MenuTree({ treeData, siteId }: MenuTreeProps) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: ({ data }: { data: TreeMenu[] }) =>
MenuApi.modifyMenu(siteId, data),
const { mutate: modifyTreeMenu } = useMutation({
mutationFn: (newTree: TreeMenu) =>
MenuApi.modifyTreeMenu(siteId, [newTree]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
@@ -38,17 +37,6 @@ function MenuTree({ treeData, siteId }: MenuTreeProps) {
null,
);
const handleSelect = (node: NodeModel<Menu>) => setSelectedNode(node);
const handleDrop = (
newTree: NodeModel<Menu>[],
options: DropOptions<Menu>,
) => {
const newTreeData = generateNewTreeData(newTree, options);
if (!newTreeData) return;
mutate({ data: [newTreeData] });
};
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
@@ -61,10 +49,10 @@ function MenuTree({ treeData, siteId }: MenuTreeProps) {
isOpen={isOpen}
isSelected={selectedNode?.id === node.id}
onToggle={onToggle}
onSelect={handleSelect}
onSelect={handleSelectNode(setSelectedNode)}
/>
)}
onDrop={handleDrop}
onDrop={handleDropNode(modifyTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
@@ -79,55 +67,4 @@ function MenuTree({ treeData, siteId }: MenuTreeProps) {
);
}
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 { id, text, data } = node;
const indent = depth * 10 + 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}
>
<div className="flex items-center">
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
</div>
);
}
export { MenuTree };