diff --git a/react/src/components/layout/AppHeader.tsx b/react/src/components/layout/AppHeader.tsx index 5977789..c529832 100644 --- a/react/src/components/layout/AppHeader.tsx +++ b/react/src/components/layout/AppHeader.tsx @@ -18,6 +18,11 @@ const pathLabels: Record = { "/settings/notifications": "알림", }; +/** 그룹 경로 → 그룹 라벨 (사이드바 그룹명과 매핑) */ +const groupLabels: Record = { + "/statistics": "발송 관리", +}; + /** * 동적 경로 패턴 매칭 규칙 * pattern으로 pathname을 매칭 → crumbs 함수가 추가할 브레드크럼 배열 반환 @@ -47,12 +52,21 @@ function buildBreadcrumbs(pathname: string) { const crumbs: { path: string; label: string }[] = []; // 1) 정적 경로 매칭 (누적 경로 기반) + const isLastSegment = (i: number) => i === segments.length - 1; let currentPath = ""; - for (const segment of segments) { - currentPath += `/${segment}`; - const label = pathLabels[currentPath]; - if (label) { - crumbs.push({ path: currentPath, label }); + for (let i = 0; i < segments.length; i++) { + currentPath += `/${segments[i]}`; + const groupLabel = groupLabels[currentPath]; + if (groupLabel) { + crumbs.push({ path: currentPath, label: groupLabel }); + // 마지막 세그먼트일 때만 페이지 라벨도 추가 + if (isLastSegment(i)) { + const label = pathLabels[currentPath]; + if (label) crumbs.push({ path: currentPath, label }); + } + } else { + const label = pathLabels[currentPath]; + if (label) crumbs.push({ path: currentPath, label }); } } diff --git a/react/src/features/message/pages/MessageListPage.tsx b/react/src/features/message/pages/MessageListPage.tsx index 0163f88..e76119f 100644 --- a/react/src/features/message/pages/MessageListPage.tsx +++ b/react/src/features/message/pages/MessageListPage.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo } from "react"; -import { Link } from "react-router-dom"; +import { useState, useMemo, useEffect } from "react"; +import { Link, useSearchParams } from "react-router-dom"; import PageHeader from "@/components/common/PageHeader"; import SearchInput from "@/components/common/SearchInput"; import FilterDropdown from "@/components/common/FilterDropdown"; @@ -14,6 +14,8 @@ import { MOCK_MESSAGES, SERVICE_FILTER_OPTIONS } from "../types"; const PAGE_SIZE = 10; export default function MessageListPage() { + const [searchParams, setSearchParams] = useSearchParams(); + // 필터 입력 상태 const [search, setSearch] = useState(""); const [serviceFilter, setServiceFilter] = useState("전체 서비스"); @@ -24,6 +26,16 @@ export default function MessageListPage() { const [appliedSearch, setAppliedSearch] = useState(""); const [appliedService, setAppliedService] = useState("전체 서비스"); + // URL 쿼리 파라미터로 messageId가 넘어온 경우 자동 검색 + useEffect(() => { + const messageId = searchParams.get("messageId"); + if (messageId) { + setSearch(messageId); + setAppliedSearch(messageId); + setSearchParams({}, { replace: true }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // 슬라이드 패널 상태 const [panelOpen, setPanelOpen] = useState(false); const [selectedMessageId, setSelectedMessageId] = useState( diff --git a/react/src/features/statistics/components/HistorySlidePanel.tsx b/react/src/features/statistics/components/HistorySlidePanel.tsx new file mode 100644 index 0000000..2f10c51 --- /dev/null +++ b/react/src/features/statistics/components/HistorySlidePanel.tsx @@ -0,0 +1,276 @@ +import { useEffect, useRef } from "react"; +import { Link } from "react-router-dom"; +import { formatNumber } from "@/utils/format"; +import CopyButton from "@/components/common/CopyButton"; +import StatusBadge from "@/components/common/StatusBadge"; +import type { SendHistory } from "../types"; + +interface HistorySlidePanelProps { + isOpen: boolean; + onClose: () => void; + history: SendHistory | null; +} + +/** 상태 → StatusBadge variant 매핑 */ +function getStatusVariant(status: string): "success" | "info" | "error" { + if (status === "완료") return "success"; + if (status === "진행") return "info"; + return "error"; +} + +/** 발송 상세 슬라이드 패널 */ +export default function HistorySlidePanel({ + isOpen, + onClose, + history, +}: HistorySlidePanelProps) { + const bodyRef = useRef(null); + + // 패널 열릴 때 스크롤 최상단 리셋 + useEffect(() => { + if (isOpen) { + bodyRef.current?.scrollTo(0, 0); + } + }, [isOpen, history]); + + // ESC 닫기 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // body 스크롤 잠금 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + return ( + <> + {/* 오버레이 */} +
+ + {/* 패널 */} + + + ); +} + +/** 기본 정보 행 */ +function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** 구분선 */ +function Divider() { + return
; +} diff --git a/react/src/features/statistics/components/HourlyBarChart.tsx b/react/src/features/statistics/components/HourlyBarChart.tsx new file mode 100644 index 0000000..9c0e415 --- /dev/null +++ b/react/src/features/statistics/components/HourlyBarChart.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { formatNumber } from "@/utils/format"; +import type { HourlyData } from "../types"; + +interface HourlyBarChartProps { + data: HourlyData[]; +} + +/** 바 높이에 따른 배경색 */ +function getBarColor(percent: number): { base: string; hover: string } { + if (percent >= 90) return { base: "bg-primary", hover: "hover:bg-blue-700" }; + if (percent >= 70) return { base: "bg-blue-400", hover: "hover:bg-blue-500" }; + if (percent >= 50) return { base: "bg-blue-300", hover: "hover:bg-blue-400" }; + if (percent >= 25) return { base: "bg-blue-200", hover: "hover:bg-blue-300" }; + return { base: "bg-blue-100", hover: "hover:bg-blue-200" }; +} + +/** 시간대별 발송 바 차트 */ +export default function HourlyBarChart({ data }: HourlyBarChartProps) { + const [hoverIndex, setHoverIndex] = useState(null); + + return ( +
+

시간대별 발송

+ +
+ {data.map((d, i) => { + const color = getBarColor(d.heightPercent); + return ( +
setHoverIndex(i)} + onMouseLeave={() => setHoverIndex(null)} + > +
+ {/* 툴팁 */} +
+
+ {String(d.hour).padStart(2, "0")}시 +
+ {formatNumber(d.count)}건 +
+
+
+ ); + })} +
+ + {/* X축 라벨 */} +
+ 00 + 06 + 12 + 18 + 23 +
+
+ ); +} diff --git a/react/src/features/statistics/components/MonthlyTrendChart.tsx b/react/src/features/statistics/components/MonthlyTrendChart.tsx new file mode 100644 index 0000000..fd198f7 --- /dev/null +++ b/react/src/features/statistics/components/MonthlyTrendChart.tsx @@ -0,0 +1,265 @@ +import { useRef, useEffect, useCallback, useState } from "react"; +import type { TrendDataPoint } from "../types"; + +interface MonthlyTrendChartProps { + data: TrendDataPoint[]; + loading?: boolean; +} + +const MARGIN = 0.02; + +/** 월간 발송 추이 라인 차트 (발송/성공/오픈 3라인) */ +export default function MonthlyTrendChart({ data, loading }: MonthlyTrendChartProps) { + 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; + + 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 purplePoints = xRatios.map((x, i) => toPixel(x, data[i].purple)); + + 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"); + const purplePath = makePath(purplePoints, "#9333EA"); + area.appendChild(svg); + + 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"); + renderLine(purplePath, purplePoints, "#9333EA"); + + animatedRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + useEffect(() => { + animatedRef.current = false; + }, [data]); + + 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]); + + 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)}%`, + }; + }; + + return ( +
+
+

월간 발송 추이

+
+
+ + 발송 +
+
+ + 성공 +
+
+ + 오픈 +
+
+
+ +
+ {loading && ( +
+ + + + +
+ )} + + {/* Y축 라벨 + 그리드 */} +
+ {["20k", "15k", "10k", "5k", "0"].map((label, i) => ( +
+ {label} +
+
+ ))} +
+ + {/* SVG 차트 영역 */} +
+ {data.map((d, i) => ( +
setHoverIndex(i)} + onMouseLeave={() => setHoverIndex(null)} + > + {/* 가이드라인 */} +
+ {/* 툴팁 */} +
+
+

+ {d.label} 발송 추이 +

+
+ + 발송 + {d.sent} +
+
+ + 성공 + {d.success} +
+
+ + 오픈 + {d.open} +
+
+
+
+ ))} +
+ + {/* X축 날짜 라벨 */} +
+ {data.map((d, i) => ( + + {d.label} + + ))} +
+
+
+ ); +} diff --git a/react/src/features/statistics/components/OpenRateTop5.tsx b/react/src/features/statistics/components/OpenRateTop5.tsx new file mode 100644 index 0000000..48efec7 --- /dev/null +++ b/react/src/features/statistics/components/OpenRateTop5.tsx @@ -0,0 +1,46 @@ +import type { OpenRateRank } from "../types"; + +interface OpenRateTop5Props { + data: OpenRateRank[]; +} + +/** 오픈율 Top 5 랭킹 */ +export default function OpenRateTop5({ data }: OpenRateTop5Props) { + return ( +
+

오픈율 Top 5

+
+ {data.map((item) => { + const isTop2 = item.rank <= 2; + return ( +
+ + {item.rank} + +
+

{item.template}

+
+
+
+
+ + {item.rate}% + +
+ ); + })} +
+
+ ); +} diff --git a/react/src/features/statistics/components/PlatformDistribution.tsx b/react/src/features/statistics/components/PlatformDistribution.tsx new file mode 100644 index 0000000..c76f747 --- /dev/null +++ b/react/src/features/statistics/components/PlatformDistribution.tsx @@ -0,0 +1,81 @@ +import { formatNumber } from "@/utils/format"; +import type { PlatformDistributionData } from "../types"; + +interface PlatformDistributionProps { + data: PlatformDistributionData; +} + +/** 플랫폼별 발송 도넛 차트 */ +export default function PlatformDistribution({ data }: PlatformDistributionProps) { + const { ios, android, iosCount, androidCount, total } = data; + + return ( +
+

플랫폼별 발송

+ + {/* 도넛 차트 */} +
+
+ + {/* 배경 원 */} + + {/* Android (green) */} + + {/* iOS (primary blue) */} + + + {/* 중앙 텍스트 */} +
+ {formatNumber(total)} + 총 발송 +
+
+
+ + {/* 범례 */} +
+
+
+ + iOS +
+
+ {formatNumber(iosCount)}건 + {ios}% +
+
+
+
+ + Android +
+
+ {formatNumber(androidCount)}건 + {android}% +
+
+
+
+ ); +} diff --git a/react/src/features/statistics/components/RecentHistoryTable.tsx b/react/src/features/statistics/components/RecentHistoryTable.tsx new file mode 100644 index 0000000..299ab9a --- /dev/null +++ b/react/src/features/statistics/components/RecentHistoryTable.tsx @@ -0,0 +1,62 @@ +import { Link } from "react-router-dom"; +import { formatNumber } from "@/utils/format"; +import StatusBadge from "@/components/common/StatusBadge"; +import type { RecentHistory } from "../types"; + +interface RecentHistoryTableProps { + data: RecentHistory[]; +} + +/** 상태 → StatusBadge variant 매핑 */ +function getStatusVariant(status: string): "success" | "info" | "error" { + if (status === "완료") return "success"; + if (status === "진행") return "info"; + return "error"; +} + +/** 최근 발송 이력 테이블 (5행) */ +export default function RecentHistoryTable({ data }: RecentHistoryTableProps) { + return ( +
+
+

최근 발송 이력

+ + 전체 보기 + +
+
+ + + + + + + + + + + + {data.map((item) => ( + + + + + + + + ))} + +
템플릿명대상 수성공률상태발송 시간
{item.template} + {formatNumber(item.target)} + + {item.successRate}% + + + {item.sentAt}
+
+
+ ); +} diff --git a/react/src/features/statistics/components/StatsSummaryCards.tsx b/react/src/features/statistics/components/StatsSummaryCards.tsx new file mode 100644 index 0000000..f80ae5b --- /dev/null +++ b/react/src/features/statistics/components/StatsSummaryCards.tsx @@ -0,0 +1,74 @@ +import { formatNumber } from "@/utils/format"; +import type { StatsSummary } from "../types"; + +interface StatsSummaryCardsProps { + data: StatsSummary; +} + +/** 4개 통계 카드 (총 발송/성공/실패/오픈율) */ +export default function StatsSummaryCards({ data }: StatsSummaryCardsProps) { + return ( +
+ {/* 총 발송 */} +
+
+

총 발송

+
+ send +
+
+
+ {formatNumber(data.totalSent)} +
+
+ + {/* 성공률 */} +
+
+

성공률

+
+ check_circle +
+
+
+
+ {data.successRate} + % +
+

{formatNumber(data.success)}건

+
+
+ + {/* 실패율 */} +
+
+

실패율

+
+ error +
+
+
+
+ {data.failRate} + % +
+

{formatNumber(data.fail)}건

+
+
+ + {/* 오픈율 */} +
+
+

오픈율

+
+ ads_click +
+
+
+ {data.openRate} + % +
+
+
+ ); +} diff --git a/react/src/features/statistics/pages/StatisticsHistoryPage.tsx b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx index 27cf737..202d2c3 100644 --- a/react/src/features/statistics/pages/StatisticsHistoryPage.tsx +++ b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx @@ -1,7 +1,305 @@ +import { useState, useMemo } from "react"; +import PageHeader from "@/components/common/PageHeader"; +import SearchInput from "@/components/common/SearchInput"; +import FilterDropdown from "@/components/common/FilterDropdown"; +import DateRangeInput from "@/components/common/DateRangeInput"; +import FilterResetButton from "@/components/common/FilterResetButton"; +import Pagination from "@/components/common/Pagination"; +import EmptyState from "@/components/common/EmptyState"; +import StatusBadge from "@/components/common/StatusBadge"; +import CopyButton from "@/components/common/CopyButton"; +import HistorySlidePanel from "../components/HistorySlidePanel"; +import { formatNumber } from "@/utils/format"; +import { + SERVICE_FILTER_OPTIONS, + STATUS_FILTER_OPTIONS, + MOCK_SEND_HISTORY, +} from "../types"; +import type { SendHistory } from "../types"; + +const PAGE_SIZE = 10; + +const COLUMNS = [ + "발송 ID", + "템플릿명", + "발송 시간", + "대상 수", + "성공", + "실패", + "오픈율", + "상태", +]; + +/** 상태 → StatusBadge variant 매핑 */ +function getStatusVariant(status: string): "success" | "info" | "error" { + if (status === "완료") return "success"; + if (status === "진행") return "info"; + return "error"; +} + +/** 오늘 날짜 YYYY-MM-DD */ +function getToday() { + return new Date().toISOString().slice(0, 10); +} +/** 1달 전 날짜 */ +function getOneMonthAgo() { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().slice(0, 10); +} + export default function StatisticsHistoryPage() { + // 필터 입력 상태 + const [search, setSearch] = useState(""); + const [serviceFilter, setServiceFilter] = useState("전체 서비스"); + const [statusFilter, setStatusFilter] = useState("전체"); + const [dateStart, setDateStart] = useState(getOneMonthAgo); + const [dateEnd, setDateEnd] = useState(getToday); + const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(false); + + // 적용된 필터 + const [appliedSearch, setAppliedSearch] = useState(""); + const [appliedService, setAppliedService] = useState("전체 서비스"); + const [appliedStatus, setAppliedStatus] = useState("전체"); + + // 슬라이드 패널 + const [panelOpen, setPanelOpen] = useState(false); + const [selectedHistory, setSelectedHistory] = useState(null); + + // 조회 + const handleQuery = () => { + setLoading(true); + setTimeout(() => { + setAppliedSearch(search); + setAppliedService(serviceFilter); + setAppliedStatus(statusFilter); + setCurrentPage(1); + setLoading(false); + }, 400); + }; + + // 필터 초기화 + const handleReset = () => { + setSearch(""); + setServiceFilter("전체 서비스"); + setStatusFilter("전체"); + setDateStart(getOneMonthAgo()); + setDateEnd(getToday()); + setAppliedSearch(""); + setAppliedService("전체 서비스"); + setAppliedStatus("전체"); + setCurrentPage(1); + }; + + // 필터링 + const filtered = useMemo(() => { + return MOCK_SEND_HISTORY.filter((d) => { + if (appliedSearch) { + const q = appliedSearch.toLowerCase(); + if ( + !d.id.toLowerCase().includes(q) && + !d.template.toLowerCase().includes(q) + ) + return false; + } + if (appliedService !== "전체 서비스" && d.service !== appliedService) return false; + if (appliedStatus !== "전체" && d.status !== appliedStatus) return false; + return true; + }); + }, [appliedSearch, appliedService, appliedStatus]); + + // 페이지네이션 + const totalItems = filtered.length; + const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); + const paged = filtered.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ); + + // 행 클릭 + const handleRowClick = (item: SendHistory) => { + setSelectedHistory(item); + setPanelOpen(true); + }; + + // 테이블 헤더 렌더링 + const renderTableHead = () => ( + + + {COLUMNS.map((col) => ( + + {col} + + ))} + + + ); + return ( -
-

통계 이력

+
+ + download + 엑셀 다운로드 + + } + /> + + {/* 필터바 */} +
+ {/* 1줄: 검색어 / 서비스 / 상태 */} +
+ + + +
+ {/* 2줄: 날짜 범위 / 버튼 */} +
+ + + +
+
+ + {/* 테이블 */} + {loading ? ( +
+ + {renderTableHead()} + + {Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + ))} + +
+
+
+
+ ) : paged.length > 0 ? ( +
+
+ + {renderTableHead()} + + {paged.map((item, idx) => ( + handleRowClick(item)} + > + {/* 발송 ID */} + + {/* 템플릿명 */} + + {/* 발송 시간 */} + + {/* 대상 수 */} + + {/* 성공 */} + + {/* 실패 */} + + {/* 오픈율 */} + + {/* 상태 */} + + + ))} + +
+ e.stopPropagation()} + > + {item.id} + + + + {item.template} + + {item.sentAt} + + {formatNumber(item.total)} + + {formatNumber(item.success)} + 0 ? "text-red-500" : "text-gray-400"}`}> + {formatNumber(item.fail)} + + {item.openRate}% + + +
+
+ + {/* 페이지네이션 */} + +
+ ) : ( + + )} + + {/* 슬라이드 패널 */} + setPanelOpen(false)} + history={selectedHistory} + />
); } diff --git a/react/src/features/statistics/pages/StatisticsPage.tsx b/react/src/features/statistics/pages/StatisticsPage.tsx index 1ea67c0..47de337 100644 --- a/react/src/features/statistics/pages/StatisticsPage.tsx +++ b/react/src/features/statistics/pages/StatisticsPage.tsx @@ -1,7 +1,110 @@ +import { useState } from "react"; +import PageHeader from "@/components/common/PageHeader"; +import FilterDropdown from "@/components/common/FilterDropdown"; +import DateRangeInput from "@/components/common/DateRangeInput"; +import FilterResetButton from "@/components/common/FilterResetButton"; +import StatsSummaryCards from "../components/StatsSummaryCards"; +import MonthlyTrendChart from "../components/MonthlyTrendChart"; +import PlatformDistribution from "../components/PlatformDistribution"; +import HourlyBarChart from "../components/HourlyBarChart"; +import RecentHistoryTable from "../components/RecentHistoryTable"; +import OpenRateTop5 from "../components/OpenRateTop5"; +import { + SERVICE_FILTER_OPTIONS, + MOCK_STATS_SUMMARY, + MOCK_TREND_DATA, + MOCK_PLATFORM_DATA, + MOCK_HOURLY_DATA, + MOCK_RECENT_HISTORY, + MOCK_OPEN_RATE_TOP5, +} from "../types"; + +/** 오늘 날짜 YYYY-MM-DD */ +function getToday() { + return new Date().toISOString().slice(0, 10); +} +/** 1달 전 날짜 */ +function getOneMonthAgo() { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().slice(0, 10); +} + export default function StatisticsPage() { + // 필터 입력 상태 + const [serviceFilter, setServiceFilter] = useState("전체 서비스"); + const [dateStart, setDateStart] = useState(getOneMonthAgo); + const [dateEnd, setDateEnd] = useState(getToday); + const [loading, setLoading] = useState(false); + + // 조회 + const handleQuery = () => { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 400); + }; + + // 필터 초기화 + const handleReset = () => { + setServiceFilter("전체 서비스"); + setDateStart(getOneMonthAgo()); + setDateEnd(getToday()); + }; + return ( -
-

통계

+
+ + + {/* 필터바 */} +
+
+ + + + +
+
+ + {/* 통계 카드 */} + + + {/* 월간 발송 추이 */} + + + {/* 플랫폼 + 시간대별 (2컬럼) */} +
+ + +
+ + {/* 최근 이력 + 오픈율 Top 5 (3:1) */} +
+ + +
); } diff --git a/react/src/features/statistics/types.ts b/react/src/features/statistics/types.ts index b047e83..0157123 100644 --- a/react/src/features/statistics/types.ts +++ b/react/src/features/statistics/types.ts @@ -1 +1,175 @@ -// Statistics feature 타입 정의 +// ===== 발송 통계 타입 ===== + +/** 통계 요약 카드 데이터 */ +export interface StatsSummary { + totalSent: number; + success: number; + successRate: number; + fail: number; + failRate: number; + openRate: number; +} + +/** 월간 추이 차트 데이터 포인트 */ +export interface TrendDataPoint { + label: string; + /** Y축 비율 (0=최상단, 1=최하단) */ + blue: number; + green: number; + purple: number; + sent: string; + success: string; + open: string; +} + +/** 플랫폼 분포 데이터 */ +export interface PlatformDistributionData { + ios: number; + android: number; + iosCount: number; + androidCount: number; + total: number; +} + +/** 시간대별 발송 데이터 */ +export interface HourlyData { + hour: number; + count: number; + heightPercent: number; +} + +/** 최근 발송 이력 (요약) */ +export interface RecentHistory { + template: string; + target: number; + successRate: number; + status: "완료" | "진행" | "실패"; + sentAt: string; +} + +/** 오픈율 Top 5 항목 */ +export interface OpenRateRank { + rank: number; + template: string; + rate: number; +} + +/** 실패 사유 */ +export interface FailReason { + reason: string; + count: number; +} + +/** 발송 이력 상세 (테이블 + 패널) */ +export interface SendHistory { + id: string; + messageId: string; + template: string; + service: string; + status: "완료" | "진행" | "실패"; + sentAt: string; + completedAt: string; + total: number; + success: number; + fail: number; + successRate: number; + openCount: number; + openRate: number; + failReasons: FailReason[]; + msgTitle: string; + msgBody: string; +} + +// ===== 필터 옵션 ===== +export const SERVICE_FILTER_OPTIONS = ["전체 서비스", "마케팅 발송", "시스템 알림", "인증 발송"]; +export const STATUS_FILTER_OPTIONS = ["전체", "완료", "진행", "실패"]; + +// ===== 목 데이터 ===== + +export const MOCK_STATS_SUMMARY: StatsSummary = { + totalSent: 128470, + success: 126340, + successRate: 98.3, + fail: 2130, + failRate: 1.7, + openRate: 67.2, +}; + +export const MOCK_TREND_DATA: TrendDataPoint[] = [ + { label: "10.01", blue: 0.80, green: 0.78, purple: 0.55, sent: "16,000", success: "15,600", open: "11,000" }, + { label: "10.05", blue: 0.70, green: 0.68, purple: 0.50, sent: "14,000", success: "13,600", open: "10,000" }, + { label: "10.10", blue: 0.55, green: 0.52, purple: 0.38, sent: "11,000", success: "10,400", open: "7,600" }, + { label: "10.15", blue: 0.60, green: 0.57, purple: 0.42, sent: "12,000", success: "11,400", open: "8,400" }, + { label: "10.20", blue: 0.40, green: 0.38, purple: 0.28, sent: "8,000", success: "7,600", open: "5,600" }, + { label: "10.25", blue: 0.45, green: 0.42, purple: 0.30, sent: "9,000", success: "8,400", open: "6,000" }, + { label: "10.31", blue: 0.25, green: 0.22, purple: 0.15, sent: "5,000", success: "4,400", open: "3,000" }, +]; + +export const MOCK_PLATFORM_DATA: PlatformDistributionData = { + ios: 45, + android: 55, + iosCount: 57811, + androidCount: 70659, + total: 128470, +}; + +export const MOCK_HOURLY_DATA: HourlyData[] = [ + { hour: 0, count: 1285, heightPercent: 10 }, + { hour: 1, count: 642, heightPercent: 5 }, + { hour: 2, count: 385, heightPercent: 3 }, + { hour: 3, count: 257, heightPercent: 2 }, + { hour: 4, count: 642, heightPercent: 5 }, + { hour: 5, count: 1927, heightPercent: 15 }, + { hour: 6, count: 3212, heightPercent: 25 }, + { hour: 7, count: 5139, heightPercent: 40 }, + { hour: 8, count: 10920, heightPercent: 85 }, + { hour: 9, count: 12847, heightPercent: 100 }, + { hour: 10, count: 11562, heightPercent: 90 }, + { hour: 11, count: 8993, heightPercent: 70 }, + { hour: 12, count: 7708, heightPercent: 60 }, + { hour: 13, count: 8351, heightPercent: 65 }, + { hour: 14, count: 9635, heightPercent: 75 }, + { hour: 15, count: 7708, heightPercent: 60 }, + { hour: 16, count: 7066, heightPercent: 55 }, + { hour: 17, count: 6424, heightPercent: 50 }, + { hour: 18, count: 5781, heightPercent: 45 }, + { hour: 19, count: 5139, heightPercent: 40 }, + { hour: 20, count: 4497, heightPercent: 35 }, + { hour: 21, count: 3854, heightPercent: 30 }, + { hour: 22, count: 2569, heightPercent: 20 }, + { hour: 23, count: 1927, heightPercent: 15 }, +]; + +export const MOCK_RECENT_HISTORY: RecentHistory[] = [ + { template: "할로윈 이벤트 알림", target: 54000, successRate: 98.5, status: "완료", sentAt: "2023-10-31 10:00" }, + { template: "주말 특가 안내", target: 32150, successRate: 99.5, status: "완료", sentAt: "2023-10-30 18:30" }, + { template: "신규 업데이트 공지", target: 120500, successRate: 97.9, status: "완료", sentAt: "2023-10-30 09:00" }, + { template: "미사용 쿠폰 알림", target: 15400, successRate: 99.7, status: "완료", sentAt: "2023-10-29 14:15" }, + { template: "배송 지연 안내", target: 2100, successRate: 97.6, status: "완료", sentAt: "2023-10-29 11:20" }, +]; + +export const MOCK_OPEN_RATE_TOP5: OpenRateRank[] = [ + { rank: 1, template: "배송 지연 안내", rate: 95.4 }, + { rank: 2, template: "신규 업데이트 공지", rate: 72.1 }, + { rank: 3, template: "할로윈 이벤트 알림", rate: 68.5 }, + { rank: 4, template: "주말 특가 안내", rate: 45.2 }, + { rank: 5, template: "미사용 쿠폰 알림", rate: 22.8 }, +]; + +export const MOCK_SEND_HISTORY: SendHistory[] = [ + { id: "MSG-20231031-001", messageId: "TPL-001",template: "신규 프로모션 안내", service: "마케팅 발송", status: "완료", sentAt: "2023-10-31 14:20:01", completedAt: "2023-10-31 14:21:15", total: 15000, success: 14850, fail: 150, successRate: 99.0, openCount: 13260, openRate: 88.4, failReasons: [{ reason: "토큰 만료", count: 95 }, { reason: "수신 거부", count: 38 }, { reason: "기기 미등록", count: 17 }], msgTitle: "신규 프로모션 안내", msgBody: "지금 가입하시면 특별 할인 혜택을 드립니다. 한정 기간 프로모션을 놓치지 마세요!" }, + { id: "MSG-20231031-002", messageId: "TPL-002", template: "월간 이용내역 리포트", service: "시스템 알림", status: "진행", sentAt: "2023-10-31 15:05:44", completedAt: "—", total: 240000, success: 82100, fail: 1200, successRate: 34.2, openCount: 28000, openRate: 34.2, failReasons: [{ reason: "서버 타임아웃", count: 850 }, { reason: "토큰 만료", count: 350 }], msgTitle: "10월 이용내역 리포트", msgBody: "고객님의 10월 서비스 이용내역을 안내드립니다. 자세한 내용은 앱에서 확인해 주세요." }, + { id: "MSG-20231030-045", messageId: "TPL-003", template: "서버 점검 긴급 공지", service: "시스템 알림", status: "완료", sentAt: "2023-10-30 23:45:12", completedAt: "2023-10-30 23:46:01", total: 500000, success: 499998, fail: 2, successRate: 99.9, openCount: 495500, openRate: 99.1, failReasons: [{ reason: "기기 미등록", count: 2 }], msgTitle: "긴급 서버 점검 안내", msgBody: "10월 31일 02:00~04:00 서버 긴급 점검이 진행됩니다. 서비스 이용에 참고 부탁드립니다." }, + { id: "MSG-20231030-012", messageId: "TPL-004", template: "휴면계정 전환 사전안내", service: "시스템 알림", status: "실패", sentAt: "2023-10-30 10:00:00", completedAt: "2023-10-30 10:02:33", total: 12400, success: 4200, fail: 8200, successRate: 33.9, openCount: 1550, openRate: 12.5, failReasons: [{ reason: "수신 거부", count: 4200 }, { reason: "토큰 만료", count: 2800 }, { reason: "서버 오류", count: 1200 }], msgTitle: "휴면 계정 전환 예정 안내", msgBody: "30일 이상 미접속 시 휴면 계정으로 전환됩니다. 로그인하여 계정을 유지해 주세요." }, + { id: "MSG-20231029-005", messageId: "TPL-005", template: "포인트 만료 예정 알림", service: "마케팅 발송", status: "완료", sentAt: "2023-10-29 11:30:15", completedAt: "2023-10-29 11:31:45", total: 85200, success: 84900, fail: 300, successRate: 99.6, openCount: 38900, openRate: 45.8, failReasons: [{ reason: "토큰 만료", count: 210 }, { reason: "수신 거부", count: 90 }], msgTitle: "포인트 만료 예정 안내", msgBody: "보유하신 포인트 중 1,200P가 11월 30일에 만료 예정입니다. 기간 내 사용해 주세요." }, + { id: "MSG-20231028-011", messageId: "TPL-006", template: "앱 업데이트 기능 소개", service: "마케팅 발송", status: "완료", sentAt: "2023-10-28 14:10:00", completedAt: "2023-10-28 14:12:30", total: 100000, success: 98200, fail: 1800, successRate: 98.2, openCount: 60900, openRate: 62.1, failReasons: [{ reason: "토큰 만료", count: 1100 }, { reason: "기기 미등록", count: 450 }, { reason: "수신 거부", count: 250 }], msgTitle: "v3.2 업데이트 안내", msgBody: "새로운 기능이 추가되었습니다! 다크 모드, 위젯 지원 등 다양한 개선 사항을 확인해 보세요." }, + { id: "MSG-20231027-023", messageId: "TPL-007", template: "이벤트 당첨자 발표", service: "마케팅 발송", status: "완료", sentAt: "2023-10-27 16:55:33", completedAt: "2023-10-27 16:55:40", total: 1000, success: 1000, fail: 0, successRate: 100, openCount: 942, openRate: 94.2, failReasons: [], msgTitle: "이벤트 당첨을 축하합니다!", msgBody: "10월 할로윈 이벤트에 당첨되셨습니다. 앱에서 쿠폰을 확인해 주세요." }, + { id: "MSG-20231026-004", messageId: "TPL-008", template: "비밀번호 변경 알림", service: "인증 발송", status: "완료", sentAt: "2023-10-26 09:20:11", completedAt: "2023-10-26 09:20:15", total: 5420, success: 5418, fail: 2, successRate: 99.9, openCount: 4135, openRate: 76.3, failReasons: [{ reason: "기기 미등록", count: 2 }], msgTitle: "비밀번호가 변경되었습니다", msgBody: "회원님의 비밀번호가 정상적으로 변경되었습니다. 본인이 아닌 경우 고객센터로 문의해 주세요." }, + { id: "MSG-20231025-015", messageId: "TPL-009", template: "위클리 뉴스레터", service: "마케팅 발송", status: "완료", sentAt: "2023-10-25 10:00:00", completedAt: "2023-10-25 10:02:20", total: 18500, success: 18400, fail: 100, successRate: 99.5, openCount: 7740, openRate: 41.9, failReasons: [{ reason: "수신 거부", count: 65 }, { reason: "토큰 만료", count: 35 }], msgTitle: "이번 주 뉴스레터", msgBody: "이번 주 인기 상품과 추천 콘텐츠를 소개합니다. 지금 확인해 보세요!" }, + { id: "MSG-20231024-001", messageId: "TPL-010", template: "신규 가입 환영 메시지", service: "시스템 알림", status: "완료", sentAt: "2023-10-24 00:01:45", completedAt: "2023-10-24 00:01:48", total: 500, success: 498, fail: 2, successRate: 99.6, openCount: 460, openRate: 92.4, failReasons: [{ reason: "기기 미등록", count: 2 }], msgTitle: "환영합니다!", msgBody: "SPMS 서비스에 가입해 주셔서 감사합니다. 지금 바로 다양한 기능을 사용해 보세요." }, + { id: "MSG-20231023-008", messageId: "TPL-003", template: "서버 점검 정기 공지", service: "시스템 알림", status: "완료", sentAt: "2023-10-23 18:00:00", completedAt: "2023-10-23 18:01:22", total: 320000, success: 319800, fail: 200, successRate: 99.9, openCount: 280500, openRate: 87.7, failReasons: [{ reason: "토큰 만료", count: 150 }, { reason: "기기 미등록", count: 50 }], msgTitle: "정기 서버 점검 안내", msgBody: "10월 24일 03:00~05:00 정기 서버 점검이 진행됩니다. 서비스 이용에 참고 부탁드립니다." }, + { id: "MSG-20231022-003", messageId: "TPL-005", template: "쿠폰 발급 완료 알림", service: "마케팅 발송", status: "완료", sentAt: "2023-10-22 12:30:00", completedAt: "2023-10-22 12:31:10", total: 45000, success: 44800, fail: 200, successRate: 99.6, openCount: 32400, openRate: 72.3, failReasons: [{ reason: "수신 거부", count: 120 }, { reason: "토큰 만료", count: 80 }], msgTitle: "쿠폰이 발급되었습니다", msgBody: "요청하신 10% 할인 쿠폰이 발급되었습니다. 마이페이지에서 확인해 주세요." }, + { id: "MSG-20231021-019", messageId: "TPL-002", template: "결제 완료 알림", service: "시스템 알림", status: "실패", sentAt: "2023-10-21 08:45:30", completedAt: "2023-10-21 08:47:55", total: 8900, success: 3100, fail: 5800, successRate: 34.8, openCount: 2200, openRate: 71.0, failReasons: [{ reason: "서버 타임아웃", count: 3500 }, { reason: "토큰 만료", count: 1800 }, { reason: "수신 거부", count: 500 }], msgTitle: "결제가 완료되었습니다", msgBody: "주문하신 상품의 결제가 정상적으로 완료되었습니다. 배송 현황은 앱에서 확인해 주세요." }, + { id: "MSG-20231020-007", messageId: "TPL-001", template: "블랙프라이데이 사전 안내", service: "마케팅 발송", status: "완료", sentAt: "2023-10-20 10:00:00", completedAt: "2023-10-20 10:03:45", total: 200000, success: 198500, fail: 1500, successRate: 99.3, openCount: 155200, openRate: 78.2, failReasons: [{ reason: "토큰 만료", count: 900 }, { reason: "수신 거부", count: 400 }, { reason: "기기 미등록", count: 200 }], msgTitle: "블랙프라이데이 사전 안내", msgBody: "11월 블랙프라이데이 최대 70% 할인! 사전 알림을 신청하시면 추가 쿠폰을 드립니다." }, + { id: "MSG-20231019-042", messageId: "TPL-008", template: "2차 인증 설정 안내", service: "인증 발송", status: "완료", sentAt: "2023-10-19 15:20:00", completedAt: "2023-10-19 15:20:08", total: 3200, success: 3198, fail: 2, successRate: 99.9, openCount: 2850, openRate: 89.1, failReasons: [{ reason: "기기 미등록", count: 2 }], msgTitle: "2차 인증 설정 완료", msgBody: "회원님의 계정에 2차 인증이 설정되었습니다. 보안이 한층 강화되었습니다." }, +];