feat: 콘텐츠 메뉴 등록 작업
This commit is contained in:
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user