diff --git a/apps/cm/app/dashboard/page.tsx b/apps/cm/app/(with-base-layout)/dashboard/page.tsx similarity index 100% rename from apps/cm/app/dashboard/page.tsx rename to apps/cm/app/(with-base-layout)/dashboard/page.tsx diff --git a/apps/cm/app/(with-base-layout)/layout.tsx b/apps/cm/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..481d8f30c --- /dev/null +++ b/apps/cm/app/(with-base-layout)/layout.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { NoLNBLayout } from "@common/components"; +import type { MenuItem } from "@/entities/menu"; +import { useMenuStore } from "@/shared/lib"; +import { PageLayout } from "@/shared/ui/page-layout"; + +export default function WithBaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + + const handleMenuClick = (item: MenuItem) => { + setCurrentMenuInfo(item); + router.push(item.urlPath); + }; + + return ( + + {children} + + ); +} diff --git a/apps/cm/app/(with-base-layout)/loading.tsx b/apps/cm/app/(with-base-layout)/loading.tsx new file mode 100644 index 000000000..3a3b15932 --- /dev/null +++ b/apps/cm/app/(with-base-layout)/loading.tsx @@ -0,0 +1,2 @@ +import { SkeletonPage } from "@/shared/ui/skeleton-page"; +export default SkeletonPage; diff --git a/packages/common/components/src/api/site.ts b/packages/common/components/src/api/site.ts index f08b7c0a0..04eb40455 100644 --- a/packages/common/components/src/api/site.ts +++ b/packages/common/components/src/api/site.ts @@ -1,3 +1,4 @@ +import { getApiClient } from "./client"; import { getConfig } from "@common/config"; import { ENDPOINT } from "../config"; @@ -9,6 +10,7 @@ export interface SiteInfo { siteKindNm: string; siteAddr: string; faxNumber: string; + dmnAddr: string; telNo: string; lwndCn: string; basicSiteYn: string; @@ -23,6 +25,35 @@ export interface SiteInfo { mdfrNm: string; } +export interface SiteListResponse { + content: SiteInfo[]; + pageable: { + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + }; + last: boolean; + totalElements: number; + totalPages: number; + size: number; + number: number; + first: boolean; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + numberOfElements: number; + empty: boolean; +} + const { PROXY_HOST } = getConfig(); const BASE_URL = `${PROXY_HOST}/server`; @@ -38,3 +69,18 @@ export const getSiteInfo = async (siteId: string) => { ); return response.json() as Promise; }; + +const client = getApiClient(); + +export const getSiteList = async () => { + const response = await client.get( + `${BASE_URL}/${ENDPOINT.CMS_SERVICE.SITE_LIST}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + return response.data; +}; diff --git a/packages/common/components/src/config/api.ts b/packages/common/components/src/config/api.ts index 62aeb59f7..404fe925c 100644 --- a/packages/common/components/src/config/api.ts +++ b/packages/common/components/src/config/api.ts @@ -10,6 +10,7 @@ export const ENDPOINT = { NOTICE: "/cms-service/api/v1/noti", NOTICE_USER: "/cms-service/api/v1/noti/user", BANNERS: "/cms-service/api/v1/bnrs/user", + SITE_LIST: "/cms-service/api/v1/sites/users/me", }, USER_SERVICE: { USERS: "/user-service/api/v1/users", diff --git a/packages/common/components/src/ui/header/header.css b/packages/common/components/src/ui/header/header.css index 748caaf52..6ad44e529 100644 --- a/packages/common/components/src/ui/header/header.css +++ b/packages/common/components/src/ui/header/header.css @@ -28,4 +28,24 @@ color: #fff; } } + + /* 상단 메뉴 호버 시 하단 라인 */ + .header-nav-link { + position: relative; + } + + .header-nav-link::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + width: 0; + height: 2px; + background-color: #063A74; + transition: width 0.3s ease; + } + + .header-nav-link:hover::after { + width: 100%; + } } diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 22e0a0d56..00f758d60 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -1,6 +1,6 @@ import "./header.css"; -import { useEffect, useCallback, useState } from "react"; +import { useEffect, useCallback, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; import { getConfig } from "@common/config"; @@ -12,6 +12,8 @@ import { getSiteInfo, getUserInfo, type Popup, + getSiteList, + type SiteListResponse, } from "../../api"; import { Button } from "../button"; import { Skeleton } from "../skeleton"; @@ -27,6 +29,8 @@ const isLoggedIn = !!userId; export function Header({ viewOnly }: { viewOnly?: boolean }) { const [mounted, setMounted] = useState(false); + const [logoWidth, setLogoWidth] = useState(0); + const logoRef = useRef(null); const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -54,6 +58,12 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) { enabled: isLoggedIn && !viewOnly, }); + const { data: userSiteList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITE_LIST], + queryFn: () => getSiteList(), + enabled: isLoggedIn && !viewOnly, + }); + const logout = async () => { try { const response = await fetch("/api/auth/logout", { @@ -118,20 +128,56 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) { }, []); return ( -
+
-

- {mounted ? ( - logo - ) : ( +

+ {mounted ? ( + logo { + if (logoRef.current) { + setLogoWidth(logoRef.current.offsetWidth); + } + }} + /> + ) : ( )} - - {siteInfo?.siteNm} - + 0 ? `${(logoWidth - 120) / (siteInfo?.siteNm?.length || 1)}px` : '0.05em' + }} + > + {siteInfo?.siteNm} +

+
+ {mounted && userSiteList?.content && userSiteList.content.length > 0 && ( + + )} +
{mounted ? ( diff --git a/packages/common/components/src/ui/header/search-form.tsx b/packages/common/components/src/ui/header/search-form.tsx index 66ffbadb1..ab0e4c47e 100644 --- a/packages/common/components/src/ui/header/search-form.tsx +++ b/packages/common/components/src/ui/header/search-form.tsx @@ -1,32 +1,63 @@ +import { useState } from "react"; import { Button } from "../button"; import { Input } from "../input"; export function SearchForm() { + const [isExpanded, setIsExpanded] = useState(false); + const [keyword, setKeyword] = useState(""); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const keyword = formData.get("keyword") as string; - + if (keyword && keyword.trim().length > 0) { window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`; } }; + const handleButtonClick = () => { + if (isExpanded) { + // 입력 박스가 열려있을 때는 검색 실행 + if (keyword && keyword.trim().length > 0) { + window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`; + } + } else { + // 입력 박스가 닫혀있을 때는 입력 박스 열기 + setIsExpanded(true); + } + }; + + const handleInputBlur = () => { + // 입력 박스에서 포커스가 벗어나면 닫기 (약간의 지연을 두어 버튼 클릭이 먼저 처리되도록) + setTimeout(() => { + if (!keyword.trim()) { + setIsExpanded(false); + } + }, 200); + }; + return ( -
- - - +
+
+ {isExpanded && ( + setKeyword(e.target.value)} + onBlur={handleInputBlur} + className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12 absolute left-0" + placeholder="검색어를 입력해주세요." + autoFocus + /> + )} + + +
); } diff --git a/packages/common/components/src/ui/layout/index.ts b/packages/common/components/src/ui/layout/index.ts index 100f53453..24b8eb565 100644 --- a/packages/common/components/src/ui/layout/index.ts +++ b/packages/common/components/src/ui/layout/index.ts @@ -1,3 +1,4 @@ export { BaseLayout } from "./base"; export { VBaseLayout } from "./vbase"; -export { NoFooterLayout } from "./nofooter"; \ No newline at end of file +export { NoFooterLayout } from "./nofooter"; +export { NoLNBLayout } from "./nolnb"; \ No newline at end of file diff --git a/packages/common/components/src/ui/layout/nolnb.tsx b/packages/common/components/src/ui/layout/nolnb.tsx new file mode 100644 index 000000000..5e64c42e0 --- /dev/null +++ b/packages/common/components/src/ui/layout/nolnb.tsx @@ -0,0 +1,42 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { QueryProvider } from "../../provider/query-provider"; +import { PopupProvider } from "../../provider/popup-provider"; +import { ClientIpGuard } from "../../lib/client-ip-guard"; +import type { MenuItem } from "../../api"; +import { SidebarProvider } from "../sidebar"; +import { LNB } from "../lnb"; +import { Header } from "../header"; +import { Footer } from "../footer"; +import "./common.css"; + +export function NoLNBLayout({ + children, + queryClient, + currentPath, + handleMenuClick, +}: { + children: React.ReactNode; + queryClient?: QueryClient; + currentPath?: string; + handleMenuClick?: (item: MenuItem) => void; +}) { + return ( + + + + +
+
+
+
+
{children}
+
+
+
+
+
+
+
+ ); +}