diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-30 08:28:13 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-30 08:28:13 +0000 |
| commit | 5b6313f16f508882a0ea67716b7dbaa1c6967f04 (patch) | |
| tree | 3d1d8dafea2f31274ace3fbda08333e889e06d1c /components | |
| parent | 3f0fad18483a5c800c79c5e33946d9bb384c10e2 (diff) | |
(대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응
Diffstat (limited to 'components')
| -rw-r--r-- | components/layout/GroupedMenuRender.tsx | 53 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 117 | ||||
| -rw-r--r-- | components/layout/MobileMenu.tsx | 87 | ||||
| -rw-r--r-- | components/layout/user-profile-badge.tsx | 62 |
4 files changed, 241 insertions, 78 deletions
diff --git a/components/layout/GroupedMenuRender.tsx b/components/layout/GroupedMenuRender.tsx index e2a5a225..9006c85d 100644 --- a/components/layout/GroupedMenuRender.tsx +++ b/components/layout/GroupedMenuRender.tsx @@ -4,6 +4,7 @@ import { NavigationMenuLink } from "@/components/ui/navigation-menu"; import { cn } from "@/lib/utils"; import * as LucideIcons from "lucide-react"; import { MenuItem } from '@/config/menuConfig'; +import { filterActiveAdditionalMenus } from "@/hooks/use-active-menus"; type GroupedMenuItems = { [key: string]: MenuItem[]; @@ -12,9 +13,15 @@ type GroupedMenuItems = { interface GroupedMenuRendererProps { items: MenuItem[]; lng: string; + activeMenus?: Record<string, boolean>; // 활성 메뉴 상태 추가 } -const GroupedMenuRenderer = ({ items, lng }: GroupedMenuRendererProps) => { +const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRendererProps) => { + // 활성 메뉴만 필터링 (activeMenus가 빈 객체면 모든 메뉴 표시) + const filteredItems = Object.keys(activeMenus).length > 0 + ? filterActiveAdditionalMenus(items, activeMenus) + : items; + // 그룹별로 아이템 분류 const groupItems = (items: MenuItem[]): GroupedMenuItems => { return items.reduce((groups, item) => { @@ -27,32 +34,44 @@ const GroupedMenuRenderer = ({ items, lng }: GroupedMenuRendererProps) => { }, {} as GroupedMenuItems); }; - const groupedItems = groupItems(items); + const groupedItems = groupItems(filteredItems); const groups = Object.keys(groupedItems); + // 활성 메뉴가 없으면 아무것도 렌더링하지 않음 + if (filteredItems.length === 0) { + return ( + <div className="p-4 w-[600px]"> + <p className="text-sm text-muted-foreground text-center py-8"> + 사용 가능한 메뉴가 없습니다. + </p> + </div> + ); + } + return ( <div className="p-4 w-[600px]"> - {groups.map((groupName, index) => ( - <div key={groupName} className={cn("mb-4", index < groups.length - 1 && "pb-2 border-b border-border/30")}> - {groupName !== 'default' && ( - <h3 className="text-sm font-semibold mb-2 text-primary">{groupName}</h3> - )} - <div className="grid grid-cols-2 gap-3"> - {groupedItems[groupName].map((item) => ( - <MenuListItem key={item.title} item={item} lng={lng} /> - ))} + {groups.map((groupName, index) => { + // 빈 그룹은 건너뛰기 + if (groupedItems[groupName].length === 0) return null; + + return ( + <div key={groupName} className={cn("mb-4", index < groups.length - 1 && "pb-2 border-b border-border/30")}> + {groupName !== 'default' && ( + <h3 className="text-sm font-semibold mb-2 text-primary">{groupName}</h3> + )} + <div className="grid grid-cols-2 gap-3"> + {groupedItems[groupName].map((item) => ( + <MenuListItem key={item.title} item={item} lng={lng} /> + ))} + </div> </div> - </div> - ))} + ); + })} </div> ); }; const MenuListItem = ({ item, lng }: { item: MenuItem; lng: string }) => { - - - - return ( <NavigationMenuLink asChild> <Link diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 6d70e6b2..45768a57 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -26,17 +26,30 @@ import { SearchIcon, BellIcon, Menu } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import Image from "next/image"; -import { mainNav, additionalNav, MenuSection, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트 +import { + mainNav, + additionalNav, + additional2Nav, + procurementNav, + salesNav, + engineeringNav, + MenuSection, + MenuItem, + mainNavVendor, + additionalNavVendor +} from "@/config/menuConfig"; import { MobileMenu } from "./MobileMenu"; import { CommandMenu } from "./command-menu"; import { useSession, signOut } from "next-auth/react"; import GroupedMenuRenderer from "./GroupedMenuRender"; +import { useActiveMenus, filterActiveMenus, filterActiveAdditionalMenus } from "@/hooks/use-active-menus"; export function Header() { const params = useParams(); const lng = params?.lng as string; const pathname = usePathname(); const { data: session } = useSession(); + const { activeMenus, isLoading } = useActiveMenus(); const userName = session?.user?.name || ""; const domain = session?.user?.domain || ""; @@ -45,23 +58,72 @@ export function Header() { .map((word) => word[0]?.toUpperCase()) .join(""); - - const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); // 모바일 메뉴 상태 + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); const toggleMobileMenu = () => { setIsMobileMenuOpen(!isMobileMenuOpen); }; - const isPartnerRoute = pathname?.includes("/partners"); + // 도메인별 메뉴 및 브랜딩 정보 가져오기 + const getDomainConfig = (pathname: string) => { + if (pathname?.includes("/partners")) { + return { + main: mainNavVendor, + additional: additionalNavVendor, + logoHref: `/${lng}/partners`, + brandName: "eVCP Partners", + basePath: `/${lng}/partners` + }; + } + + if (pathname?.includes("/procurement")) { + return { + main: procurementNav, + additional: additional2Nav, + logoHref: `/${lng}/procurement`, + brandName: "eVCP 구매관리", + basePath: `/${lng}/procurement` + }; + } + + if (pathname?.includes("/sales")) { + return { + main: salesNav, + additional: additional2Nav, + logoHref: `/${lng}/sales`, + brandName: "eVCP 기술영업", + basePath: `/${lng}/sales` + }; + } + + if (pathname?.includes("/engineering")) { + return { + main: engineeringNav, + additional: additional2Nav, + logoHref: `/${lng}/engineering`, + brandName: "eVCP 설계관리", + basePath: `/${lng}/engineering` + }; + } + + // 기본값: /evcp (전체 메뉴) + return { + main: mainNav, + additional: additionalNav, + logoHref: `/${lng}/evcp`, + brandName: "eVCP 삼성중공업", + basePath: `/${lng}/evcp` + }; + }; - const main = isPartnerRoute ? mainNavVendor : mainNav; - const additional = isPartnerRoute ? additionalNavVendor : additionalNav; + const { main: originalMain, additional: originalAdditional, logoHref, brandName, basePath } = getDomainConfig(pathname); - const basePath = `/${lng}${isPartnerRoute ? "/partners" : "/evcp"}`; + // 활성 메뉴만 필터링 (로딩 중이거나 에러 시에는 모든 메뉴 표시) + const main = isLoading ? originalMain : filterActiveMenus(originalMain, activeMenus); + const additional = isLoading ? originalAdditional : filterActiveAdditionalMenus(originalAdditional, activeMenus); return ( <> - {/* <header className="border-grid sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> */} <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="container-wrapper"> <div className="container flex h-14 items-center"> @@ -88,9 +150,9 @@ export function Header() { <span className="sr-only">Toggle Menu</span> </Button> - {/* 로고 영역 - 항상 표시 */} + {/* 로고 영역 - 도메인별 브랜딩 */} <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> - <Link href={`/${lng}/evcp`} className="flex items-center gap-2"> + <Link href={logoHref} className="flex items-center gap-2"> <Image className="dark:invert" src="/images/vercel.svg" @@ -99,20 +161,14 @@ export function Header() { height={20} /> <span className="hidden font-bold lg:inline-block"> - {isPartnerRoute - ? "eVCP Partners" - : pathname?.includes("/evcp") - ? "eVCP 삼성중공업" - : "eVCP"} + {brandName} </span> </Link> </div> - {/* 네비게이션 메뉴 - 내부 스크롤 적용, 드롭다운은 제약 없이 표시 */} + {/* 네비게이션 메뉴 - 도메인별 활성화된 메뉴만 표시 */} <div className="hidden md:block flex-1 min-w-0"> - {/* NavigationMenu는 z-index를 높게 설정하여 드롭다운이 제대로 표시되도록 함 */} <NavigationMenu className="relative z-50"> - {/* 스크롤 가능한 메뉴 리스트 컨테이너 */} <div className="w-full overflow-x-auto pb-1"> <NavigationMenuList className="flex-nowrap w-max"> {main.map((section: MenuSection) => ( @@ -124,7 +180,11 @@ export function Header() { {/* 그룹핑이 필요한 메뉴의 경우 GroupedMenuRenderer 사용 */} {section.useGrouping ? ( <NavigationMenuContent> - <GroupedMenuRenderer items={section.items} lng={lng} /> + <GroupedMenuRenderer + items={section.items} + lng={lng} + activeMenus={activeMenus} + /> </NavigationMenuContent> ) : ( <NavigationMenuContent> @@ -144,7 +204,7 @@ export function Header() { </NavigationMenuItem> ))} - {/* 추가 네비게이션 항목 */} + {/* 추가 네비게이션 항목 - 도메인별 활성화된 것만 */} {additional.map((item) => ( <NavigationMenuItem key={item.title}> <Link href={`/${lng}${item.href}`} legacyBehavior passHref> @@ -164,7 +224,7 @@ export function Header() { </NavigationMenu> </div> - {/* 우측 영역 - 고정 너비와 우선순위로 항상 표시되도록 함 */} + {/* 우측 영역 */} <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} <div className="hidden md:block md:w-auto"> @@ -177,11 +237,10 @@ export function Header() { {/* 알림 버튼 */} <Button variant="ghost" size="icon" className="relative" aria-label="Notifications"> <BellIcon className="h-5 w-5" /> - {/* 알림 뱃지 예시 */} <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span> </Button> - {/* 사용자 메뉴 (DropdownMenu) */} + {/* 사용자 메뉴 */} <DropdownMenu> <DropdownMenuTrigger asChild> <Avatar className="cursor-pointer h-8 w-8"> @@ -207,8 +266,16 @@ export function Header() { </div> </div> - {/* 모바일 메뉴 */} - {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} />} + {/* 모바일 메뉴 - 도메인별 활성화된 메뉴만 전달 */} + {isMobileMenuOpen && ( + <MobileMenu + lng={lng} + onClose={toggleMobileMenu} + activeMenus={activeMenus} + domainMain={originalMain} + domainAdditional={originalAdditional} + /> + )} </header> </> ); diff --git a/components/layout/MobileMenu.tsx b/components/layout/MobileMenu.tsx index 2e70aeba..dc02d2e3 100644 --- a/components/layout/MobileMenu.tsx +++ b/components/layout/MobileMenu.tsx @@ -5,29 +5,42 @@ import * as React from "react"; import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; -import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { MenuSection, MenuItem } from "@/config/menuConfig"; import { cn } from "@/lib/utils"; import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; +import { filterActiveMenus, filterActiveAdditionalMenus } from "@/hooks/use-active-menus"; interface MobileMenuProps { lng: string; onClose: () => void; + activeMenus?: Record<string, boolean>; + domainMain?: MenuSection[]; // 헤더에서 계산된 도메인별 메인 메뉴 + domainAdditional?: MenuItem[]; // 헤더에서 계산된 도메인별 추가 메뉴 } -export function MobileMenu({ lng, onClose }: MobileMenuProps) { +export function MobileMenu({ + lng, + onClose, + activeMenus = {}, + domainMain = [], + domainAdditional = [] +}: MobileMenuProps) { const router = useRouter(); - - + const handleLinkClick = (href: string) => { router.push(href); onClose(); }; - const pathname = usePathname(); - const isPartnerRoute = pathname?.includes("/partners"); - const main = isPartnerRoute ? mainNavVendor : mainNav; - const additional = isPartnerRoute ? additionalNavVendor : additionalNav; + // 활성 메뉴만 필터링 (activeMenus가 빈 객체면 모든 메뉴 표시) + const main = Object.keys(activeMenus).length > 0 + ? filterActiveMenus(domainMain, activeMenus) + : domainMain; + + const additional = Object.keys(activeMenus).length > 0 + ? filterActiveAdditionalMenus(domainAdditional, activeMenus) + : domainAdditional; return ( <Drawer open={true} onOpenChange={onClose}> @@ -36,42 +49,44 @@ export function MobileMenu({ lng, onClose }: MobileMenuProps) { <DrawerTitle /> <DrawerContent className="max-h-[60vh] p-0"> <div className="overflow-auto p-6"> - <nav> <ul className="space-y-4"> - {/* 메인 네비게이션 섹션 */} + {/* 메인 네비게이션 섹션 - 도메인별 활성화된 메뉴만 표시 */} {main.map((section: MenuSection) => ( - <li key={section.title}> - <h3 className="text-md font-medium">{section.title}</h3> - <ul className="mt-2 space-y-2"> - {section.items.map((item: MenuItem) => ( - <li key={item.title}> - <Link - href={`/${lng}${item.href}`} - className="text-indigo-600" - onClick={() => handleLinkClick(item.href)} - > - {item.title} - {item.label && ( - <span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs text-[#000000]"> - {item.label} - </span> + // 섹션에 아이템이 있는 경우에만 표시 + section.items.length > 0 && ( + <li key={section.title}> + <h3 className="text-md font-medium">{section.title}</h3> + <ul className="mt-2 space-y-2"> + {section.items.map((item: MenuItem) => ( + <li key={item.title}> + <Link + href={`/${lng}${item.href}`} + className="text-indigo-600" + onClick={() => handleLinkClick(item.href)} + > + {item.title} + {item.label && ( + <span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs text-[#000000]"> + {item.label} + </span> + )} + </Link> + {item.description && ( + <p className="text-xs text-gray-500">{item.description}</p> )} - </Link> - {item.description && ( - <p className="text-xs text-gray-500">{item.description}</p> - )} - </li> - ))} - </ul> - </li> + </li> + ))} + </ul> + </li> + ) ))} - - {/* 추가 네비게이션 항목 */} + + {/* 추가 네비게이션 항목 - 도메인별 활성화된 메뉴만 표시 */} {additional.map((item: MenuItem) => ( <li key={item.title}> <Link - href={item.href} + href={`/${lng}${item.href}`} className="block text-sm text-indigo-600" onClick={() => handleLinkClick(`/${lng}${item.href}`)} > diff --git a/components/layout/user-profile-badge.tsx b/components/layout/user-profile-badge.tsx new file mode 100644 index 00000000..815ef05d --- /dev/null +++ b/components/layout/user-profile-badge.tsx @@ -0,0 +1,62 @@ +// app/pending/components/user-profile-badge.tsx +"use client" + +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Clock, LogOut } from "lucide-react" +import { signOut } from "next-auth/react" + +interface UserProfileBadgeProps { + user?: { + name?: string | null + email?: string | null + image?: string | null + } | null +} + +export function UserProfileBadge({ user }: UserProfileBadgeProps) { + if (!user) return null + + const initials = user.name + ?.split(" ") + .map((word) => word[0]?.toUpperCase()) + .join("") + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <Avatar className="w-8 h-8"> + <AvatarImage src={user.image || ""} alt={user.name || ""} /> + <AvatarFallback> + {initials || "?"} + </AvatarFallback> + </Avatar> + <span className="text-sm font-medium">{user.name}</span> + </Button> + </DropdownMenuTrigger> + + <DropdownMenuContent align="end"> + <DropdownMenuLabel>계정 정보</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem disabled> + <Clock className="w-4 h-4 mr-2" /> + 승인 대기 중 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => signOut()}> + <LogOut className="w-4 h-4 mr-2" /> + 로그아웃 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +}
\ No newline at end of file |
