Merge pull request 'iipbs_cs' (#13) from iipbs_cs into main

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2025-03-31 10:41:29 +09:00
46 changed files with 1791 additions and 826 deletions
+9 -2
View File
@@ -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 <BaseLayout>{children}</BaseLayout>;
const router = useRouter();
const pathname = usePathname();
return (
<BaseLayout currentPath={pathname ?? ""} routeHandler={router.push}>
{children}
</BaseLayout>
);
}
+61 -19
View File
@@ -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;
@@ -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<BoardPopupList> {
const response = await client.get<BoardPopupList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
}
export const BoardApi = new BoardApiService();
+2
View File
@@ -0,0 +1,2 @@
export { BoardApi } from "./api/board-service";
export { type BoardPopup } from "./model/board-interface";
@@ -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<BoardPopup>;
export interface BoardService {
getBoardPopupList: (params: BoardPopupParams) => Promise<BoardPopupList>;
}
@@ -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<SiteList> {
public async getSiteList(params: SiteParams): Promise<SiteList> {
const response = await client.get<SiteList>(
`${ENDPOINT.CMS_SERVICE.SITES}`,
{ params },
@@ -18,8 +18,8 @@ export class SiteApiService implements SiteService {
return response.data;
}
public async siteDetail(siteId: string): Promise<ISiteDetail> {
const response = await client.get<ISiteDetail>(
public async siteDetail(siteId: string): Promise<SiteDetail> {
const response = await client.get<SiteDetail>(
`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
);
return response.data;
+2 -2
View File
@@ -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";
@@ -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<typeof siteFormSchema>;
export type SiteList = PageableList<ISite[]>;
export type SiteList = PageableList<Site>;
export interface SiteService {
getSiteList(params: ISiteParams): Promise<SiteList>;
siteDetail(siteId: string): Promise<ISiteDetail>;
getSiteList(params: SiteParams): Promise<SiteList>;
siteDetail(siteId: string): Promise<SiteDetail>;
modifySite(siteId: string, data: SiteFormData): Promise<void>;
createSite(data: SiteFormData): Promise<void>;
}
@@ -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<ContentList> {
const response = await client.get<ContentList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
}
export const ContentApi = new ContentApiService();
+43 -4
View File
@@ -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<Menu[]> {
const response = await client.get<Menu[]>(
public async getMenuInfo(menuCd: number): Promise<Menu> {
const response = await client.get<Menu>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
);
return response.data;
}
public async createMenu(data: MenuFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}`,
data,
);
return response.data;
}
public async modifyMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
data,
);
return response.data;
}
public async deleteMenu(menuCd: number): Promise<void> {
const response = await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
);
return response.data;
}
public async getTreeMenuList(siteId: string): Promise<TreeMenu[]> {
const response = await client.get<TreeMenu[]>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
);
return response.data;
}
public async modifyTreeMenu(siteId: string, data: TreeMenu[]): Promise<void> {
public async modifyTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
@@ -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();
+88 -21
View File
@@ -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<Menu>[] => {
return data.reduce<NodeModel<Menu>[]>((acc, menu) => {
const node: NodeModel<Menu> = {
export const generateMenuTreeData = (
data: TreeMenu[],
): NodeModel<TreeMenu>[] => {
return data.reduce<NodeModel<TreeMenu>[]>((acc, menu) => {
const node: NodeModel<TreeMenu> = {
id: menu.menuCd ?? 0,
parent: menu.parentId ?? 0,
text: menu.menuKornNm,
@@ -14,31 +17,37 @@ export const generateTreeData = (data: Menu[]): NodeModel<Menu>[] => {
acc.push(node);
if (menu.children?.length) {
acc.push(...generateTreeData(menu.children));
acc.push(...generateMenuTreeData(menu.children));
}
return acc;
}, []);
};
export const generateNewTreeData = (
treeData: NodeModel<Menu>[],
options: DropOptions<Menu>,
): TreeMenu | null => {
export const generateNewMenuTreeData = (
treeData: NodeModel<TreeMenu>[],
options: DropOptions<TreeMenu>,
): 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<Menu>): TreeMenu => {
const baseNode = (node: NodeModel<TreeMenu>): 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<Menu>) => void) => (node: NodeModel<Menu>) => {
(callback: (node: NodeModel<TreeMenu>) => void) =>
(node: NodeModel<TreeMenu>) => {
callback(node);
};
export const handleDropNode =
(callback: (newTree: TreeMenu) => void) =>
(newTree: NodeModel<Menu>[], options: DropOptions<Menu>) => {
const newTreeData = generateNewTreeData(newTree, options);
(callback: (newTree: TreeNode) => void) =>
(newTree: NodeModel<TreeMenu>[], options: DropOptions<TreeMenu>) => {
const newTreeData = generateNewMenuTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
export const handleNodeTextChange =
(treeData: NodeModel<Menu>[]) =>
(callback: (newTree: NodeModel<Menu>[]) => void) =>
(treeData: NodeModel<TreeMenu>[]) =>
(callback: (newTree: NodeModel<TreeMenu>[]) => 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<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,43 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { BoardPopup } from "@/entities/board";
const columnHelper = createColumnHelper<BoardPopup>();
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 (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
className="mx-4 flex justify-center"
/>
);
},
}),
];
export { columns };
@@ -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<Content>;
export interface ContentService {
getContentList(params: ContentParams): Promise<ContentList>;
}
+31 -15
View File
@@ -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<TreeMenu, "name" | "icon" | "children"> {
export interface TreeMenu extends Omit<TreeNode, "name" | "icon" | "children"> {
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<typeof menuFormSchema>;
export type Menu = Omit<MenuFormData, "iconNm"> & {
menuCd: number;
iconName?: string;
};
export interface MenuService {
getTreeMenuList: (siteId: string) => Promise<Menu[]>;
modifyTreeMenu: (siteId: string, data: TreeMenu[]) => Promise<void>;
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
modifyMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
getTreeMenuList: (siteId: string) => Promise<TreeMenu[]>;
modifyTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
modifyMenuName: (menuCd: number, name: string) => Promise<void>;
}
@@ -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,32 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Content } from "../model/content-interface";
const columnHelper = createColumnHelper<Content>();
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 (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={row.getToggleSelectedHandler()}
className="mx-4 flex justify-center"
/>
);
},
}),
];
export { columns };
@@ -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<MenuFormData>;
}) {
const handleIconClick = (icon: string) => {
form.setValue("iconNm", icon);
};
return (
<>
<TableRow>
<TableHead>메뉴 서브명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuSubKornNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="메뉴 서브명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 아이콘</TableHead>
<TableCell className="border">
<div className="flex 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("iconNm")}
</div>
<IconFinder handleClick={handleIconClick} />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>출력 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="otptYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="otptY" />
<Label htmlFor="otptY">출력</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="otptN" />
<Label htmlFor="otptN">출력안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</>
);
}
+17 -15
View File
@@ -20,10 +20,10 @@ import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
interface BaseMenuFormProps {
form: UseFormReturn<MenuFormData>;
menuId?: number;
menuCd?: number;
}
export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
return (
<Table variant="secondary">
<colgroup>
@@ -31,14 +31,16 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>메뉴ID</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">
{menuId}
</div>
</TableCell>
</TableRow>
{menuCd && (
<TableRow>
<TableHead>메뉴ID</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">
{menuCd}
</div>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>메뉴명</TableHead>
<TableCell className="border">
@@ -50,6 +52,7 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="메뉴명을 입력하십시오."
/>
@@ -101,11 +104,8 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
key={key}
className="flex items-center space-x-2"
>
<RadioGroupItem
value={value}
id={`menuType${key}`}
/>
<Label htmlFor={`menuType${key}`}>
<RadioGroupItem value={value} id={key} />
<Label htmlFor={key}>
{key === "EMPTY" && "빈메뉴"}
{key === "CONTENTS" && "콘텐츠"}
{key === "BOARD" && "게시판"}
@@ -128,3 +128,5 @@ export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
</Table>
);
}
export { BaseMenuForm };
@@ -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<MenuFormData> }) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<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>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="self" id="self" />
<Label htmlFor="self">현재창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="blank" id="blank" />
<Label htmlFor="blank">새창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<BaseBottomMenuForm form={form} />
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<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>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -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<typeof Dialog>;
function BoardSearchDialog({
handleBoardSelect,
...props
}: BoardSearchDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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 (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" 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
placeholder="검색어를 입력하세요."
className="!text-[1.3rem]"
value={bbsNm}
onChange={(e) => setBbsNm(e.target.value)}
/>
<Button onClick={() => refetch()}>
<i className="diveicon di-search" />
조회
</Button>
</div>
<div className="h-[40rem] w-full overflow-y-auto">
<DataTable table={table} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { BoardSearchDialog };
@@ -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<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -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<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<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>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="self" id="self" />
<Label htmlFor="self">현재창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="blank" id="blank" />
<Label htmlFor="blank">새창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<BaseBottomMenuForm form={form} />
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<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>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -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<typeof Dialog>;
function ContentSearchDialog({
handleContentSelect,
...props
}: ContentSearchDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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 (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-search" />
조회
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" 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
placeholder="검색어를 입력하세요."
className="!text-[1.3rem]"
value={contsTtl}
onChange={(e) => setContsTtl(e.target.value)}
/>
<Button onClick={() => refetch()}>
<i className="diveicon di-search" />
조회
</Button>
</div>
<div className="h-[40rem] w-full overflow-y-auto">
<DataTable table={table} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { ContentSearchDialog };
@@ -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 };
@@ -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<MenuFormData> }) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -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<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
+58 -57
View File
@@ -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<MenuFormData>;
menuId?: number;
handleSave: (data: MenuFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
menuCd?: number;
handleCreate: (data: MenuFormData) => void;
handleModify: ({
menuCd,
data,
}: {
menuCd: number;
data: Omit<MenuFormData, "siteId">;
}) => 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<void>) => {
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({
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
메뉴 등록/수정
{isModifyMode ? "메뉴 수정" : "메뉴 등록"}
</h3>
<PermissionsManagementDialog menuCd={menuCd} />
</div>
<div className="card">
<BaseMenuForm form={form} menuId={menuId} />
<div className="-mt-[0.1rem]">
{renderMenuTypeComponent(menuType, form)}
</div>
<BaseMenuForm form={form} menuCd={menuCd} />
<TypeMenuForm form={form} menuType={menuType} />
</div>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" type="submit">
<Button size="lg" type="submit" disabled={isSubmitDisabled}>
저장
</Button>
{isModifyMode && (
@@ -88,24 +109,4 @@ export function MenuForm({
);
}
function renderMenuTypeComponent(
menuType: string,
form: UseFormReturn<MenuFormData>,
) {
switch (menuType) {
case MENU_TYPE.EMPTY:
return <EmptyMenuForm form={form} />;
case MENU_TYPE.CONTENTS:
return <ContentMenuForm form={form} />;
case MENU_TYPE.BOARD:
return <BoardMenuForm form={form} />;
case MENU_TYPE.INSIDE:
return <InsideMenuForm form={form} />;
case MENU_TYPE.OUTSIDE:
return <OutsideMenuForm form={form} />;
case MENU_TYPE.BOTTOM:
return <BottomMenuForm form={form} />;
default:
return null;
}
}
export { MenuForm };
+48 -49
View File
@@ -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<Menu>;
node: NodeModel<TreeMenu>;
depth: number;
isOpen: boolean;
isSelected: boolean;
isAdditionalParent: boolean;
treeMethods: TreeMethods | null;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<Menu>) => void;
onSelect: (node: NodeModel<TreeMenu>) => 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 (
<div
style={{ paddingInlineStart: indent }}
@@ -51,63 +73,40 @@ function MenuNode({
"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)]",
)}
onClick={handleSelect}
>
<div className="flex items-center">
{editMode ? (
<>
<Input
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
className="h-[2.5rem] !text-[1.3rem] text-foreground"
/>
</>
) : (
<>
{editMode ? (
<EditableNode
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<>
<div className="flex items-center" onClick={handleSelect}>
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-base text-label",
isOpen && "rotate-90",
)}
onClick={handleToggle}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</>
)}
</div>
{editMode ? (
<div className="flex items-center gap-1">
</div>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={handleSave}
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-check text-2xl" />
<i className="diveicon di-pencil" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 !bg-transparent"
onClick={() => {
setEditMode(false);
setNodeName(text);
}}
>
<i className="diveicon di-x" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="invisible !bg-transparent group-hover:visible"
onClick={() => setEditMode(true)}
>
<i className="diveicon di-pencil" />
</Button>
</>
)}
</div>
);
+206 -45
View File
@@ -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<TreeMenu | null>(null);
const [additionalNode, setAdditionalNode] =
useState<NodeModel<TreeMenu> | 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<MenuFormData, "siteId">;
}) => 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<MenuFormData>({
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<MenuFormData>({
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<TreeMenu>) => {
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 (
<PageLayout pageTitle="메뉴 관리">
<div className="card flex gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={setSiteId}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{treeData && (
<>
<div className="my-4 flex gap-2">
<Button size="sm" color="primary" className="flex-1">
메뉴 추가
</Button>
<Button size="sm" color="red" className="flex-1">
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree treeData={treeData ?? []} siteId={siteId} />
</div>
</>
)}
<>
<PageLayout pageTitle="메뉴 관리">
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleModify={modifyMenu}
handleDelete={deleteMenu}
/>
</div>
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
handleSave={handleSave}
handleDelete={handleDelete}
/>
</div>
</div>
</PageLayout>
</PageLayout>
<ConfirmDialog open={onConfirm} />
</>
);
}
+114 -46
View File
@@ -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<Menu>[];
tree: NodeModel<TreeMenu>[];
siteId: string;
selectedMenu: TreeMenu | null;
additionalNodeForm: UseFormReturn<MenuFormData>;
onSelectNode: (node: NodeModel<TreeMenu>) => 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<TreeMethods>(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<NodeModel<Menu> | null>(
null,
);
const additionalNode = tree.find((n) => n.id === -1);
const isAdditionalParent = (node: NodeModel<TreeMenu>): boolean => {
if (!additionalNode) return false;
return additionalNode?.parent === node.id;
};
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={treeData}
rootId={0}
render={(node: NodeModel<Menu>, { depth, isOpen, onToggle }) => (
<MenuNode
node={node}
depth={depth}
isOpen={isOpen}
isSelected={selectedNode?.id === node.id}
onToggle={onToggle}
onSelect={handleSelectNode(setSelectedNode)}
<>
<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<TreeMenu>,
{ depth, isOpen, onToggle },
) => {
if (node.id === -1) {
return (
<EditableNode
value={additionalNodeForm.watch("menuKornNm") ?? ""}
onChange={(e) =>
additionalNodeForm.setValue("menuKornNm", e.target.value)
}
depth={depth}
onSave={onAdditionalNodeSave}
onCancel={onAdditionalNodeCancel}
/>
);
}
return (
<MenuNode
node={node}
depth={depth}
treeMethods={treeMethods.current}
isOpen={isOpen}
isSelected={selectedMenu?.menuCd === node.id}
isAdditionalParent={isAdditionalParent(node)}
onToggle={onToggle}
onSelect={handleSelectNode(onSelectNode)}
/>
);
}}
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 (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
)}
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 (
<div
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
style={{ left: depth * 10 + 5 }}
/>
);
}}
/>
</DndProvider>
</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>
</>
);
}
@@ -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<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 { OrganizationSearchDialog };
@@ -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<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -0,0 +1,22 @@
import { Button, Dialog, DialogTrigger } from "@common/components/ui";
type PermissionsManagementDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function PermissionsManagementDialog({
...props
}: PermissionsManagementDialogProps) {
return (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3">
<i className="diveicon di-personal-information" />
권한관리
</Button>
</DialogTrigger>
</Dialog>
);
}
export { PermissionsManagementDialog };
@@ -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<MenuFormData>;
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 (
<Table variant="secondary" className="-mt-[0.1rem]">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
{menuType === "CONTENTS" && (
<TableRow>
<TableHead>콘텐츠</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-1/2 cursor-not-allowed overflow-hidden whitespace-nowrap break-keep rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("menuLnkgSnNm") &&
"메뉴에 표시될 콘텐츠를 조회 후 선택하십시오."}
{form.watch("menuLnkgSnNm")}
</div>
<ContentSearchDialog
handleContentSelect={handleContentSelect}
/>
</div>
</TableCell>
</TableRow>
)}
{menuType === "BOARD" && (
<TableRow>
<TableHead>게시판</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<div className="h-[3.2rem] w-1/2 cursor-not-allowed overflow-hidden whitespace-nowrap break-keep rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{!form.watch("menuLnkgSnNm") &&
"메뉴에 표시될 게시판을 조회 후 선택하십시오."}
{form.watch("menuLnkgSnNm")}
</div>
<BoardSearchDialog handleBoardSelect={handleBoardSelect} />
</div>
</TableCell>
</TableRow>
)}
{(menuType === "INSIDE" || menuType === "OUTSIDE") && (
<TableRow>
<TableHead>연결 URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="urlPath"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder={
menuType === "INSIDE"
? "메뉴에 연결된 페이지의 경로를 입력하십시오."
: "'http://' 또는 'https://'를 포함하여 입력하십시오."
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{(menuType === "CONTENTS" ||
menuType === "BOARD" ||
menuType === "INSIDE" ||
menuType === "OUTSIDE") && (
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="newWndwYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="newWndwY" />
<Label htmlFor="newWndwY">새창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="newWndwN" />
<Label htmlFor="newWndwN">현재창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{(menuType === "CONTENTS" || menuType === "BOARD") && (
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{/* 메뉴 유형 공통 폼 S */}
<TableRow>
<TableHead>메뉴 서브명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuSubKornNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트맵에서 다른 이름으로 사용할 경우 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 아이콘</TableHead>
<TableCell className="border">
<div className="flex 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("iconNm") && "아이콘을 조회 후 선택하십시오."}
{form.watch("iconNm")}
</div>
<IconFinder handleClick={handleIconClick} />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>출력 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="otptYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="otptY" />
<Label htmlFor="otptY">출력</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="otptN" />
<Label htmlFor="otptN">출력안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 메뉴 유형 공통 폼 E */}
{(menuType === "CONTENTS" || menuType === "BOARD") && (
<>
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>담당자</TableHead>
<TableCell className="border">
<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") &&
"메뉴 담당자를 조회 후 선택하십시오."}
{form.watch("menuPicUserUnqIdNm")}
</div>
<OrganizationSearchDialog
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>
</>
)}
</TableBody>
</Table>
);
}
export { TypeMenuForm };
+2 -2
View File
@@ -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<DomainList> {
public async getDomainList(params: DomainParams): Promise<DomainList> {
const response = await client.get<DomainList>(
`${ENDPOINT.CMS_SERVICE.DOMAINS}`,
{ params },
+2 -2
View File
@@ -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<IpList> {
public async getIpList(params: IpParams): Promise<IpList> {
const response = await client.get<IpList>(`${ENDPOINT.CMS_SERVICE.IPS}`, {
params,
});
@@ -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<typeof domainSchema>;
export type DomainFormData = z.infer<typeof domainFormSchema>;
export type DomainList = PageableList<Domain[]>;
export type DomainList = PageableList<Domain>;
export interface DomainService {
getDomainList(params: IDomainParams): Promise<DomainList>;
getDomainList(params: DomainParams): Promise<DomainList>;
saveDomainList(body: Domain[]): Promise<void>;
}
+4 -4
View File
@@ -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<typeof ipSchema>;
export type IPFormData = z.infer<typeof ipFormSchema>;
export type IpList = PageableList<IP[]>;
export type IpList = PageableList<IP>;
export interface IpService {
getIpList(params: IIpParams): Promise<IpList>;
getIpList(params: IpParams): Promise<IpList>;
saveIpList(body: IP[]): Promise<void>;
}
@@ -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<ISite>();
const columnHelper = createColumnHelper<Site>();
const columns = [
columnHelper.accessor((_, index) => index + 1, {
+3 -3
View File
@@ -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<ISiteParams>({
const [siteQuery, setSiteQuery] = useState<SiteParams>({
siteNm: "",
});
@@ -35,7 +35,7 @@ function SitePage() {
refetch();
};
const handleRowClick = (row: ISite) => {
const handleRowClick = (row: Site) => {
router.push(`/site/register/${row.siteId}`);
};
+5
View File
@@ -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",
},
};
+2 -2
View File
@@ -14,7 +14,7 @@ interface PageableConfig {
}
export interface PageableList<T> {
content: T;
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
@@ -27,7 +27,7 @@ export interface PageableList<T> {
empty: boolean;
}
export interface IPageableParams {
export interface PageableParams {
page?: number;
size?: number;
sort?: string[];
@@ -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 (
<QueryProvider client={queryClient}>
@@ -20,7 +24,7 @@ export function BaseLayout({
<div className="flex-1">
<Header />
<div className="flex h-[calc(100vh-50px)]">
<LNB />
<LNB currentPath={currentPath} routeHandler={routeHandler} />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
@@ -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<number[]>([]);
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 <MenuGroup item={item} />;
}
return (
<SidebarMenuItem>
<SidebarMenuButton className="h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent">
<SidebarMenuButton
className={cn(
"h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
isMatched && "bg-accent text-primary",
)}
onClick={handleClick}
>
<i className={cn("diveicon ml-2", `di-${item.iconNm}`)} />
{item.menuKornNm}
</SidebarMenuButton>
@@ -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) {
@@ -79,11 +79,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn("align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
<th ref={ref} className={cn("align-middle", className)} {...props} />
));
TableHead.displayName = "TableHead";
@@ -93,10 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"align-middle text-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
className={cn("align-middle text-foreground", className)}
{...props}
/>
));