feat: 발송 관리 페이지 구현 (#23) #24

Merged
seonkyu.kim merged 1 commits from feature/SPMS-23-statistics-pages into develop 2026-02-27 14:46:07 +00:00
12 changed files with 1488 additions and 12 deletions
Showing only changes of commit 530e92240a - Show all commits

View File

@ -18,6 +18,11 @@ const pathLabels: Record<string, string> = {
"/settings/notifications": "알림", "/settings/notifications": "알림",
}; };
/** 그룹 경로 → 그룹 라벨 (사이드바 그룹명과 매핑) */
const groupLabels: Record<string, string> = {
"/statistics": "발송 관리",
};
/** /**
* *
* pattern으로 pathname을 crumbs * pattern으로 pathname을 crumbs
@ -47,12 +52,21 @@ function buildBreadcrumbs(pathname: string) {
const crumbs: { path: string; label: string }[] = []; const crumbs: { path: string; label: string }[] = [];
// 1) 정적 경로 매칭 (누적 경로 기반) // 1) 정적 경로 매칭 (누적 경로 기반)
const isLastSegment = (i: number) => i === segments.length - 1;
let currentPath = ""; let currentPath = "";
for (const segment of segments) { for (let i = 0; i < segments.length; i++) {
currentPath += `/${segment}`; currentPath += `/${segments[i]}`;
const groupLabel = groupLabels[currentPath];
if (groupLabel) {
crumbs.push({ path: currentPath, label: groupLabel });
// 마지막 세그먼트일 때만 페이지 라벨도 추가
if (isLastSegment(i)) {
const label = pathLabels[currentPath]; const label = pathLabels[currentPath];
if (label) { if (label) crumbs.push({ path: currentPath, label });
crumbs.push({ path: currentPath, label }); }
} else {
const label = pathLabels[currentPath];
if (label) crumbs.push({ path: currentPath, label });
} }
} }

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown"; import FilterDropdown from "@/components/common/FilterDropdown";
@ -14,6 +14,8 @@ import { MOCK_MESSAGES, SERVICE_FILTER_OPTIONS } from "../types";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
export default function MessageListPage() { export default function MessageListPage() {
const [searchParams, setSearchParams] = useSearchParams();
// 필터 입력 상태 // 필터 입력 상태
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스"); const [serviceFilter, setServiceFilter] = useState("전체 서비스");
@ -24,6 +26,16 @@ export default function MessageListPage() {
const [appliedSearch, setAppliedSearch] = useState(""); const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = 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 [panelOpen, setPanelOpen] = useState(false);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>( const [selectedMessageId, setSelectedMessageId] = useState<string | null>(

View File

@ -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<HTMLDivElement>(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 (
<>
{/* 오버레이 */}
<div
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 패널 */}
<aside
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<button
onClick={onClose}
className="size-8 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 본문 */}
<div
ref={bodyRef}
className="flex-1 overflow-y-auto p-6 space-y-6"
style={{ overscrollBehavior: "contain" }}
>
{history ? (
<>
{/* 기본 정보 */}
<div>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h3>
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
<InfoRow label="발송 ID">
<span className="inline-flex items-center gap-1.5">
<span className="text-sm font-mono text-gray-900">{history.id}</span>
<CopyButton text={history.id} />
</span>
</InfoRow>
<Divider />
<InfoRow label="메시지 ID">
<span className="inline-flex items-center gap-1.5">
<span className="text-sm font-mono text-gray-900">{history.messageId}</span>
<CopyButton text={history.messageId} />
</span>
</InfoRow>
<Divider />
<InfoRow label="템플릿명">
<span className="text-sm font-medium text-gray-900 text-right max-w-[240px]">
{history.template}
</span>
</InfoRow>
<Divider />
<InfoRow label="서비스">
<span className="text-sm text-gray-900">{history.service}</span>
</InfoRow>
<Divider />
<InfoRow label="상태">
<StatusBadge variant={getStatusVariant(history.status)} label={history.status} />
</InfoRow>
<Divider />
<InfoRow label="발송 시간">
<span className="text-sm text-gray-900">{history.sentAt}</span>
</InfoRow>
<Divider />
<InfoRow label="완료 시간">
<span className="text-sm text-gray-900">{history.completedAt}</span>
</InfoRow>
</div>
</div>
{/* 발송 결과 */}
<div>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h3>
<div className="grid grid-cols-3 gap-3">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{formatNumber(history.total)}</p>
<p className="text-xs text-gray-500 mt-1"> </p>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center border border-green-100">
<p className="text-2xl font-bold text-green-700">{formatNumber(history.success)}</p>
<p className="text-xs text-green-600 mt-1"></p>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center border border-red-100">
<p className="text-2xl font-bold text-red-700">{formatNumber(history.fail)}</p>
<p className="text-xs text-red-600 mt-1"></p>
</div>
</div>
{/* 성공률 바 */}
<div className="mt-5">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">
<span className="text-green-600">{history.successRate}</span> %
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all duration-500"
style={{ width: `${history.successRate}%` }}
/>
</div>
</div>
{/* 오픈율 바 */}
<div className="mt-4">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">
<span className="text-primary">{formatNumber(history.openCount)}</span> {" "}
<span className="text-gray-300 mx-0.5">|</span>{" "}
<span className="text-primary">{history.openRate}</span> %
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${history.openRate}%` }}
/>
</div>
</div>
</div>
{/* 실패 사유 */}
{history.failReasons.length > 0 && (
<div>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h3>
<div className="space-y-2">
{history.failReasons.map((f) => (
<div
key={f.reason}
className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2.5"
>
<span className="text-sm text-gray-700">{f.reason}</span>
<span className="text-sm font-medium text-red-600">
{formatNumber(f.count)}
</span>
</div>
))}
</div>
</div>
)}
{/* 발송 내용 미리보기 */}
<div>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h3>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-900 mb-2">{history.msgTitle}</p>
<p className="text-sm text-gray-600 leading-relaxed">{history.msgBody}</p>
</div>
<div className="flex items-center justify-end gap-1.5 mt-2.5">
<span
className="material-symbols-outlined text-gray-400"
style={{ fontSize: "14px" }}
>
info
</span>
<span className="text-xs text-gray-400">
{" "}
<Link
to={`/messages?messageId=${encodeURIComponent(history.messageId)}`}
className="text-primary hover:text-blue-700 font-medium transition-colors"
>
</Link>
.
</span>
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
</div>
)}
</div>
{/* 푸터 */}
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-200">
<button
onClick={onClose}
className="w-full px-4 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
</div>
</aside>
</>
);
}
/** 기본 정보 행 */
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">{label}</span>
{children}
</div>
);
}
/** 구분선 */
function Divider() {
return <div className="h-px bg-gray-200" />;
}

View File

@ -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<number | null>(null);
return (
<div className="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 justify-between gap-[3px] min-h-[200px]">
{data.map((d, i) => {
const color = getBarColor(d.heightPercent);
return (
<div
key={d.hour}
className="flex-1 flex flex-col justify-end relative"
onMouseEnter={() => setHoverIndex(i)}
onMouseLeave={() => setHoverIndex(null)}
>
<div
className={`w-full rounded-t transition-colors cursor-pointer ${color.base} ${color.hover}`}
style={{ height: `${Math.max(d.heightPercent, 1)}%` }}
/>
{/* 툴팁 */}
<div
className="absolute left-1/2 z-20 pointer-events-none transition-all duration-150"
style={{
bottom: `calc(${d.heightPercent}% + 8px)`,
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-3 py-2 text-center whitespace-nowrap text-[11px] text-gray-700 leading-relaxed">
<strong>{String(d.hour).padStart(2, "0")}</strong>
<br />
{formatNumber(d.count)}
</div>
</div>
</div>
);
})}
</div>
{/* X축 라벨 */}
<div className="flex justify-between mt-3 text-xs text-gray-400 px-1">
<span>00</span>
<span>06</span>
<span>12</span>
<span>18</span>
<span>23</span>
</div>
</div>
);
}

View File

@ -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<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;
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<typeof setTimeout>;
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 (
<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]"> </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 className="flex items-center gap-2">
<span className="size-3 rounded-full bg-purple-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">
{["20k", "15k", "10k", "5k", "0"].map((label, i) => (
<div key={label} className="flex items-center w-full">
<span className="w-8 text-right mr-2">{label}</span>
<div className={`flex-1 h-px ${i === 4 ? "bg-gray-200" : "bg-gray-100"}`} />
</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, d.purple) * 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-[160px]">
<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 mb-1.5">
<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.success}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[#9333EA] flex-shrink-0" />
<span className="text-xs text-gray-500 flex-1"></span>
<span className="text-xs font-semibold text-gray-900">{d.open}</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

@ -0,0 +1,46 @@
import type { OpenRateRank } from "../types";
interface OpenRateTop5Props {
data: OpenRateRank[];
}
/** 오픈율 Top 5 랭킹 */
export default function OpenRateTop5({ data }: OpenRateTop5Props) {
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"> Top 5</h2>
<div className="flex-1 flex flex-col justify-between">
{data.map((item) => {
const isTop2 = item.rank <= 2;
return (
<div key={item.rank} className="flex items-center gap-3">
<span
className={`text-xs font-bold size-6 rounded-full flex items-center justify-center flex-shrink-0 ${
isTop2
? "text-primary bg-blue-50"
: "text-gray-500 bg-gray-100"
}`}
>
{item.rank}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{item.template}</p>
<div className="mt-1.5 h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className={`h-full rounded-full ${
isTop2 ? "bg-primary" : item.rank === 3 ? "bg-blue-300" : "bg-blue-200"
}`}
style={{ width: `${item.rate}%` }}
/>
</div>
</div>
<span className="text-sm font-bold text-gray-900 flex-shrink-0">
{item.rate}%
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@ -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 (
<div className="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 (green) */}
<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-2xl font-bold text-gray-900">{formatNumber(total)}</span>
<span className="text-xs text-gray-500"> </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>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{formatNumber(iosCount)}</span>
<span className="text-lg font-bold text-gray-900">{ios}%</span>
</div>
</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>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{formatNumber(androidCount)}</span>
<span className="text-lg font-bold text-gray-900">{android}%</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<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-blue-700 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="text-xs text-gray-500 uppercase bg-gray-100 border-b border-gray-200">
<tr>
<th className="px-6 py-3 font-semibold">릿</th>
<th className="px-6 py-3 font-semibold text-center"> </th>
<th className="px-6 py-3 font-semibold text-center"></th>
<th className="px-6 py-3 font-semibold text-center"></th>
<th className="px-6 py-3 font-semibold text-center"> </th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.map((item) => (
<tr key={item.template + item.sentAt} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-3.5 font-medium text-gray-900">{item.template}</td>
<td className="px-6 py-3.5 text-center text-gray-600">
{formatNumber(item.target)}
</td>
<td className="px-6 py-3.5 text-center font-medium text-gray-900">
{item.successRate}%
</td>
<td className="px-6 py-3.5 text-center">
<StatusBadge variant={getStatusVariant(item.status)} label={item.status} />
</td>
<td className="px-6 py-3.5 text-center text-gray-500">{item.sentAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{/* 총 발송 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500"> </h4>
<div className="bg-blue-50 text-primary rounded-full size-6 inline-flex items-center justify-center">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>send</span>
</div>
</div>
<div className="text-2xl font-bold text-gray-900">
{formatNumber(data.totalSent)}
</div>
</div>
{/* 성공률 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500"></h4>
<div className="bg-green-100 text-green-600 rounded-full size-6 inline-flex items-center justify-center">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>check_circle</span>
</div>
</div>
<div className="flex items-end justify-between">
<div className="text-2xl font-bold text-gray-900">
{data.successRate}
<span className="text-base text-gray-400 ml-0.5 font-normal">%</span>
</div>
<p className="text-xs text-gray-400">{formatNumber(data.success)}</p>
</div>
</div>
{/* 실패율 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500"></h4>
<div className="bg-red-50 text-red-600 rounded-full size-6 inline-flex items-center justify-center">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>error</span>
</div>
</div>
<div className="flex items-end justify-between">
<div className="text-2xl font-bold text-gray-900">
{data.failRate}
<span className="text-base text-gray-400 ml-0.5 font-normal">%</span>
</div>
<p className="text-xs text-gray-400">{formatNumber(data.fail)}</p>
</div>
</div>
{/* 오픈율 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500"></h4>
<div className="bg-purple-50 text-purple-600 rounded-full size-6 inline-flex items-center justify-center">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>ads_click</span>
</div>
</div>
<div className="text-2xl font-bold text-gray-900">
{data.openRate}
<span className="text-base text-gray-400 ml-0.5 font-normal">%</span>
</div>
</div>
</div>
);
}

View File

@ -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() { 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<SendHistory | null>(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 = () => (
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{COLUMNS.map((col) => (
<th
key={col}
className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center"
>
{col}
</th>
))}
</tr>
</thead>
);
return ( return (
<div className="p-6"> <div>
<h1 className="text-2xl font-bold"> </h1> <PageHeader
title="발송 이력"
description="서비스별 메시지 발송 상세 이력을 확인합니다."
action={
<button className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] rounded-lg text-sm font-medium transition-colors">
<span className="material-symbols-outlined text-lg">download</span>
</button>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
{/* 1줄: 검색어 / 서비스 / 상태 */}
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="발송 ID 또는 템플릿명 검색"
label="검색어"
disabled={loading}
/>
<FilterDropdown
label="서비스 분류"
value={serviceFilter}
options={SERVICE_FILTER_OPTIONS}
onChange={setServiceFilter}
className="w-[160px] flex-shrink-0"
disabled={loading}
/>
<FilterDropdown
label="상태"
value={statusFilter}
options={STATUS_FILTER_OPTIONS}
onChange={setStatusFilter}
className="w-[120px] flex-shrink-0"
disabled={loading}
/>
</div>
{/* 2줄: 날짜 범위 / 버튼 */}
<div className="flex items-end gap-4 mt-3">
<DateRangeInput
startDate={dateStart}
endDate={dateEnd}
onStartChange={setDateStart}
onEndChange={setDateEnd}
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
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>
{/* 테이블 */}
{loading ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
{renderTableHead()}
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-6 py-4 text-center">
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : paged.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
{renderTableHead()}
<tbody>
{paged.map((item, idx) => (
<tr
key={item.id}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(item)}
>
{/* 발송 ID */}
<td className="px-6 py-4 text-center">
<span
className="inline-flex items-center gap-1.5 font-mono text-xs text-gray-500"
onClick={(e) => e.stopPropagation()}
>
{item.id}
<CopyButton text={item.id} />
</span>
</td>
{/* 템플릿명 */}
<td className="px-6 py-4 text-center text-sm font-medium text-gray-900">
{item.template}
</td>
{/* 발송 시간 */}
<td className="px-6 py-4 text-center text-sm text-gray-500">
{item.sentAt}
</td>
{/* 대상 수 */}
<td className="px-6 py-4 text-center text-sm text-gray-900">
{formatNumber(item.total)}
</td>
{/* 성공 */}
<td className="px-6 py-4 text-center text-sm font-semibold text-primary">
{formatNumber(item.success)}
</td>
{/* 실패 */}
<td className={`px-6 py-4 text-center text-sm font-semibold ${item.fail > 0 ? "text-red-500" : "text-gray-400"}`}>
{formatNumber(item.fail)}
</td>
{/* 오픈율 */}
<td className="px-6 py-4 text-center text-sm font-bold text-gray-900">
{item.openRate}%
</td>
{/* 상태 */}
<td className="px-6 py-4 text-center">
<StatusBadge variant={getStatusVariant(item.status)} label={item.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
{/* 슬라이드 패널 */}
<HistorySlidePanel
isOpen={panelOpen}
onClose={() => setPanelOpen(false)}
history={selectedHistory}
/>
</div> </div>
); );
} }

View File

@ -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() { 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 ( return (
<div className="p-6"> <div>
<h1 className="text-2xl font-bold"></h1> <PageHeader
title="발송 통계"
description="서비스별 메시지 발송 현황과 통계를 확인할 수 있습니다."
/>
{/* 필터바 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-end gap-4">
<DateRangeInput
startDate={dateStart}
endDate={dateEnd}
onStartChange={setDateStart}
onEndChange={setDateEnd}
disabled={loading}
/>
<FilterDropdown
label="서비스"
value={serviceFilter}
options={SERVICE_FILTER_OPTIONS}
onChange={setServiceFilter}
className="w-[160px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
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>
{/* 통계 카드 */}
<StatsSummaryCards data={MOCK_STATS_SUMMARY} />
{/* 월간 발송 추이 */}
<MonthlyTrendChart data={MOCK_TREND_DATA} loading={loading} />
{/* 플랫폼 + 시간대별 (2컬럼) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<PlatformDistribution data={MOCK_PLATFORM_DATA} />
<HourlyBarChart data={MOCK_HOURLY_DATA} />
</div>
{/* 최근 이력 + 오픈율 Top 5 (3:1) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentHistoryTable data={MOCK_RECENT_HISTORY} />
<OpenRateTop5 data={MOCK_OPEN_RATE_TOP5} />
</div>
</div> </div>
); );
} }

View File

@ -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차 인증이 설정되었습니다. 보안이 한층 강화되었습니다." },
];