feat: 발송 통계 페이지 API 연동 (#39)
- types.ts: Mock 데이터 7개 + SERVICE_FILTER_OPTIONS 삭제, swagger 기준 요청/응답 타입 15개 추가 - statistics.api.ts: 신규 생성 (fetchDailyStats, fetchHourlyStats, fetchDeviceStats, fetchHistoryList, fetchHistoryDetail, exportHistory) - StatisticsPage.tsx: 4개 API 병렬 호출 + mapper 함수 6개로 차트 props 변환, fetchServices 서비스 필터 동적 로드 - StatisticsHistoryPage.tsx: 서버 필터링, API 페이지네이션, 엑셀 blob 다운로드, 패널에 messageCode 전달 - HistorySlidePanel.tsx: props를 messageCode로 변경, fetchHistoryDetail API 호출, 로딩 스켈레톤 추가 Closes #39
This commit is contained in:
parent
e37066ce31
commit
21dcc6335d
82
react/src/api/statistics.api.ts
Normal file
82
react/src/api/statistics.api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } 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";
|
||||
import { fetchHistoryDetail } from "@/api/statistics.api";
|
||||
import type { HistoryDetailResponse } from "../types";
|
||||
|
||||
interface HistorySlidePanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
history: SendHistory | null;
|
||||
messageCode: string | null;
|
||||
serviceCode?: string;
|
||||
}
|
||||
|
||||
/** 상태 → StatusBadge variant 매핑 */
|
||||
|
|
@ -22,16 +24,31 @@ function getStatusVariant(status: string): "success" | "info" | "error" {
|
|||
export default function HistorySlidePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
history,
|
||||
messageCode,
|
||||
serviceCode,
|
||||
}: HistorySlidePanelProps) {
|
||||
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(() => {
|
||||
if (isOpen) {
|
||||
bodyRef.current?.scrollTo(0, 0);
|
||||
}
|
||||
}, [isOpen, history]);
|
||||
}, [isOpen, messageCode]);
|
||||
|
||||
// ESC 닫기
|
||||
useEffect(() => {
|
||||
|
|
@ -54,6 +71,37 @@ export default function HistorySlidePanel({
|
|||
};
|
||||
}, [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 (
|
||||
<>
|
||||
{/* 오버레이 */}
|
||||
|
|
@ -87,7 +135,9 @@ export default function HistorySlidePanel({
|
|||
className="flex-1 overflow-y-auto p-6 space-y-6"
|
||||
style={{ overscrollBehavior: "contain" }}
|
||||
>
|
||||
{history ? (
|
||||
{loading ? (
|
||||
renderSkeleton()
|
||||
) : detail ? (
|
||||
<>
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
|
|
@ -95,40 +145,33 @@ export default function HistorySlidePanel({
|
|||
기본 정보
|
||||
</h3>
|
||||
<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="text-sm font-mono text-gray-900">{history.id}</span>
|
||||
<CopyButton text={history.id} />
|
||||
<span className="text-sm font-mono text-gray-900">{detail.message_code}</span>
|
||||
<CopyButton text={detail.message_code} />
|
||||
</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="템플릿명">
|
||||
<InfoRow label="제목">
|
||||
<span className="text-sm font-medium text-gray-900 text-right max-w-[240px]">
|
||||
{history.template}
|
||||
{detail.title}
|
||||
</span>
|
||||
</InfoRow>
|
||||
<Divider />
|
||||
<InfoRow label="서비스">
|
||||
<span className="text-sm text-gray-900">{history.service}</span>
|
||||
<span className="text-sm text-gray-900">{detail.service_name}</span>
|
||||
</InfoRow>
|
||||
<Divider />
|
||||
<InfoRow label="상태">
|
||||
<StatusBadge variant={getStatusVariant(history.status)} label={history.status} />
|
||||
<StatusBadge variant={getStatusVariant(detail.status)} label={detail.status} />
|
||||
</InfoRow>
|
||||
<Divider />
|
||||
<InfoRow label="발송 시간">
|
||||
<span className="text-sm text-gray-900">{history.sentAt}</span>
|
||||
<InfoRow label="최초 발송">
|
||||
<span className="text-sm text-gray-900">{detail.first_sent_at}</span>
|
||||
</InfoRow>
|
||||
<Divider />
|
||||
<InfoRow label="완료 시간">
|
||||
<span className="text-sm text-gray-900">{history.completedAt}</span>
|
||||
<InfoRow label="최종 발송">
|
||||
<span className="text-sm text-gray-900">{detail.last_sent_at}</span>
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,15 +183,15 @@ export default function HistorySlidePanel({
|
|||
</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-2xl font-bold text-gray-900">{formatNumber(detail.target_count)}</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-2xl font-bold text-green-700">{formatNumber(detail.success_count)}</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-2xl font-bold text-red-700">{formatNumber(detail.fail_count)}</p>
|
||||
<p className="text-xs text-red-600 mt-1">실패</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,13 +201,13 @@ export default function HistorySlidePanel({
|
|||
<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 className="text-green-600">{detail.success_rate}</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}%` }}
|
||||
style={{ width: `${detail.success_rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -174,28 +217,28 @@ export default function HistorySlidePanel({
|
|||
<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-primary">{formatNumber(detail.open_count)}</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>
|
||||
</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}%` }}
|
||||
style={{ width: `${detail.open_rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실패 사유 */}
|
||||
{history.failReasons.length > 0 && (
|
||||
{detail.fail_reasons && detail.fail_reasons.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) => (
|
||||
{detail.fail_reasons.map((f) => (
|
||||
<div
|
||||
key={f.reason}
|
||||
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>
|
||||
<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>
|
||||
<p className="text-sm font-medium text-gray-900 mb-2">{detail.title}</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{detail.body}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 mt-2.5">
|
||||
<span
|
||||
|
|
@ -229,7 +272,7 @@ export default function HistorySlidePanel({
|
|||
<span className="text-xs text-gray-400">
|
||||
자세한 내용은{" "}
|
||||
<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"
|
||||
>
|
||||
메시지 목록
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||
|
|
@ -10,12 +10,10 @@ 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";
|
||||
import { fetchHistoryList, exportHistory } from "@/api/statistics.api";
|
||||
import { fetchServices } from "@/api/service.api";
|
||||
import { STATUS_FILTER_OPTIONS } from "../types";
|
||||
import type { HistoryListItem } from "../types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
|
|
@ -49,6 +47,10 @@ function getOneMonthAgo() {
|
|||
}
|
||||
|
||||
export default function StatisticsHistoryPage() {
|
||||
// 서비스 필터
|
||||
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>(["전체 서비스"]);
|
||||
const [serviceCodeMap, setServiceCodeMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 필터 입력 상태
|
||||
const [search, setSearch] = useState("");
|
||||
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
|
||||
|
|
@ -58,25 +60,82 @@ export default function StatisticsHistoryPage() {
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 적용된 필터
|
||||
const [appliedSearch, setAppliedSearch] = useState("");
|
||||
const [appliedService, setAppliedService] = useState("전체 서비스");
|
||||
const [appliedStatus, setAppliedStatus] = useState("전체");
|
||||
// 테이블 데이터
|
||||
const [items, setItems] = useState<HistoryListItem[]>([]);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 슬라이드 패널
|
||||
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);
|
||||
|
||||
// 조회
|
||||
const handleQuery = () => {
|
||||
// 서비스 목록 로드 (초기 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 (page = 1) => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setAppliedSearch(search);
|
||||
setAppliedService(serviceFilter);
|
||||
setAppliedStatus(statusFilter);
|
||||
setCurrentPage(1);
|
||||
try {
|
||||
const svcCode = serviceFilter !== "전체 서비스"
|
||||
? serviceCodeMap[serviceFilter]
|
||||
: undefined;
|
||||
|
||||
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);
|
||||
}, 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,41 +145,47 @@ export default function StatisticsHistoryPage() {
|
|||
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 handleRowClick = (item: HistoryListItem) => {
|
||||
const svcCode = serviceFilter !== "전체 서비스"
|
||||
? serviceCodeMap[serviceFilter]
|
||||
: undefined;
|
||||
setSelectedMessageCode(item.message_code);
|
||||
setSelectedServiceCode(svcCode);
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
// 페이지네이션
|
||||
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 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 handleRowClick = (item: SendHistory) => {
|
||||
setSelectedHistory(item);
|
||||
setPanelOpen(true);
|
||||
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 {
|
||||
// 다운로드 실패
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 헤더 렌더링
|
||||
|
|
@ -145,7 +210,10 @@ export default function StatisticsHistoryPage() {
|
|||
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">
|
||||
<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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
|
|
@ -166,7 +234,7 @@ export default function StatisticsHistoryPage() {
|
|||
<FilterDropdown
|
||||
label="서비스 분류"
|
||||
value={serviceFilter}
|
||||
options={SERVICE_FILTER_OPTIONS}
|
||||
options={serviceFilterOptions}
|
||||
onChange={setServiceFilter}
|
||||
className="w-[160px] flex-shrink-0"
|
||||
disabled={loading}
|
||||
|
|
@ -221,16 +289,16 @@ export default function StatisticsHistoryPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : paged.length > 0 ? (
|
||||
) : items.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) => (
|
||||
{items.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`}
|
||||
key={item.message_code}
|
||||
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{/* 발송 ID */}
|
||||
|
|
@ -239,33 +307,33 @@ export default function StatisticsHistoryPage() {
|
|||
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} />
|
||||
{item.message_code}
|
||||
<CopyButton text={item.message_code} />
|
||||
</span>
|
||||
</td>
|
||||
{/* 템플릿명 */}
|
||||
<td className="px-6 py-4 text-center text-sm font-medium text-gray-900">
|
||||
{item.template}
|
||||
{item.title}
|
||||
</td>
|
||||
{/* 발송 시간 */}
|
||||
<td className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
{item.sentAt}
|
||||
{item.sent_at}
|
||||
</td>
|
||||
{/* 대상 수 */}
|
||||
<td className="px-6 py-4 text-center text-sm text-gray-900">
|
||||
{formatNumber(item.total)}
|
||||
{formatNumber(item.target_count)}
|
||||
</td>
|
||||
{/* 성공 */}
|
||||
<td className="px-6 py-4 text-center text-sm font-semibold text-primary">
|
||||
{formatNumber(item.success)}
|
||||
{formatNumber(item.success_count)}
|
||||
</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 className={`px-6 py-4 text-center text-sm font-semibold ${item.fail_count > 0 ? "text-red-500" : "text-gray-400"}`}>
|
||||
{formatNumber(item.fail_count)}
|
||||
</td>
|
||||
{/* 오픈율 */}
|
||||
<td className="px-6 py-4 text-center text-sm font-bold text-gray-900">
|
||||
{item.openRate}%
|
||||
{item.open_rate}%
|
||||
</td>
|
||||
{/* 상태 */}
|
||||
<td className="px-6 py-4 text-center">
|
||||
|
|
@ -283,7 +351,7 @@ export default function StatisticsHistoryPage() {
|
|||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -298,7 +366,8 @@ export default function StatisticsHistoryPage() {
|
|||
<HistorySlidePanel
|
||||
isOpen={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
history={selectedHistory}
|
||||
messageCode={selectedMessageCode}
|
||||
serviceCode={selectedServiceCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||
import DateRangeInput from "@/components/common/DateRangeInput";
|
||||
|
|
@ -9,14 +9,21 @@ 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,
|
||||
import { fetchDailyStats, fetchHourlyStats, fetchDeviceStats, fetchHistoryList } from "@/api/statistics.api";
|
||||
import { fetchServices } from "@/api/service.api";
|
||||
import { formatNumber } from "@/utils/format";
|
||||
import type {
|
||||
StatsSummary,
|
||||
TrendDataPoint,
|
||||
PlatformDistributionData,
|
||||
HourlyData,
|
||||
RecentHistory,
|
||||
OpenRateRank,
|
||||
DailyStatSummary,
|
||||
DailyStatItem,
|
||||
DeviceStatResponse,
|
||||
HourlyStatItem,
|
||||
HistoryListItem,
|
||||
} from "../types";
|
||||
|
||||
/** 오늘 날짜 YYYY-MM-DD */
|
||||
|
|
@ -30,20 +37,178 @@ function getOneMonthAgo() {
|
|||
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() {
|
||||
// 필터 입력 상태
|
||||
// 서비스 필터
|
||||
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>(["전체 서비스"]);
|
||||
const [serviceCodeMap, setServiceCodeMap] = useState<Record<string, string>>({});
|
||||
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
|
||||
|
||||
// 날짜 필터
|
||||
const [dateStart, setDateStart] = useState(getOneMonthAgo);
|
||||
const [dateEnd, setDateEnd] = useState(getToday);
|
||||
|
||||
// 로딩
|
||||
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);
|
||||
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);
|
||||
}, 400);
|
||||
};
|
||||
}
|
||||
}, [serviceFilter, serviceCodeMap, dateStart, dateEnd]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 필터 초기화
|
||||
const handleReset = () => {
|
||||
|
|
@ -72,14 +237,14 @@ export default function StatisticsPage() {
|
|||
<FilterDropdown
|
||||
label="서비스"
|
||||
value={serviceFilter}
|
||||
options={SERVICE_FILTER_OPTIONS}
|
||||
options={serviceFilterOptions}
|
||||
onChange={setServiceFilter}
|
||||
className="w-[160px] flex-shrink-0"
|
||||
disabled={loading}
|
||||
/>
|
||||
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||
<button
|
||||
onClick={handleQuery}
|
||||
onClick={loadData}
|
||||
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"
|
||||
>
|
||||
|
|
@ -89,21 +254,21 @@ export default function StatisticsPage() {
|
|||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<StatsSummaryCards data={MOCK_STATS_SUMMARY} />
|
||||
<StatsSummaryCards data={summaryData} />
|
||||
|
||||
{/* 월간 발송 추이 */}
|
||||
<MonthlyTrendChart data={MOCK_TREND_DATA} loading={loading} />
|
||||
<MonthlyTrendChart data={trendData} 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} />
|
||||
<PlatformDistribution data={platformData} />
|
||||
<HourlyBarChart data={hourlyData} />
|
||||
</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} />
|
||||
<RecentHistoryTable data={recentHistory} />
|
||||
<OpenRateTop5 data={openRateTop5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -54,122 +199,5 @@ export interface OpenRateRank {
|
|||
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