o2o-negosium-original/negodata/front/src/components/layout/Layout.tsx

517 lines
20 KiB
TypeScript

import { useEffect, useState, type ReactNode, type ElementType } from 'react';
import { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth';
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import {
LayoutDashboard,
BarChart3,
Briefcase,
Users,
UserCog,
UserPen,
FileSpreadsheet,
Layers,
LogOut,
Search,
Sun,
Moon,
Building,
ChevronRight,
Menu,
X,
} from 'lucide-react';
interface LayoutProps {
children: ReactNode;
currentPage: PageType;
setPage: (page: PageType) => void;
onLogout: () => void;
}
type SidebarUser = ReturnType<typeof useAuth>['user'];
type MenuItem = { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean };
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
// 그룹 라벨은 사이드바 섹션 헤더로 노출(접힘 상태에선 숨김).
const menuGroups: { label?: string; items: MenuItem[] }[] = [
{
items: [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
],
},
{
label: '업무',
items: [
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' },
],
},
{
label: '관리',
items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }],
},
];
const menuItems: MenuItem[] = menuGroups.flatMap((g) => g.items);
const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드',
STATISTICS: '통계',
PRODUCTS: '상품관리',
PARTNERS: '협력사관리',
QUOTATION: '견적관리',
CARDS: '협상카드관리',
MEMBERS: '회원관리',
NOTIFICATIONS: '알림',
};
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
const { user } = useAuth();
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
const today = new Date().toLocaleDateString('sv-SE');
const [isDark, setIsDark] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글
const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림
const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트
const [isCmdOpen, setIsCmdOpen] = useState(false); // ⌘K 빠른 이동
// 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다.
const expanded = isMobileOpen || isSidebarOpen;
const visibleItems = menuItems.filter((item) => !item.ownerOnly || user?.role === '최고관리자');
// ⌘K / Ctrl+K 로 빠른 이동 열기
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setIsCmdOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const toggleDarkMode = () => {
setIsDark(!isDark);
if (!isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<div className="min-h-screen bg-background text-foreground flex">
{/* 토스트는 sonner <Toaster/>(main.tsx)가 전역으로 처리한다 */}
{/* 모바일 드로어 백드롭 */}
{isMobileOpen && (
<div
className="fixed inset-0 z-30 bg-black/40 backdrop-blur-xs md:hidden"
onClick={() => setIsMobileOpen(false)}
aria-hidden
/>
)}
{/* Main Sidebar */}
<aside
className={cn(
'bg-sidebar border-r border-sidebar-border flex flex-col justify-between transition-all duration-300 z-40 fixed inset-y-0 left-0',
// 폭: 모바일은 항상 펼친 드로어(w-60), 데스크톱만 접기 토글
'w-60',
isSidebarOpen ? 'md:w-60' : 'md:w-16',
// 모바일 슬라이드 인/아웃 — 데스크톱은 항상 보임
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
'md:translate-x-0'
)}
>
<div className="min-h-0 flex flex-col">
{/* Sidebar Brand Header */}
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
{expanded && (
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
NegoData
</Typography>
)}
{/* 데스크톱: 사이드바 접기/펼치기 */}
<Button
variant="ghost"
onClick={() => setIsSidebarOpen((v) => !v)}
className={cn(
'hidden md:inline-flex p-1 h-auto rounded text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent',
!isSidebarOpen && 'mx-auto bg-sidebar-accent'
)}
title={isSidebarOpen ? '메뉴 접기' : '메뉴 열기'}
>
<ChevronRight size={14} className={cn('size-3.5', isSidebarOpen && 'rotate-180')} />
</Button>
{/* 모바일: 드로어 닫기 */}
<Button
variant="ghost"
onClick={() => setIsMobileOpen(false)}
className="md:hidden p-1 h-auto rounded text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
title="메뉴 닫기"
>
<X size={16} className="size-4" />
</Button>
</div>
{/* Nav User Profile Card */}
{expanded && <SidebarProfileCard user={user} />}
{/* Nav Items */}
<nav className="mt-1 px-3 overflow-y-auto">
{menuGroups.map((group, gi) => {
const items = group.items.filter((item) => !item.ownerOnly || user?.role === '최고관리자');
if (items.length === 0) return null;
return (
<div key={group.label ?? gi} className="space-y-0.5">
{group.label && expanded && (
<Typography
variant="caption"
className="block px-3 pt-4 pb-1 text-[10px] font-bold uppercase tracking-wider text-sidebar-foreground/70"
>
{group.label}
</Typography>
)}
{/* 접힘 상태에선 라벨 대신 얇은 구분선으로 그룹 경계를 남긴다 */}
{group.label && !expanded && <div className="my-2 border-t border-sidebar-border" />}
{items.map((item) => (
<NavItem
key={item.type}
item={item}
isActive={currentPage === item.type}
isOpen={expanded}
onClick={() => {
setPage(item.type);
setIsMobileOpen(false);
}}
/>
))}
</div>
);
})}
</nav>
</div>
{/* Sidebar Footer Controls */}
<div className="p-2 border-t border-sidebar-border space-y-1">
{/* 내 정보 수정 */}
<Button
id="sidebar-profile-edit"
variant="ghost"
onClick={() => setIsProfileOpen(true)}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
>
<UserPen size={14} className="size-3.5" />
{expanded && <span> </span>}
</Button>
{/* Theme switcher */}
<Button
variant="ghost"
onClick={toggleDarkMode}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
>
{isDark ? <Sun size={14} className="text-warning size-3.5" /> : <Moon size={14} className="size-3.5" />}
{expanded && <span>{isDark ? '라이트 모드' : '다크 모드'}</span>}
</Button>
{/* Logout btn */}
<Button
id="header-logout-button"
variant="ghost"
onClick={onLogout}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
>
<LogOut size={14} className="size-3.5" />
{expanded && <span> </span>}
</Button>
</div>
</aside>
{/* Main Container Wrapper */}
<div
className={cn(
'flex-1 flex flex-col transition-all duration-300',
// 모바일: 사이드바가 오버레이라 패딩 없음 / 데스크톱: 사이드바 폭만큼 확보
isSidebarOpen ? 'md:pl-60' : 'md:pl-16'
)}
>
{/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
<header className="h-14 bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* 모바일 햄버거 */}
<Button
variant="ghost"
onClick={() => setIsMobileOpen(true)}
className="md:hidden p-1.5 h-auto rounded text-muted-foreground"
title="메뉴 열기"
>
<Menu size={18} className="size-[18px]" />
</Button>
{/* 현재 페이지 — 모바일·데스크톱 모두 표시(로고는 사이드바/드로어 몫) */}
<Typography as="div" variant="small" className="font-bold truncate">
{pageLabelMap[currentPage]}
</Typography>
</div>
{/* 빠른 이동 트리거 */}
<button
type="button"
onClick={() => setIsCmdOpen(true)}
className="hidden md:flex w-56 lg:w-72 items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-muted-foreground transition-colors hover:bg-muted/70 cursor-pointer"
>
<span className="flex items-center gap-2 min-w-0">
<Search size={13} className="shrink-0" />
<Typography as="span" variant="caption" className="truncate">
</Typography>
</span>
<Typography as="kbd" variant="caption" className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
K
</Typography>
</button>
<div className="flex items-center gap-3 md:gap-4 justify-self-end">
<NotificationBell />
<HeaderMeta user={user} today={today} />
</div>
</header>
{/* Content Body Area */}
<main className="flex-1 p-4 md:p-8 overflow-y-auto bg-background">
<div className="max-w-7xl mx-auto space-y-6">{children}</div>
</main>
{/* Compact Admin footer info */}
<footer className="h-10 border-t border-border/60 bg-card flex items-center justify-between px-4 md:px-8">
<Typography variant="mono">Copyright &copy; O2O Inc. All rights reserved</Typography>
</footer>
</div>
<CommandMenu
open={isCmdOpen}
onOpenChange={setIsCmdOpen}
items={visibleItems}
currentPage={currentPage}
onSelect={(type) => {
setPage(type);
setIsCmdOpen(false);
}}
/>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div>
);
}
/** ⌘K 빠른 이동 — 메뉴 검색·이동. 검색 대상 확장(견적명 등)은 추후 단계 */
function CommandMenu({
open,
onOpenChange,
items,
currentPage,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
items: MenuItem[];
currentPage: PageType;
onSelect: (type: PageType) => void;
}) {
const [query, setQuery] = useState('');
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const q = query.trim().toLowerCase();
const filtered = q ? items.filter((i) => i.label.toLowerCase().includes(q)) : items;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="top-[18%] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-md"
>
<DialogTitle className="sr-only"> </DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-3.5">
<Search size={15} className="shrink-0 text-muted-foreground" />
<input
/* 커맨드 입력은 보더 없는 관례라 ui/Input 대신 소재 그대로 사용 */
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered.length > 0) onSelect(filtered[0].type);
}}
placeholder="이동할 메뉴 검색…"
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-72 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<Typography variant="muted" className="px-3 py-4 text-xs">
</Typography>
) : (
filtered.map((item) => {
const Icon = item.icon;
return (
<button
key={item.type}
type="button"
onClick={() => onSelect(item.type)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<Icon size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{item.label}
</Typography>
{item.type === currentPage && (
<Typography as="span" variant="caption">
</Typography>
)}
</button>
);
})
)}
</div>
</DialogContent>
</Dialog>
);
}
/** 라벨:값 한 줄 (사이드바 프로필 카드용) */
function InfoRow({
label,
value,
valueClassName,
title,
}: {
label: string;
value?: string;
valueClassName?: string;
title?: string;
}) {
return (
<div className="flex justify-between items-center">
<Typography variant="caption" className="text-sidebar-foreground">{label}</Typography>
<Typography variant="caption" title={title} className={cn('font-bold text-foreground/90', valueClassName)}>
{value}
</Typography>
</div>
);
}
/** 사이드바 사용자 프로필 카드 */
function SidebarProfileCard({ user }: { user: SidebarUser }) {
return (
<div className="p-3.5 mx-3 my-3 rounded-lg bg-card border border-sidebar-border flex flex-col gap-1.5">
<div className="flex items-center gap-2 pb-1.5 border-b border-sidebar-border/60">
<Building size={13} className="text-sidebar-primary" />
<Typography variant="caption" className="font-bold text-foreground truncate max-w-[140px]" title="소속 고객사">
{user?.company}
</Typography>
</div>
<div className="space-y-1">
<InfoRow label="사용자명:" value={user?.name} />
<InfoRow label="로그인 ID:" value={user?.loginId} valueClassName="font-mono" />
<InfoRow
label="이메일:"
value={user?.email}
title={user?.email}
valueClassName="font-mono truncate max-w-[80px]"
/>
<InfoRow label="연락처:" value={user?.contact} valueClassName="font-mono" />
<div className="flex justify-between items-center pt-1 border-t border-sidebar-border/60">
<Typography variant="caption" className="text-sidebar-foreground">:</Typography>
<Badge variant="secondary" className="text-[10px]">{user?.role}</Badge>
</div>
</div>
</div>
);
}
/** 사이드바 메뉴 항목 */
function NavItem({
item,
isActive,
isOpen,
onClick,
}: {
item: MenuItem;
isActive: boolean;
isOpen: boolean;
onClick: () => void;
}) {
const Icon = item.icon;
return (
<Button
id={item.id}
variant="ghost"
onClick={onClick}
className={cn(
'w-full justify-start gap-3 py-2 px-3 h-auto rounded-md text-xs tracking-tight',
isActive
? 'bg-sidebar-accent font-bold text-foreground hover:bg-sidebar-accent hover:text-foreground dark:hover:bg-sidebar-accent'
: 'font-semibold text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent/60'
)}
>
<Icon size={16} className={cn('size-4', isActive ? 'text-primary' : 'text-sidebar-foreground/85')} />
{isOpen && <span className="truncate">{item.label}</span>}
</Button>
);
}
/** 글로벌 헤더 우측 메타 정보 */
function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
return (
<div className="flex items-center gap-3 md:gap-4">
<Typography as="div" variant="caption" className="hidden lg:block">
: <span className="font-semibold text-foreground">{today}</span>
</Typography>
<div className="h-4 w-px bg-border hidden lg:block" />
<div className="flex items-center gap-2 min-w-0">
<span className="inline-block h-2 w-2 rounded-full bg-success animate-pulse shrink-0" />
<Typography as="span" variant="caption" className="shrink-0">:</Typography>
<Typography
as="span"
variant="caption"
id="header-company-name"
className="font-bold text-foreground truncate max-w-[140px] md:max-w-none"
>
{user?.company}
</Typography>
</div>
<div className="h-4 w-px bg-border hidden lg:block" />
<div className="hidden lg:flex items-center gap-2">
<Typography as="span" variant="caption">:</Typography>
<Typography as="span" variant="caption" className="font-semibold text-foreground">
{user?.name} ({user?.loginId})
</Typography>
<Badge variant="secondary" className="text-[9px]">{user?.role}</Badge>
</div>
</div>
);
}