summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-30 08:28:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-30 08:28:13 +0000
commit5b6313f16f508882a0ea67716b7dbaa1c6967f04 (patch)
tree3d1d8dafea2f31274ace3fbda08333e889e06d1c /components
parent3f0fad18483a5c800c79c5e33946d9bb384c10e2 (diff)
(대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응
Diffstat (limited to 'components')
-rw-r--r--components/layout/GroupedMenuRender.tsx53
-rw-r--r--components/layout/Header.tsx117
-rw-r--r--components/layout/MobileMenu.tsx87
-rw-r--r--components/layout/user-profile-badge.tsx62
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