- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음 - 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용) - 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출 - /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅 - 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
600 lines
23 KiB
TypeScript
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 © 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>
|
|
);
|
|
}
|