o2o-negosium-original/negodata/front/src/components/layout/Layout.tsx
Mina Choi cfee6e89d0 [feat] negodata: 권한 3단계(개발자 신설)·개발자 메뉴 그룹·디자인 시스템 페이지
- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음
- 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용)
- 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출
- /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅
- 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
2026-07-23 15:41:36 +09:00

600 lines
23 KiB
TypeScript

import { useEffect, useState, type ReactNode, type ElementType } from 'react';
import { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth';
import { useBranding } from '@/features/settings/useCompanySettings';
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 { useNavigate } from 'react-router';
import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal';
import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView';
import {
LayoutDashboard,
BarChart3,
Briefcase,
Users,
UserCog,
UserPen,
FileSpreadsheet,
Layers,
LogOut,
Search,
Sun,
Moon,
Building,
Palette,
ChevronRight,
Menu,
X,
BookOpen,
} 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; devOnly?: boolean };
// ownerOnly = 최고관리자 이상, devOnly = 개발자(내부 운영)만. 렌더 시 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 },
],
},
{
label: '개발자',
items: [
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', devOnly: true },
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
],
},
];
const menuItems: MenuItem[] = menuGroups.flatMap((g) => g.items);
// 메뉴 노출 판정 — devOnly 는 개발자만, ownerOnly 는 최고관리자 이상.
const canSee = (item: MenuItem, role?: string): boolean => {
if (item.devOnly) return role === '개발자';
if (item.ownerOnly) return role === '최고관리자' || role === '개발자';
return true;
};
const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드',
STATISTICS: '통계',
PRODUCTS: '상품관리',
PARTNERS: '협력사관리',
QUOTATION: '견적관리',
CARDS: '협상카드관리',
MEMBERS: '회원관리',
SETTINGS: '회사 설정',
DESIGN: '디자인 시스템',
NOTIFICATIONS: '알림',
};
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
const { user } = useAuth();
const navigate = useNavigate();
const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData
// 기준일시(오늘) — 로컬 타임존 기준 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) => canSee(item, 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-[100dvh] safe-x 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">
{branding.logoUrl ? (
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
) : (
<span
aria-hidden
className="size-4 rounded-[5px] bg-primary"
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
/>
)}
{branding.serviceName}
</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) => canSee(item, 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 box-content safe-t 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);
}}
onSelectGuide={(tab) => {
navigate(`/dashboard?guide=${tab}`);
setIsCmdOpen(false);
}}
onSelectSettings={(tab) => {
navigate(`/settings?tab=${tab}`);
setIsCmdOpen(false);
}}
canSeeSettings={visibleItems.some((i) => i.type === 'SETTINGS')}
/>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div>
);
}
/** ⌘K 빠른 이동 — 메뉴 검색·이동. 검색 대상 확장(견적명 등)은 추후 단계 */
function CommandMenu({
open,
onOpenChange,
items,
currentPage,
onSelect,
onSelectGuide,
onSelectSettings,
canSeeSettings,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
items: MenuItem[];
currentPage: PageType;
onSelect: (type: PageType) => void;
onSelectGuide: (tab: GuideTab) => void;
onSelectSettings: (tab: SettingsTab) => void;
canSeeSettings: boolean;
}) {
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;
// 이용안내 탭도 이동 대상 — 대시보드로 가면서 ?guide=<탭> 을 붙여 해당 탭으로 바로 연다.
const guideEntries = GUIDE_TABS.map((t) => ({ tab: t, label: `이용안내 · ${TAB_LABEL[t]}` }));
const filteredGuides = q ? guideEntries.filter((g) => g.label.toLowerCase().includes(q)) : guideEntries;
// 회사 설정 탭도 이동 대상(최고관리자만 — 메뉴와 같은 게이팅).
const settingsEntries = canSeeSettings
? SETTINGS_TABS.map((t) => ({ tab: t, label: `회사 설정 · ${SETTINGS_TAB_LABEL[t]}` }))
: [];
const filteredSettings = q ? settingsEntries.filter((e) => e.label.toLowerCase().includes(q)) : settingsEntries;
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 && filteredGuides.length === 0 && filteredSettings.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>
);
})
)}
{filteredSettings.map((e) => (
<button
key={e.tab}
type="button"
onClick={() => onSelectSettings(e.tab)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<Building size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{e.label}
</Typography>
</button>
))}
{filteredGuides.map((g) => (
<button
key={g.tab}
type="button"
onClick={() => onSelectGuide(g.tab)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<BookOpen size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{g.label}
</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="flex items-center gap-2 min-w-0 md:hidden">
<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]"
>
{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>
);
}