diff --git a/apps/cs/src/entities/menu/api/menu-service.ts b/apps/cs/src/entities/menu/api/menu-service.ts index 33ad06837..07e9e552a 100644 --- a/apps/cs/src/entities/menu/api/menu-service.ts +++ b/apps/cs/src/entities/menu/api/menu-service.ts @@ -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 { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`, + data, + ); + return response.data; + } } export const MenuApi = new MenuApiService(); diff --git a/apps/cs/src/entities/menu/index.ts b/apps/cs/src/entities/menu/index.ts index e4a9cec40..e4c61dbb7 100644 --- a/apps/cs/src/entities/menu/index.ts +++ b/apps/cs/src/entities/menu/index.ts @@ -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"; diff --git a/apps/cs/src/entities/menu/model/menu-interface.ts b/apps/cs/src/entities/menu/model/menu-interface.ts index b1693f616..0c5f4b54c 100644 --- a/apps/cs/src/entities/menu/model/menu-interface.ts +++ b/apps/cs/src/entities/menu/model/menu-interface.ts @@ -72,8 +72,19 @@ export const menuFormSchema = z.object({ menuPicDsgnYmd: z.string().nullish(), }); -export type MenuFormData = z.infer; +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; +export type SiteMapFormData = z.infer; +export type SiteMapPayloadData = z.infer; export type Menu = Omit & { menuCd: number; iconName?: string; @@ -92,4 +103,5 @@ export interface MenuService { getMenuTemporarilyStoredData: ( menuCd: number, ) => Promise; + publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise; } diff --git a/apps/cs/src/entities/tree-menu/model/tree-menu-interface.ts b/apps/cs/src/entities/tree-menu/model/tree-menu-interface.ts index 39a6f92c9..d0b28b8fa 100644 --- a/apps/cs/src/entities/tree-menu/model/tree-menu-interface.ts +++ b/apps/cs/src/entities/tree-menu/model/tree-menu-interface.ts @@ -19,6 +19,7 @@ export interface TreeMenu extends Omit { qrcd?: string; useYn: string; otptYn: string; + menuSubKornNm?: string; requiredUrlPath?: boolean; } diff --git a/apps/cs/src/pages/site/lib/utils.ts b/apps/cs/src/pages/site/lib/utils.ts new file mode 100644 index 000000000..aa809a4be --- /dev/null +++ b/apps/cs/src/pages/site/lib/utils.ts @@ -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[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { + 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; + }, []); +}; diff --git a/apps/cs/src/pages/site/model/table-columns.tsx b/apps/cs/src/pages/site/model/table-columns.tsx index 4418e20a0..6cf3effba 100644 --- a/apps/cs/src/pages/site/model/table-columns.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -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[] = [ { @@ -45,4 +71,81 @@ export const columns: ColumnDef[] = [ return date.format("YYYY-MM-DD"); }, }, + { + id: "action", + header: "관리", + cell: ({ row }) => { + return ( +
e.stopPropagation()}> + +
+ ); + }, + }, ]; + +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({ + resolver: zodResolver(siteMapFormSchema), + }); + const fieldArray = useFieldArray({ + control: form.control, + name: "siteMap", + }); + + const handlePublishSiteMap = async () => { + if (await confirm("저장하시겠습니까?")) { + publishSiteMap(form.getValues("siteMap")); + } + }; + + return ( + + + + + + + {data.siteNm} - 사이트맵 관리 + +
+
+ 표시여부 + +
+ +
+ + + + + + +
+
+ ); +}); + +ActionCell.displayName = "ActionCell"; diff --git a/apps/cs/src/pages/site/ui/editable-node.tsx b/apps/cs/src/pages/site/ui/editable-node.tsx new file mode 100644 index 000000000..20ac90812 --- /dev/null +++ b/apps/cs/src/pages/site/ui/editable-node.tsx @@ -0,0 +1,45 @@ +import type { ComponentProps } from "react"; +import { Button, Input } from "@common/components"; + +type EditableNodeProps = { + depth?: number; + onSave: () => void; + onCancel: () => void; +} & ComponentProps; + +function EditableNode({ + depth, + onSave, + onCancel, + ...props +}: EditableNodeProps) { + return ( +
e.stopPropagation()} + > + +
+ + +
+
+ ); +} + +export { EditableNode }; diff --git a/apps/cs/src/pages/site/ui/menu-node.tsx b/apps/cs/src/pages/site/ui/menu-node.tsx new file mode 100644 index 000000000..fbb5e9391 --- /dev/null +++ b/apps/cs/src/pages/site/ui/menu-node.tsx @@ -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; + depth: number; + isOpen: boolean; + fieldArray: UseFieldArrayReturn; + 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 ( +
{ + e.stopPropagation(); + handleToggle(); + }} + > + {editMode ? ( + setNodeName(e.target.value)} + onSave={handleSave} + onCancel={handleCancel} + /> + ) : ( +
+
+ {data?.children?.length && ( + + )} + + {nodeName} + +
+ +
+ )} + { + e.stopPropagation(); + }} + onCheckedChange={handleOtptYnChange} + /> +
+ ); +} + +export { MenuNode }; diff --git a/apps/cs/src/pages/site/ui/menu-tree.tsx b/apps/cs/src/pages/site/ui/menu-tree.tsx new file mode 100644 index 000000000..daaa278c0 --- /dev/null +++ b/apps/cs/src/pages/site/ui/menu-tree.tsx @@ -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[]; + fieldArray: UseFieldArrayReturn; +}; + +function MenuTree({ tree, fieldArray }: MenuTreeProps) { + const treeMethods = useRef(null); + + return ( + <> +
+ , props) => { + return ; + }} + onDrop={() => {}} + canDrag={() => false} + canDrop={() => false} + classes={{ + container: "flex flex-col", + dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm", + placeholder: "relative", + }} + sort={false} + /> +
+
+ + +
+ + ); +} + +export { MenuTree };