feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/11
This commit is contained in:
김선규 2026-02-27 01:29:17 +00:00
commit c89cfeaa56
24 changed files with 1563 additions and 38 deletions

View File

@ -0,0 +1,27 @@
type CategoryVariant = "success" | "warning" | "error" | "info" | "default";
interface CategoryBadgeProps {
variant: CategoryVariant;
icon: string;
label: string;
}
const VARIANT_STYLES: Record<CategoryVariant, string> = {
success: "bg-green-50 text-green-700",
warning: "bg-amber-50 text-amber-700",
error: "bg-red-50 text-red-700",
info: "bg-blue-50 text-blue-700",
default: "bg-gray-100 text-gray-600",
};
/** 카테고리 뱃지 (아이콘 + 텍스트 패턴) */
export default function CategoryBadge({ variant, icon, label }: CategoryBadgeProps) {
return (
<span
className={`inline-flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ${VARIANT_STYLES[variant]}`}
>
<span className="material-symbols-outlined" style={{ fontSize: "10px" }}>{icon}</span>
{label}
</span>
);
}

View File

@ -0,0 +1,37 @@
import { useState } from "react";
import { toast } from "sonner";
interface CopyButtonProps {
text: string;
}
/** 클립보드 복사 + 피드백 */
export default function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("클립보드에 복사되었습니다");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("복사에 실패했습니다");
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center justify-center size-7 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title="복사"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{copied ? "check" : "content_copy"}
</span>
</button>
);
}

View File

@ -0,0 +1,74 @@
interface DateRangeInputProps {
startDate: string;
endDate: string;
onStartChange: (value: string) => void;
onEndChange: (value: string) => void;
startLabel?: string;
endLabel?: string;
disabled?: boolean;
}
/** 시작일~종료일 날짜 입력 (자동 보정) */
export default function DateRangeInput({
startDate,
endDate,
onStartChange,
onEndChange,
startLabel = "시작일",
endLabel = "종료일",
disabled,
}: DateRangeInputProps) {
const handleStartChange = (value: string) => {
onStartChange(value);
if (endDate && value > endDate) onEndChange(value);
};
const handleEndChange = (value: string) => {
onEndChange(value);
if (startDate && value < startDate) onStartChange(value);
};
return (
<>
{/* 시작일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{startLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={startDate}
onChange={(e) => handleStartChange(e.target.value)}
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>
<span className="text-gray-400 text-sm font-medium pb-2">~</span>
{/* 종료일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{endLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={endDate}
onChange={(e) => handleEndChange(e.target.value)}
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
message: string;
description?: string;
action?: ReactNode;
}
/** 빈 데이터 상태 표시 */
export default function EmptyState({ icon = "inbox", message, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span
className="material-symbols-outlined text-gray-300 mb-4"
style={{ fontSize: "48px" }}
>
{icon}
</span>
<p className="text-sm font-medium text-gray-500 mb-1">{message}</p>
{description && (
<p className="text-xs text-gray-400 mb-4">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useState, useRef, useEffect } from "react";
interface FilterDropdownProps {
label?: string;
value: string;
options: string[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
export default function FilterDropdown({
label,
value,
options,
onChange,
className = "",
disabled,
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
return (
<div className={`relative ${className}`} ref={ref}>
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<button
type="button"
onClick={() => !disabled && setOpen((v) => !v)}
disabled={disabled}
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300"
>
<span className="truncate">{value}</span>
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
expand_more
</span>
</button>
{open && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{options.map((opt) => (
<li
key={opt}
onClick={() => {
onChange(opt);
setOpen(false);
}}
className={`px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
opt === value ? "text-primary font-medium" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { useState } from "react";
interface FilterResetButtonProps {
onClick: () => void;
disabled?: boolean;
}
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
export default function FilterResetButton({ onClick, disabled }: FilterResetButtonProps) {
const [spinning, setSpinning] = useState(false);
const handleClick = () => {
onClick();
setSpinning(true);
setTimeout(() => setSpinning(false), 500);
};
return (
<button
onClick={handleClick}
disabled={disabled}
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-gray-500 disabled:hover:border-gray-300 disabled:hover:bg-transparent"
>
<span className="reset-tooltip"> </span>
<span
className={`material-symbols-outlined text-lg${spinning ? " spinning" : ""}`}
>
restart_alt
</span>
</button>
);
}

View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
action?: ReactNode;
}
/** 페이지 제목 + 설명 + 우측 액션 버튼 */
export default function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-[#0f172a]">{title}</h1>
{description && (
<p className="text-[#64748b] text-sm mt-1">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}

View File

@ -0,0 +1,103 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
onPageChange: (page: number) => void;
}
/** 테이블 페이지네이션 */
export default function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
onPageChange,
}: PaginationProps) {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalItems);
/** 표시할 페이지 번호 목록 (최대 5개) */
const getPageNumbers = () => {
const pages: number[] = [];
let startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, startPage + 4);
startPage = Math.max(1, endPage - 4);
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
if (totalPages <= 0) return null;
return (
<div className="flex items-center justify-between px-2 py-3">
<span className="text-sm text-gray-500">
{totalItems.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
</span>
<div className="flex items-center gap-1">
{/* 처음 */}
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
first_page
</span>
</button>
{/* 이전 */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_left
</span>
</button>
{/* 페이지 번호 */}
{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-8 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-primary text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
{/* 다음 */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_right
</span>
</button>
{/* 마지막 */}
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
last_page
</span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
interface PlatformBadgeProps {
platform: "ios" | "android";
}
/** iOS/Android 아이콘 뱃지 */
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
if (platform === "ios") {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<svg className="size-3" viewBox="0 0 384 512" fill="currentColor">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
iOS
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
android
</span>
Android
</span>
);
}

View File

@ -0,0 +1,36 @@
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
}
/** 검색 아이콘 + 텍스트 입력 */
export default function SearchInput({
value,
onChange,
placeholder = "검색...",
label,
}: SearchInputProps) {
return (
<div className="flex-1">
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
search
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
type StatusVariant = "success" | "error" | "warning" | "info" | "default";
interface StatusBadgeProps {
variant: StatusVariant;
label: string;
}
const VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
success: {
badge: "bg-green-50 text-green-700 border-green-200",
dot: "bg-green-500",
},
error: {
badge: "bg-red-50 text-red-700 border-red-200",
dot: "bg-red-500",
},
warning: {
badge: "bg-yellow-50 text-yellow-700 border-yellow-200",
dot: "bg-yellow-500",
},
info: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
},
default: {
badge: "bg-gray-100 text-gray-600 border-gray-200",
dot: "bg-gray-400",
},
};
/** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
const style = VARIANT_STYLES[variant];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${style.badge}`}
>
<span className={`size-1.5 rounded-full ${style.dot}`} />
{label}
</span>
);
}

View File

@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import CategoryBadge from "@/components/common/CategoryBadge";
/** 경로 → breadcrumb 레이블 매핑 */ /** 경로 → breadcrumb 레이블 매핑 */
const pathLabels: Record<string, string> = { const pathLabels: Record<string, string> = {
"/": "Home",
"/dashboard": "대시보드", "/dashboard": "대시보드",
"/services": "서비스 관리", "/services": "서비스 관리",
"/services/register": "서비스 등록", "/services/register": "서비스 등록",
@ -17,36 +18,139 @@ const pathLabels: Record<string, string> = {
"/settings/notifications": "알림", "/settings/notifications": "알림",
}; };
/** pathname → breadcrumb 배열 생성 (누적 경로 기반) */
function buildBreadcrumbs(pathname: string) {
const segments = pathname.split("/").filter(Boolean);
const crumbs: { path: string; label: string }[] = [];
let currentPath = "";
for (const segment of segments) {
currentPath += `/${segment}`;
const label = pathLabels[currentPath];
if (label) {
crumbs.push({ path: currentPath, label });
}
}
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() { export default function AppHeader() {
const location = useLocation(); const { pathname } = useLocation();
const isHome = location.pathname === "/"; const crumbs = buildBreadcrumbs(pathname);
const currentLabel = pathLabels[location.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 ( 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"> <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 items-center text-sm"> <nav className="flex min-w-0 flex-1 items-center overflow-hidden text-sm">
{isHome ? ( {crumbs.length === 0 ? (
<span className="font-medium text-foreground">Home</span> <span className="font-medium text-foreground">Home</span>
) : ( ) : (
<> <>
<Link to="/" className="text-gray-500 transition-colors hover:text-primary">Home</Link> <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> <span className="material-symbols-outlined mx-2 text-base text-gray-300">chevron_right</span>
<span className="font-medium text-foreground">{currentLabel}</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> </nav>
{/* 우측 아이콘 */} {/* 우측 아이콘 */}
<div className="ml-auto flex flex-shrink-0 items-center gap-5 text-gray-400"> <div className="ml-auto flex flex-shrink-0 items-center gap-5 pl-4 text-gray-400">
{/* 알림 */} {/* 알림 버튼 + 드롭다운 */}
<button className="group relative transition-colors hover:text-primary"> <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"> <span className="material-symbols-outlined text-2xl group-hover:animate-pulse">
notifications notifications
</span> </span>
<span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" /> <span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" />
</button> </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" /> <div className="h-6 w-px bg-gray-200" />

View File

@ -1,8 +1,12 @@
import { useState } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import AppSidebar from "./AppSidebar"; import AppSidebar from "./AppSidebar";
import AppHeader from "./AppHeader"; import AppHeader from "./AppHeader";
export default function AppLayout() { export default function AppLayout() {
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);
return ( return (
<div className="flex min-h-screen w-full flex-row overflow-x-hidden"> <div className="flex min-h-screen w-full flex-row overflow-x-hidden">
{/* 사이드바 (w-64 고정) */} {/* 사이드바 (w-64 고정) */}
@ -22,12 +26,84 @@ export default function AppLayout() {
<footer className="mt-auto flex items-center justify-between border-t border-gray-100 px-10 py-6 text-sm text-gray-400"> <footer className="mt-auto flex items-center justify-between border-t border-gray-100 px-10 py-6 text-sm text-gray-400">
<p>&copy; 2026 Team.Stein. All rights reserved.</p> <p>&copy; 2026 Team.Stein. All rights reserved.</p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a href="#" className="transition-colors hover:text-foreground"></a> <button
onClick={() => setPrivacyModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
<span className="text-gray-300">|</span> <span className="text-gray-300">|</span>
<a href="#" className="transition-colors hover:text-foreground"></a> <button
onClick={() => setTermsModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
</div> </div>
</footer> </footer>
</main> </main>
{/* ───── 서비스 이용약관 모달 ───── */}
{termsModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setTermsModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
description
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setTermsModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
{/* ───── 개인정보 처리방침 모달 ───── */}
{privacyModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setPrivacyModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
shield
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -171,11 +171,11 @@ export default function AppSidebar() {
<div className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm"> <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"} {user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
<p className="truncate text-sm font-medium text-white"> <p className="truncate text-sm font-medium text-white">
{user?.name ?? "Admin User"} {user?.name ?? "Admin User"}
</p> </p>
<p className="truncate text-xs text-gray-400"> <p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
{user?.email ?? "admin@spms.com"} {user?.email ?? "admin@spms.com"}
</p> </p>
</div> </div>

View File

@ -464,20 +464,18 @@ export default function SignupPage() {
description description
</span> </span>
</div> </div>
<h3 className="text-lg font-bold text-foreground"> <h3 className="flex-1 text-lg font-bold text-foreground">
</h3> </h3>
</div>
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
<div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setTermsModal(false)} onClick={() => setTermsModal(false)}
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50" className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
> >
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button> </button>
</div> </div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div> </div>
</div> </div>
)} )}
@ -497,20 +495,18 @@ export default function SignupPage() {
shield shield
</span> </span>
</div> </div>
<h3 className="text-lg font-bold text-foreground"> <h3 className="flex-1 text-lg font-bold text-foreground">
</h3> </h3>
</div>
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
<div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setPrivacyModal(false)} onClick={() => setPrivacyModal(false)}
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50" className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
> >
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button> </button>
</div> </div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,77 @@
import { useState } from "react";
import FilterDropdown from "@/components/common/FilterDropdown";
import DateRangeInput from "@/components/common/DateRangeInput";
import FilterResetButton from "@/components/common/FilterResetButton";
interface DashboardFilterProps {
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
loading?: boolean;
}
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
/** 오늘 날짜 YYYY-MM-DD */
function today() {
return new Date().toISOString().slice(0, 10);
}
/** 30일 전 */
function thirtyDaysAgo() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
}
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
const [dateEnd, setDateEnd] = useState(today);
const [selectedService, setSelectedService] = useState(SERVICES[0]);
// 초기화
const handleReset = () => {
setDateStart(thirtyDaysAgo());
setDateEnd(today());
setSelectedService(SERVICES[0]);
};
// 조회
const handleSearch = () => {
onSearch?.({ dateStart, dateEnd, service: selectedService });
};
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div className="flex items-end gap-4">
{/* 날짜 범위 */}
<DateRangeInput
startDate={dateStart}
endDate={dateEnd}
onStartChange={setDateStart}
onEndChange={setDateEnd}
disabled={loading}
/>
{/* 서비스 드롭다운 */}
<FilterDropdown
label="서비스"
value={selectedService}
options={SERVICES}
onChange={setSelectedService}
className="flex-1"
disabled={loading}
/>
{/* 초기화 */}
<FilterResetButton onClick={handleReset} disabled={loading} />
{/* 조회 */}
<button
onClick={handleSearch}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,87 @@
interface PlatformData {
ios: number;
android: number;
}
interface PlatformDonutProps {
data?: PlatformData;
loading?: boolean;
}
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
export default function PlatformDonut({ data = DEFAULT_DATA, loading }: PlatformDonutProps) {
const { ios, android } = data;
return (
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-xl">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
<h2 className="text-base font-bold text-[#0f172a] mb-6"> </h2>
{/* 도넛 차트 */}
<div className="flex-1 flex flex-col items-center justify-center relative">
<div className="relative size-48">
<svg className="size-full" viewBox="0 0 36 36">
{/* 배경 원 */}
<path
className="text-gray-100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="3"
/>
{/* Android (teal) */}
<path
className="text-teal-400"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${android}, 100`}
strokeWidth="3"
/>
{/* iOS (primary blue) */}
<path
className="text-primary"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${ios}, 100`}
strokeDashoffset={`${-android}`}
strokeWidth="3"
/>
</svg>
{/* 중앙 텍스트 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-gray-900">Total</span>
<span className="text-sm text-gray-500">Devices</span>
</div>
</div>
</div>
{/* 범례 */}
<div className="mt-8 flex flex-col gap-3 w-full px-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-primary flex-shrink-0" />
<span className="text-sm font-medium text-gray-600">iOS</span>
</div>
<span className="text-lg font-bold text-gray-900">{ios}%</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-teal-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-600">Android</span>
</div>
<span className="text-lg font-bold text-gray-900">{android}%</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { Link } from "react-router-dom";
import StatusBadge from "@/components/common/StatusBadge";
type StatusVariant = "success" | "error" | "warning" | "default";
const STATUS_MAP: Record<string, StatusVariant> = {
: "success",
: "error",
: "warning",
: "default",
};
type MessageStatus = keyof typeof STATUS_MAP;
interface RecentMessage {
template: string;
targetCount: string;
status: MessageStatus;
sentAt: string;
}
interface RecentMessagesProps {
messages?: RecentMessage[];
loading?: boolean;
}
const DEFAULT_MESSAGES: RecentMessage[] = [
{ template: "가을맞이 프로모션 알림", targetCount: "12,405", status: "완료", sentAt: "2026-02-15 14:00" },
{ template: "정기 점검 안내", targetCount: "45,100", status: "완료", sentAt: "2026-02-15 10:30" },
{ template: "비밀번호 변경 알림", targetCount: "1", status: "실패", sentAt: "2026-02-15 09:15" },
{ template: "신규 서비스 런칭", targetCount: "8,500", status: "진행", sentAt: "2026-02-15 09:00" },
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
];
export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }: RecentMessagesProps) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-base font-bold text-[#0f172a]"> </h2>
<Link
to="/statistics/history"
className="text-primary hover:text-[#1d4ed8] text-sm font-medium"
>
</Link>
</div>
<div className="overflow-x-auto flex-1">
<table className="w-full text-sm text-left h-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
릿
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{messages.map((msg, i) => (
<tr key={i} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-3.5 font-medium text-gray-900 text-center">
{msg.template}
</td>
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
<td className="px-6 py-3.5 text-center">
<StatusBadge
variant={STATUS_MAP[msg.status]}
label={msg.status}
/>
</td>
<td className="px-6 py-3.5 text-center text-gray-500">{msg.sentAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { Link } from "react-router-dom";
interface StatCard {
label: string;
value: string;
/** 값 뒤에 붙는 단위 (예: "%") */
unit?: string;
/** 우상단 뱃지 */
badge?: { type: "trend"; value: string } | { type: "icon"; icon: string; color: string };
link: string;
}
interface StatsCardsProps {
cards?: StatCard[];
loading?: boolean;
}
/** 하드코딩 기본 데이터 */
const DEFAULT_CARDS: StatCard[] = [
{
label: "오늘 발송 수",
value: "12,847",
badge: { type: "trend", value: "15%" },
link: "/statistics",
},
{
label: "성공률",
value: "98.7",
unit: "%",
badge: { type: "icon", icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: "45,230",
badge: { type: "icon", icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: "8",
badge: { type: "icon", icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
export default function StatsCards({ cards = DEFAULT_CARDS, loading }: StatsCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{cards.map((card) => (
<Link
key={card.label}
to={card.link}
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md hover:border-primary/30 transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
{card.badge?.type === "trend" && (
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full inline-flex items-center gap-0.5">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "10px" }}>trending_up</span>
{card.badge.value}
</div>
)}
{card.badge?.type === "icon" && (
<div className={`${card.badge.color} rounded-full size-6 inline-flex items-center justify-center`}>
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>{card.badge.icon}</span>
</div>
)}
</div>
{loading ? (
<div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
) : (
<div className="text-2xl font-bold text-gray-900">
{card.value}
{card.unit && (
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
)}
</div>
)}
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,300 @@
import { useRef, useEffect, useCallback, useState } from "react";
interface ChartDataPoint {
label: string;
/** Y축 비율 (0=최상단, 1=최하단) — 0이 최대값 */
blue: number;
green: number;
sent: string;
reach: string;
}
interface WeeklyChartProps {
data?: ChartDataPoint[];
loading?: boolean;
}
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
const DEFAULT_DATA: ChartDataPoint[] = [
{ label: "02.09", blue: 0.75, green: 0.8, sent: "3,750", reach: "3,000" },
{ label: "02.10", blue: 0.6, green: 0.675, sent: "6,000", reach: "4,875" },
{ label: "02.11", blue: 0.4, green: 0.475, sent: "9,000", reach: "7,875" },
{ label: "02.12", blue: 0.5, green: 0.575, sent: "7,500", reach: "6,375" },
{ label: "02.13", blue: 0.2, green: 0.275, sent: "12,000", reach: "10,875" },
{ label: "02.14", blue: 0.3, green: 0.35, sent: "10,500", reach: "9,750" },
{ label: "Today", blue: 0.1, green: 0.175, sent: "13,500", reach: "12,375" },
];
const MARGIN = 0.02;
export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) {
const areaRef = useRef<HTMLDivElement>(null);
const animatedRef = useRef(false);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const count = data.length;
const xRatios =
count === 1
? [MARGIN]
: data.map((_, i) => MARGIN + (i / (count - 1)) * (1 - MARGIN * 2));
const drawChart = useCallback(() => {
const area = areaRef.current;
if (!area || count === 0) return;
// 기존 SVG + 점 제거
area.querySelectorAll("svg, .chart-dot").forEach((el) => el.remove());
const W = area.offsetWidth;
const H = area.offsetHeight;
if (W === 0 || H === 0) return;
const toPixel = (xr: number, yr: number): [number, number] => [
Math.round(xr * W * 10) / 10,
Math.round(yr * H * 10) / 10,
];
const bluePoints = xRatios.map((x, i) => toPixel(x, data[i].blue));
const greenPoints = xRatios.map((x, i) => toPixel(x, data[i].green));
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
function makePath(points: [number, number][], color: string) {
if (points.length < 2) return null;
const path = document.createElementNS(NS, "path");
path.setAttribute(
"d",
points.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "),
);
path.setAttribute("fill", "none");
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", "2.5");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
return path;
}
const bluePath = makePath(bluePoints, "#2563EB");
const greenPath = makePath(greenPoints, "#22C55E");
area.appendChild(svg);
// 애니메이션 (최초 1회만)
const DURATION = 2000;
function getCumulativeRatios(pts: [number, number][]) {
const d = [0];
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0];
const dy = pts[i][1] - pts[i - 1][1];
d.push(d[i - 1] + Math.sqrt(dx * dx + dy * dy));
}
const t = d[d.length - 1];
return t > 0 ? d.map((v) => v / t) : d.map(() => 0);
}
function easeOutTime(r: number) {
return 1 - Math.sqrt(1 - r);
}
function renderLine(
path: SVGPathElement | null,
points: [number, number][],
color: string,
) {
if (path && !animatedRef.current) {
const len = path.getTotalLength();
path.style.strokeDasharray = String(len);
path.style.strokeDashoffset = String(len);
path.getBoundingClientRect(); // 강제 리플로
path.style.transition = `stroke-dashoffset ${DURATION}ms ease-out`;
path.style.strokeDashoffset = "0";
}
const ratios = getCumulativeRatios(points);
points.forEach(([px, py], i) => {
const dot = document.createElement("div");
dot.className = "chart-dot";
dot.style.left = px + "px";
dot.style.top = py + "px";
dot.style.borderColor = color;
area!.appendChild(dot);
if (!animatedRef.current && path) {
setTimeout(() => dot.classList.add("show"), easeOutTime(ratios[i]) * DURATION);
} else {
dot.classList.add("show");
}
});
}
renderLine(bluePath, bluePoints, "#2563EB");
renderLine(greenPath, greenPoints, "#22C55E");
animatedRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
/* data가 바뀌면 애니메이션 재생 */
useEffect(() => {
animatedRef.current = false;
}, [data]);
useEffect(() => {
drawChart();
let resizeTimer: ReturnType<typeof setTimeout>;
const handleResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(drawChart, 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
clearTimeout(resizeTimer);
};
}, [drawChart]);
// 호버존 계산 (CSS 기반)
const getHoverZoneStyle = (i: number): React.CSSProperties => {
if (count <= 1) return { left: 0, width: "100%" };
const halfGap =
i === 0
? ((xRatios[1] - xRatios[0]) / 2) * 100
: i === count - 1
? ((xRatios[count - 1] - xRatios[count - 2]) / 2) * 100
: ((xRatios[i + 1] - xRatios[i - 1]) / 4) * 100;
return {
left: `${(xRatios[i] * 100 - halfGap).toFixed(2)}%`,
width: `${(halfGap * 2).toFixed(2)}%`,
};
};
if (count === 0) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-bold text-[#0f172a]"> 7 </h2>
</div>
<div className="w-full h-72 flex items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-bold text-[#0f172a]"> 7 </h2>
<div className="flex items-center gap-4 text-xs font-medium">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-primary" />
<span className="text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-green-500" />
<span className="text-gray-600"></span>
</div>
</div>
</div>
{/* 차트 영역 */}
<div className="w-full h-72 relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-lg">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* Y축 라벨 + 그리드 */}
<div className="absolute inset-0 flex flex-col justify-between text-xs text-gray-400 pb-6 pr-4 pointer-events-none">
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">15k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">10k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">5k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">0</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
</div>
{/* SVG 차트 영역 */}
<div ref={areaRef} className="absolute top-1 bottom-6 left-10 right-4">
{/* 호버존 */}
{data.map((d, i) => (
<div
key={d.label}
className="absolute top-0 bottom-0 cursor-pointer z-[5]"
style={getHoverZoneStyle(i)}
onMouseEnter={() => setHoverIndex(i)}
onMouseLeave={() => setHoverIndex(null)}
>
{/* 가이드라인 */}
<div
className="absolute top-0 bottom-0 w-px left-1/2 bg-gray-300 pointer-events-none transition-opacity duration-150"
style={{ opacity: hoverIndex === i ? 1 : 0 }}
/>
{/* 툴팁 */}
<div
className="absolute left-1/2 pointer-events-none z-10 transition-all duration-150"
style={{
top: `${Math.max(0, Math.min(d.blue, d.green) * 100 - 5)}%`,
transform: `translateX(-50%) translateY(${hoverIndex === i ? 0 : 4}px)`,
opacity: hoverIndex === i ? 1 : 0,
visibility: hoverIndex === i ? "visible" : "hidden",
}}
>
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 min-w-[140px]">
<p className="text-xs font-bold text-gray-900 mb-2 pb-1.5 border-b border-gray-100">
{d.label}
</p>
<div className="flex items-center gap-2 mb-1.5">
<span className="size-2.5 rounded-full bg-[#2563EB] flex-shrink-0" />
<span className="text-xs text-gray-500 flex-1"></span>
<span className="text-xs font-semibold text-gray-900">{d.sent}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[#22C55E] flex-shrink-0" />
<span className="text-xs text-gray-500 flex-1"></span>
<span className="text-xs font-semibold text-gray-900">{d.reach}</span>
</div>
</div>
</div>
</div>
))}
</div>
{/* X축 날짜 라벨 */}
<div className="absolute bottom-0 left-10 right-4 h-5 text-xs text-gray-400">
{data.map((d, i) => (
<span
key={d.label}
className="absolute"
style={{
left: `${xRatios[i] * 100}%`,
transform: "translateX(-50%)",
}}
>
{d.label}
</span>
))}
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,136 @@
import { useState, useCallback } from "react";
import PageHeader from "@/components/common/PageHeader";
import DashboardFilter from "../components/DashboardFilter";
import StatsCards from "../components/StatsCards";
import WeeklyChart from "../components/WeeklyChart";
import RecentMessages from "../components/RecentMessages";
import PlatformDonut from "../components/PlatformDonut";
/** 랜덤 정수 (min~max) */
function rand(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/** 숫자를 천단위 콤마로 포맷 */
function fmt(n: number) {
return n.toLocaleString();
}
/** 통계 카드 랜덤 데이터 생성 */
function randomCards() {
const sent = rand(5000, 30000);
const rate = (95 + Math.random() * 4.9).toFixed(1);
const devices = rand(20000, 80000);
const services = rand(3, 15);
const trend = rand(1, 30);
return [
{
label: "오늘 발송 수",
value: fmt(sent),
badge: { type: "trend" as const, value: `${trend}%` },
link: "/statistics",
},
{
label: "성공률",
value: rate,
unit: "%",
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: fmt(devices),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(services),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** 주간 차트 랜덤 데이터 생성 */
function randomChart() {
const now = new Date();
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - (6 - i));
const label = i === 6 ? "Today" : `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
const blue = 0.1 + Math.random() * 0.7;
const green = Math.min(blue + 0.05 + Math.random() * 0.1, 0.95);
const sent = Math.round((1 - blue) * 15000);
const reach = Math.round((1 - green) * 15000);
return { label, blue, green, sent: fmt(sent), reach: fmt(reach) };
});
}
/** 최근 발송 내역 랜덤 데이터 생성 */
function randomMessages() {
const templates = [
"가을맞이 프로모션 알림", "정기 점검 안내", "비밀번호 변경 알림",
"신규 서비스 런칭", "야간 푸시 마케팅", "결제 완료 알림",
"이벤트 당첨 안내", "서비스 업데이트 공지", "보안 알림",
];
const statuses = ["완료", "완료", "완료", "실패", "진행", "예약"] as const;
const hours = ["09:00", "09:15", "10:30", "11:00", "14:00", "15:30", "18:00", "20:00"];
return Array.from({ length: 5 }, (_, i) => ({
template: templates[rand(0, templates.length - 1)],
targetCount: fmt(rand(1, 50000)),
status: statuses[rand(0, statuses.length - 1)],
sentAt: `2026-02-${String(rand(10, 27)).padStart(2, "0")} ${hours[rand(0, hours.length - 1)]}`,
})).sort((a, b) => (a.sentAt > b.sentAt ? -1 : 1));
}
/** 플랫폼 비율 랜덤 데이터 생성 */
function randomPlatform() {
const ios = rand(25, 75);
return { ios, android: 100 - ios };
}
export default function DashboardPage() { export default function DashboardPage() {
const [loading, setLoading] = useState(false);
const [cards, setCards] = useState<ReturnType<typeof randomCards> | undefined>();
const [chart, setChart] = useState<ReturnType<typeof randomChart> | undefined>();
const [messages, setMessages] = useState<ReturnType<typeof randomMessages> | undefined>();
const [platform, setPlatform] = useState<ReturnType<typeof randomPlatform> | undefined>();
const handleSearch = useCallback(() => {
setLoading(true);
setTimeout(() => {
setCards(randomCards());
setChart(randomChart());
setMessages(randomMessages());
setPlatform(randomPlatform());
setLoading(false);
}, 1200);
}, []);
return ( return (
<div className="p-6"> <>
<h1 className="text-2xl font-bold"></h1> {/* 페이지 헤더 */}
<PageHeader
title="대시보드"
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
/>
{/* 필터 */}
<DashboardFilter onSearch={handleSearch} loading={loading} />
{/* 통계 카드 */}
<StatsCards cards={cards} loading={loading} />
{/* 7일 발송 추이 차트 */}
<WeeklyChart data={chart} loading={loading} />
{/* 최근 발송 내역 + 플랫폼 도넛 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentMessages messages={messages} loading={loading} />
<PlatformDonut data={platform} loading={loading} />
</div> </div>
</>
); );
} }

View File

@ -139,3 +139,70 @@
.animate-shake { .animate-shake {
animation: shake 0.4s ease; animation: shake 0.4s ease;
} }
/* 필터 초기화 버튼 스핀 */
@keyframes spin-once {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.filter-reset-btn .spinning {
animation: spin-once 0.5s ease;
}
.filter-reset-btn .reset-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(4px);
background: #1f2937;
color: #fff;
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.15s,
transform 0.15s;
pointer-events: none;
z-index: 50;
}
.filter-reset-btn .reset-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: #1f2937;
}
.filter-reset-btn:hover .reset-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* 차트 공통 스타일 */
.chart-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background: white;
border: 2.5px solid;
transform: translate(-50%, -50%) scale(0);
box-sizing: border-box;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.chart-dot.show {
transform: translate(-50%, -50%) scale(1);
}

View File

@ -11,7 +11,15 @@ import { useAuthStore } from "@/stores/authStore";
function lazyPage(importFn: () => Promise<{ default: ComponentType }>) { function lazyPage(importFn: () => Promise<{ default: ComponentType }>) {
const LazyComponent = lazy(importFn); const LazyComponent = lazy(importFn);
return ( return (
<Suspense fallback={<div className="flex h-full items-center justify-center p-6"> ...</div>}> <Suspense fallback={
<div className="flex h-full flex-col items-center justify-center gap-3">
<svg className="size-9 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm text-gray-400">Loading...</span>
</div>
}>
<LazyComponent /> <LazyComponent />
</Suspense> </Suspense>
); );