diff --git a/apps/fo/.env.local b/apps/fo/.env.local
index adaac0461..a87d13587 100644
--- a/apps/fo/.env.local
+++ b/apps/fo/.env.local
@@ -1,4 +1,11 @@
NEXT_PUBLIC_SITE_ID = FO
+NEXT_PUBLIC_CLAIM_NAME = Authorization
+NEXT_PUBLIC_AUTH_USER_ID = token-id
+NEXT_PUBLIC_ACCESS_TOKEN = access-token
+NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
+NEXT_PUBLIC_DEV_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
+NEXT_PUBLIC_VDI_DEV_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
+NEXT_PUBLIC_VDI_DEV_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
NEXT_PUBLIC_PORT = 3000
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11082
@@ -8,10 +15,6 @@ NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
-NEXT_PUBLIC_CLAIM_NAME = Authorization
-NEXT_PUBLIC_AUTH_USER_ID = token-id
-NEXT_PUBLIC_ACCESS_TOKEN = access-token
-NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
diff --git a/apps/fo/app/base.tsx b/apps/fo/app/base.tsx
deleted file mode 100644
index a01ce8643..000000000
--- a/apps/fo/app/base.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { BaseLayout } from "@common/components";
-import type { MenuItem } from "./store/common";
-import { useFoStore } from "./store";
-import { usePathname, useRouter } from "next/navigation";
-
-export default function Base({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- const { getMenuAuth } = useFoStore((state) => state);
- const pathname = usePathname();
- const router = useRouter();
-
- const handleClick = (id: MenuItem) => {
- getMenuAuth(id.menuCd);
- router.push(id.urlPath);
- };
-
- return (
-
- {children}
-
- );
-}
diff --git a/apps/fo/app/layout.tsx b/apps/fo/app/layout.tsx
index 842ec5ed1..683e6d0ec 100644
--- a/apps/fo/app/layout.tsx
+++ b/apps/fo/app/layout.tsx
@@ -1,29 +1,14 @@
-"use client"
-import "./globals.css";
-import { StoreProvider } from "./store";
-import { LoginApi } from "@/src/entities/auth";
-import { useEffect } from "react";
+import type { Metadata } from "next";
+import { App } from "@/src/app";
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export const metadata: Metadata = {
+ title: "디지털 하수관로",
+ description: "디지털 하수관로 시스템입니다",
+ keywords: ["하수관로"],
+ openGraph: {
+ title: "디지털 하수관로",
+ description: "디지털 하수관로 시스템입니다"
+ }
+};
- useEffect(() => {
- // 렌더 중이 아니라 클라이언트에서만 실행 + 에러 캐치
- void LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" })
- .catch((e) => {
- console.error("Auto login failed:", e);
- });
- }, []);
- return (
-
-
-
- {children}
-
-
-
- );
-}
+export default App;
diff --git a/apps/fo/app/page.tsx b/apps/fo/app/page.tsx
index 823e5a408..b1a103bca 100644
--- a/apps/fo/app/page.tsx
+++ b/apps/fo/app/page.tsx
@@ -1,44 +1,28 @@
-"use client"
+"use client";
+import { LoginApi } from "@/src/entities/auth";
+import { useEffect } from "react";
+import { AUTH_USER_ID } from "@/src/shared/config";
+import { getCookie } from "@/src/shared/lib/cookie";
+import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { Button } from "@common/components/ui";
export default function Home() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+ const router = useRouter();
+ const checkToken = getCookie(AUTH_USER_ID);
+
+ useEffect(() => {
+ if (!checkToken) {
+ async function doLogin() {
+ try {
+ await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
+ router.push("/pages/dashboard");
+ } catch (err) {
+ console.error("로그인 실패", err);
+ }
+ }
+ doLogin();
+ } else {
+ router.push("/pages/dashboard");
+ }
+ }, []);
+}
\ No newline at end of file
diff --git a/apps/fo/app/pages/(with-layout)/layout.tsx b/apps/fo/app/pages/(with-layout)/layout.tsx
index f7c0e62ba..851d79497 100644
--- a/apps/fo/app/pages/(with-layout)/layout.tsx
+++ b/apps/fo/app/pages/(with-layout)/layout.tsx
@@ -1,13 +1,30 @@
"use client"
-import Base from "@/app/base";
+import { BaseLayout } from "@common/components";
+import type { MenuItem } from "@/app/store/common";
+import { useFoStore } from "@/app/store";
+import { usePathname, useRouter } from "next/navigation";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ const { getMenuAuth } = useFoStore((state) => state);
+ const pathname = usePathname();
+ const router = useRouter();
+
+ const handleClick = (id: MenuItem) => {
+ getMenuAuth(id.menuCd);
+ router.push(id.urlPath);
+ };
+
return (
- {children}
+
+ {children}
+
);
}
diff --git a/apps/fo/app/pages/(with-layout)/processFacility/page.tsx b/apps/fo/app/pages/(with-layout)/processFacility/page.tsx
index e44420eaa..775dd1a45 100644
--- a/apps/fo/app/pages/(with-layout)/processFacility/page.tsx
+++ b/apps/fo/app/pages/(with-layout)/processFacility/page.tsx
@@ -210,13 +210,13 @@ export default function ProcessFacilitySearch() {
const cellClick = (index: number) => {
const facility = processFacilityList[index];
if (facility) {
- const fctCpc = String(facility.fctCpc).replace(/,/g, "").trim();
+ const fctCpc = Number(String(facility.fctCpc).replace(/,/g, "").trim());
setModalFcltyNm(facility.bscFctNm);
setModalFcltyCd(facility.bscFctCd);
setModalSido(facility.sido);
setModalGugun(facility.gugun);
setModalAddr(facility.daddr);
- setModalFctCpc(Number(fctCpc));
+ setModalFctCpc(fctCpc);
setModalTrtFrfNm(facility.trtFrfNm);
setModalRnDt(facility.rnDt);
setModalCmplDt(facility.cmplDt);
diff --git a/apps/fo/middleware.ts b/apps/fo/middleware.ts
index 1d8fe949c..bb9c9effa 100644
--- a/apps/fo/middleware.ts
+++ b/apps/fo/middleware.ts
@@ -139,10 +139,10 @@ export function middleware(req: NextRequest) {
});
}
- if (pathname === "/") {
- return NextResponse.redirect(new URL("/pages/dashboard", req.url));
- }
- return NextResponse.next();
+ // if (pathname === "/") {
+ // return NextResponse.redirect(new URL("/pages/dashboard", req.url));
+ // }
+ // return NextResponse.next();
}
export const config = {
diff --git a/apps/fo/package.json b/apps/fo/package.json
index 8697a116a..53423decf 100644
--- a/apps/fo/package.json
+++ b/apps/fo/package.json
@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
+ "@common/assets": "workspace:*",
"@common/business_components": "workspace:*",
"@common/components": "workspace:*",
"@tailwindcss/vite": "^4.0.10",
diff --git a/apps/fo/src/app/index.tsx b/apps/fo/src/app/index.tsx
new file mode 100644
index 000000000..692b55ef0
--- /dev/null
+++ b/apps/fo/src/app/index.tsx
@@ -0,0 +1,20 @@
+import "./styles/base.css";
+import "./styles/components.css";
+import "@fontsource-variable/noto-sans-kr";
+
+import { AppProvider } from "./providers";
+import { cookies } from "next/headers";
+
+const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
+ const theme = (await cookies()).get("site-theme-key");
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export { App };
diff --git a/apps/fo/src/app/providers/app-provider.tsx b/apps/fo/src/app/providers/app-provider.tsx
new file mode 100644
index 000000000..edce6071e
--- /dev/null
+++ b/apps/fo/src/app/providers/app-provider.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { DndProvider } from "react-dnd";
+import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
+import { AuthWrapper } from "@/src/entities/auth/lib/auth-wrapper";
+import { MenuWrapper } from "@/src/entities/menu";
+import { PromptProvider, StoreProvider } from "@/src/shared/providers";
+import { AlertDialog } from "@/src/shared/ui/alert-dialog";
+import { PromptDialog } from "@/src/shared/ui/prompt-dialog";
+import { ConfirmDialog } from "@/src/shared/ui/confirm-dialog";
+import { StoreProvider as FoStoreProvider } from "@/app/store";
+
+
+const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
+ const queryClient = new QueryClient();
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export { AppProvider };
diff --git a/apps/fo/src/app/providers/index.ts b/apps/fo/src/app/providers/index.ts
new file mode 100644
index 000000000..15f99b7fd
--- /dev/null
+++ b/apps/fo/src/app/providers/index.ts
@@ -0,0 +1 @@
+export { AppProvider } from "./app-provider";
diff --git a/apps/fo/src/app/styles/base.css b/apps/fo/src/app/styles/base.css
new file mode 100644
index 000000000..8453ff9e8
--- /dev/null
+++ b/apps/fo/src/app/styles/base.css
@@ -0,0 +1,12 @@
+@import "@common/assets/styles/base.css";
+@import "@common/assets/styles/diveicon.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body {
+ font-family: "Noto Sans KR", sans-serif;
+ font-size: 10px;
+}
diff --git a/apps/fo/src/app/styles/components.css b/apps/fo/src/app/styles/components.css
new file mode 100644
index 000000000..4dc30bafd
--- /dev/null
+++ b/apps/fo/src/app/styles/components.css
@@ -0,0 +1,29 @@
+@tailwind components;
+
+@layer components {
+ .card {
+ @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
+ }
+ .card-border {
+ @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
+ }
+ .card-header {
+ @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
+ }
+ .card-footer {
+ @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
+ }
+
+ [data-theme="black"] {
+ .card-border {
+ @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
+ }
+ .card-header {
+ @apply border-b-[#3f4042];
+ }
+ }
+
+ input[type="file"] {
+ @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
+ }
+}
diff --git a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
index 678ff50d5..65bcf48cc 100644
--- a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx
@@ -1,45 +1,54 @@
-// "use client";
+"use client";
-// import { useEffect } from "react";
-// import { useRouter } from "next/navigation";
-// import { LoginApi } from "../api/login-service";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { client } from "@/src/shared/api";
+import type { IUserInfo } from "@/src/shared/store";
+import { useUserStore, getCookie } from "@/src/shared/lib";
+import { ENDPOINT, AUTH_USER_ID } from "@/src/shared/config";
+import { LoginApi } from "../api/login-service";
-// const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
-// const router = useRouter();
-// const { userInfo, setUserInfo } = useUserStore((state) => state);
+const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_DEV_CM_LOGIN_PAGE_URL || "http://localhost:3001/auth/login"; // 개발 환경 URL
-// async function authCheck() {
-// const userId = getCookie(AUTH_USER_ID);
-// if (!userId) {
-// throw new Error("Missing required authentication tokens");
-// }
+const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
+ const router = useRouter();
+ const { userInfo, setUserInfo } = useUserStore((state) => state);
-// const response = await client.get(
-// `${ENDPOINT.USER_SERVICE.USERS}/info/me`,
-// );
-// if (!userInfo) setUserInfo(response.data);
-// }
+ async function authCheck() {
+ const userId = getCookie(AUTH_USER_ID);
+ if (!userId) {
+ throw new Error("Missing required authentication tokens");
+ }
-// useEffect(() => {
-// (async () => {
-// try {
-// await authCheck();
-// } catch (error) {
-// console.error(error);
+ const response = await client.get(
+ `${ENDPOINT.USER_SERVICE.USERS}/info/me`,
+ );
+ if (!userInfo) setUserInfo(response.data);
+ }
-// try {
-// await LoginApi.silentRefresh();
-// await authCheck();
-// } catch (refreshError) {
-// console.error("Token refresh failed:", refreshError);
-// setUserInfo(null);
-// router.push("/auth/login");
-// }
-// }
-// })();
-// }, []);
+ useEffect(() => {
+ (async () => {
+ try {
+ await authCheck();
+ } catch (error) {
+ console.error(error);
-// return <>{children}>;
-// };
+ try {
+ await LoginApi.silentRefresh();
+ await authCheck();
+ } catch (refreshError) {
+ console.error("Token refresh failed:", refreshError);
+ setUserInfo(null);
+ router.push("/"); // 개발시 홈으로 돌아가기
-// export { AuthWrapper };
+ // window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
+ // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
+ }
+ }
+ })();
+ }, []);
+
+ return <>{children}>;
+};
+
+export { AuthWrapper };
diff --git a/apps/fo/src/entities/menu-authority/@x/menu.ts b/apps/fo/src/entities/menu-authority/@x/menu.ts
new file mode 100644
index 000000000..86479f859
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/@x/menu.ts
@@ -0,0 +1 @@
+export { MenuAuthorityApi } from "../api/menu-authority-service";
diff --git a/apps/fo/src/entities/menu-authority/@x/posting.ts b/apps/fo/src/entities/menu-authority/@x/posting.ts
new file mode 100644
index 000000000..bfe84fa0d
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/@x/posting.ts
@@ -0,0 +1 @@
+export { AuthorityButton } from "../ui/authority-button";
diff --git a/apps/fo/src/entities/menu-authority/api/menu-authority-service.ts b/apps/fo/src/entities/menu-authority/api/menu-authority-service.ts
new file mode 100644
index 000000000..c7ac056f4
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/api/menu-authority-service.ts
@@ -0,0 +1,40 @@
+import { client } from "@/src/shared/api";
+import { ENDPOINT } from "@/src/shared/config";
+import type {
+ MenuAuthorityList,
+ MenuAuthorityParams,
+ MenuAuthorityPayload,
+ MenuAuthorityService,
+ PageMenuAuthority,
+} from "../model/menu-authority-interface";
+
+export class MenuAuthorityApiService implements MenuAuthorityService {
+ public async getMenuAuthorityList(
+ params: MenuAuthorityParams,
+ ): Promise {
+ const response = await client.get(
+ `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
+ { params },
+ );
+ return response.data;
+ }
+
+ public async getPageMenuAuthority(
+ menuCd: number,
+ ): Promise {
+ const response = await client.get(
+ `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
+ );
+ return response.data;
+ }
+
+ public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise {
+ const response = await client.post(
+ `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
+ data,
+ );
+ return response.data;
+ }
+}
+
+export const MenuAuthorityApi = new MenuAuthorityApiService();
diff --git a/apps/fo/src/entities/menu-authority/index.ts b/apps/fo/src/entities/menu-authority/index.ts
new file mode 100644
index 000000000..2a555b77e
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/index.ts
@@ -0,0 +1,2 @@
+export { AuthorityButton } from "./ui/authority-button";
+export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
diff --git a/apps/fo/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/fo/src/entities/menu-authority/model/menu-authority-interface.ts
new file mode 100644
index 000000000..f659f58af
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/model/menu-authority-interface.ts
@@ -0,0 +1,48 @@
+import { z } from "zod";
+import type { PageableList, PageableParams } from "@/src/shared/model";
+
+export interface MenuAuthorityParams extends PageableParams {
+ groupId?: string;
+ menuCd?: number;
+ useYn?: string;
+}
+
+export interface PageMenuAuthority {
+ mngAuthrtYn: string;
+ inqAuthrtYn: string;
+ regAuthrtYn: string;
+ mdfcnAuthrtYn: string;
+ delAuthrtYn: string;
+}
+
+export const menuAuthoritySchema = z.object({
+ groupId: z.string(),
+ groupNm: z.string(),
+ menuCd: z.number(),
+ mngAuthrtYn: z.string(),
+ inqAuthrtYn: z.string(),
+ regAuthrtYn: z.string(),
+ mdfcnAuthrtYn: z.string(),
+ delAuthrtYn: z.string(),
+ useYn: z.string(),
+ mode: z.string(),
+});
+
+export const menuAuthorityPayloadSchema = menuAuthoritySchema.omit({
+ groupNm: true,
+});
+
+export const menuAuthorityFormSchema = z.object({
+ menuAuthority: z.array(menuAuthorityPayloadSchema),
+});
+
+export type MenuAuthority = z.infer;
+export type MenuAuthorityList = PageableList;
+export type MenuAuthorityPayload = z.infer;
+export type MenuAuthorityFormData = z.infer;
+
+export interface MenuAuthorityService {
+ getMenuAuthorityList(params: MenuAuthorityParams): Promise;
+ getPageMenuAuthority(menuCd: number): Promise;
+ saveMenuAuthority(data: MenuAuthorityPayload[]): Promise;
+}
diff --git a/apps/fo/src/entities/menu-authority/model/menu-authority-table-columns.tsx b/apps/fo/src/entities/menu-authority/model/menu-authority-table-columns.tsx
new file mode 100644
index 000000000..6969a85e9
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/model/menu-authority-table-columns.tsx
@@ -0,0 +1,117 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import type { UseFormReturn } from "react-hook-form";
+import { Checkbox } from "@common/components";
+import type {
+ MenuAuthority,
+ MenuAuthorityFormData,
+ MenuAuthorityPayload,
+} from "./menu-authority-interface";
+
+export const createColumns = (
+ form: UseFormReturn,
+): ColumnDef[] => {
+ const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
+
+ const handleCheckedChange =
+ (i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
+ if (getCurrentRow(i)?.mode === "R") {
+ form.setValue(`menuAuthority.${i}.mode`, "U");
+ }
+ form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N");
+ };
+
+ return [
+ {
+ accessorKey: "groupNm",
+ header: "그룹명",
+ },
+ {
+ accessorKey: "mngAuthrtYn",
+ header: "관리권한",
+ cell: ({ row }) => {
+ return (
+ {
+ if (checked) {
+ handleCheckedChange(
+ row.index,
+ "regAuthrtYn",
+ )(checked as boolean);
+ handleCheckedChange(
+ row.index,
+ "mdfcnAuthrtYn",
+ )(checked as boolean);
+ handleCheckedChange(
+ row.index,
+ "delAuthrtYn",
+ )(checked as boolean);
+ handleCheckedChange(
+ row.index,
+ "inqAuthrtYn",
+ )(checked as boolean);
+ }
+ handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean);
+ }}
+ />
+ );
+ },
+ },
+ {
+ accessorKey: "regAuthrtYn",
+ header: "등록권한",
+ cell: ({ row }) => {
+ return (
+ {
+ if (checked) {
+ handleCheckedChange(
+ row.index,
+ "inqAuthrtYn",
+ )(checked as boolean);
+ }
+ handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean);
+ }}
+ />
+ );
+ },
+ },
+ {
+ accessorKey: "mdfcnAuthrtYn",
+ header: "수정권한 ",
+ cell: ({ row }) => {
+ return (
+
+ );
+ },
+ },
+ {
+ accessorKey: "delAuthrtYn",
+ header: "삭제권한",
+ cell: ({ row }) => {
+ return (
+
+ );
+ },
+ },
+ {
+ accessorKey: "inqAuthrtYn",
+ header: "조회권한",
+ cell: ({ row }) => {
+ return (
+
+ );
+ },
+ },
+ ];
+};
diff --git a/apps/fo/src/entities/menu-authority/ui/authority-button.tsx b/apps/fo/src/entities/menu-authority/ui/authority-button.tsx
new file mode 100644
index 000000000..a4cd666d9
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/ui/authority-button.tsx
@@ -0,0 +1,26 @@
+import { useMenuStore } from "@/src/shared/lib";
+import { Button, cn, type ButtonProps } from "@common/components";
+import type { PageMenuAuthority } from "../model/menu-authority-interface";
+
+type AuthorityButtonProps = {
+ authority: keyof PageMenuAuthority;
+} & ButtonProps;
+
+function AuthorityButton({
+ authority,
+ className,
+ children,
+ ...props
+}: AuthorityButtonProps) {
+ const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority);
+
+ const isAuthority = pageMenuAuthority?.[authority] === "Y";
+
+ return (
+
+ );
+}
+
+export { AuthorityButton };
diff --git a/apps/fo/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/fo/src/entities/menu-authority/ui/management-authority-dialog.tsx
new file mode 100644
index 000000000..839b9a7a4
--- /dev/null
+++ b/apps/fo/src/entities/menu-authority/ui/management-authority-dialog.tsx
@@ -0,0 +1,103 @@
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQuery } from "@tanstack/react-query";
+import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import {
+ Button,
+ DataTable,
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@common/components";
+import { ENDPOINT } from "@/src/shared/config";
+import { useAlertStore, useMutationWrapper } from "@/src/shared/lib";
+import {
+ menuAuthorityFormSchema,
+ type MenuAuthorityFormData,
+ type MenuAuthorityPayload,
+} from "../model/menu-authority-interface";
+import { createColumns } from "../model/menu-authority-table-columns";
+import { MenuAuthorityApi } from "../api/menu-authority-service";
+
+type ManagementAuthorityDialogProps = {
+ menuCd?: number;
+} & React.ComponentProps;
+
+function ManagementAuthorityDialog({
+ menuCd,
+ ...props
+}: ManagementAuthorityDialogProps) {
+ const { confirm } = useConfirm();
+ const alert = useAlertStore((state) => state.setMessage);
+
+ const { data: menuAuthorityList } = useQuery({
+ queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd],
+ queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }),
+ enabled: !!menuCd,
+ });
+
+ const { mutateAsync: saveMenuAuthority } = useMutationWrapper({
+ mutationFn: (data: MenuAuthorityPayload[]) =>
+ MenuAuthorityApi.saveMenuAuthority(data),
+ onSuccess: () => {
+ alert("저장되었습니다.");
+ },
+ });
+
+ const form = useForm({
+ resolver: zodResolver(menuAuthorityFormSchema),
+ values: {
+ menuAuthority: menuAuthorityList?.content ?? [],
+ },
+ });
+
+ const table = useReactTable({
+ data: menuAuthorityList?.content ?? [],
+ columns: createColumns(form),
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ const handleSave = async () => {
+ if (await confirm("저장하시겠습니까?")) {
+ const data = form.getValues("menuAuthority");
+ const filteredData = data.filter((item) => item.mode !== "R");
+ await saveMenuAuthority(filteredData);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export { ManagementAuthorityDialog };
diff --git a/apps/fo/src/entities/menu/api/menu-service.ts b/apps/fo/src/entities/menu/api/menu-service.ts
new file mode 100644
index 000000000..8b08d7fa5
--- /dev/null
+++ b/apps/fo/src/entities/menu/api/menu-service.ts
@@ -0,0 +1,14 @@
+import { client } from "@/src/shared/api";
+import { ENDPOINT } from "@/src/shared/config";
+import type { MenuItem, MenuService } from "../model/menu-interface";
+
+export class MenuApiService implements MenuService {
+ async getMenuRoles(siteId: string): Promise