SPMS_WEB/react/src/components/layout/AppSidebar.tsx
SEAN af6ecab428 feat: 가이드라인 기반 공통 컴포넌트 및 레이아웃 개선
- 공통 컴포넌트 11개 생성 (PageHeader, StatusBadge, CategoryBadge, FilterDropdown, DateRangeInput, SearchInput, FilterResetButton, Pagination, EmptyState, CopyButton, PlatformBadge)
- AppHeader: 다단계 breadcrumb, 알림 드롭다운 구현
- AppLayout: 푸터 개인정보처리방침/이용약관 모달 추가
- AppSidebar: 이메일 폰트 자동 축소 (clamp)
- SignupPage: 모달 닫기 버튼 x 아이콘으로 통일
- Suspense fallback SVG 스피너로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:27:21 +09:00

194 lines
6.6 KiB
TypeScript

import { useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
/** 단일 메뉴 항목 */
interface NavItem {
to: string;
icon: string;
label: string;
}
/** 드롭다운 메뉴 그룹 */
interface NavGroup {
icon: string;
label: string;
children: { to: string; label: string }[];
}
type NavEntry =
| { type: "item"; item: NavItem }
| { type: "group"; group: NavGroup }
| { type: "separator" };
/** 네비게이션 메뉴 정의 (가이드라인 기준) */
const navEntries: NavEntry[] = [
{ type: "item", item: { to: "/dashboard", icon: "dashboard", label: "대시보드" } },
{ type: "item", item: { to: "/services", icon: "manage_accounts", label: "서비스 관리" } },
{ type: "item", item: { to: "/messages", icon: "mail", label: "메시지 관리" } },
{ type: "item", item: { to: "/devices", icon: "devices", label: "기기 관리" } },
{
type: "group",
group: {
icon: "send",
label: "발송 관리",
children: [
{ to: "/statistics", label: "발송 통계" },
{ to: "/statistics/history", label: "발송 이력" },
],
},
},
{ type: "item", item: { to: "/tags", icon: "sell", label: "태그 관리" } },
{ type: "separator" },
{
type: "group",
group: {
icon: "settings",
label: "설정",
children: [
{ to: "/settings/notifications", label: "알림" },
{ to: "/settings", label: "마이 페이지" },
],
},
},
];
/** 단일 메뉴 아이템 */
function SidebarItem({ item }: { item: NavItem }) {
const location = useLocation();
const isActive = location.pathname === item.to || location.pathname.startsWith(item.to + "/");
return (
<NavLink
to={item.to}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
isActive
? "bg-white/10 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
>
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
{item.icon}
</span>
<span className="flex-1">{item.label}</span>
</NavLink>
);
}
/** 드롭다운 메뉴 그룹 */
function SidebarGroup({ group }: { group: NavGroup }) {
const location = useLocation();
const isChildActive = group.children.some(
(child) => location.pathname === child.to || location.pathname.startsWith(child.to + "/"),
);
const [isOpen, setIsOpen] = useState(isChildActive);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
isChildActive
? "bg-white/10 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
>
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
{group.icon}
</span>
<span className="flex-1 text-left">{group.label}</span>
<span
className="material-symbols-outlined text-base transition-transform"
style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
>
keyboard_arrow_down
</span>
</button>
{isOpen && (
<div className="mt-1 flex flex-col gap-1 pl-9">
{group.children.map((child) => {
const isActive = location.pathname === child.to;
return (
<NavLink
key={child.to}
to={child.to}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isActive
? "bg-white/5 text-primary"
: "text-gray-500 hover:bg-white/5 hover:text-gray-300"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isActive ? "bg-primary" : "bg-gray-500"
}`}
/>
<span>{child.label}</span>
</NavLink>
);
})}
</div>
)}
</div>
);
}
export default function AppSidebar() {
const { user, logout } = useAuthStore();
return (
<aside className="fixed left-0 top-0 z-50 flex h-screen w-64 flex-shrink-0 flex-col justify-between border-r border-gray-800 bg-sidebar text-white">
<div className="flex flex-col gap-6 p-6">
{/* 로고 → 홈 이동 */}
<Link to="/" className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/20">
<span className="material-symbols-outlined text-2xl text-white">grid_view</span>
</div>
<div className="flex flex-col">
<h1 className="text-base font-bold leading-none tracking-tight text-white">SPMS</h1>
<p className="mt-1 text-xs font-normal text-gray-400">Admin Console</p>
</div>
</Link>
{/* 네비게이션 */}
<nav className="flex flex-col gap-1">
{navEntries.map((entry, idx) => {
if (entry.type === "separator") {
return <div key={idx} className="mx-3 my-2 h-px bg-gray-800" />;
}
if (entry.type === "item") {
return <SidebarItem key={entry.item.to} item={entry.item} />;
}
return <SidebarGroup key={entry.group.label} group={entry.group} />;
})}
</nav>
</div>
{/* 사용자 프로필 */}
<div className="border-t border-gray-800 p-4">
<div className="flex items-center gap-3 px-2">
<div className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm">
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
</div>
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
<p className="truncate text-sm font-medium text-white">
{user?.name ?? "Admin User"}
</p>
<p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
{user?.email ?? "admin@spms.com"}
</p>
</div>
<button
onClick={logout}
className="rounded p-1 text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
title="로그아웃"
>
<span className="material-symbols-outlined text-[20px]">logout</span>
</button>
</div>
</div>
</aside>
);
}