diff --git a/apps/cs/app/(with-base-layout)/layout.tsx b/apps/cs/app/(with-base-layout)/layout.tsx index cbcaee847..dfb290abd 100644 --- a/apps/cs/app/(with-base-layout)/layout.tsx +++ b/apps/cs/app/(with-base-layout)/layout.tsx @@ -1,11 +1,18 @@ "use client"; +import { usePathname, useRouter } from "next/navigation"; import { BaseLayout } from "@common/components/ui"; - export default function WithBaseLayout({ children, }: { children: React.ReactNode; }) { - return {children}; + const router = useRouter(); + const pathname = usePathname(); + + return ( + + {children} + + ); } diff --git a/apps/cs/src/app/styles/components.css b/apps/cs/src/app/styles/components.css index c1eb79210..17cd78834 100644 --- a/apps/cs/src/app/styles/components.css +++ b/apps/cs/src/app/styles/components.css @@ -9,43 +9,84 @@ color: #666; } -.card_width10 {width:calc(10% - 12px);} +.card_width10 { + width: calc(10% - 12px); +} -.card_width20 {width:calc(20% - 12px);} +.card_width20 { + width: calc(20% - 12px); +} -.card_width25 {width:calc(25% - 12px);} +.card_width25 { + width: calc(25% - 12px); +} -.card_width30 {width:calc(30% - 12px);} +.card_width30 { + width: calc(30% - 12px); +} -.card_width33 {width:calc(33.333% - 12px);} +.card_width33 { + width: calc(33.333% - 12px); +} -.card_width40 {width:calc(40% - 12px);} +.card_width40 { + width: calc(40% - 12px); +} -.card_width45 {width:calc(45% - 12px);} +.card_width45 { + width: calc(45% - 12px); +} -.card_width50 {width:calc(50% - 12px);} +.card_width50 { + width: calc(50% - 12px); +} -.card_width60 {width:calc(60% - 12px);} +.card_width60 { + width: calc(60% - 12px); +} -.card_width66 {width:calc(66.666% - 12px);} +.card_width66 { + width: calc(66.666% - 12px); +} -.card_width70 {width:calc(70% - 12px);} +.card_width70 { + width: calc(70% - 12px); +} -.card_width75 {width:calc(75% - 12px);} +.card_width75 { + width: calc(75% - 12px); +} -.card_width80 {width:calc(80% - 12px);} +.card_width80 { + width: calc(80% - 12px); +} -.card_width90 {width:calc(90% - 12px);} +.card_width90 { + width: calc(90% - 12px); +} -.card_width100 {width:calc(100% - 12px);} +.card_width100 { + width: calc(100% - 12px); +} -.card_nopadding {padding:0 !important;} +.card_nopadding { + padding: 0 !important; +} -.card_nomargin {width:100%;margin:0 !important;padding:0 !important;} +.card_nomargin { + width: 100%; + margin: 0 !important; + padding: 0 !important; +} -.topline {border-top: 1px solid #ccc;} +.topline { + border-top: 1px solid #ccc; +} -.bottomline {border-bottom: 1px solid #e6eef3;background: #fff;} +.bottomline { + border-bottom: 1px solid #e6eef3; + background: #fff; +} .card-border { border: 1px solid #dbe2e6; @@ -54,6 +95,7 @@ } .card-header { + margin-top: -0.6rem; margin-left: -0.6rem; margin-right: -0.6rem; display: flex; diff --git a/apps/cs/src/entities/board/api/board-service.ts b/apps/cs/src/entities/board/api/board-service.ts new file mode 100644 index 000000000..9cf29599b --- /dev/null +++ b/apps/cs/src/entities/board/api/board-service.ts @@ -0,0 +1,21 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + BoardPopupList, + BoardPopupParams, + BoardService, +} from "../model/board-interface"; + +export class BoardApiService implements BoardService { + public async getBoardPopupList( + params: BoardPopupParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } +} + +export const BoardApi = new BoardApiService(); diff --git a/apps/cs/src/entities/board/index.ts b/apps/cs/src/entities/board/index.ts new file mode 100644 index 000000000..2173bb4a0 --- /dev/null +++ b/apps/cs/src/entities/board/index.ts @@ -0,0 +1,2 @@ +export { BoardApi } from "./api/board-service"; +export { type BoardPopup } from "./model/board-interface"; diff --git a/apps/cs/src/entities/board/model/board-interface.ts b/apps/cs/src/entities/board/model/board-interface.ts new file mode 100644 index 000000000..942840d74 --- /dev/null +++ b/apps/cs/src/entities/board/model/board-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export const BOARD_TYPE = { + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; + +export interface BoardPopupParams extends PageableParams { + bbsNm?: string; +} + +export interface BoardPopup { + bbsId: number; + bbsTypeCd: BoardType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + regDt: string; + pstCnt: number; +} + +export type BoardPopupList = PageableList; + +export interface BoardService { + getBoardPopupList: (params: BoardPopupParams) => Promise; +} diff --git a/apps/cs/src/entities/site/api/site-service.ts b/apps/cs/src/entities/site/api/site-service.ts index 629bf5f01..65f6a95a0 100644 --- a/apps/cs/src/entities/site/api/site-service.ts +++ b/apps/cs/src/entities/site/api/site-service.ts @@ -4,13 +4,13 @@ import { siteFormSchema, type SiteService, type SiteList, - type ISiteDetail, - type ISiteParams, + type SiteDetail, + type SiteParams, type SiteFormData, } from "../model/site-interface"; export class SiteApiService implements SiteService { - public async getSiteList(params: ISiteParams): Promise { + public async getSiteList(params: SiteParams): Promise { const response = await client.get( `${ENDPOINT.CMS_SERVICE.SITES}`, { params }, @@ -18,8 +18,8 @@ export class SiteApiService implements SiteService { return response.data; } - public async siteDetail(siteId: string): Promise { - const response = await client.get( + public async siteDetail(siteId: string): Promise { + const response = await client.get( `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, ); return response.data; diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts index 985b9346a..c7b437a64 100644 --- a/apps/cs/src/entities/site/index.ts +++ b/apps/cs/src/entities/site/index.ts @@ -1,7 +1,7 @@ export { SiteApi } from "./api/site-service"; export { siteFormSchema, - type ISiteParams, - type ISite, + type SiteParams, + type Site, type SiteFormData, } from "./model/site-interface"; diff --git a/apps/cs/src/entities/site/model/site-interface.ts b/apps/cs/src/entities/site/model/site-interface.ts index ae14297ae..ef83a53c1 100644 --- a/apps/cs/src/entities/site/model/site-interface.ts +++ b/apps/cs/src/entities/site/model/site-interface.ts @@ -1,13 +1,13 @@ import { z } from "zod"; -import type { IPageableParams, PageableList } from "@/shared/model"; +import type { PageableParams, PageableList } from "@/shared/model"; -export interface ISiteParams extends IPageableParams { +export interface SiteParams extends PageableParams { siteNm?: string; siteKndCd?: string; useYn?: string; } -export interface ISite { +export interface Site { siteId: string; siteNm: string; siteKindCode: string; @@ -19,7 +19,7 @@ export interface ISite { dmnAddr: string; } -export interface ISiteDetail extends SiteFormData { +export interface SiteDetail extends SiteFormData { siteId: string; siteKindNm: string; basicSiteYn: string; @@ -49,11 +49,11 @@ export const siteFormSchema = z.object({ }); export type SiteFormData = z.infer; -export type SiteList = PageableList; +export type SiteList = PageableList; export interface SiteService { - getSiteList(params: ISiteParams): Promise; - siteDetail(siteId: string): Promise; + getSiteList(params: SiteParams): Promise; + siteDetail(siteId: string): Promise; modifySite(siteId: string, data: SiteFormData): Promise; createSite(data: SiteFormData): Promise; } diff --git a/apps/cs/src/pages/menu/api/content-service.ts b/apps/cs/src/pages/menu/api/content-service.ts new file mode 100644 index 000000000..2f76baec8 --- /dev/null +++ b/apps/cs/src/pages/menu/api/content-service.ts @@ -0,0 +1,19 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + ContentService, + ContentParams, + ContentList, +} from "../model/content-interface"; + +export class ContentApiService implements ContentService { + public async getContentList(params: ContentParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}`, + { params }, + ); + return response.data; + } +} + +export const ContentApi = new ContentApiService(); diff --git a/apps/cs/src/pages/menu/api/menu-service.ts b/apps/cs/src/pages/menu/api/menu-service.ts index 4fe6c845a..4e03f6cdf 100644 --- a/apps/cs/src/pages/menu/api/menu-service.ts +++ b/apps/cs/src/pages/menu/api/menu-service.ts @@ -1,16 +1,55 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { Menu, MenuService, TreeMenu } from "../model/menu-interface"; +import type { + Menu, + MenuFormData, + MenuService, + TreeMenu, + TreeNode, +} from "../model/menu-interface"; export class MenuApiService implements MenuService { - public async getTreeMenuList(siteId: string): Promise { - const response = await client.get( + public async getMenuInfo(menuCd: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + ); + return response.data; + } + + public async createMenu(data: MenuFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENUS}`, + data, + ); + return response.data; + } + + public async modifyMenu( + menuCd: number, + data: Omit, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + data, + ); + return response.data; + } + + public async deleteMenu(menuCd: number): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + ); + return response.data; + } + + public async getTreeMenuList(siteId: string): Promise { + const response = await client.get( `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, ); return response.data; } - public async modifyTreeMenu(siteId: string, data: TreeMenu[]): Promise { + public async modifyTreeMenu(siteId: string, data: TreeNode[]): Promise { const response = await client.put( `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, data, diff --git a/apps/cs/src/pages/menu/api/organization-service.ts b/apps/cs/src/pages/menu/api/organization-service.ts new file mode 100644 index 000000000..9ef3b55fa --- /dev/null +++ b/apps/cs/src/pages/menu/api/organization-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_OGNZ}/treeuser`, + ); + return response.data; + } +} + +export const OrganizationApi = new OrganizationApiService(); diff --git a/apps/cs/src/pages/menu/lib/utils.ts b/apps/cs/src/pages/menu/lib/utils.ts index 333ebad94..b1dbafe5f 100644 --- a/apps/cs/src/pages/menu/lib/utils.ts +++ b/apps/cs/src/pages/menu/lib/utils.ts @@ -1,9 +1,12 @@ import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview"; -import type { Menu, TreeMenu } from "../model/menu-interface"; +import type { TreeMenu, TreeNode } from "../model/menu-interface"; +import type { Organization, User } from "../model/organization-interface"; -export const generateTreeData = (data: Menu[]): NodeModel[] => { - return data.reduce[]>((acc, menu) => { - const node: NodeModel = { +export const generateMenuTreeData = ( + data: TreeMenu[], +): NodeModel[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { id: menu.menuCd ?? 0, parent: menu.parentId ?? 0, text: menu.menuKornNm, @@ -14,31 +17,37 @@ export const generateTreeData = (data: Menu[]): NodeModel[] => { acc.push(node); if (menu.children?.length) { - acc.push(...generateTreeData(menu.children)); + acc.push(...generateMenuTreeData(menu.children)); } return acc; }, []); }; -export const generateNewTreeData = ( - treeData: NodeModel[], - options: DropOptions, -): TreeMenu | null => { +export const generateNewMenuTreeData = ( + treeData: NodeModel[], + options: DropOptions, +): TreeNode | null => { const { dragSourceId, dragSource, dropTargetId, dropTarget } = options; if (!dragSource?.data) return null; const siblings = treeData.filter((node) => node.parent === dropTargetId); + const dragSourceIndex = siblings.findIndex( + (node) => node.id === dragSourceId, + ); + + const prevSibling = siblings[dragSourceIndex - 1]; + const newSortSeq = - siblings.findIndex((sibling) => sibling.id === dragSourceId) + 1; + dragSourceIndex === 0 ? 1 : (prevSibling?.data?.sortSeq ?? 0) + 1; const childNodes = treeData.filter( (node) => node.parent === dragSourceId && node.data, ); - const baseNode = (node: NodeModel): TreeMenu => { + const baseNode = (node: NodeModel): TreeNode => { if (!node.data) return {}; return { menuCd: node.data?.menuCd, @@ -47,20 +56,20 @@ export const generateNewTreeData = ( }; }; - const treeMenu: TreeMenu = { + const treeNode: TreeNode = { ...baseNode(dragSource), parentId: dropTargetId === 0 ? null : (dropTargetId as number), sortSeq: newSortSeq, levelNo: (dropTarget?.data?.levelNo ?? 0) + 1, }; - const result: TreeMenu = { - ...treeMenu, + const result: TreeNode = { + ...treeNode, children: childNodes.map((child) => ({ ...baseNode(child), parentId: child.data?.parentId === 0 ? null : (dropTargetId as number), sortSeq: child.data?.sortSeq, - levelNo: (treeMenu?.levelNo ?? 0) + 1, + levelNo: (treeNode?.levelNo ?? 0) + 1, })), }; @@ -68,21 +77,22 @@ export const generateNewTreeData = ( }; export const handleSelectNode = - (callback: (node: NodeModel) => void) => (node: NodeModel) => { + (callback: (node: NodeModel) => void) => + (node: NodeModel) => { callback(node); }; export const handleDropNode = - (callback: (newTree: TreeMenu) => void) => - (newTree: NodeModel[], options: DropOptions) => { - const newTreeData = generateNewTreeData(newTree, options); + (callback: (newTree: TreeNode) => void) => + (newTree: NodeModel[], options: DropOptions) => { + const newTreeData = generateNewMenuTreeData(newTree, options); if (!newTreeData) return; callback(newTreeData); }; export const handleNodeTextChange = - (treeData: NodeModel[]) => - (callback: (newTree: NodeModel[]) => void) => + (treeData: NodeModel[]) => + (callback: (newTree: NodeModel[]) => void) => (id: NodeModel["id"], value: string) => { const newTree = treeData.map((node) => { if (node.id === id) { @@ -96,3 +106,60 @@ export const handleNodeTextChange = }); callback(newTree); }; + +export const generateOrganizationTreeData = ( + organizations: Organization[], +): NodeModel[] => { + return organizations.reduce[]>((acc, org) => { + const orgNode: NodeModel = { + 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[], + keyword: string, +): NodeModel[] => { + 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>()), + ); +}; diff --git a/apps/cs/src/pages/menu/model/board-table-columns.tsx b/apps/cs/src/pages/menu/model/board-table-columns.tsx new file mode 100644 index 000000000..f144ac158 --- /dev/null +++ b/apps/cs/src/pages/menu/model/board-table-columns.tsx @@ -0,0 +1,43 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { Checkbox } from "@common/components/ui"; +import type { BoardPopup } from "@/entities/board"; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor((_, index) => index + 1, { + id: "no", + header: "NO", + }), + columnHelper.accessor("bbsNm", { + id: "bbsNm", + header: "게시판명", + }), + columnHelper.accessor("bbsTypeNm", { + id: "bbsTypeNm", + header: "게시판유형", + }), + columnHelper.accessor("regDt", { + id: "regDt", + header: "최초생성일", + }), + columnHelper.accessor("pstCnt", { + id: "pstCnt", + header: "게시물건수", + }), + columnHelper.display({ + id: "select", + header: "선택", + cell: ({ row }) => { + return ( + + ); + }, + }), +]; + +export { columns }; diff --git a/apps/cs/src/pages/menu/model/content-interface.ts b/apps/cs/src/pages/menu/model/content-interface.ts new file mode 100644 index 000000000..8d59b1516 --- /dev/null +++ b/apps/cs/src/pages/menu/model/content-interface.ts @@ -0,0 +1,22 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface ContentParams extends PageableParams { + contsTtl?: string; + aprvContsVer?: number; + useYn?: string; +} + +export interface Content { + contsSn: number; + contsTtl: string; + aprvContsVer: number; + useYn: string; + rgtr: string; + regDt: string; +} + +export type ContentList = PageableList; + +export interface ContentService { + getContentList(params: ContentParams): Promise; +} diff --git a/apps/cs/src/pages/menu/model/menu-interface.ts b/apps/cs/src/pages/menu/model/menu-interface.ts index fc22c462c..51258fc49 100644 --- a/apps/cs/src/pages/menu/model/menu-interface.ts +++ b/apps/cs/src/pages/menu/model/menu-interface.ts @@ -11,28 +11,28 @@ export const MENU_TYPE = { export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE]; -export interface TreeMenu { +export interface TreeNode { menuCd?: number; name?: string; sortSeq?: number; parentId?: number | null; levelNo?: number; icon?: string; - children?: TreeMenu[]; + children?: TreeNode[]; } -export interface Menu extends Omit { +export interface TreeMenu extends Omit { menuKornNm: string; - menuEngNm: string; - iconNm: string; - children: Menu[]; - urlPath: string; + menuEngNm?: string; + iconNm?: string; + children?: TreeMenu[]; + urlPath?: string; menuLnkgTypeCd: string; - menuLnkgSn: number; - qrcd: string; + menuLnkgSn?: number; + qrcd?: string; useYn: string; otptYn: string; - requiredUrlPath: boolean; + requiredUrlPath?: boolean; } export const menuFormSchema = z.object({ @@ -53,18 +53,34 @@ export const menuFormSchema = z.object({ MENU_TYPE.BOTTOM, ]), menuLnkgSn: z.number().nullish(), - otptYn: z.enum(["Y", "N"]).default("Y").nullish(), + otptYn: z.string().default("Y"), prntMenuCd: z.number().nullish(), sortSeq: z.number(), - evlUseYn: z.enum(["Y", "N"]).nullish(), + evlUseYn: z.string().default("Y"), qrcd: z.string().nullish(), - useYn: z.enum(["Y", "N"]).default("Y").nullish(), + useYn: z.string().default("Y"), + newWndwYn: z.string().nullish(), + menuLnkgSnNm: z.string().nullish(), + menuPicUserUnqId: z.string().nullish(), + menuPicUserUnqIdNm: z.string().nullish(), + menuPicDsgnYmd: z.string().nullish(), }); export type MenuFormData = z.infer; +export type Menu = Omit & { + menuCd: number; + iconName?: string; +}; export interface MenuService { - getTreeMenuList: (siteId: string) => Promise; - modifyTreeMenu: (siteId: string, data: TreeMenu[]) => Promise; + getMenuInfo: (menuCd: number) => Promise; + createMenu: (data: MenuFormData) => Promise; + modifyMenu: ( + menuCd: number, + data: Omit, + ) => Promise; + deleteMenu: (menuCd: number) => Promise; + getTreeMenuList: (siteId: string) => Promise; + modifyTreeMenu: (siteId: string, data: TreeNode[]) => Promise; modifyMenuName: (menuCd: number, name: string) => Promise; } diff --git a/apps/cs/src/pages/menu/model/organization-interface.ts b/apps/cs/src/pages/menu/model/organization-interface.ts new file mode 100644 index 000000000..86a8b8e31 --- /dev/null +++ b/apps/cs/src/pages/menu/model/organization-interface.ts @@ -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; +} diff --git a/apps/cs/src/pages/menu/model/table-colums.tsx b/apps/cs/src/pages/menu/model/table-colums.tsx new file mode 100644 index 000000000..b24b506fd --- /dev/null +++ b/apps/cs/src/pages/menu/model/table-colums.tsx @@ -0,0 +1,32 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { Checkbox } from "@common/components/ui"; +import type { Content } from "../model/content-interface"; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor((_, index) => index + 1, { + id: "no", + header: "NO", + }), + columnHelper.accessor("contsTtl", { + id: "contsTtl", + header: "콘텐츠명", + }), + // TODO: 최종수정자, 최종수정일, 승인일시 추가 + columnHelper.display({ + id: "select", + header: "선택", + cell: ({ row }) => { + return ( + + ); + }, + }), +]; + +export { columns }; diff --git a/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx deleted file mode 100644 index d8afc344d..000000000 --- a/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { - IconFinder, - Input, - Label, - RadioGroup, - RadioGroupItem, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { - FormItem, - FormControl, - FormField, - FormMessage, -} from "@/shared/ui/form"; -import type { MenuFormData } from "../model/menu-interface"; - -export function BaseBottomMenuForm({ - form, -}: { - form: UseFormReturn; -}) { - const handleIconClick = (icon: string) => { - form.setValue("iconNm", icon); - }; - - return ( - <> - - 메뉴 서브명 - - ( - - - - - - - )} - /> - - - - 메뉴 아이콘 - -
-
- {form.watch("iconNm")} -
- -
-
-
- - 출력 여부 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - 사용 여부 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - ); -} diff --git a/apps/cs/src/pages/menu/ui/base-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-menu-form.tsx index 9af6dd6f1..62d69bb38 100644 --- a/apps/cs/src/pages/menu/ui/base-menu-form.tsx +++ b/apps/cs/src/pages/menu/ui/base-menu-form.tsx @@ -20,10 +20,10 @@ import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; interface BaseMenuFormProps { form: UseFormReturn; - menuId?: number; + menuCd?: number; } -export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { +function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) { return ( @@ -31,14 +31,16 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { - - 메뉴ID - -
- {menuId} -
-
-
+ {menuCd && ( + + 메뉴ID + +
+ {menuCd} +
+
+
+ )} 메뉴명 @@ -50,6 +52,7 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { @@ -101,11 +104,8 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { key={key} className="flex items-center space-x-2" > - -
); } + +export { BaseMenuForm }; diff --git a/apps/cs/src/pages/menu/ui/board-menu-form.tsx b/apps/cs/src/pages/menu/ui/board-menu-form.tsx deleted file mode 100644 index a63fe24ff..000000000 --- a/apps/cs/src/pages/menu/ui/board-menu-form.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { - Label, - RadioGroup, - RadioGroupItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { - FormItem, - FormControl, - FormField, - FormMessage, -} from "@/shared/ui/form"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function BoardMenuForm({ form }: { form: UseFormReturn }) { - return ( - - - - - - - - 게시판 - -
- {form.watch("menuLnkgSn")} -
-
-
- - 연결 형태 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - 메뉴 평가 사용 여부 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - - - - QR코드 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - 담당자 - -
- {form.watch("menuLnkgSn")} -
-
-
-
-
- ); -} diff --git a/apps/cs/src/pages/menu/ui/board-search-dialog.tsx b/apps/cs/src/pages/menu/ui/board-search-dialog.tsx new file mode 100644 index 000000000..531e17c86 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/board-search-dialog.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + Input, +} from "@common/components/ui"; +import { ENDPOINT } from "@/shared/config"; +import { BoardApi, type BoardPopup } from "@/entities/board"; +import { columns } from "../model/board-table-columns"; + +type BoardSearchDialogProps = { + handleBoardSelect: (board: BoardPopup) => void; +} & React.ComponentProps; + +function BoardSearchDialog({ + handleBoardSelect, + ...props +}: BoardSearchDialogProps) { + const [rowSelection, setRowSelection] = useState({}); + const [bbsNm, setBbsNm] = useState(""); + + const { data: boardList, refetch } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS], + queryFn: () => BoardApi.getBoardPopupList({ bbsNm }), + enabled: false, + }); + + const table = useReactTable({ + data: boardList?.content ?? [], + columns, + state: { + rowSelection, + }, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + }); + + useEffect(() => { + const boardData = table.getSelectedRowModel().rows[0] + ?.original as BoardPopup; + if (!boardData) return; + handleBoardSelect(boardData); + }, [table.getSelectedRowModel().rows]); + + useEffect(() => { + refetch(); + }, []); + + return ( + + + + + + + 게시판 조회 + +
+
+ setBbsNm(e.target.value)} + /> + +
+
+ +
+
+
+
+ ); +} + +export { BoardSearchDialog }; diff --git a/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx deleted file mode 100644 index 88199147b..000000000 --- a/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { Table, TableBody } from "@common/components"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function BottomMenuForm({ - form, -}: { - form: UseFormReturn; -}) { - return ( - - - - - - - - -
- ); -} diff --git a/apps/cs/src/pages/menu/ui/content-menu-form.tsx b/apps/cs/src/pages/menu/ui/content-menu-form.tsx deleted file mode 100644 index 65e806f25..000000000 --- a/apps/cs/src/pages/menu/ui/content-menu-form.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { - Label, - RadioGroup, - RadioGroupItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { - FormItem, - FormControl, - FormField, - FormMessage, -} from "@/shared/ui/form"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function ContentMenuForm({ - form, -}: { - form: UseFormReturn; -}) { - return ( - - - - - - - - 콘텐츠 - -
- {form.watch("menuLnkgSn")} -
-
-
- - 연결 형태 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - 메뉴 평가 사용 여부 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - - - - QR코드 - - ( - - - -
-
- - -
-
- - -
-
-
-
- -
- )} - /> -
-
- - 담당자 - -
- {form.watch("menuLnkgSn")} -
-
-
-
-
- ); -} diff --git a/apps/cs/src/pages/menu/ui/content-search-dialog.tsx b/apps/cs/src/pages/menu/ui/content-search-dialog.tsx new file mode 100644 index 000000000..60ee794b8 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/content-search-dialog.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + Input, +} from "@common/components/ui"; +import { ENDPOINT } from "@/shared/config"; +import { ContentApi } from "../api/content-service"; +import type { Content } from "../model/content-interface"; +import { columns } from "../model/table-colums"; + +type ContentSearchDialogProps = { + handleContentSelect: (content: Content) => void; +} & React.ComponentProps; + +function ContentSearchDialog({ + handleContentSelect, + ...props +}: ContentSearchDialogProps) { + const [rowSelection, setRowSelection] = useState({}); + const [contsTtl, setContsTtl] = useState(""); + + const { data: contentList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS], + queryFn: () => + ContentApi.getContentList({ contsTtl, useYn: "Y", page: 0, size: 100 }), + enabled: false, + }); + + const table = useReactTable({ + data: contentList?.content ?? [], + columns, + state: { + rowSelection, + }, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + }); + + useEffect(() => { + const contentData = table.getSelectedRowModel().rows[0] + ?.original as Content; + if (!contentData) return; + handleContentSelect(contentData); + }, [table.getSelectedRowModel().rows]); + + useEffect(() => { + refetch(); + }, []); + + return ( + + + + + + + 콘텐츠 조회 + +
+
+ setContsTtl(e.target.value)} + /> + +
+
+ +
+
+
+
+ ); +} + +export { ContentSearchDialog }; diff --git a/apps/cs/src/pages/menu/ui/editable-node.tsx b/apps/cs/src/pages/menu/ui/editable-node.tsx new file mode 100644 index 000000000..9ada835e2 --- /dev/null +++ b/apps/cs/src/pages/menu/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 }; diff --git a/apps/cs/src/pages/menu/ui/empty-menu-form.tsx b/apps/cs/src/pages/menu/ui/empty-menu-form.tsx deleted file mode 100644 index 1cdaaed97..000000000 --- a/apps/cs/src/pages/menu/ui/empty-menu-form.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { Table, TableBody } from "@common/components"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function EmptyMenuForm({ form }: { form: UseFormReturn }) { - return ( - - - - - - - - -
- ); -} diff --git a/apps/cs/src/pages/menu/ui/inside-menu-form.tsx b/apps/cs/src/pages/menu/ui/inside-menu-form.tsx deleted file mode 100644 index 8e63a2615..000000000 --- a/apps/cs/src/pages/menu/ui/inside-menu-form.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { Table, TableBody } from "@common/components"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function InsideMenuForm({ - form, -}: { - form: UseFormReturn; -}) { - return ( - - - - - - - - -
- ); -} diff --git a/apps/cs/src/pages/menu/ui/menu-form.tsx b/apps/cs/src/pages/menu/ui/menu-form.tsx index 894927ba0..48e7589be 100644 --- a/apps/cs/src/pages/menu/ui/menu-form.tsx +++ b/apps/cs/src/pages/menu/ui/menu-form.tsx @@ -1,58 +1,80 @@ import { useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { type UseFormReturn } from "react-hook-form"; import { Button } from "@common/components"; import { Form } from "@/shared/ui/form"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; -import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; +import type { MenuFormData } from "../model/menu-interface"; import { BaseMenuForm } from "./base-menu-form"; -import { EmptyMenuForm } from "./empty-menu-form"; -import { ContentMenuForm } from "./content-menu-form"; -import { BoardMenuForm } from "./board-menu-form"; -import { InsideMenuForm } from "./inside-menu-form"; -import { OutsideMenuForm } from "./outside-menu-form"; -import { BottomMenuForm } from "./bottom-menu-form"; +import { TypeMenuForm } from "./type-menu-form"; +import { PermissionsManagementDialog } from "./permissions-management-dialog"; interface MenuFormProps { form: UseFormReturn; - menuId?: number; - handleSave: (data: MenuFormData) => Promise; - handleDelete?: () => Promise; + menuCd?: number; + handleCreate: (data: MenuFormData) => void; + handleModify: ({ + menuCd, + data, + }: { + menuCd: number; + data: Omit; + }) => void; + handleDelete?: (menuCd: number) => void; } -export function MenuForm({ +function MenuForm({ form, - menuId, - handleSave, + menuCd, + handleCreate, + handleModify, handleDelete, }: MenuFormProps) { - const isModifyMode = !!menuId; + const isModifyMode = !!menuCd; + const { confirm } = useConfirm(); const [onConfirm, setOnConfirm] = useState(false); - const onSubmit = async (data: MenuFormData) => { - const confirmMessage = isModifyMode - ? "수정하시겠습니까?" - : "저장하시겠습니까?"; - + const handleConfirm = async (fn: () => Promise) => { setOnConfirm(true); - if (await confirm(confirmMessage)) { - setOnConfirm(false); - handleSave(data); - } + await fn(); setOnConfirm(false); }; - const onDelete = async () => { - setOnConfirm(true); - if (await confirm("삭제하시겠습니까?")) { - setOnConfirm(false); - handleDelete?.(); + const onSubmit = async (data: MenuFormData) => { + if (isModifyMode) { + await onModify(data); + } else { + await onCreate(data); } - setOnConfirm(false); + }; + + const onCreate = async (data: MenuFormData) => { + await handleConfirm(async () => { + if (await confirm("저장하시겠습니까?")) { + handleCreate(data); + } + }); + }; + + const onModify = async (data: MenuFormData) => { + await handleConfirm(async () => { + if (await confirm("수정하시겠습니까?")) { + handleModify({ menuCd: menuCd!, data }); + } + }); + }; + + const onDelete = () => { + handleConfirm(async () => { + if (await confirm("삭제하시겠습니까?")) { + handleDelete?.(menuCd!); + } + }); }; const menuType = form.watch("menuLnkgTypeCd"); + const isSubmitDisabled = !form.watch("siteId"); return ( <> @@ -61,18 +83,17 @@ export function MenuForm({

- 메뉴 등록/수정 + {isModifyMode ? "메뉴 수정" : "메뉴 등록"}

+
- -
- {renderMenuTypeComponent(menuType, form)} -
+ +
- {isModifyMode && ( @@ -88,24 +109,4 @@ export function MenuForm({ ); } -function renderMenuTypeComponent( - menuType: string, - form: UseFormReturn, -) { - switch (menuType) { - case MENU_TYPE.EMPTY: - return ; - case MENU_TYPE.CONTENTS: - return ; - case MENU_TYPE.BOARD: - return ; - case MENU_TYPE.INSIDE: - return ; - case MENU_TYPE.OUTSIDE: - return ; - case MENU_TYPE.BOTTOM: - return ; - default: - return null; - } -} +export { MenuForm }; diff --git a/apps/cs/src/pages/menu/ui/menu-node.tsx b/apps/cs/src/pages/menu/ui/menu-node.tsx index f8a150633..b2e595923 100644 --- a/apps/cs/src/pages/menu/ui/menu-node.tsx +++ b/apps/cs/src/pages/menu/ui/menu-node.tsx @@ -1,18 +1,22 @@ -import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import type { NodeModel } from "@minoru/react-dnd-treeview"; +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview"; +import { Button } from "@common/components/ui"; import { cn } from "@common/components/lib"; -import { Button, Input } from "@common/components/ui"; -import type { Menu } from "../model/menu-interface"; +import { ENDPOINT } from "@/shared/config"; import { MenuApi } from "../api/menu-service"; +import type { TreeMenu } from "../model/menu-interface"; +import { EditableNode } from "./editable-node"; type MenuNodeProps = { - node: NodeModel; + node: NodeModel; depth: number; isOpen: boolean; isSelected: boolean; + isAdditionalParent: boolean; + treeMethods: TreeMethods | null; onToggle: (id: NodeModel["id"]) => void; - onSelect: (node: NodeModel) => void; + onSelect: (node: NodeModel) => void; }; function MenuNode({ @@ -20,17 +24,26 @@ function MenuNode({ depth, isOpen, isSelected, + isAdditionalParent, + treeMethods, onToggle, onSelect, }: MenuNodeProps) { const { id, text, data } = node; const indent = depth * 10 + 5; + const queryClient = useQueryClient(); + const { mutateAsync: modifyMenuName } = useMutation({ mutationFn: (nodeName: string) => { const encodedName = encodeURIComponent(nodeName); return MenuApi.modifyMenuName(id as number, encodedName); }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS], + }); + }, }); const [editMode, setEditMode] = useState(false); @@ -44,6 +57,15 @@ function MenuNode({ setEditMode(false); }; + const handleCancel = () => { + setEditMode(false); + setNodeName(text); + }; + + useEffect(() => { + if (isAdditionalParent) treeMethods?.open(id); + }, [isAdditionalParent]); + return (
-
- {editMode ? ( - <> - setNodeName(e.target.value)} - className="h-[2.5rem] !text-[1.3rem] text-foreground" - /> - - ) : ( - <> + {editMode ? ( + setNodeName(e.target.value)} + onSave={handleSave} + onCancel={handleCancel} + /> + ) : ( + <> +
{data?.children?.length && ( { + e.stopPropagation(); + handleToggle(); + }} /> )} {text} - - )} -
- {editMode ? ( -
+
- -
- ) : ( - + )}
); diff --git a/apps/cs/src/pages/menu/ui/menu-page.tsx b/apps/cs/src/pages/menu/ui/menu-page.tsx index 5cc63b627..52a63eb8a 100644 --- a/apps/cs/src/pages/menu/ui/menu-page.tsx +++ b/apps/cs/src/pages/menu/ui/menu-page.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; 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, Select, @@ -15,6 +17,9 @@ import { import { SiteApi } from "@/entities/site"; import { ENDPOINT } from "@/shared/config"; import { PageLayout } from "@/shared/ui/page-layout"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import type { TreeMenu } from "../model/menu-interface"; import { MenuApi } from "../api/menu-service"; import { MENU_TYPE, @@ -23,15 +28,36 @@ import { } from "../model/menu-interface"; import { MenuTree } from "./menu-tree"; import { MenuForm } from "./menu-form"; -import { generateTreeData } from "../lib/utils"; +import { generateMenuTreeData } from "../lib/utils"; function MenuPage() { + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + const [siteId, setSiteId] = useState(""); + const [onConfirm, setOnConfirm] = useState(false); + const [selectedMenu, setSelectedMenu] = useState(null); + const [additionalNode, setAdditionalNode] = + useState | null>(null); + + const { data: menuInfo } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, selectedMenu?.menuCd], + queryFn: () => MenuApi.getMenuInfo(selectedMenu?.menuCd ?? 0), + enabled: !!selectedMenu?.menuCd, + }); const { data: treeData } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], queryFn: () => MenuApi.getTreeMenuList(siteId), - select: (data) => generateTreeData(data), + select: (data) => { + const treeData = generateMenuTreeData(data); + const defaultSortSeq = + treeData.filter((node) => node.data?.levelNo === 1).length + 1; + form.setValue("sortSeq", defaultSortSeq); + + return treeData; + }, enabled: !!siteId, }); @@ -45,60 +71,195 @@ function MenuPage() { })), }); + const { mutateAsync: createMenu } = useMutation({ + mutationFn: (data: MenuFormData) => MenuApi.createMenu(data), + onSuccess: () => { + alert("저장되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], + }); + }, + }); + + const { mutate: modifyMenu } = useMutation({ + mutationFn: ({ + menuCd, + data, + }: { + menuCd: number; + data: Omit; + }) => MenuApi.modifyMenu(menuCd, data), + onSuccess: () => { + alert("수정되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], + }); + }, + }); + + const { mutate: deleteMenu } = useMutation({ + mutationFn: (menuCd: number) => MenuApi.deleteMenu(menuCd), + onSuccess: () => { + alert("삭제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], + }); + }, + }); + const form = useForm({ resolver: zodResolver(menuFormSchema), defaultValues: { menuLnkgTypeCd: MENU_TYPE.EMPTY, }, + values: { + ...menuInfo, + siteId, + levelNo: menuInfo?.levelNo ?? 1, + sortSeq: menuInfo?.sortSeq ?? 1, + menuKornNm: menuInfo?.menuKornNm ?? "", + menuLnkgTypeCd: menuInfo?.menuLnkgTypeCd ?? MENU_TYPE.EMPTY, + iconNm: menuInfo?.iconName, + otptYn: menuInfo?.otptYn ?? "Y", + evlUseYn: menuInfo?.evlUseYn ?? "Y", + useYn: menuInfo?.useYn ?? "Y", + }, }); - const handleSave = async (data: MenuFormData) => { - console.log(data); + const additionalNodeForm = useForm({ + resolver: zodResolver(menuFormSchema), + defaultValues: { + menuLnkgTypeCd: MENU_TYPE.EMPTY, + levelNo: 1, + sortSeq: 1, + otptYn: "Y", + evlUseYn: "Y", + useYn: "Y", + }, + }); + + const handleAdditionalNodeSave = async () => { + const prntMenuCd = !additionalNode?.parent + ? null + : (additionalNode?.parent as number); + + await createMenu({ + ...additionalNodeForm.getValues(), + prntMenuCd, + }); + resetAdditionalNode(); }; - const handleDelete = async () => {}; + const resetAdditionalNode = () => { + additionalNodeForm.setValue("menuKornNm", ""); + setAdditionalNode(null); + }; + + const handleSiteIdChange = (value: string) => { + form.setValue("siteId", value); + additionalNodeForm.setValue("siteId", value); + + setSiteId(value); + setSelectedMenu(null); + }; + + const handleNodeSelect = (node: NodeModel) => { + if (!node.data) return; + if (node.data.menuCd === selectedMenu?.menuCd) { + setSelectedMenu(null); + additionalNodeForm.setValue("levelNo", 1); + return; + } + setSelectedMenu(node.data); + additionalNodeForm.setValue("levelNo", (node.data.levelNo ?? 0) + 1); + }; + + const handleNodeDelete = async () => { + setOnConfirm(true); + if (await confirm("삭제하시겠습니까?")) { + setOnConfirm(false); + deleteMenu(selectedMenu!.menuCd!); + } + }; + + const handleAddMenu = () => { + if (additionalNode) return; + setAdditionalNode({ + id: -1, + parent: selectedMenu?.menuCd ?? 0, + text: "", + }); + }; + + const tree = additionalNode + ? [...(additionalNode ? [additionalNode] : []), ...(treeData ?? [])] + : (treeData ?? []); return ( - -
-
- - {treeData && ( - <> -
- - -
-
- -
- - )} + <> + +
+
+ + {siteId && ( + <> +
+ + +
+
+ +
+ + )} +
+
+ +
-
- -
-
- + + + ); } diff --git a/apps/cs/src/pages/menu/ui/menu-tree.tsx b/apps/cs/src/pages/menu/ui/menu-tree.tsx index 629dd1252..76dc02610 100644 --- a/apps/cs/src/pages/menu/ui/menu-tree.tsx +++ b/apps/cs/src/pages/menu/ui/menu-tree.tsx @@ -1,30 +1,48 @@ "use client"; -import { useState } from "react"; +import { useRef } from "react"; import { DndProvider } from "react-dnd"; +import type { UseFormReturn } from "react-hook-form"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Tree, MultiBackend, getBackendOptions, type NodeModel, + type TreeMethods, } from "@minoru/react-dnd-treeview"; +import { Button } from "@common/components/ui"; import { ENDPOINT } from "@/shared/config"; import { MenuApi } from "../api/menu-service"; -import type { Menu, TreeMenu } from "../model/menu-interface"; +import type { MenuFormData, TreeMenu, TreeNode } from "../model/menu-interface"; import { handleDropNode, handleSelectNode } from "../lib/utils"; import { MenuNode } from "./menu-node"; +import { EditableNode } from "./editable-node"; type MenuTreeProps = { - treeData: NodeModel[]; + tree: NodeModel[]; siteId: string; + selectedMenu: TreeMenu | null; + additionalNodeForm: UseFormReturn; + onSelectNode: (node: NodeModel) => void; + onAdditionalNodeSave: () => void; + onAdditionalNodeCancel: () => void; }; -function MenuTree({ treeData, siteId }: MenuTreeProps) { +function MenuTree({ + tree, + siteId, + selectedMenu, + additionalNodeForm, + onSelectNode, + onAdditionalNodeSave, + onAdditionalNodeCancel, +}: MenuTreeProps) { const queryClient = useQueryClient(); + const treeMethods = useRef(null); const { mutate: modifyTreeMenu } = useMutation({ - mutationFn: (newTree: TreeMenu) => + mutationFn: (newTree: TreeNode) => MenuApi.modifyTreeMenu(siteId, [newTree]), onSuccess: () => { queryClient.invalidateQueries({ @@ -33,50 +51,100 @@ function MenuTree({ treeData, siteId }: MenuTreeProps) { }, }); - const [selectedNode, setSelectedNode] = useState | null>( - null, - ); + const additionalNode = tree.find((n) => n.id === -1); + + const isAdditionalParent = (node: NodeModel): boolean => { + if (!additionalNode) return false; + return additionalNode?.parent === node.id; + }; return ( - - , { depth, isOpen, onToggle }) => ( - +
+ + , + { depth, isOpen, onToggle }, + ) => { + if (node.id === -1) { + return ( + + additionalNodeForm.setValue("menuKornNm", e.target.value) + } + depth={depth} + onSave={onAdditionalNodeSave} + onCancel={onAdditionalNodeCancel} + /> + ); + } + return ( + + ); + }} + onDrop={handleDropNode(modifyTreeMenu)} + 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 ( +
+ ); + }} /> - )} - onDrop={handleDropNode(modifyTreeMenu)} - 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 ( -
- ); - }} - /> - + +
+
+ + +
+ ); } diff --git a/apps/cs/src/pages/menu/ui/organization-search-dialog.tsx b/apps/cs/src/pages/menu/ui/organization-search-dialog.tsx new file mode 100644 index 000000000..9e0637082 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/organization-search-dialog.tsx @@ -0,0 +1,184 @@ +"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 OrganizationSearchDialogProps { + handleUserSelect: (user: User) => void; +} + +function OrganizationSearchDialog({ + handleUserSelect, +}: OrganizationSearchDialogProps) { + const [keyword, setKeyword] = useState(""); + const [tree, setTree] = useState[]>([]); + const [selectedNode, setSelectedNode] = useState | null>( + null, + ); + + const { data: treeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.USER_OGNZ], + queryFn: () => OrganizationApi.getOrganizationUserTreeList(), + select: (data) => generateOrganizationTreeData(data), + }); + + const handleSelectNode = (node: NodeModel) => { + setSelectedNode(node); + handleUserSelect(node.data as User); + }; + + useEffect(() => { + setTree(treeData ?? []); + }, [treeData]); + + return ( + + + + + + + 담당자 조회 + +
+
+ setKeyword(e.target.value)} + /> + +
+
+ + , + { depth, isOpen, onToggle }, + ) => ( + + )} + classes={{ + container: "flex flex-col", + }} + sort={false} + onDrop={() => {}} + /> + +
+
+
+
+ ); +} + +interface TreeNodeProps { + node: NodeModel; + depth: number; + isOpen: boolean; + selectedNode: NodeModel | null; + onToggle: (id: NodeModel["id"]) => void; + onSelect: (node: NodeModel) => 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 ( +
+
+
+ {data && ("children" in data || "users" in data) && ( + + )} + {text} +
+ {isSelected && } +
+
+ ); +} + +export { OrganizationSearchDialog }; diff --git a/apps/cs/src/pages/menu/ui/outside-menu-form.tsx b/apps/cs/src/pages/menu/ui/outside-menu-form.tsx deleted file mode 100644 index b92977a83..000000000 --- a/apps/cs/src/pages/menu/ui/outside-menu-form.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { type UseFormReturn } from "react-hook-form"; -import { Table, TableBody } from "@common/components"; -import type { MenuFormData } from "../model/menu-interface"; -import { BaseBottomMenuForm } from "./base-bottom-menu-form"; - -export function OutsideMenuForm({ - form, -}: { - form: UseFormReturn; -}) { - return ( - - - - - - - - -
- ); -} diff --git a/apps/cs/src/pages/menu/ui/permissions-management-dialog.tsx b/apps/cs/src/pages/menu/ui/permissions-management-dialog.tsx new file mode 100644 index 000000000..c5f782cbb --- /dev/null +++ b/apps/cs/src/pages/menu/ui/permissions-management-dialog.tsx @@ -0,0 +1,22 @@ +import { Button, Dialog, DialogTrigger } from "@common/components/ui"; + +type PermissionsManagementDialogProps = { + menuCd?: number; +} & React.ComponentProps; + +function PermissionsManagementDialog({ + ...props +}: PermissionsManagementDialogProps) { + return ( + + + + + + ); +} + +export { PermissionsManagementDialog }; diff --git a/apps/cs/src/pages/menu/ui/type-menu-form.tsx b/apps/cs/src/pages/menu/ui/type-menu-form.tsx new file mode 100644 index 000000000..5081a7e51 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/type-menu-form.tsx @@ -0,0 +1,380 @@ +import dayjs from "dayjs"; +import { type UseFormReturn } from "react-hook-form"; +import { + DatePicker, + IconFinder, + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { BoardPopup } from "@/entities/board"; +import type { Content } from "../model/content-interface"; +import type { User } from "../model/organization-interface"; +import type { MenuFormData, MenuType } from "../model/menu-interface"; +import { OrganizationSearchDialog } from "./organization-search-dialog"; +import { ContentSearchDialog } from "./content-search-dialog"; +import { BoardSearchDialog } from "./board-search-dialog"; + +type TypeMenuFormProps = { + form: UseFormReturn; + menuType: MenuType; +}; + +function TypeMenuForm({ form, menuType }: TypeMenuFormProps) { + const handleIconClick = (icon: string) => { + form.setValue("iconNm", icon); + }; + + const handleContentSelect = (content: Content) => { + form.setValue("menuLnkgSn", content.contsSn); + form.setValue("menuLnkgSnNm", content.contsTtl); + }; + + const handleUserSelect = (user: User) => { + form.setValue("menuPicUserUnqId", user.userUnqId); + form.setValue("menuPicUserUnqIdNm", user.userNm); + }; + + const handleBoardSelect = (board: BoardPopup) => { + form.setValue("menuLnkgSn", board.bbsId); + form.setValue("menuLnkgSnNm", board.bbsNm); + }; + + const handleDateSelect = (date: Date | undefined) => { + if (!date) return; + form.setValue("menuPicDsgnYmd", dayjs(date).format("YYYYMMDD")); + }; + + return ( + + + + + + + {menuType === "CONTENTS" && ( + + 콘텐츠 + +
+
+ {!form.watch("menuLnkgSnNm") && + "메뉴에 표시될 콘텐츠를 조회 후 선택하십시오."} + {form.watch("menuLnkgSnNm")} +
+ +
+
+
+ )} + {menuType === "BOARD" && ( + + 게시판 + +
+
+ {!form.watch("menuLnkgSnNm") && + "메뉴에 표시될 게시판을 조회 후 선택하십시오."} + {form.watch("menuLnkgSnNm")} +
+ +
+
+
+ )} + {(menuType === "INSIDE" || menuType === "OUTSIDE") && ( + + 연결 URL + + ( + + + + + + + )} + /> + + + )} + {(menuType === "CONTENTS" || + menuType === "BOARD" || + menuType === "INSIDE" || + menuType === "OUTSIDE") && ( + + 연결 형태 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ )} + {(menuType === "CONTENTS" || menuType === "BOARD") && ( + + 메뉴 평가 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ )} + + {/* 메뉴 유형 공통 폼 S */} + + 메뉴 서브명 + + ( + + + + + + + )} + /> + + + + 메뉴 아이콘 + +
+
+ {!form.watch("iconNm") && "아이콘을 조회 후 선택하십시오."} + {form.watch("iconNm")} +
+ +
+
+
+ + 출력 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ {/* 메뉴 유형 공통 폼 E */} + + {(menuType === "CONTENTS" || menuType === "BOARD") && ( + <> + + QR코드 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 담당자 + +
+
+
+ {!form.watch("menuPicUserUnqIdNm") && + "메뉴 담당자를 조회 후 선택하십시오."} + {form.watch("menuPicUserUnqIdNm")} +
+ +
+
+ 지정일: + ( + + + { + field.onChange(date); + handleDateSelect(date); + }} + /> + + + )} + /> +
+
+
+
+ + )} +
+
+ ); +} + +export { TypeMenuForm }; diff --git a/apps/cs/src/pages/site/api/domain-service.ts b/apps/cs/src/pages/site/api/domain-service.ts index 7cc36363f..a8d2acfab 100644 --- a/apps/cs/src/pages/site/api/domain-service.ts +++ b/apps/cs/src/pages/site/api/domain-service.ts @@ -4,11 +4,11 @@ import type { Domain, DomainService, DomainList, - IDomainParams, + DomainParams, } from "../model/domain-interface"; export class DomainApiService implements DomainService { - public async getDomainList(params: IDomainParams): Promise { + public async getDomainList(params: DomainParams): Promise { const response = await client.get( `${ENDPOINT.CMS_SERVICE.DOMAINS}`, { params }, diff --git a/apps/cs/src/pages/site/api/ip-service.ts b/apps/cs/src/pages/site/api/ip-service.ts index 748676b21..903f0407e 100644 --- a/apps/cs/src/pages/site/api/ip-service.ts +++ b/apps/cs/src/pages/site/api/ip-service.ts @@ -1,9 +1,9 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { IIpParams, IP, IpList, IpService } from "../model/ip-interface"; +import type { IpParams, IP, IpList, IpService } from "../model/ip-interface"; export class IpApiService implements IpService { - public async getIpList(params: IIpParams): Promise { + public async getIpList(params: IpParams): Promise { const response = await client.get(`${ENDPOINT.CMS_SERVICE.IPS}`, { params, }); diff --git a/apps/cs/src/pages/site/model/domain-interface.ts b/apps/cs/src/pages/site/model/domain-interface.ts index 5f1076275..e93f86bc1 100644 --- a/apps/cs/src/pages/site/model/domain-interface.ts +++ b/apps/cs/src/pages/site/model/domain-interface.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import type { IPageableParams, PageableList } from "@/shared/model"; +import type { PageableParams, PageableList } from "@/shared/model"; -export interface IDomainParams extends IPageableParams { +export interface DomainParams extends PageableParams { keywordType?: string; keyword?: string; siteId?: string; @@ -23,9 +23,9 @@ export const domainFormSchema = z.object({ export type Domain = z.infer; export type DomainFormData = z.infer; -export type DomainList = PageableList; +export type DomainList = PageableList; export interface DomainService { - getDomainList(params: IDomainParams): Promise; + getDomainList(params: DomainParams): Promise; saveDomainList(body: Domain[]): Promise; } diff --git a/apps/cs/src/pages/site/model/ip-interface.ts b/apps/cs/src/pages/site/model/ip-interface.ts index db1d393f1..427fbfe57 100644 --- a/apps/cs/src/pages/site/model/ip-interface.ts +++ b/apps/cs/src/pages/site/model/ip-interface.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import type { IPageableParams, PageableList } from "@/shared/model"; +import type { PageableParams, PageableList } from "@/shared/model"; -export interface IIpParams extends IPageableParams { +export interface IpParams extends PageableParams { siteId: string; ipAddr?: string; prmYn?: string; @@ -21,9 +21,9 @@ export const ipFormSchema = z.object({ export type IP = z.infer; export type IPFormData = z.infer; -export type IpList = PageableList; +export type IpList = PageableList; export interface IpService { - getIpList(params: IIpParams): Promise; + getIpList(params: IpParams): Promise; saveIpList(body: IP[]): Promise; } diff --git a/apps/cs/src/pages/site/model/table-columns.tsx b/apps/cs/src/pages/site/model/table-columns.tsx index 2a07ce4b1..8a2d852cd 100644 --- a/apps/cs/src/pages/site/model/table-columns.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -1,8 +1,8 @@ import dayjs from "dayjs"; import { createColumnHelper } from "@tanstack/react-table"; -import type { ISite } from "@/entities/site"; +import type { Site } from "@/entities/site"; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); const columns = [ columnHelper.accessor((_, index) => index + 1, { diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index 33431f316..b5210df61 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { Button, DataTable, Input } from "@common/components"; -import { SiteApi, type ISite, type ISiteParams } from "@/entities/site"; +import { SiteApi, type Site, type SiteParams } from "@/entities/site"; import { ENDPOINT } from "@/shared/config"; import { PageLayout } from "@/shared/ui/page-layout"; import { columns } from "../model/table-columns"; @@ -20,7 +20,7 @@ function SitePage() { enabled: false, }); - const [siteQuery, setSiteQuery] = useState({ + const [siteQuery, setSiteQuery] = useState({ siteNm: "", }); @@ -35,7 +35,7 @@ function SitePage() { refetch(); }; - const handleRowClick = (row: ISite) => { + const handleRowClick = (row: Site) => { router.push(`/site/register/${row.siteId}`); }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index e07b6c9ff..ff519b649 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -14,6 +14,11 @@ export const ENDPOINT = { IPS: "/cms-service/api/v1/site-ips", 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", + }, + BOARD_SERVICE: { + BBS: "/board-service/api/v1/bbs", }, }; diff --git a/apps/cs/src/shared/model/api-interface.ts b/apps/cs/src/shared/model/api-interface.ts index 5aa4cd09b..133c0c401 100644 --- a/apps/cs/src/shared/model/api-interface.ts +++ b/apps/cs/src/shared/model/api-interface.ts @@ -14,7 +14,7 @@ interface PageableConfig { } export interface PageableList { - content: T; + content: T[]; pageable: PageableConfig; sort: SortConfig; totalPages: number; @@ -27,7 +27,7 @@ export interface PageableList { empty: boolean; } -export interface IPageableParams { +export interface PageableParams { page?: number; size?: number; sort?: string[]; diff --git a/packages/common/components/src/ui/layout/base.tsx b/packages/common/components/src/ui/layout/base.tsx index 9dd778e03..b48e2d9d9 100644 --- a/packages/common/components/src/ui/layout/base.tsx +++ b/packages/common/components/src/ui/layout/base.tsx @@ -10,9 +10,13 @@ import type { QueryClient } from "@tanstack/react-query"; export function BaseLayout({ children, queryClient, + currentPath, + routeHandler, }: { children: React.ReactNode; queryClient?: QueryClient; + currentPath?: string; + routeHandler?: (path: string) => void; }) { return ( @@ -20,7 +24,7 @@ export function BaseLayout({
- +
{children} diff --git a/packages/common/components/src/ui/lnb/index.tsx b/packages/common/components/src/ui/lnb/index.tsx index b1a7fe094..197a44a9e 100644 --- a/packages/common/components/src/ui/lnb/index.tsx +++ b/packages/common/components/src/ui/lnb/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight } from "lucide-react"; -import { cn } from "../../lib/utils"; +import { cn, setCookie } from "../../lib"; import { ENDPOINT, SITE_ID } from "../../config"; import { getMenuList, type MenuItem } from "../../api"; import { @@ -19,7 +19,15 @@ import { } from "../collapsible"; import { getParentIds } from "./lib"; -export function LNB({ viewOnly }: { viewOnly?: boolean }) { +export function LNB({ + viewOnly, + currentPath, + routeHandler, +}: { + viewOnly?: boolean; + currentPath?: string; + routeHandler?: (path: string) => void; +}) { const { data: menuList } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], queryFn: () => getMenuList(SITE_ID), @@ -28,14 +36,31 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) { const [openMenuIds, setOpenMenuIds] = useState([]); + function isPathMatched(path: string) { + return currentPath === path || currentPath?.startsWith(`${path}/`); + } + function MenuItem({ item }: { item: MenuItem }) { + const isMatched = isPathMatched(item.urlPath); + + const handleClick = () => { + setCookie("menu-code-key", item.menuCd.toString()); + routeHandler?.(item.urlPath); + }; + if (item.children) { return ; } return ( - + {item.menuKornNm} @@ -44,7 +69,11 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) { } function MenuGroup({ item }: { item: MenuItem }) { - const isOpen = openMenuIds.includes(item.menuCd); + const hasMatchedChild = item.children?.some((child) => + isPathMatched(child.urlPath), + ); + + const isOpen = openMenuIds.includes(item.menuCd) || hasMatchedChild; const handleOpenChange = (open: boolean) => { if (open) { diff --git a/packages/common/components/src/ui/table/index.tsx b/packages/common/components/src/ui/table/index.tsx index 797f48dfd..46c8db39c 100644 --- a/packages/common/components/src/ui/table/index.tsx +++ b/packages/common/components/src/ui/table/index.tsx @@ -79,11 +79,7 @@ const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes >(({ className, ...props }, ref) => ( - + )); TableHead.displayName = "TableHead"; @@ -93,10 +89,7 @@ const TableCell = React.forwardRef< >(({ className, ...props }, ref) => ( ));