From baf3cb0b11b6ad1250b222c2b6a8106a7666086d Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 21 Apr 2025 18:19:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A1=B0=EC=A7=81=EB=8F=84=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80,=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/organization/page.tsx | 2 + .../organization/api/organization-service.ts | 33 ++++ apps/cs/src/entities/organization/index.ts | 7 + .../cs/src/entities/organization/lib/utils.ts | 23 +++ .../model/organization-interface.ts | 26 +++ apps/cs/src/pages/menu/ui/menu-page.tsx | 6 +- apps/cs/src/pages/organization/index.ts | 1 + .../organization/ui/organization-page.tsx | 157 ++++++++++++++++++ apps/cs/src/shared/config/api.ts | 1 + apps/cs/src/widgets/dnd-tree/index.ts | 2 + .../src/widgets/dnd-tree/ui/custom-node.tsx | 103 ++++++++++++ apps/cs/src/widgets/dnd-tree/ui/dnd-tree.tsx | 123 ++++++++++++++ .../src/widgets/dnd-tree/ui/editable-node.tsx | 44 +++++ 13 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/organization/page.tsx create mode 100644 apps/cs/src/entities/organization/api/organization-service.ts create mode 100644 apps/cs/src/entities/organization/index.ts create mode 100644 apps/cs/src/entities/organization/lib/utils.ts create mode 100644 apps/cs/src/entities/organization/model/organization-interface.ts create mode 100644 apps/cs/src/pages/organization/index.ts create mode 100644 apps/cs/src/pages/organization/ui/organization-page.tsx create mode 100644 apps/cs/src/widgets/dnd-tree/index.ts create mode 100644 apps/cs/src/widgets/dnd-tree/ui/custom-node.tsx create mode 100644 apps/cs/src/widgets/dnd-tree/ui/dnd-tree.tsx create mode 100644 apps/cs/src/widgets/dnd-tree/ui/editable-node.tsx diff --git a/apps/cs/app/(with-base-layout)/organization/page.tsx b/apps/cs/app/(with-base-layout)/organization/page.tsx new file mode 100644 index 000000000..39a15cf83 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/organization/page.tsx @@ -0,0 +1,2 @@ +import { OrganizationPage } from "@/pages/organization"; +export default OrganizationPage; diff --git a/apps/cs/src/entities/organization/api/organization-service.ts b/apps/cs/src/entities/organization/api/organization-service.ts new file mode 100644 index 000000000..3a362df19 --- /dev/null +++ b/apps/cs/src/entities/organization/api/organization-service.ts @@ -0,0 +1,33 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type OrganizationFormData, + type OrganizationService, + type OrganizationTree, +} from "../model/organization-interface"; + +export class OrganizationApiService implements OrganizationService { + public async getOrganizationsTreeList(): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/tree`, + ); + return response.data; + } + + public async createOrganization( + organization: OrganizationFormData, + ): Promise { + await client.post( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}`, + organization, + ); + } + + public async deleteOrganization(ognzSn: number): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`, + ); + } +} + +export const OrganizationApi = new OrganizationApiService(); diff --git a/apps/cs/src/entities/organization/index.ts b/apps/cs/src/entities/organization/index.ts new file mode 100644 index 000000000..e04563805 --- /dev/null +++ b/apps/cs/src/entities/organization/index.ts @@ -0,0 +1,7 @@ +export { OrganizationApi } from "./api/organization-service"; +export { + organizationFormSchema, + type OrganizationTree, + type OrganizationFormData, +} from "./model/organization-interface"; +export { generateOrganizationTreeData } from "./lib/utils"; diff --git a/apps/cs/src/entities/organization/lib/utils.ts b/apps/cs/src/entities/organization/lib/utils.ts new file mode 100644 index 000000000..d923fc35b --- /dev/null +++ b/apps/cs/src/entities/organization/lib/utils.ts @@ -0,0 +1,23 @@ +import type { NodeModel } from "@minoru/react-dnd-treeview"; +import type { OrganizationTree } from "../model/organization-interface"; + +export const generateOrganizationTreeData = ( + data: OrganizationTree[], +): NodeModel[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { + id: menu.ognzSn ?? 0, + parent: menu.upOgnzSn ?? 0, + text: menu.ognzNm, + droppable: true, + data: menu, + }; + acc.push(node); + + if (menu.children?.length) { + acc.push(...generateOrganizationTreeData(menu.children)); + } + + return acc; + }, []); +}; diff --git a/apps/cs/src/entities/organization/model/organization-interface.ts b/apps/cs/src/entities/organization/model/organization-interface.ts new file mode 100644 index 000000000..ad8167708 --- /dev/null +++ b/apps/cs/src/entities/organization/model/organization-interface.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const organizationSchema = z.object({ + ognzSn: z.number().nullish(), + ognzNm: z.string(), + ognzExpln: z.string().nullish(), + upOgnzSn: z.number().nullish(), + sortSeq: z.number(), + useYn: z.string().nullish(), +}); + +export const organizationFormSchema = organizationSchema.omit({ + ognzSn: true, +}); + +export type Organization = z.infer; +export type OrganizationFormData = z.infer; +export type OrganizationTree = Organization & { + children: OrganizationTree[]; +}; + +export interface OrganizationService { + getOrganizationsTreeList(): Promise; + createOrganization(organization: OrganizationFormData): Promise; + deleteOrganization(ognzSn: number): Promise; +} diff --git a/apps/cs/src/pages/menu/ui/menu-page.tsx b/apps/cs/src/pages/menu/ui/menu-page.tsx index 5f5dd6c70..a54a42563 100644 --- a/apps/cs/src/pages/menu/ui/menu-page.tsx +++ b/apps/cs/src/pages/menu/ui/menu-page.tsx @@ -44,7 +44,7 @@ function MenuPage() { enabled: !!selectedMenu?.menuCd, }); - const { data: treeData } = useQuery({ + const { data: menuTreeData } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], queryFn: () => MenuApi.getTreeMenuList(siteId), select: (data) => { @@ -187,8 +187,8 @@ function MenuPage() { }; const tree = additionalNode - ? [...(additionalNode ? [additionalNode] : []), ...(treeData ?? [])] - : (treeData ?? []); + ? [...(additionalNode ? [additionalNode] : []), ...(menuTreeData ?? [])] + : (menuTreeData ?? []); return (
diff --git a/apps/cs/src/pages/organization/index.ts b/apps/cs/src/pages/organization/index.ts new file mode 100644 index 000000000..cf047f517 --- /dev/null +++ b/apps/cs/src/pages/organization/index.ts @@ -0,0 +1 @@ +export { OrganizationPage } from "./ui/organization-page"; diff --git a/apps/cs/src/pages/organization/ui/organization-page.tsx b/apps/cs/src/pages/organization/ui/organization-page.tsx new file mode 100644 index 000000000..5a767ba6b --- /dev/null +++ b/apps/cs/src/pages/organization/ui/organization-page.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { NodeModel } from "@minoru/react-dnd-treeview"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@common/components/ui"; +import { DndTree, EditableNode } from "@/widgets/dnd-tree"; +import { + OrganizationApi, + generateOrganizationTreeData, + organizationFormSchema, + type OrganizationFormData, + type OrganizationTree, +} from "@/entities/organization"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; + +function OrganizationPage() { + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + + const [additionalNode, setAdditionalNode] = + useState | null>(null); + const [selectedOrganization, setSelectedOrganization] = + useState(null); + + const { data: organizationTreeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS], + queryFn: () => OrganizationApi.getOrganizationsTreeList(), + select: (data) => generateOrganizationTreeData(data), + }); + + const { mutateAsync: createOrganization } = useMutation({ + mutationFn: (organization: OrganizationFormData) => + OrganizationApi.createOrganization(organization), + onSuccess: () => { + alert("저장되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS], + }); + }, + }); + + const { mutateAsync: deleteOrganization } = useMutation({ + mutationFn: (ognzSn: number) => OrganizationApi.deleteOrganization(ognzSn), + onSuccess: () => { + alert("삭제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.ORGANIZATIONS], + }); + }, + }); + + const additionalNodeForm = useForm({ + resolver: zodResolver(organizationFormSchema), + defaultValues: { + ognzNm: "", + sortSeq: 1, + }, + }); + + const tree = additionalNode + ? [ + ...(additionalNode ? [additionalNode] : []), + ...(organizationTreeData ?? []), + ] + : (organizationTreeData ?? []); + + const onSaveAdditionalNode = async () => { + await createOrganization({ ...additionalNodeForm.getValues() }); + resetAdditionalNode(); + }; + + const onDeleteOrganization = async () => { + if (await confirm("삭제하시겠습니까?")) { + deleteOrganization(selectedOrganization!.ognzSn!); + } + }; + + const resetAdditionalNode = () => { + additionalNodeForm.setValue("ognzNm", ""); + setAdditionalNode(null); + }; + + const handleNodeSelect = (node: NodeModel) => { + if (!node.data) return; + if (node.data.ognzSn === selectedOrganization?.ognzSn) { + setSelectedOrganization(null); + additionalNodeForm.setValue("upOgnzSn", null); + return; + } + setSelectedOrganization(node.data); + additionalNodeForm.setValue("upOgnzSn", (node.id as number) ?? 0); + }; + + const handleAddOrganization = () => { + if (additionalNode) return; + setAdditionalNode({ + id: -1, + parent: selectedOrganization?.ognzSn ?? 0, + text: "", + }); + }; + + return ( +
+
+
+ + +
+
+ ( + + additionalNodeForm.setValue("ognzNm", e.target.value) + } + onSave={onSaveAdditionalNode} + onCancel={resetAdditionalNode} + {...props} + /> + )} + handleSelectNode={handleNodeSelect} + handleDropNode={() => {}} + updateNodeName={async () => {}} + /> +
+
+
+
+ ); +} + +export { OrganizationPage }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 1a77bbb10..49379f521 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -27,6 +27,7 @@ export const ENDPOINT = { MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts", CONTENTS: "/cms-service/api/v1/contents", CONTENTS_DETAILS: "/cms-service/api/v1/contents-details", + ORGANIZATIONS: "/cms-service/api/v1/ognzs", USER_ORGANIZATION: "/cms-service/api/v1/user-ognz", IMAGES: "/cms-service/api/v1/images", BANNERS: "/cms-service/api/v1/bnrs", diff --git a/apps/cs/src/widgets/dnd-tree/index.ts b/apps/cs/src/widgets/dnd-tree/index.ts new file mode 100644 index 000000000..ca1c20f8d --- /dev/null +++ b/apps/cs/src/widgets/dnd-tree/index.ts @@ -0,0 +1,2 @@ +export { DndTree } from "./ui/dnd-tree"; +export { EditableNode } from "./ui/editable-node"; diff --git a/apps/cs/src/widgets/dnd-tree/ui/custom-node.tsx b/apps/cs/src/widgets/dnd-tree/ui/custom-node.tsx new file mode 100644 index 000000000..b909f1bfb --- /dev/null +++ b/apps/cs/src/widgets/dnd-tree/ui/custom-node.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview"; +import { Button } from "@common/components/ui"; +import { cn } from "@common/components/lib"; +import { EditableNode } from "./editable-node"; + +interface TreeData extends Record { + children: unknown[]; +} + +type CustomNodeProps = { + node: NodeModel; + depth: number; + isOpen: boolean; + isSelected: boolean; + isAdditionalParent: boolean; + treeMethods: TreeMethods | null; + onToggle: (id: NodeModel["id"]) => void; + onSelect: (node: NodeModel) => void; + updateNodeName: (nodeName: string) => Promise; +}; + +function CustomNode({ + node, + depth, + isOpen, + isSelected, + isAdditionalParent, + treeMethods, + onToggle, + onSelect, + updateNodeName, +}: CustomNodeProps) { + const { id, text, data } = node; + const indent = depth * 10 + 5; + + const [editMode, setEditMode] = useState(false); + const [nodeName, setNodeName] = useState(text); + + const handleToggle = () => onToggle(id); + const handleSelect = () => onSelect(node); + + const handleSave = async () => { + await updateNodeName(nodeName); + setEditMode(false); + }; + + const handleCancel = () => { + setEditMode(false); + setNodeName(text); + }; + + useEffect(() => { + if (isAdditionalParent) treeMethods?.open(id); + }, [isAdditionalParent]); + + return ( +
+ {editMode ? ( + setNodeName(e.target.value)} + onSave={handleSave} + onCancel={handleCancel} + /> + ) : ( + <> +
+ {data && data.children.length > 0 && ( + { + e.stopPropagation(); + handleToggle(); + }} + /> + )} + {text} +
+ + + )} +
+ ); +} + +export { CustomNode }; diff --git a/apps/cs/src/widgets/dnd-tree/ui/dnd-tree.tsx b/apps/cs/src/widgets/dnd-tree/ui/dnd-tree.tsx new file mode 100644 index 000000000..98386f09c --- /dev/null +++ b/apps/cs/src/widgets/dnd-tree/ui/dnd-tree.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { type ReactElement, useRef } from "react"; +import { DndProvider } from "react-dnd"; +import { + Tree, + MultiBackend, + getBackendOptions, + type NodeModel, + type TreeMethods, + type DropOptions, +} from "@minoru/react-dnd-treeview"; +import { Button } from "@common/components/ui"; +import { CustomNode } from "./custom-node"; + +interface TreeData extends Record { + children: unknown[]; +} + +type EditableNodeProps = { depth: number }; + +type TreeProps = { + tree: NodeModel[]; + selectedNodeId: number; + renderEditableNode: (props: EditableNodeProps) => ReactElement; + handleSelectNode: (node: NodeModel) => void; + handleDropNode: (newTree: NodeModel[], options: DropOptions) => void; + updateNodeName: (nodeName: string) => Promise; +}; + +function DndTree({ + tree, + selectedNodeId, + renderEditableNode, + handleSelectNode, + handleDropNode, + updateNodeName, +}: TreeProps) { + const treeMethods = useRef(null); + + const isAdditionalParent = (node: NodeModel): boolean => { + const additionalNode = tree.find((n) => n.id === -1); + if (!additionalNode) return false; + return additionalNode?.parent === node.id; + }; + + return ( + <> +
+ + , { depth, isOpen, onToggle }) => { + if (node.id === -1) return renderEditableNode({ depth }); + return ( + + ); + }} + onDrop={handleDropNode} + classes={{ + root: "p-4", + container: "flex flex-col", + dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm", + placeholder: "relative", + }} + sort={false} + dropTargetOffset={5} + insertDroppableFirst={false} + canDrop={(_, { dragSource, dropTargetId }) => { + if (dragSource?.parent === dropTargetId) { + return true; + } + }} + placeholderRender={(_, { depth }) => { + return ( +
+ ); + }} + /> + +
+
+ + +
+ + ); +} + +export { DndTree }; diff --git a/apps/cs/src/widgets/dnd-tree/ui/editable-node.tsx b/apps/cs/src/widgets/dnd-tree/ui/editable-node.tsx new file mode 100644 index 000000000..9ada835e2 --- /dev/null +++ b/apps/cs/src/widgets/dnd-tree/ui/editable-node.tsx @@ -0,0 +1,44 @@ +import type { ComponentProps } from "react"; +import { Button, Input } from "@common/components/ui"; + +type EditableNodeProps = { + depth?: number; + onSave: () => void; + onCancel: () => void; +} & ComponentProps; + +function EditableNode({ + depth, + onSave, + onCancel, + ...props +}: EditableNodeProps) { + return ( +
+ +
+ + +
+
+ ); +} + +export { EditableNode };