feat: 발송 통계 페이지 API 연동 (#39) #40

Merged
seonkyu.kim merged 1 commits from feature/SPMS-39-statistics-api-integration into develop 2026-03-02 02:54:14 +00:00
5 changed files with 633 additions and 246 deletions

View File

@ -0,0 +1,82 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
DailyStatRequest,
DailyStatResponse,
HourlyStatRequest,
HourlyStatResponse,
DeviceStatResponse,
HistoryListRequest,
HistoryListResponse,
HistoryDetailRequest,
HistoryDetailResponse,
HistoryExportRequest,
} from "@/features/statistics/types";
/** 일별 통계 조회 */
export function fetchDailyStats(
data: DailyStatRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<DailyStatResponse>>(
"/v1/in/stats/daily",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 시간대별 통계 조회 */
export function fetchHourlyStats(
data: HourlyStatRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HourlyStatResponse>>(
"/v1/in/stats/hourly",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 디바이스 통계 조회 */
export function fetchDeviceStats(serviceCode?: string) {
return apiClient.post<ApiResponse<DeviceStatResponse>>(
"/v1/in/stats/device",
{},
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 목록 조회 */
export function fetchHistoryList(
data: HistoryListRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HistoryListResponse>>(
"/v1/in/stats/history/list",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 상세 조회 */
export function fetchHistoryDetail(
data: HistoryDetailRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HistoryDetailResponse>>(
"/v1/in/stats/history/detail",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 내보내기 (xlsx blob) */
export function exportHistory(
data: HistoryExportRequest,
serviceCode?: string,
) {
return apiClient.post("/v1/in/stats/history/export", data, {
...(serviceCode ? { headers: { "X-Service-Code": serviceCode } } : {}),
responseType: "blob",
});
}

View File

@ -1,14 +1,16 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { formatNumber } from "@/utils/format"; import { formatNumber } from "@/utils/format";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import StatusBadge from "@/components/common/StatusBadge"; import StatusBadge from "@/components/common/StatusBadge";
import type { SendHistory } from "../types"; import { fetchHistoryDetail } from "@/api/statistics.api";
import type { HistoryDetailResponse } from "../types";
interface HistorySlidePanelProps { interface HistorySlidePanelProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
history: SendHistory | null; messageCode: string | null;
serviceCode?: string;
} }
/** 상태 → StatusBadge variant 매핑 */ /** 상태 → StatusBadge variant 매핑 */
@ -22,16 +24,31 @@ function getStatusVariant(status: string): "success" | "info" | "error" {
export default function HistorySlidePanel({ export default function HistorySlidePanel({
isOpen, isOpen,
onClose, onClose,
history, messageCode,
serviceCode,
}: HistorySlidePanelProps) { }: HistorySlidePanelProps) {
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
const [detail, setDetail] = useState<HistoryDetailResponse | null>(null);
const [loading, setLoading] = useState(false);
// 패널 열릴 때 상세 데이터 로드
useEffect(() => {
if (isOpen && messageCode) {
setLoading(true);
setDetail(null);
fetchHistoryDetail({ message_code: messageCode }, serviceCode)
.then((res) => setDetail(res.data.data))
.catch(() => setDetail(null))
.finally(() => setLoading(false));
}
}, [isOpen, messageCode, serviceCode]);
// 패널 열릴 때 스크롤 최상단 리셋 // 패널 열릴 때 스크롤 최상단 리셋
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
bodyRef.current?.scrollTo(0, 0); bodyRef.current?.scrollTo(0, 0);
} }
}, [isOpen, history]); }, [isOpen, messageCode]);
// ESC 닫기 // ESC 닫기
useEffect(() => { useEffect(() => {
@ -54,6 +71,37 @@ export default function HistorySlidePanel({
}; };
}, [isOpen]); }, [isOpen]);
// 스켈레톤 렌더링
const renderSkeleton = () => (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-3 w-16 bg-gray-200 rounded mb-3" />
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i}>
<div className="flex items-center justify-between">
<div className="h-3 w-16 bg-gray-200 rounded" />
<div className="h-3 w-24 bg-gray-200 rounded" />
</div>
{i < 6 && <div className="h-px bg-gray-200 mt-3" />}
</div>
))}
</div>
</div>
<div>
<div className="h-3 w-16 bg-gray-200 rounded mb-3" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-gray-50 rounded-lg p-4 text-center">
<div className="h-7 w-16 bg-gray-200 rounded mx-auto mb-1" />
<div className="h-3 w-12 bg-gray-200 rounded mx-auto" />
</div>
))}
</div>
</div>
</div>
);
return ( return (
<> <>
{/* 오버레이 */} {/* 오버레이 */}
@ -87,7 +135,9 @@ export default function HistorySlidePanel({
className="flex-1 overflow-y-auto p-6 space-y-6" className="flex-1 overflow-y-auto p-6 space-y-6"
style={{ overscrollBehavior: "contain" }} style={{ overscrollBehavior: "contain" }}
> >
{history ? ( {loading ? (
renderSkeleton()
) : detail ? (
<> <>
{/* 기본 정보 */} {/* 기본 정보 */}
<div> <div>
@ -95,40 +145,33 @@ export default function HistorySlidePanel({
</h3> </h3>
<div className="bg-gray-50 rounded-lg p-4 space-y-3"> <div className="bg-gray-50 rounded-lg p-4 space-y-3">
<InfoRow label="발송 ID"> <InfoRow label="메시지 코드">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="text-sm font-mono text-gray-900">{history.id}</span> <span className="text-sm font-mono text-gray-900">{detail.message_code}</span>
<CopyButton text={history.id} /> <CopyButton text={detail.message_code} />
</span> </span>
</InfoRow> </InfoRow>
<Divider /> <Divider />
<InfoRow label="메시지 ID"> <InfoRow label="제목">
<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]"> <span className="text-sm font-medium text-gray-900 text-right max-w-[240px]">
{history.template} {detail.title}
</span> </span>
</InfoRow> </InfoRow>
<Divider /> <Divider />
<InfoRow label="서비스"> <InfoRow label="서비스">
<span className="text-sm text-gray-900">{history.service}</span> <span className="text-sm text-gray-900">{detail.service_name}</span>
</InfoRow> </InfoRow>
<Divider /> <Divider />
<InfoRow label="상태"> <InfoRow label="상태">
<StatusBadge variant={getStatusVariant(history.status)} label={history.status} /> <StatusBadge variant={getStatusVariant(detail.status)} label={detail.status} />
</InfoRow> </InfoRow>
<Divider /> <Divider />
<InfoRow label="발송 시간"> <InfoRow label="최초 발송">
<span className="text-sm text-gray-900">{history.sentAt}</span> <span className="text-sm text-gray-900">{detail.first_sent_at}</span>
</InfoRow> </InfoRow>
<Divider /> <Divider />
<InfoRow label="완료 시간"> <InfoRow label="최종 발송">
<span className="text-sm text-gray-900">{history.completedAt}</span> <span className="text-sm text-gray-900">{detail.last_sent_at}</span>
</InfoRow> </InfoRow>
</div> </div>
</div> </div>
@ -140,15 +183,15 @@ export default function HistorySlidePanel({
</h3> </h3>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="bg-gray-50 rounded-lg p-4 text-center"> <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-2xl font-bold text-gray-900">{formatNumber(detail.target_count)}</p>
<p className="text-xs text-gray-500 mt-1"> </p> <p className="text-xs text-gray-500 mt-1"> </p>
</div> </div>
<div className="bg-green-50 rounded-lg p-4 text-center border border-green-100"> <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-2xl font-bold text-green-700">{formatNumber(detail.success_count)}</p>
<p className="text-xs text-green-600 mt-1"></p> <p className="text-xs text-green-600 mt-1"></p>
</div> </div>
<div className="bg-red-50 rounded-lg p-4 text-center border border-red-100"> <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-2xl font-bold text-red-700">{formatNumber(detail.fail_count)}</p>
<p className="text-xs text-red-600 mt-1"></p> <p className="text-xs text-red-600 mt-1"></p>
</div> </div>
</div> </div>
@ -158,13 +201,13 @@ export default function HistorySlidePanel({
<div className="flex justify-between text-xs mb-1.5"> <div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
<span className="text-green-600">{history.successRate}</span> % <span className="text-green-600">{detail.success_rate}</span> %
</span> </span>
</div> </div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden"> <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div <div
className="h-full bg-green-500 rounded-full transition-all duration-500" className="h-full bg-green-500 rounded-full transition-all duration-500"
style={{ width: `${history.successRate}%` }} style={{ width: `${detail.success_rate}%` }}
/> />
</div> </div>
</div> </div>
@ -174,28 +217,28 @@ export default function HistorySlidePanel({
<div className="flex justify-between text-xs mb-1.5"> <div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
<span className="text-primary">{formatNumber(history.openCount)}</span> {" "} <span className="text-primary">{formatNumber(detail.open_count)}</span> {" "}
<span className="text-gray-300 mx-0.5">|</span>{" "} <span className="text-gray-300 mx-0.5">|</span>{" "}
<span className="text-primary">{history.openRate}</span> % <span className="text-primary">{detail.open_rate}</span> %
</span> </span>
</div> </div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden"> <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div <div
className="h-full bg-primary rounded-full transition-all duration-500" className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${history.openRate}%` }} style={{ width: `${detail.open_rate}%` }}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* 실패 사유 */} {/* 실패 사유 */}
{history.failReasons.length > 0 && ( {detail.fail_reasons && detail.fail_reasons.length > 0 && (
<div> <div>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3"> <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{history.failReasons.map((f) => ( {detail.fail_reasons.map((f) => (
<div <div
key={f.reason} key={f.reason}
className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2.5" className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2.5"
@ -216,8 +259,8 @@ export default function HistorySlidePanel({
</h3> </h3>
<div className="bg-gray-50 rounded-lg p-4"> <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 font-medium text-gray-900 mb-2">{detail.title}</p>
<p className="text-sm text-gray-600 leading-relaxed">{history.msgBody}</p> <p className="text-sm text-gray-600 leading-relaxed">{detail.body}</p>
</div> </div>
<div className="flex items-center justify-end gap-1.5 mt-2.5"> <div className="flex items-center justify-end gap-1.5 mt-2.5">
<span <span
@ -229,7 +272,7 @@ export default function HistorySlidePanel({
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{" "} {" "}
<Link <Link
to={`/messages?messageId=${encodeURIComponent(history.messageId)}`} to={`/messages?messageId=${encodeURIComponent(detail.message_code)}`}
className="text-primary hover:text-blue-700 font-medium transition-colors" className="text-primary hover:text-blue-700 font-medium transition-colors"
> >

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState, useEffect, useCallback } from "react";
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";
@ -10,12 +10,10 @@ import StatusBadge from "@/components/common/StatusBadge";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import HistorySlidePanel from "../components/HistorySlidePanel"; import HistorySlidePanel from "../components/HistorySlidePanel";
import { formatNumber } from "@/utils/format"; import { formatNumber } from "@/utils/format";
import { import { fetchHistoryList, exportHistory } from "@/api/statistics.api";
SERVICE_FILTER_OPTIONS, import { fetchServices } from "@/api/service.api";
STATUS_FILTER_OPTIONS, import { STATUS_FILTER_OPTIONS } from "../types";
MOCK_SEND_HISTORY, import type { HistoryListItem } from "../types";
} from "../types";
import type { SendHistory } from "../types";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@ -49,6 +47,10 @@ function getOneMonthAgo() {
} }
export default function StatisticsHistoryPage() { export default function StatisticsHistoryPage() {
// 서비스 필터
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>(["전체 서비스"]);
const [serviceCodeMap, setServiceCodeMap] = useState<Record<string, string>>({});
// 필터 입력 상태 // 필터 입력 상태
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스"); const [serviceFilter, setServiceFilter] = useState("전체 서비스");
@ -58,25 +60,82 @@ export default function StatisticsHistoryPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 적용된 필 // 테이블 데이
const [appliedSearch, setAppliedSearch] = useState(""); const [items, setItems] = useState<HistoryListItem[]>([]);
const [appliedService, setAppliedService] = useState("전체 서비스"); const [totalItems, setTotalItems] = useState(0);
const [appliedStatus, setAppliedStatus] = useState("전체"); const [totalPages, setTotalPages] = useState(1);
// 슬라이드 패널 // 슬라이드 패널
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [selectedHistory, setSelectedHistory] = useState<SendHistory | null>(null); const [selectedMessageCode, setSelectedMessageCode] = useState<string | null>(null);
const [selectedServiceCode, setSelectedServiceCode] = useState<string | undefined>(undefined);
// 조회 // 서비스 목록 로드 (초기 1회)
const handleQuery = () => { useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const svcItems = res.data.data.items ?? [];
const names = svcItems.map((s) => s.serviceName);
setServiceFilterOptions(["전체 서비스", ...names]);
const codeMap: Record<string, string> = {};
svcItems.forEach((s) => {
codeMap[s.serviceName] = s.serviceCode;
});
setServiceCodeMap(codeMap);
} catch {
// 서비스 목록 로드 실패 시 기본값 유지
}
})();
}, []);
// 데이터 로드
const loadData = useCallback(async (page = 1) => {
setLoading(true); setLoading(true);
setTimeout(() => { try {
setAppliedSearch(search); const svcCode = serviceFilter !== "전체 서비스"
setAppliedService(serviceFilter); ? serviceCodeMap[serviceFilter]
setAppliedStatus(statusFilter); : undefined;
setCurrentPage(1);
const res = await fetchHistoryList(
{
page,
size: PAGE_SIZE,
keyword: search || undefined,
status: statusFilter !== "전체" ? statusFilter : undefined,
start_date: dateStart,
end_date: dateEnd,
},
svcCode,
);
const data = res.data.data;
setItems(data.items);
setTotalItems(data.pagination.total_count);
setTotalPages(data.pagination.total_pages || 1);
setCurrentPage(page);
} catch {
setItems([]);
setTotalItems(0);
setTotalPages(1);
} finally {
setLoading(false); setLoading(false);
}, 400); }
}, [search, serviceFilter, serviceCodeMap, statusFilter, dateStart, dateEnd]);
// 초기 로드
useEffect(() => {
loadData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 조회 버튼
const handleQuery = () => {
loadData(1);
};
// 페이지 변경
const handlePageChange = (page: number) => {
loadData(page);
}; };
// 필터 초기화 // 필터 초기화
@ -86,43 +145,49 @@ export default function StatisticsHistoryPage() {
setStatusFilter("전체"); setStatusFilter("전체");
setDateStart(getOneMonthAgo()); setDateStart(getOneMonthAgo());
setDateEnd(getToday()); 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) => { const handleRowClick = (item: HistoryListItem) => {
setSelectedHistory(item); const svcCode = serviceFilter !== "전체 서비스"
? serviceCodeMap[serviceFilter]
: undefined;
setSelectedMessageCode(item.message_code);
setSelectedServiceCode(svcCode);
setPanelOpen(true); setPanelOpen(true);
}; };
// 엑셀 다운로드
const handleExport = async () => {
try {
const svcCode = serviceFilter !== "전체 서비스"
? serviceCodeMap[serviceFilter]
: undefined;
const res = await exportHistory(
{
keyword: search || undefined,
status: statusFilter !== "전체" ? statusFilter : undefined,
start_date: dateStart,
end_date: dateEnd,
},
svcCode,
);
const blob = new Blob([res.data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `발송이력_${dateStart}_${dateEnd}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
// 다운로드 실패
}
};
// 테이블 헤더 렌더링 // 테이블 헤더 렌더링
const renderTableHead = () => ( const renderTableHead = () => (
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
@ -145,7 +210,10 @@ export default function StatisticsHistoryPage() {
title="발송 이력" title="발송 이력"
description="서비스별 메시지 발송 상세 이력을 확인합니다." description="서비스별 메시지 발송 상세 이력을 확인합니다."
action={ 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"> <button
onClick={handleExport}
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> <span className="material-symbols-outlined text-lg">download</span>
</button> </button>
@ -166,7 +234,7 @@ export default function StatisticsHistoryPage() {
<FilterDropdown <FilterDropdown
label="서비스 분류" label="서비스 분류"
value={serviceFilter} value={serviceFilter}
options={SERVICE_FILTER_OPTIONS} options={serviceFilterOptions}
onChange={setServiceFilter} onChange={setServiceFilter}
className="w-[160px] flex-shrink-0" className="w-[160px] flex-shrink-0"
disabled={loading} disabled={loading}
@ -221,16 +289,16 @@ export default function StatisticsHistoryPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
) : paged.length > 0 ? ( ) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
{renderTableHead()} {renderTableHead()}
<tbody> <tbody>
{paged.map((item, idx) => ( {items.map((item, idx) => (
<tr <tr
key={item.id} key={item.message_code}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`} className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
> >
{/* 발송 ID */} {/* 발송 ID */}
@ -239,33 +307,33 @@ export default function StatisticsHistoryPage() {
className="inline-flex items-center gap-1.5 font-mono text-xs text-gray-500" className="inline-flex items-center gap-1.5 font-mono text-xs text-gray-500"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{item.id} {item.message_code}
<CopyButton text={item.id} /> <CopyButton text={item.message_code} />
</span> </span>
</td> </td>
{/* 템플릿명 */} {/* 템플릿명 */}
<td className="px-6 py-4 text-center text-sm font-medium text-gray-900"> <td className="px-6 py-4 text-center text-sm font-medium text-gray-900">
{item.template} {item.title}
</td> </td>
{/* 발송 시간 */} {/* 발송 시간 */}
<td className="px-6 py-4 text-center text-sm text-gray-500"> <td className="px-6 py-4 text-center text-sm text-gray-500">
{item.sentAt} {item.sent_at}
</td> </td>
{/* 대상 수 */} {/* 대상 수 */}
<td className="px-6 py-4 text-center text-sm text-gray-900"> <td className="px-6 py-4 text-center text-sm text-gray-900">
{formatNumber(item.total)} {formatNumber(item.target_count)}
</td> </td>
{/* 성공 */} {/* 성공 */}
<td className="px-6 py-4 text-center text-sm font-semibold text-primary"> <td className="px-6 py-4 text-center text-sm font-semibold text-primary">
{formatNumber(item.success)} {formatNumber(item.success_count)}
</td> </td>
{/* 실패 */} {/* 실패 */}
<td className={`px-6 py-4 text-center text-sm font-semibold ${item.fail > 0 ? "text-red-500" : "text-gray-400"}`}> <td className={`px-6 py-4 text-center text-sm font-semibold ${item.fail_count > 0 ? "text-red-500" : "text-gray-400"}`}>
{formatNumber(item.fail)} {formatNumber(item.fail_count)}
</td> </td>
{/* 오픈율 */} {/* 오픈율 */}
<td className="px-6 py-4 text-center text-sm font-bold text-gray-900"> <td className="px-6 py-4 text-center text-sm font-bold text-gray-900">
{item.openRate}% {item.open_rate}%
</td> </td>
{/* 상태 */} {/* 상태 */}
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
@ -283,7 +351,7 @@ export default function StatisticsHistoryPage() {
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems} totalItems={totalItems}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
onPageChange={setCurrentPage} onPageChange={handlePageChange}
/> />
</div> </div>
) : ( ) : (
@ -298,7 +366,8 @@ export default function StatisticsHistoryPage() {
<HistorySlidePanel <HistorySlidePanel
isOpen={panelOpen} isOpen={panelOpen}
onClose={() => setPanelOpen(false)} onClose={() => setPanelOpen(false)}
history={selectedHistory} messageCode={selectedMessageCode}
serviceCode={selectedServiceCode}
/> />
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import FilterDropdown from "@/components/common/FilterDropdown"; import FilterDropdown from "@/components/common/FilterDropdown";
import DateRangeInput from "@/components/common/DateRangeInput"; import DateRangeInput from "@/components/common/DateRangeInput";
@ -9,14 +9,21 @@ import PlatformDistribution from "../components/PlatformDistribution";
import HourlyBarChart from "../components/HourlyBarChart"; import HourlyBarChart from "../components/HourlyBarChart";
import RecentHistoryTable from "../components/RecentHistoryTable"; import RecentHistoryTable from "../components/RecentHistoryTable";
import OpenRateTop5 from "../components/OpenRateTop5"; import OpenRateTop5 from "../components/OpenRateTop5";
import { import { fetchDailyStats, fetchHourlyStats, fetchDeviceStats, fetchHistoryList } from "@/api/statistics.api";
SERVICE_FILTER_OPTIONS, import { fetchServices } from "@/api/service.api";
MOCK_STATS_SUMMARY, import { formatNumber } from "@/utils/format";
MOCK_TREND_DATA, import type {
MOCK_PLATFORM_DATA, StatsSummary,
MOCK_HOURLY_DATA, TrendDataPoint,
MOCK_RECENT_HISTORY, PlatformDistributionData,
MOCK_OPEN_RATE_TOP5, HourlyData,
RecentHistory,
OpenRateRank,
DailyStatSummary,
DailyStatItem,
DeviceStatResponse,
HourlyStatItem,
HistoryListItem,
} from "../types"; } from "../types";
/** 오늘 날짜 YYYY-MM-DD */ /** 오늘 날짜 YYYY-MM-DD */
@ -30,20 +37,178 @@ function getOneMonthAgo() {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
// ===== Mapper 함수 =====
/** DailyStatSummary → StatsSummary (카드 props) */
function mapSummary(s: DailyStatSummary): StatsSummary {
const successRate = s.total_send > 0
? Math.round((s.total_success / s.total_send) * 1000) / 10
: 0;
const failRate = s.total_send > 0
? Math.round((s.total_fail / s.total_send) * 1000) / 10
: 0;
return {
totalSent: s.total_send,
success: s.total_success,
successRate,
fail: s.total_fail,
failRate,
openRate: Math.round(s.avg_ctr * 10) / 10,
};
}
/** DailyStatItem[] → TrendDataPoint[] (추이 차트 props) */
function mapTrend(items: DailyStatItem[]): TrendDataPoint[] {
if (items.length === 0) return [];
const maxSend = Math.max(...items.map((d) => d.send_count), 1);
return items.map((d) => ({
label: d.stat_date.slice(5), // "MM-DD"
blue: 1 - d.send_count / maxSend,
green: 1 - d.success_count / maxSend,
purple: 1 - d.open_count / maxSend,
sent: formatNumber(d.send_count),
success: formatNumber(d.success_count),
open: formatNumber(d.open_count),
}));
}
/** DeviceStatResponse → PlatformDistributionData (도넛 차트 props) */
function mapPlatform(res: DeviceStatResponse): PlatformDistributionData {
const ios = res.by_platform.find((p) => p.platform.toLowerCase() === "ios");
const android = res.by_platform.find((p) => p.platform.toLowerCase() === "android");
return {
ios: ios?.ratio ?? 0,
android: android?.ratio ?? 0,
iosCount: ios?.count ?? 0,
androidCount: android?.count ?? 0,
total: res.total,
};
}
/** HourlyStatItem[] → HourlyData[] (시간대별 바 차트 props) */
function mapHourly(items: HourlyStatItem[]): HourlyData[] {
const maxCount = Math.max(...items.map((h) => h.send_count), 1);
return items.map((h) => ({
hour: h.hour,
count: h.send_count,
heightPercent: Math.round((h.send_count / maxCount) * 100),
}));
}
/** HistoryListItem[] → RecentHistory[] (최근이력 테이블 props) */
function mapRecentHistory(items: HistoryListItem[]): RecentHistory[] {
return items.slice(0, 5).map((h) => ({
template: h.title,
target: h.target_count,
successRate: h.target_count > 0
? Math.round((h.success_count / h.target_count) * 1000) / 10
: 0,
status: (h.status === "완료" || h.status === "진행" || h.status === "실패"
? h.status
: "완료") as "완료" | "진행" | "실패",
sentAt: h.sent_at,
}));
}
/** HistoryListItem[] → OpenRateRank[] (오픈율 Top5 props) */
function mapOpenRateTop5(items: HistoryListItem[]): OpenRateRank[] {
return [...items]
.sort((a, b) => b.open_rate - a.open_rate)
.slice(0, 5)
.map((h, i) => ({
rank: i + 1,
template: h.title,
rate: h.open_rate,
}));
}
// ===== 빈 초기값 =====
const EMPTY_SUMMARY: StatsSummary = { totalSent: 0, success: 0, successRate: 0, fail: 0, failRate: 0, openRate: 0 };
const EMPTY_PLATFORM: PlatformDistributionData = { ios: 0, android: 0, iosCount: 0, androidCount: 0, total: 0 };
export default function StatisticsPage() { export default function StatisticsPage() {
// 필터 입력 상태 // 서비스 필터
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>(["전체 서비스"]);
const [serviceCodeMap, setServiceCodeMap] = useState<Record<string, string>>({});
const [serviceFilter, setServiceFilter] = useState("전체 서비스"); const [serviceFilter, setServiceFilter] = useState("전체 서비스");
// 날짜 필터
const [dateStart, setDateStart] = useState(getOneMonthAgo); const [dateStart, setDateStart] = useState(getOneMonthAgo);
const [dateEnd, setDateEnd] = useState(getToday); const [dateEnd, setDateEnd] = useState(getToday);
// 로딩
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 조회 // 차트 데이터
const handleQuery = () => { const [summaryData, setSummaryData] = useState<StatsSummary>(EMPTY_SUMMARY);
const [trendData, setTrendData] = useState<TrendDataPoint[]>([]);
const [platformData, setPlatformData] = useState<PlatformDistributionData>(EMPTY_PLATFORM);
const [hourlyData, setHourlyData] = useState<HourlyData[]>([]);
const [recentHistory, setRecentHistory] = useState<RecentHistory[]>([]);
const [openRateTop5, setOpenRateTop5] = useState<OpenRateRank[]>([]);
// 서비스 목록 로드 (초기 1회)
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const svcItems = res.data.data.items ?? [];
const names = svcItems.map((s) => s.serviceName);
setServiceFilterOptions(["전체 서비스", ...names]);
const codeMap: Record<string, string> = {};
svcItems.forEach((s) => {
codeMap[s.serviceName] = s.serviceCode;
});
setServiceCodeMap(codeMap);
} catch {
// 서비스 목록 로드 실패 시 기본값 유지
}
})();
}, []);
// 데이터 로드
const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setTimeout(() => { try {
const svcCode = serviceFilter !== "전체 서비스"
? serviceCodeMap[serviceFilter]
: undefined;
const dateParams = { start_date: dateStart, end_date: dateEnd };
const [dailyRes, hourlyRes, deviceRes, historyRes] = await Promise.all([
fetchDailyStats(dateParams, svcCode),
fetchHourlyStats(dateParams, svcCode),
fetchDeviceStats(svcCode),
fetchHistoryList({ page: 1, size: 20, ...dateParams }, svcCode),
]);
// 일별 → 카드 + 추이 차트
const daily = dailyRes.data.data;
setSummaryData(mapSummary(daily.summary));
setTrendData(mapTrend(daily.items));
// 시간대별
setHourlyData(mapHourly(hourlyRes.data.data.items));
// 플랫폼
setPlatformData(mapPlatform(deviceRes.data.data));
// 이력 → 최근 5건 + 오픈율 Top5
const historyItems = historyRes.data.data.items;
setRecentHistory(mapRecentHistory(historyItems));
setOpenRateTop5(mapOpenRateTop5(historyItems));
} catch {
// API 에러 시 빈 상태 유지
} finally {
setLoading(false); setLoading(false);
}, 400); }
}; }, [serviceFilter, serviceCodeMap, dateStart, dateEnd]);
// 초기 로드
useEffect(() => {
loadData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 필터 초기화 // 필터 초기화
const handleReset = () => { const handleReset = () => {
@ -72,14 +237,14 @@ export default function StatisticsPage() {
<FilterDropdown <FilterDropdown
label="서비스" label="서비스"
value={serviceFilter} value={serviceFilter}
options={SERVICE_FILTER_OPTIONS} options={serviceFilterOptions}
onChange={setServiceFilter} onChange={setServiceFilter}
className="w-[160px] flex-shrink-0" className="w-[160px] flex-shrink-0"
disabled={loading} disabled={loading}
/> />
<FilterResetButton onClick={handleReset} disabled={loading} /> <FilterResetButton onClick={handleReset} disabled={loading} />
<button <button
onClick={handleQuery} onClick={loadData}
disabled={loading} 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" 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"
> >
@ -89,21 +254,21 @@ export default function StatisticsPage() {
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<StatsSummaryCards data={MOCK_STATS_SUMMARY} /> <StatsSummaryCards data={summaryData} />
{/* 월간 발송 추이 */} {/* 월간 발송 추이 */}
<MonthlyTrendChart data={MOCK_TREND_DATA} loading={loading} /> <MonthlyTrendChart data={trendData} loading={loading} />
{/* 플랫폼 + 시간대별 (2컬럼) */} {/* 플랫폼 + 시간대별 (2컬럼) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<PlatformDistribution data={MOCK_PLATFORM_DATA} /> <PlatformDistribution data={platformData} />
<HourlyBarChart data={MOCK_HOURLY_DATA} /> <HourlyBarChart data={hourlyData} />
</div> </div>
{/* 최근 이력 + 오픈율 Top 5 (3:1) */} {/* 최근 이력 + 오픈율 Top 5 (3:1) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentHistoryTable data={MOCK_RECENT_HISTORY} /> <RecentHistoryTable data={recentHistory} />
<OpenRateTop5 data={MOCK_OPEN_RATE_TOP5} /> <OpenRateTop5 data={openRateTop5} />
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,149 @@
// ===== 발송 통계 타입 ===== // ===== 발송 통계 API 타입 (swagger 기준 snake_case) =====
// --- 요청 타입 ---
/** 일별 통계 요청 */
export interface DailyStatRequest {
start_date?: string;
end_date?: string;
}
/** 시간대별 통계 요청 */
export interface HourlyStatRequest {
start_date?: string;
end_date?: string;
}
/** 이력 목록 요청 */
export interface HistoryListRequest {
page: number;
size: number;
keyword?: string;
status?: string;
start_date?: string;
end_date?: string;
}
/** 이력 상세 요청 */
export interface HistoryDetailRequest {
message_code: string;
}
/** 이력 내보내기 요청 */
export interface HistoryExportRequest {
keyword?: string;
status?: string;
start_date?: string;
end_date?: string;
}
// --- 응답 타입 ---
/** 일별 통계 항목 */
export interface DailyStatItem {
stat_date: string;
send_count: number;
success_count: number;
fail_count: number;
open_count: number;
ctr: number;
}
/** 일별 통계 요약 */
export interface DailyStatSummary {
total_send: number;
total_success: number;
total_fail: number;
total_open: number;
avg_ctr: number;
}
/** 일별 통계 응답 */
export interface DailyStatResponse {
items: DailyStatItem[];
summary: DailyStatSummary;
}
/** 시간대별 통계 항목 */
export interface HourlyStatItem {
hour: number;
send_count: number;
open_count: number;
ctr: number;
}
/** 시간대별 통계 응답 */
export interface HourlyStatResponse {
items: HourlyStatItem[];
best_hours: number[];
}
/** 플랫폼별 통계 항목 */
export interface PlatformStat {
platform: string;
count: number;
ratio: number;
}
/** 디바이스 통계 응답 */
export interface DeviceStatResponse {
total: number;
by_platform: PlatformStat[];
}
/** 이력 목록 항목 */
export interface HistoryListItem {
message_code: string;
title: string;
service_name: string;
sent_at: string;
target_count: number;
success_count: number;
fail_count: number;
open_rate: number;
status: string;
}
/** 이력 페이지네이션 */
export interface HistoryPagination {
page: number;
size: number;
total_count: number;
total_pages: number;
}
/** 이력 목록 응답 */
export interface HistoryListResponse {
items: HistoryListItem[];
pagination: HistoryPagination;
}
/** 이력 실패 사유 */
export interface HistoryFailReason {
reason: string;
count: number;
description: string;
}
/** 이력 상세 응답 */
export interface HistoryDetailResponse {
message_code: string;
title: string;
body: string;
service_name: string;
first_sent_at: string;
last_sent_at: string;
status: string;
target_count: number;
success_count: number;
fail_count: number;
success_rate: number;
open_count: number;
open_rate: number;
fail_reasons: HistoryFailReason[];
}
// ===== 차트 컴포넌트 props용 타입 (유지) =====
/** 통계 요약 카드 데이터 */ /** 통계 요약 카드 데이터 */
export interface StatsSummary { export interface StatsSummary {
@ -54,122 +199,5 @@ export interface OpenRateRank {
rate: number; 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 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차 인증이 설정되었습니다. 보안이 한층 강화되었습니다." },
];