From 21dcc6335db23b976736cc8f699ef6949a26627e Mon Sep 17 00:00:00 2001 From: SEAN Date: Mon, 2 Mar 2026 11:52:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=9C=EC=86=A1=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0=EB=8F=99=20(#3?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- react/src/api/statistics.api.ts | 82 ++++++ .../components/HistorySlidePanel.tsx | 117 +++++--- .../pages/StatisticsHistoryPage.tsx | 205 +++++++++----- .../statistics/pages/StatisticsPage.tsx | 211 ++++++++++++-- react/src/features/statistics/types.ts | 264 ++++++++++-------- 5 files changed, 633 insertions(+), 246 deletions(-) create mode 100644 react/src/api/statistics.api.ts diff --git a/react/src/api/statistics.api.ts b/react/src/api/statistics.api.ts new file mode 100644 index 0000000..07b393c --- /dev/null +++ b/react/src/api/statistics.api.ts @@ -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>( + "/v1/in/stats/daily", + data, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 시간대별 통계 조회 */ +export function fetchHourlyStats( + data: HourlyStatRequest, + serviceCode?: string, +) { + return apiClient.post>( + "/v1/in/stats/hourly", + data, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 디바이스 통계 조회 */ +export function fetchDeviceStats(serviceCode?: string) { + return apiClient.post>( + "/v1/in/stats/device", + {}, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 이력 목록 조회 */ +export function fetchHistoryList( + data: HistoryListRequest, + serviceCode?: string, +) { + return apiClient.post>( + "/v1/in/stats/history/list", + data, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 이력 상세 조회 */ +export function fetchHistoryDetail( + data: HistoryDetailRequest, + serviceCode?: string, +) { + return apiClient.post>( + "/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", + }); +} diff --git a/react/src/features/statistics/components/HistorySlidePanel.tsx b/react/src/features/statistics/components/HistorySlidePanel.tsx index 2f10c51..00ccf12 100644 --- a/react/src/features/statistics/components/HistorySlidePanel.tsx +++ b/react/src/features/statistics/components/HistorySlidePanel.tsx @@ -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(null); + const [detail, setDetail] = useState(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 = () => ( +
+
+
+
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+
+
+
+ {i < 6 &&
} +
+ ))} +
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); + 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 ? ( <> {/* 기본 정보 */}
@@ -95,40 +145,33 @@ export default function HistorySlidePanel({ 기본 정보
- + - {history.id} - + {detail.message_code} + - - - {history.messageId} - - - - - + - {history.template} + {detail.title} - {history.service} + {detail.service_name} - + - - {history.sentAt} + + {detail.first_sent_at} - - {history.completedAt} + + {detail.last_sent_at}
@@ -140,15 +183,15 @@ export default function HistorySlidePanel({
-

{formatNumber(history.total)}

+

{formatNumber(detail.target_count)}

전체 대상

-

{formatNumber(history.success)}

+

{formatNumber(detail.success_count)}

성공

-

{formatNumber(history.fail)}

+

{formatNumber(detail.fail_count)}

실패

@@ -158,13 +201,13 @@ export default function HistorySlidePanel({
성공률 - {history.successRate} % + {detail.success_rate} %
@@ -174,28 +217,28 @@ export default function HistorySlidePanel({
오픈율 - {formatNumber(history.openCount)} 건{" "} + {formatNumber(detail.open_count)} 건{" "} |{" "} - {history.openRate} % + {detail.open_rate} %
{/* 실패 사유 */} - {history.failReasons.length > 0 && ( + {detail.fail_reasons && detail.fail_reasons.length > 0 && (

실패 사유

- {history.failReasons.map((f) => ( + {detail.fail_reasons.map((f) => (
-

{history.msgTitle}

-

{history.msgBody}

+

{detail.title}

+

{detail.body}

자세한 내용은{" "} 메시지 목록 diff --git a/react/src/features/statistics/pages/StatisticsHistoryPage.tsx b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx index 202d2c3..d1fd5dd 100644 --- a/react/src/features/statistics/pages/StatisticsHistoryPage.tsx +++ b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx @@ -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(["전체 서비스"]); + const [serviceCodeMap, setServiceCodeMap] = useState>({}); + // 필터 입력 상태 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([]); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); // 슬라이드 패널 const [panelOpen, setPanelOpen] = useState(false); - const [selectedHistory, setSelectedHistory] = useState(null); + const [selectedMessageCode, setSelectedMessageCode] = useState(null); + const [selectedServiceCode, setSelectedServiceCode] = useState(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 = {}; + 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,43 +145,49 @@ 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 totalItems = filtered.length; - const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); - const paged = filtered.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE, - ); - // 행 클릭 - const handleRowClick = (item: SendHistory) => { - setSelectedHistory(item); + const handleRowClick = (item: HistoryListItem) => { + const svcCode = serviceFilter !== "전체 서비스" + ? serviceCodeMap[serviceFilter] + : undefined; + setSelectedMessageCode(item.message_code); + setSelectedServiceCode(svcCode); 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 = () => ( @@ -145,7 +210,10 @@ export default function StatisticsHistoryPage() { title="발송 이력" description="서비스별 메시지 발송 상세 이력을 확인합니다." action={ - @@ -166,7 +234,7 @@ export default function StatisticsHistoryPage() {
- ) : paged.length > 0 ? ( + ) : items.length > 0 ? (
{renderTableHead()} - {paged.map((item, idx) => ( + {items.map((item, idx) => ( 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} - + {item.message_code} + {/* 템플릿명 */} {/* 발송 시간 */} {/* 대상 수 */} {/* 성공 */} {/* 실패 */} - {/* 오픈율 */} {/* 상태 */}
- {item.template} + {item.title} - {item.sentAt} + {item.sent_at} - {formatNumber(item.total)} + {formatNumber(item.target_count)} - {formatNumber(item.success)} + {formatNumber(item.success_count)} 0 ? "text-red-500" : "text-gray-400"}`}> - {formatNumber(item.fail)} + 0 ? "text-red-500" : "text-gray-400"}`}> + {formatNumber(item.fail_count)} - {item.openRate}% + {item.open_rate}% @@ -283,7 +351,7 @@ export default function StatisticsHistoryPage() { totalPages={totalPages} totalItems={totalItems} pageSize={PAGE_SIZE} - onPageChange={setCurrentPage} + onPageChange={handlePageChange} /> ) : ( @@ -298,7 +366,8 @@ export default function StatisticsHistoryPage() { setPanelOpen(false)} - history={selectedHistory} + messageCode={selectedMessageCode} + serviceCode={selectedServiceCode} /> ); diff --git a/react/src/features/statistics/pages/StatisticsPage.tsx b/react/src/features/statistics/pages/StatisticsPage.tsx index 47de337..d0d5161 100644 --- a/react/src/features/statistics/pages/StatisticsPage.tsx +++ b/react/src/features/statistics/pages/StatisticsPage.tsx @@ -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(["전체 서비스"]); + const [serviceCodeMap, setServiceCodeMap] = useState>({}); 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(EMPTY_SUMMARY); + const [trendData, setTrendData] = useState([]); + const [platformData, setPlatformData] = useState(EMPTY_PLATFORM); + const [hourlyData, setHourlyData] = useState([]); + const [recentHistory, setRecentHistory] = useState([]); + const [openRateTop5, setOpenRateTop5] = useState([]); + + // 서비스 목록 로드 (초기 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 = {}; + 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() {