- 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세) - 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달) - 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크) - 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달) - 공통 훅 추출 (useShake, useBreadcrumbBack) - 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit) - 인증 페이지 useShake 공통 훅 리팩터링 Closes #14
200 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|