feat: 조직도 트리 추가, 삭제 기능 작업

This commit is contained in:
2025-04-21 18:19:06 +09:00
parent 6290634171
commit baf3cb0b11
13 changed files with 525 additions and 3 deletions
@@ -0,0 +1,2 @@
import { OrganizationPage } from "@/pages/organization";
export default OrganizationPage;
@@ -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<OrganizationTree[]> {
const response = await client.get<OrganizationTree[]>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/tree`,
);
return response.data;
}
public async createOrganization(
organization: OrganizationFormData,
): Promise<void> {
await client.post<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}`,
organization,
);
}
public async deleteOrganization(ognzSn: number): Promise<void> {
await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
);
}
}
export const OrganizationApi = new OrganizationApiService();
@@ -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";
@@ -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<OrganizationTree>[] => {
return data.reduce<NodeModel<OrganizationTree>[]>((acc, menu) => {
const node: NodeModel<OrganizationTree> = {
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;
}, []);
};
@@ -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<typeof organizationSchema>;
export type OrganizationFormData = z.infer<typeof organizationFormSchema>;
export type OrganizationTree = Organization & {
children: OrganizationTree[];
};
export interface OrganizationService {
getOrganizationsTreeList(): Promise<OrganizationTree[]>;
createOrganization(organization: OrganizationFormData): Promise<void>;
deleteOrganization(ognzSn: number): Promise<void>;
}
+3 -3
View File
@@ -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 (
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
+1
View File
@@ -0,0 +1 @@
export { OrganizationPage } from "./ui/organization-page";
@@ -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<NodeModel<OrganizationTree> | null>(null);
const [selectedOrganization, setSelectedOrganization] =
useState<OrganizationTree | null>(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<OrganizationFormData>({
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<OrganizationTree>) => {
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 (
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<div className="flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddOrganization}
>
조직 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={onDeleteOrganization}
disabled={!selectedOrganization}
>
조직 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<DndTree
tree={tree}
selectedNodeId={selectedOrganization?.ognzSn ?? 0}
renderEditableNode={(props) => (
<EditableNode
value={additionalNodeForm.watch("ognzNm")}
onChange={(e) =>
additionalNodeForm.setValue("ognzNm", e.target.value)
}
onSave={onSaveAdditionalNode}
onCancel={resetAdditionalNode}
{...props}
/>
)}
handleSelectNode={handleNodeSelect}
handleDropNode={() => {}}
updateNodeName={async () => {}}
/>
</div>
</div>
<div className="card card-border flex-1"></div>
</div>
);
}
export { OrganizationPage };
+1
View File
@@ -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",
+2
View File
@@ -0,0 +1,2 @@
export { DndTree } from "./ui/dnd-tree";
export { EditableNode } from "./ui/editable-node";
@@ -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<string, unknown> {
children: unknown[];
}
type CustomNodeProps<T extends TreeData> = {
node: NodeModel<T>;
depth: number;
isOpen: boolean;
isSelected: boolean;
isAdditionalParent: boolean;
treeMethods: TreeMethods | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<T>) => void;
updateNodeName: (nodeName: string) => Promise<void>;
};
function CustomNode<T extends TreeData>({
node,
depth,
isOpen,
isSelected,
isAdditionalParent,
treeMethods,
onToggle,
onSelect,
updateNodeName,
}: CustomNodeProps<T>) {
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 (
<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)]",
)}
>
{editMode ? (
<EditableNode
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<>
<div className="flex items-center" onClick={handleSelect}>
{data && data.children.length > 0 && (
<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>
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-pencil" />
</Button>
</>
)}
</div>
);
}
export { CustomNode };
@@ -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<string, unknown> {
children: unknown[];
}
type EditableNodeProps = { depth: number };
type TreeProps<T extends TreeData> = {
tree: NodeModel<T>[];
selectedNodeId: number;
renderEditableNode: (props: EditableNodeProps) => ReactElement;
handleSelectNode: (node: NodeModel<T>) => void;
handleDropNode: (newTree: NodeModel<T>[], options: DropOptions<T>) => void;
updateNodeName: (nodeName: string) => Promise<void>;
};
function DndTree<T extends TreeData>({
tree,
selectedNodeId,
renderEditableNode,
handleSelectNode,
handleDropNode,
updateNodeName,
}: TreeProps<T>) {
const treeMethods = useRef<TreeMethods>(null);
const isAdditionalParent = (node: NodeModel<T>): boolean => {
const additionalNode = tree.find((n) => n.id === -1);
if (!additionalNode) return false;
return additionalNode?.parent === node.id;
};
return (
<>
<div className="max-h-[calc(100vh-33.5rem)] overflow-auto">
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
ref={treeMethods}
render={(node: NodeModel<T>, { depth, isOpen, onToggle }) => {
if (node.id === -1) return renderEditableNode({ depth });
return (
<CustomNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedNodeId === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode}
updateNodeName={updateNodeName}
/>
);
}}
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 (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</DndProvider>
</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 { DndTree };
@@ -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<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 }}
>
<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 };