diff --git a/react/src/features/dashboard/components/.gitkeep b/react/src/features/dashboard/components/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/react/src/features/dashboard/components/DashboardFilter.tsx b/react/src/features/dashboard/components/DashboardFilter.tsx
new file mode 100644
index 0000000..917baa4
--- /dev/null
+++ b/react/src/features/dashboard/components/DashboardFilter.tsx
@@ -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 (
+
+
+ {/* 날짜 범위 */}
+
+
+ {/* 서비스 드롭다운 */}
+
+
+ {/* 초기화 */}
+
+
+ {/* 조회 */}
+
+
+
+ );
+}
diff --git a/react/src/features/dashboard/components/PlatformDonut.tsx b/react/src/features/dashboard/components/PlatformDonut.tsx
new file mode 100644
index 0000000..436ca5b
--- /dev/null
+++ b/react/src/features/dashboard/components/PlatformDonut.tsx
@@ -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 (
+
+
플랫폼 비율
+
+ {/* 도넛 차트 */}
+
+
+
+ {/* 중앙 텍스트 */}
+
+ Total
+ Devices
+
+
+
+
+ {/* 범례 */}
+
+
+
+
+
+ Android
+
+
{android}%
+
+
+
+ );
+}
diff --git a/react/src/features/dashboard/components/RecentMessages.tsx b/react/src/features/dashboard/components/RecentMessages.tsx
new file mode 100644
index 0000000..c9edf6c
--- /dev/null
+++ b/react/src/features/dashboard/components/RecentMessages.tsx
@@ -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 = {
+ 완료: "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 (
+
+
+
최근 발송 내역
+
+ 전체 보기
+
+
+
+
+
+
+ |
+ 템플릿명
+ |
+
+ 타겟 수
+ |
+
+ 상태
+ |
+
+ 발송 시간
+ |
+
+
+
+ {messages.map((msg, i) => (
+
+ |
+ {msg.template}
+ |
+ {msg.targetCount} |
+
+
+ |
+ {msg.sentAt} |
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/react/src/features/dashboard/components/StatsCards.tsx b/react/src/features/dashboard/components/StatsCards.tsx
new file mode 100644
index 0000000..8348070
--- /dev/null
+++ b/react/src/features/dashboard/components/StatsCards.tsx
@@ -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 (
+
+ {cards.map((card) => (
+
+
+
{card.label}
+ {card.badge?.type === "trend" && (
+
+ trending_up
+ {card.badge.value}
+
+ )}
+ {card.badge?.type === "icon" && (
+
+ {card.badge.icon}
+
+ )}
+
+
+ {card.value}
+ {card.unit && (
+ {card.unit}
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/react/src/features/dashboard/components/WeeklyChart.tsx b/react/src/features/dashboard/components/WeeklyChart.tsx
new file mode 100644
index 0000000..1ea6c38
--- /dev/null
+++ b/react/src/features/dashboard/components/WeeklyChart.tsx
@@ -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(null);
+ const animatedRef = useRef(false);
+ const [hoverIndex, setHoverIndex] = useState(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;
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {/* 차트 영역 */}
+
+ {/* Y축 라벨 + 그리드 */}
+
+
+ {/* SVG 차트 영역 */}
+
+ {/* 호버존 */}
+ {data.map((d, i) => (
+
setHoverIndex(i)}
+ onMouseLeave={() => setHoverIndex(null)}
+ >
+ {/* 가이드라인 */}
+
+ {/* 툴팁 */}
+
+
+
+ {d.label} 발송 추이
+
+
+
+ 발송
+ {d.sent}
+
+
+
+ 도달
+ {d.reach}
+
+
+
+
+ ))}
+
+
+ {/* X축 날짜 라벨 */}
+
+ {data.map((d, i) => (
+
+ {d.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/react/src/features/dashboard/pages/DashboardPage.tsx b/react/src/features/dashboard/pages/DashboardPage.tsx
index 639e7a5..a35271e 100644
--- a/react/src/features/dashboard/pages/DashboardPage.tsx
+++ b/react/src/features/dashboard/pages/DashboardPage.tsx
@@ -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() {
return (
-
-
대시보드
-
+ <>
+ {/* 페이지 헤더 */}
+
+
+ {/* 필터 */}
+
+
+ {/* 통계 카드 */}
+
+
+ {/* 7일 발송 추이 차트 */}
+
+
+ {/* 최근 발송 내역 + 플랫폼 도넛 */}
+
+ >
);
}