This commit is contained in:
2025-07-18 11:05:46 +09:00
19 changed files with 413 additions and 15 deletions
@@ -6,6 +6,7 @@ import type {
MenuItem,
MenuFormData,
MenuTemporarilyStoredData,
SiteMapFormData,
} from "../model/menu-interface";
export class MenuApiService implements MenuService {
@@ -65,6 +66,14 @@ export class MenuApiService implements MenuService {
);
return response.data;
}
public async publishSiteMap(data: SiteMapFormData["siteMap"]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`,
data,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+3
View File
@@ -2,8 +2,11 @@ export { MenuApi } from "./api/menu-service";
export {
MENU_TYPE,
menuFormSchema,
siteMapFormSchema,
type MenuType,
type MenuItem,
type MenuFormData,
type SiteMapFormData,
type SiteMapPayloadData,
} from "./model/menu-interface";
export { MenuWrapper } from "./lib/menu-wrapper";
@@ -72,8 +72,19 @@ export const menuFormSchema = z.object({
menuPicDsgnYmd: z.string().nullish(),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export const siteMapPayloadSchema = z.object({
menuCd: z.number(),
menuSubKornNm: z.string(),
otptYn: z.string(),
});
export const siteMapFormSchema = z.object({
siteMap: z.array(siteMapPayloadSchema),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export type SiteMapFormData = z.infer<typeof siteMapFormSchema>;
export type SiteMapPayloadData = z.infer<typeof siteMapPayloadSchema>;
export type Menu = Omit<MenuFormData, "iconNm"> & {
menuCd: number;
iconName?: string;
@@ -92,4 +103,5 @@ export interface MenuService {
getMenuTemporarilyStoredData: (
menuCd: number,
) => Promise<MenuTemporarilyStoredData[]>;
publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise<void>;
}
+2
View File
@@ -0,0 +1,2 @@
export { TreeMenuApi } from "./api/tree-menu-service";
export type { TreeMenu, TreeNode } from "./model/tree-menu-interface";
@@ -19,6 +19,7 @@ export interface TreeMenu extends Omit<TreeNode, "name" | "icon" | "children"> {
qrcd?: string;
useYn: string;
otptYn: string;
menuSubKornNm?: string;
requiredUrlPath?: boolean;
}
+1 -2
View File
@@ -15,8 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { ApiLogApi } from "@/entities/apilog";
import { type ApiLogParams } from "@/entities/apilog/model/apilog-interface";
import { ApiLogApi, type ApiLogParams } from "@/entities/apilog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/apilog/model/table-columns";
@@ -16,8 +16,7 @@ import {
SelectValue,
LoadingWithImage,
} from "@common/components";
import { LoginlogApi } from "@/entities/loginlog";
import { type LoginlogParams } from "@/entities/loginlog/model/loginlog-interface";
import { LoginlogApi, type LoginlogParams } from "@/entities/loginlog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/loginlog/model/table-columns";
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
TreeUserOrganization,
TreeUser,
} from "@/entities/user-organization";
import type { TreeMenu, TreeNode } from "../model/tree-menu-interface";
import type { TreeMenu, TreeNode } from "@/entities/tree-menu";
export const generateMenuTreeData = (
data: TreeMenu[],
+1 -2
View File
@@ -2,10 +2,9 @@ import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
import { Button, cn } from "@common/components";
import { TreeMenuApi, type TreeMenu } from "@/entities/tree-menu";
import { useMutationWrapper } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { TreeMenuApi } from "../api/tree-menu-service";
import type { TreeMenu } from "../model/tree-menu-interface";
import { EditableNode } from "./editable-node";
type MenuNodeProps = {
+1 -2
View File
@@ -21,11 +21,10 @@ import {
type MenuFormData,
} from "@/entities/menu";
import { SiteApi } from "@/entities/site";
import { TreeMenuApi, type TreeMenu } from "@/entities/tree-menu";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { generateMenuTreeData } from "../lib/utils";
import { TreeMenuApi } from "../api/tree-menu-service";
import type { TreeMenu } from "../model/tree-menu-interface";
import { MenuTree } from "./menu-tree";
import { MenuForm } from "./menu-form";
+5 -2
View File
@@ -12,8 +12,11 @@ import { Button } from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper } from "@/shared/lib";
import type { MenuFormData } from "@/entities/menu";
import { TreeMenuApi } from "../api/tree-menu-service";
import type { TreeMenu, TreeNode } from "../model/tree-menu-interface";
import {
TreeMenuApi,
type TreeMenu,
type TreeNode,
} from "@/entities/tree-menu";
import { handleDropNode, handleSelectNode } from "../lib/utils";
import { MenuNode } from "./menu-node";
import { EditableNode } from "./editable-node";
@@ -15,8 +15,10 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { PrivacyLogPrivacy } from "@/entities/privacylog";
import type { PrivacyLogParams } from "@/entities/privacylog/model/privacylog-interface";
import {
PrivacyLogPrivacy,
type PrivacyLogParams,
} from "@/entities/privacylog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/privacylog/model/table-columns";
+24
View File
@@ -0,0 +1,24 @@
import type { NodeModel } from "@minoru/react-dnd-treeview";
import type { TreeMenu } from "@/entities/tree-menu";
export const generateMenuTreeData = (
data: TreeMenu[],
): NodeModel<TreeMenu>[] => {
return data.reduce<NodeModel<TreeMenu>[]>((acc, menu) => {
const node: NodeModel<TreeMenu> = {
id: menu.menuCd ?? 0,
parent: menu.parentId ?? 0,
text: menu.menuSubKornNm ?? "",
droppable: true,
data: menu,
};
acc.push(node);
if (menu.children?.length) {
acc.push(...generateMenuTreeData(menu.children));
}
return acc;
}, []);
};
@@ -1,6 +1,32 @@
import dayjs from "dayjs";
import { memo, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { type ColumnDef } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Checkbox,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components";
import type { Site } from "@/entities/site";
import { TreeMenuApi } from "@/entities/tree-menu";
import {
MenuApi,
siteMapFormSchema,
type SiteMapFormData,
} from "@/entities/menu";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { MenuTree } from "../ui/menu-tree";
import { generateMenuTreeData } from "../lib/utils";
export const columns: ColumnDef<Site>[] = [
{
@@ -45,4 +71,81 @@ export const columns: ColumnDef<Site>[] = [
return date.format("YYYY-MM-DD");
},
},
{
id: "action",
header: "관리",
cell: ({ row }) => {
return (
<div onClick={(e) => e.stopPropagation()}>
<ActionCell data={row.original} />
</div>
);
},
},
];
const ActionCell = memo(({ data }: { data: Site }) => {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const { data: menuTreeData, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, data.siteId],
queryFn: () => TreeMenuApi.getTreeMenuList(data.siteId),
select: (data) => generateMenuTreeData(data),
enabled: !!data.siteId && open,
});
const { mutate: publishSiteMap } = useMutationWrapper({
mutationFn: (data: SiteMapFormData["siteMap"]) =>
MenuApi.publishSiteMap(data),
onSuccess: () => {
alert("사이트맵이 저장되었습니다.", () => {
setOpen(false);
refetch();
});
},
});
const form = useForm<SiteMapFormData>({
resolver: zodResolver(siteMapFormSchema),
});
const fieldArray = useFieldArray({
control: form.control,
name: "siteMap",
});
const handlePublishSiteMap = async () => {
if (await confirm("저장하시겠습니까?")) {
publishSiteMap(form.getValues("siteMap"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">사이트맵</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{data.siteNm} - 사이트맵 관리</DialogTitle>
</DialogHeader>
<div>
<div className="flex items-center justify-end gap-2 p-2">
<span className="text-xl">표시여부</span>
<Checkbox checked />
</div>
<MenuTree tree={menuTreeData ?? []} fieldArray={fieldArray} />
</div>
<DialogFooter>
<Button onClick={handlePublishSiteMap}>저장</Button>
<DialogClose asChild>
<Button color="white">취소</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
ActionCell.displayName = "ActionCell";
@@ -0,0 +1,45 @@
import type { ComponentProps } from "react";
import { Button, Input } from "@common/components";
type EditableNodeProps = {
depth?: number;
onSave: () => void;
onCancel: () => void;
} & ComponentProps<typeof Input>;
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 }}
onClick={(e) => e.stopPropagation()}
>
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onSave}
>
<i className="diveicon di-check text-2xl" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={onCancel}
>
<i className="diveicon di-x" />
</Button>
</div>
</div>
);
}
export { EditableNode };
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import type { NodeModel } from "@minoru/react-dnd-treeview";
import type { UseFieldArrayReturn } from "react-hook-form";
import { Button, Checkbox, cn } from "@common/components";
import { type TreeMenu } from "@/entities/tree-menu";
import type { SiteMapFormData, SiteMapPayloadData } from "@/entities/menu";
import { EditableNode } from "./editable-node";
type MenuNodeProps = {
node: NodeModel<TreeMenu>;
depth: number;
isOpen: boolean;
fieldArray: UseFieldArrayReturn<SiteMapFormData>;
onToggle: (id: NodeModel["id"]) => void;
};
function MenuNode({
node,
depth,
isOpen,
fieldArray,
onToggle,
}: MenuNodeProps) {
const { id, text, data } = node;
const indent = depth * 10 + 5;
const [editMode, setEditMode] = useState(false);
const [nodeName, setNodeName] = useState(text);
const [otptYn, setOtptYn] = useState(data?.otptYn ?? "N");
const updateField = (id: number, newField: SiteMapPayloadData) => {
const existingFieldIndex = fieldArray.fields.findIndex(
(field) => field.menuCd === id,
);
if (existingFieldIndex !== -1) {
fieldArray.update(existingFieldIndex, newField);
} else {
fieldArray.append(newField);
}
};
const handleToggle = () => onToggle(id);
const handleSave = async () => {
const newField = {
menuCd: id as number,
menuSubKornNm: nodeName,
otptYn,
};
updateField(id as number, newField);
setEditMode(false);
};
const handleCancel = () => {
setEditMode(false);
setNodeName(text);
};
const handleOtptYnChange = (checked: boolean) => {
const newField = {
menuCd: id as number,
menuSubKornNm: nodeName,
otptYn: checked ? "Y" : "N",
};
updateField(id as number, newField);
setOtptYn(checked ? "Y" : "N");
};
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"group flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
editMode && "p-0 pr-2 hover:bg-transparent",
)}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
{editMode ? (
<EditableNode
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
/>
)}
<span className="text-[1.4rem] font-medium text-label">
{nodeName}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={(e) => {
e.stopPropagation();
setEditMode(true);
}}
>
<i className="diveicon di-pencil" />
</Button>
</div>
)}
<Checkbox
className="ml-auto"
checked={otptYn === "Y"}
onClick={(e) => {
e.stopPropagation();
}}
onCheckedChange={handleOtptYnChange}
/>
</div>
);
}
export { MenuNode };
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useRef } from "react";
import type { UseFieldArrayReturn } from "react-hook-form";
import {
Tree,
type NodeModel,
type TreeMethods,
} from "@minoru/react-dnd-treeview";
import { Button } from "@common/components";
import type { SiteMapFormData } from "@/entities/menu";
import { type TreeMenu } from "@/entities/tree-menu";
import { MenuNode } from "./menu-node";
type MenuTreeProps = {
tree: NodeModel<TreeMenu>[];
fieldArray: UseFieldArrayReturn<SiteMapFormData>;
};
function MenuTree({ tree, fieldArray }: MenuTreeProps) {
const treeMethods = useRef<TreeMethods>(null);
return (
<>
<div className="max-h-[calc(100vh-33.5rem)] min-h-[40vh] overflow-auto">
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(node: NodeModel<TreeMenu>, props) => {
return <MenuNode node={node} fieldArray={fieldArray} {...props} />;
}}
onDrop={() => {}}
canDrag={() => false}
canDrop={() => false}
classes={{
container: "flex flex-col",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
placeholder: "relative",
}}
sort={false}
/>
</div>
<div className="flex gap-2">
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.openAll();
}}
>
<i className="diveicon di-arrows-angle-expand" />
전체펼침
</Button>
<Button
color="lightGray"
className="flex-1 items-center px-0"
onClick={() => {
treeMethods.current?.closeAll();
}}
>
<i className="diveicon di-arrows-angle-contract" />
전체접기
</Button>
</div>
</>
);
}
export { MenuTree };
@@ -63,7 +63,7 @@ export function LNB({
function isPathMatched(path: string) {
if (!path) return false;
return currentPath?.startsWith(path);
return currentPath === path;
}
useEffect(() => {