feat: 콘텐츠 메뉴 등록 작업

This commit is contained in:
2025-03-28 15:22:14 +09:00
parent 6a784cecf3
commit 572f18cc1a
7 changed files with 331 additions and 9 deletions
@@ -0,0 +1,17 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Organization,
OrganizationService,
} from "../model/organization-interface";
export class OrganizationApiService implements OrganizationService {
public async getOrganizationUserTreeList(): Promise<Organization[]> {
const response = await client.get<Organization[]>(
`${ENDPOINT.CMS_SERVICE.USER_OGNZ}/treeuser`,
);
return response.data;
}
}
export const OrganizationApi = new OrganizationApiService();
+64 -4
View File
@@ -1,7 +1,10 @@
import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview";
import type { TreeMenu, TreeNode } from "../model/menu-interface";
import type { Organization, User } from "../model/organization-interface";
export const generateTreeData = (data: TreeMenu[]): NodeModel<TreeMenu>[] => {
export const generateMenuTreeData = (
data: TreeMenu[],
): NodeModel<TreeMenu>[] => {
return data.reduce<NodeModel<TreeMenu>[]>((acc, menu) => {
const node: NodeModel<TreeMenu> = {
id: menu.menuCd ?? 0,
@@ -14,14 +17,14 @@ export const generateTreeData = (data: TreeMenu[]): NodeModel<TreeMenu>[] => {
acc.push(node);
if (menu.children?.length) {
acc.push(...generateTreeData(menu.children));
acc.push(...generateMenuTreeData(menu.children));
}
return acc;
}, []);
};
export const generateNewTreeData = (
export const generateNewMenuTreeData = (
treeData: NodeModel<TreeMenu>[],
options: DropOptions<TreeMenu>,
): TreeNode | null => {
@@ -82,7 +85,7 @@ export const handleSelectNode =
export const handleDropNode =
(callback: (newTree: TreeNode) => void) =>
(newTree: NodeModel<TreeMenu>[], options: DropOptions<TreeMenu>) => {
const newTreeData = generateNewTreeData(newTree, options);
const newTreeData = generateNewMenuTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
@@ -103,3 +106,60 @@ export const handleNodeTextChange =
});
callback(newTree);
};
export const generateOrganizationTreeData = (
organizations: Organization[],
): NodeModel<Organization | User>[] => {
return organizations.reduce<NodeModel<Organization | User>[]>((acc, org) => {
const orgNode: NodeModel<Organization> = {
id: org.ognzId,
parent: org.upOgnzId ?? 0,
text: org.ognzNm,
data: org,
droppable: false,
};
acc.push(orgNode);
if (org.children?.length) {
acc.push(...generateOrganizationTreeData(org.children));
}
if (org.users?.length) {
acc.push(
...org.users.map((user) => ({
id: user.userUnqId,
parent: org.ognzId,
text: user.userNm,
data: user,
droppable: false,
})),
);
}
return acc;
}, []);
};
export const filterOrganizationTreeDataByKeyword = (
treeData: NodeModel<Organization | User>[],
keyword: string,
): NodeModel<Organization | User>[] => {
return Array.from(
treeData.reduce((matchedNodes, node) => {
if (!node.text.includes(keyword)) return matchedNodes;
matchedNodes.add(node);
let parentId = node.parent;
while (parentId) {
const parentNode = treeData.find((n) => n.id === parentId);
if (!parentNode) break;
matchedNodes.add(parentNode);
parentId = parentNode.parent;
}
return matchedNodes;
}, new Set<NodeModel<Organization | User>>()),
);
};
@@ -0,0 +1,18 @@
export interface User {
userUnqId: string;
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
}
export interface Organization {
ognzId: string;
upOgnzId: string;
ognzNm: string;
users: User[];
children: Organization[];
}
export interface OrganizationService {
getOrganizationUserTreeList(): Promise<Organization[]>;
}
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { DndProvider } from "react-dnd";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
} from "@minoru/react-dnd-treeview";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
import { ENDPOINT } from "@/shared/config";
import { OrganizationApi } from "../../api/organization-service";
import type { Organization, User } from "../../model/organization-interface";
import {
filterOrganizationTreeDataByKeyword,
generateOrganizationTreeData,
} from "../../lib/utils";
type TreeModel = Organization | User;
interface OrganizationDialogProps {
handleUserSelect: (user: User) => void;
}
function OrganizationDialog({ handleUserSelect }: OrganizationDialogProps) {
const [keyword, setKeyword] = useState("");
const [tree, setTree] = useState<NodeModel<TreeModel>[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeModel<TreeModel> | null>(
null,
);
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.USER_OGNZ],
queryFn: () => OrganizationApi.getOrganizationUserTreeList(),
select: (data) => generateOrganizationTreeData(data),
});
const handleSelectNode = (node: NodeModel<TreeModel>) => {
setSelectedNode(node);
handleUserSelect(node.data as User);
};
useEffect(() => {
setTree(treeData ?? []);
}, [treeData]);
return (
<Dialog>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[30rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>담당자 조회</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 flex w-full items-center gap-2">
<Input
className="h-[2.5rem] !text-[1.3rem]"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button
size="sm"
onClick={() =>
setTree(filterOrganizationTreeDataByKeyword(tree, keyword))
}
>
<i className="diveicon di-search text-base" />
조회
</Button>
</div>
<div className="h-[36rem] overflow-auto">
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
rootId={0}
render={(
node: NodeModel<TreeModel>,
{ depth, isOpen, onToggle },
) => (
<TreeNode
node={node}
depth={depth}
isOpen={isOpen}
selectedNode={selectedNode}
onToggle={onToggle}
onSelect={handleSelectNode}
/>
)}
classes={{
container: "flex flex-col",
}}
sort={false}
onDrop={() => {}}
/>
</DndProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface TreeNodeProps {
node: NodeModel<TreeModel>;
depth: number;
isOpen: boolean;
selectedNode: NodeModel<TreeModel> | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<TreeModel>) => void;
}
const ORGANIZATION_PREFIX = "OGNZ";
function TreeNode({
node,
depth,
isOpen,
selectedNode,
onToggle,
onSelect,
}: TreeNodeProps) {
const { id, text, data } = node;
const indent = depth * 15 + 5;
const handleToggle = () => onToggle(id);
const handleSelect = () => {
if (id.toString().startsWith(ORGANIZATION_PREFIX)) return;
onSelect(node);
};
const isSelected = selectedNode?.id === id;
return (
<div
style={{ paddingInlineStart: indent }}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent"
onClick={handleSelect}
>
<div className="flex w-full items-center justify-between">
<div>
{data && ("children" in data || "users" in data) && (
<Button
size="icon"
className="h-6 w-6 !bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
/>
</Button>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
{isSelected && <i className="diveicon di-check text-xl text-primary" />}
</div>
</div>
);
}
export { OrganizationDialog };
@@ -1,5 +1,7 @@
import { type UseFormReturn } from "react-hook-form";
import dayjs from "dayjs";
import type { UseFormReturn } from "react-hook-form";
import {
DatePicker,
Label,
RadioGroup,
RadioGroupItem,
@@ -17,7 +19,9 @@ import {
} from "@/shared/ui/form";
import type { MenuFormData } from "../../model/menu-interface";
import type { Content } from "../../model/content-interface";
import type { User } from "../../model/organization-interface";
import { ContentDialog } from "../dialog/content-dialog";
import { OrganizationDialog } from "../dialog/organization-dialog";
import { BaseTypeMenuForm } from "./base-type-menu-form";
function ContentMenuForm({ form }: { form: UseFormReturn<MenuFormData> }) {
@@ -26,6 +30,16 @@ function ContentMenuForm({ form }: { form: UseFormReturn<MenuFormData> }) {
form.setValue("menuLnkgSnNm", content.contsTtl);
};
const handleUserSelect = (user: User) => {
form.setValue("menuPicUserUnqId", user.userUnqId);
form.setValue("menuPicUserUnqIdNm", user.userNm);
};
const handleDateSelect = (date: Date | undefined) => {
if (!date) return;
form.setValue("menuPicDsgnYmd", dayjs(date).format("YYYYMMDD"));
};
return (
<Table variant="secondary">
<colgroup>
@@ -113,8 +127,38 @@ function ContentMenuForm({ form }: { form: UseFormReturn<MenuFormData> }) {
<TableRow>
<TableHead>담당자</TableHead>
<TableCell className="border">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("menuLnkgSn")}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("menuPicUserUnqIdNm")}
</div>
<OrganizationDialog handleUserSelect={handleUserSelect} />
</div>
<div className="flex items-center gap-2">
<b>지정일:</b>
<FormField
control={form.control}
name="menuPicDsgnYmd"
render={({ field }) => (
<FormItem>
<FormControl>
<DatePicker
mode="single"
selected={
field.value
? dayjs(field.value).toDate()
: undefined
}
onSelect={(date) => {
field.onChange(date);
handleDateSelect(date);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TableCell>
</TableRow>
+2 -2
View File
@@ -28,7 +28,7 @@ import {
} from "../model/menu-interface";
import { MenuTree } from "./menu-tree";
import { MenuForm } from "./form/menu-form";
import { generateTreeData } from "../lib/utils";
import { generateMenuTreeData } from "../lib/utils";
function MenuPage() {
const queryClient = useQueryClient();
@@ -51,7 +51,7 @@ function MenuPage() {
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getTreeMenuList(siteId),
select: (data) => {
const treeData = generateTreeData(data);
const treeData = generateMenuTreeData(data);
const defaultSortSeq =
treeData.filter((node) => node.data?.levelNo === 1).length + 1;
form.setValue("sortSeq", defaultSortSeq);
+1
View File
@@ -15,6 +15,7 @@ export const ENDPOINT = {
DOMAINS: "/cms-service/api/v1/site-dmns",
MENUS: "/cms-service/api/v1/menus",
CONTENTS: "/cms-service/api/v1/contents",
USER_OGNZ: "/cms-service/api/v1/user-ognz",
},
};