feat: 조직도 트리 추가, 삭제 기능 작업
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user