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 ( +
+

플랫폼 비율

+ + {/* 도넛 차트 */} +
+
+ + {/* 배경 원 */} + + {/* Android (teal) */} + + {/* iOS (primary blue) */} + + + {/* 중앙 텍스트 */} +
+ Total + Devices +
+
+
+ + {/* 범례 */} +
+
+
+ + iOS +
+ {ios}% +
+
+
+ + 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 ( +
+
+

최근 7일 발송 추이

+
+
+

발송 내역이 없습니다

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

최근 7일 발송 추이

+
+
+ + 발송 +
+
+ + 도달 +
+
+
+ + {/* 차트 영역 */} +
+ {/* Y축 라벨 + 그리드 */} +
+
+ 15k +
+
+
+ 10k +
+
+
+ 5k +
+
+
+ 0 +
+
+
+ + {/* 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일 발송 추이 차트 */} + + + {/* 최근 발송 내역 + 플랫폼 도넛 */} +
+ + +
+ ); }