feat: 발송 관리 페이지 구현 (#23) #24
|
|
@ -18,6 +18,11 @@ const pathLabels: Record<string, string> = {
|
|||
"/settings/notifications": "알림",
|
||||
};
|
||||
|
||||
/** 그룹 경로 → 그룹 라벨 (사이드바 그룹명과 매핑) */
|
||||
const groupLabels: Record<string, string> = {
|
||||
"/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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(
|
||||
|
|
|
|||
276
react/src/features/statistics/components/HistorySlidePanel.tsx
Normal file
276
react/src/features/statistics/components/HistorySlidePanel.tsx
Normal 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" />;
|
||||
}
|
||||
71
react/src/features/statistics/components/HourlyBarChart.tsx
Normal file
71
react/src/features/statistics/components/HourlyBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
react/src/features/statistics/components/MonthlyTrendChart.tsx
Normal file
265
react/src/features/statistics/components/MonthlyTrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
react/src/features/statistics/components/OpenRateTop5.tsx
Normal file
46
react/src/features/statistics/components/OpenRateTop5.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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 (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">통계 이력</h1>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">통계</h1>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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차 인증이 설정되었습니다. 보안이 한층 강화되었습니다." },
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user