feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9) #11

Merged
seonkyu.kim merged 3 commits from feature/9-dashboard into develop 2026-02-27 01:29:18 +00:00
7 changed files with 629 additions and 3 deletions
Showing only changes of commit 61508e25f7 - Show all commits

View File

@ -0,0 +1,73 @@
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;
}
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 }: 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}
/>
{/* 서비스 드롭다운 */}
<FilterDropdown
label="서비스"
value={selectedService}
options={SERVICES}
onChange={setSelectedService}
className="flex-1"
/>
{/* 초기화 */}
<FilterResetButton onClick={handleReset} />
{/* 조회 */}
<button
onClick={handleSearch}
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"
>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
interface PlatformData {
ios: number;
android: number;
}
interface PlatformDonutProps {
data?: PlatformData;
}
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
export default function PlatformDonut({ data = DEFAULT_DATA }: 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">
<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,85 @@
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[];
}
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 }: RecentMessagesProps) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden">
<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,79 @@
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[];
}
/** 하드코딩 기본 데이터 */
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 }: 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-start 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 flex items-center gap-0.5">
<span className="material-symbols-outlined text-[10px]">trending_up</span>
{card.badge.value}
</div>
)}
{card.badge?.type === "icon" && (
<div className={`${card.badge.color} rounded-full p-0.5`}>
<span className="material-symbols-outlined text-sm">{card.badge.icon}</span>
</div>
)}
</div>
<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,285 @@
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[];
}
/** 하드코딩 기본 데이터 (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 }: 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;
}, [data, count, xRatios]);
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">
{/* 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,33 @@
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";
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className="p-6"> <>
<h1 className="text-2xl font-bold"></h1> {/* 페이지 헤더 */}
</div> <PageHeader
title="대시보드"
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
/>
{/* 필터 */}
<DashboardFilter />
{/* 통계 카드 */}
<StatsCards />
{/* 7일 발송 추이 차트 */}
<WeeklyChart />
{/* 최근 발송 내역 + 플랫폼 도넛 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentMessages />
<PlatformDonut />
</div>
</>
); );
} }