SPMS_WEB/react/src/components/layout/AppHeader.tsx
SEAN 9db9d87dea feat: 서비스 관리 페이지 구현 (#14)
- 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세)
- 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달)
- 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크)
- 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달)
- 공통 훅 추출 (useShake, useBreadcrumbBack)
- 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit)
- 인증 페이지 useShake 공통 훅 리팩터링

Closes #14
2026-02-27 13:53:56 +09:00

200 lines
7.9 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import CategoryBadge from "@/components/common/CategoryBadge";
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
const pathLabels: Record<string, string> = {
"/dashboard": "대시보드",
"/services": "서비스 관리",
"/services/register": "서비스 등록",
"/messages": "메시지 관리",
"/messages/register": "메시지 등록",
"/statistics": "발송 통계",
"/statistics/history": "발송 이력",
"/devices": "기기 관리",
"/tags": "태그 관리",
"/settings": "마이 페이지",
"/settings/profile": "프로필 수정",
"/settings/notifications": "알림",
};
/**
* 동적 경로 패턴 매칭 규칙
* pattern으로 pathname을 매칭 → crumbs 함수가 추가할 브레드크럼 배열 반환
*/
const dynamicPatterns: {
pattern: RegExp;
crumbs: (match: RegExpMatchArray) => { path: string; label: string }[];
}[] = [
{
// /services/:id 또는 /services/:id/edit (register 제외)
pattern: /^\/services\/(?!register$)([^/]+)(\/edit)?$/,
crumbs: (match) => {
const id = match[1];
const isEdit = !!match[2];
const result = [{ path: `/services/${id}`, label: "서비스 상세" }];
if (isEdit) {
result.push({ path: `/services/${id}/edit`, label: "서비스 수정" });
}
return result;
},
},
];
/** pathname → breadcrumb 배열 생성 */
function buildBreadcrumbs(pathname: string) {
const segments = pathname.split("/").filter(Boolean);
const crumbs: { path: string; label: string }[] = [];
// 1) 정적 경로 매칭 (누적 경로 기반)
let currentPath = "";
for (const segment of segments) {
currentPath += `/${segment}`;
const label = pathLabels[currentPath];
if (label) {
crumbs.push({ path: currentPath, label });
}
}
// 2) 동적 경로 패턴 매칭
for (const { pattern, crumbs: buildDynamic } of dynamicPatterns) {
const match = pathname.match(pattern);
if (match) {
crumbs.push(...buildDynamic(match));
}
}
return crumbs;
}
/* ── 알림 더미 데이터 ── */
interface NotificationItem {
variant: "success" | "warning" | "error" | "info" | "default";
icon: string;
category: string;
title: string;
description: string;
time: string;
}
const MOCK_NOTIFICATIONS: NotificationItem[] = [
{ variant: "success", icon: "check_circle", category: "발송", title: "마케팅 발송", description: "마케팅 캠페인 발송이 완료되었습니다 (1,234건 중 1,230건 성공)", time: "14:30" },
{ variant: "warning", icon: "verified", category: "인증서", title: "iOS Production", description: "인증서가 7일 후 만료 예정입니다. 갱신이 필요합니다.", time: "09:15" },
{ variant: "error", icon: "error", category: "실패", title: "프로모션 알림", description: "FCM 토큰 만료로 인해 320건 발송에 실패했습니다", time: "1일 전" },
{ variant: "info", icon: "cloud", category: "서비스", title: "마케팅 발송", description: "신규 서비스가 정상 등록되었습니다", time: "2일 전" },
{ variant: "default", icon: "settings", category: "시스템", title: "SPMS", description: "정기 점검이 완료되어 정상 운영 중입니다", time: "3일 전" },
];
export default function AppHeader() {
const { pathname } = useLocation();
const crumbs = buildBreadcrumbs(pathname);
const [notiOpen, setNotiOpen] = useState(false);
const notiRef = useRef<HTMLDivElement>(null);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (notiRef.current && !notiRef.current.contains(e.target as Node)) {
setNotiOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 페이지 이동 시 닫기
useEffect(() => {
setNotiOpen(false);
}, [pathname]);
return (
<header className="fixed left-64 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-gray-200 bg-white px-10 shadow-sm">
{/* 브레드크럼 (다단계) */}
<nav className="flex min-w-0 flex-1 items-center overflow-hidden text-sm">
{crumbs.length === 0 ? (
<span className="font-medium text-foreground">Home</span>
) : (
<>
<Link to="/" className="flex-shrink-0 whitespace-nowrap text-gray-500 transition-colors hover:text-primary">Home</Link>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={crumb.path} className="flex flex-shrink-0 items-center">
<span className="material-symbols-outlined mx-2 text-base text-gray-300">chevron_right</span>
{isLast ? (
<span className="whitespace-nowrap font-medium text-foreground">{crumb.label}</span>
) : (
<Link to={crumb.path} className="whitespace-nowrap text-gray-500 transition-colors hover:text-primary">
{crumb.label}
</Link>
)}
</span>
);
})}
</>
)}
</nav>
{/* 우측 아이콘 */}
<div className="ml-auto flex flex-shrink-0 items-center gap-5 pl-4 text-gray-400">
{/* 알림 버튼 + 드롭다운 */}
<div className="relative" ref={notiRef}>
<button
onClick={() => setNotiOpen((v) => !v)}
className="group relative transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-2xl group-hover:animate-pulse">
notifications
</span>
<span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" />
</button>
{/* 알림 드롭다운 패널 */}
{notiOpen && (
<div className="absolute right-0 top-full mt-2 w-96 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg z-50">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 className="text-sm font-bold text-foreground"></h3>
<Link
to="/settings/notifications"
className="text-xs font-medium text-primary transition-colors hover:text-[#1d4ed8]"
>
</Link>
</div>
{/* 알림 목록 */}
<div className="overflow-y-auto" style={{ maxHeight: 200, overscrollBehavior: "contain" }}>
{MOCK_NOTIFICATIONS.map((noti, i) => (
<div
key={i}
className="cursor-pointer border-b border-gray-50 px-4 py-3 transition-colors hover:bg-gray-50"
>
<div className="mb-1 flex items-center gap-2">
<CategoryBadge variant={noti.variant} icon={noti.icon} label={noti.category} />
<p className="flex-1 truncate text-xs font-bold text-foreground">{noti.title}</p>
<span className="flex-shrink-0 text-[10px] text-gray-400">{noti.time}</span>
</div>
<p className="truncate text-[11px] text-gray-500">{noti.description}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* 구분선 */}
<div className="h-6 w-px bg-gray-200" />
{/* 프로필 */}
<Link
to="/settings"
className="flex items-center gap-2 transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-2xl">account_circle</span>
</Link>
</div>
</header>
);
}