From cf7076f525859bc0efb252ce43e0b616642968d5 Mon Sep 17 00:00:00 2001 From: SEAN Date: Sat, 28 Feb 2026 17:07:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대시보드 API 타입 정의 (DashboardRequest, DashboardData 등) - API 함수 생성 (fetchDashboard, fetchServiceList) - DashboardFilter 서비스 드롭다운 API 연동 (하드코딩 제거) - DashboardPage 랜덤 더미 데이터 → API 호출로 전환 - 에러/빈 데이터 오버레이 분리 (API 에러 vs 조회 결과 없음) Closes #29 --- react/src/api/dashboard.api.ts | 20 ++ .../dashboard/components/DashboardFilter.tsx | 44 +++- .../dashboard/pages/DashboardPage.tsx | 229 ++++++++++++------ react/src/features/dashboard/types.ts | 50 +++- 4 files changed, 261 insertions(+), 82 deletions(-) create mode 100644 react/src/api/dashboard.api.ts diff --git a/react/src/api/dashboard.api.ts b/react/src/api/dashboard.api.ts new file mode 100644 index 0000000..e21d0e0 --- /dev/null +++ b/react/src/api/dashboard.api.ts @@ -0,0 +1,20 @@ +import { apiClient } from "./client"; +import type { ApiResponse, PaginatedResponse } from "@/types/api"; +import type { DashboardRequest, DashboardData, ServiceOption } from "@/features/dashboard/types"; + +/** 대시보드 통합 조회 */ +export function fetchDashboard(data: DashboardRequest, serviceCode?: string) { + return apiClient.post>( + "/v1/in/stats/dashboard", + data, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 서비스 목록 조회 (필터 드롭다운용) */ +export function fetchServiceList() { + return apiClient.post>( + "/v1/in/service/list", + { page: 1, pageSize: 100 }, + ); +} diff --git a/react/src/features/dashboard/components/DashboardFilter.tsx b/react/src/features/dashboard/components/DashboardFilter.tsx index 8d4e938..b845868 100644 --- a/react/src/features/dashboard/components/DashboardFilter.tsx +++ b/react/src/features/dashboard/components/DashboardFilter.tsx @@ -1,14 +1,21 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import FilterDropdown from "@/components/common/FilterDropdown"; import DateRangeInput from "@/components/common/DateRangeInput"; import FilterResetButton from "@/components/common/FilterResetButton"; +import { fetchServiceList } from "@/api/dashboard.api"; + +export interface DashboardFilterValues { + dateStart: string; + dateEnd: string; + serviceCode?: string; // 미지정 시 전체 서비스 +} interface DashboardFilterProps { - onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void; + onSearch?: (filter: DashboardFilterValues) => void; loading?: boolean; } -const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"]; +const ALL_SERVICES_LABEL = "전체 서비스"; /** 오늘 날짜 YYYY-MM-DD */ function today() { @@ -24,18 +31,41 @@ function thirtyDaysAgo() { export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) { const [dateStart, setDateStart] = useState(thirtyDaysAgo); const [dateEnd, setDateEnd] = useState(today); - const [selectedService, setSelectedService] = useState(SERVICES[0]); + const [selectedService, setSelectedService] = useState(ALL_SERVICES_LABEL); + + // 서비스 목록: label 배열 + code 매핑 + const [serviceOptions, setServiceOptions] = useState([ALL_SERVICES_LABEL]); + const [serviceCodeMap, setServiceCodeMap] = useState>({}); + + // 서비스 목록 로드 + useEffect(() => { + fetchServiceList() + .then((res) => { + const items = res.data.data?.items ?? []; + const labels = [ALL_SERVICES_LABEL, ...items.map((s) => s.service_name)]; + const codeMap: Record = {}; + items.forEach((s) => { + codeMap[s.service_name] = s.service_code; + }); + setServiceOptions(labels); + setServiceCodeMap(codeMap); + }) + .catch(() => { + // 서비스 목록 로드 실패 시 "전체 서비스"만 유지 + }); + }, []); // 초기화 const handleReset = () => { setDateStart(thirtyDaysAgo()); setDateEnd(today()); - setSelectedService(SERVICES[0]); + setSelectedService(ALL_SERVICES_LABEL); }; // 조회 const handleSearch = () => { - onSearch?.({ dateStart, dateEnd, service: selectedService }); + const serviceCode = serviceCodeMap[selectedService]; // 전체 서비스면 undefined + onSearch?.({ dateStart, dateEnd, serviceCode }); }; return ( @@ -54,7 +84,7 @@ export default function DashboardFilter({ onSearch, loading }: DashboardFilterPr { - const d = new Date(now); - d.setDate(d.getDate() - (6 - i)); - const label = i === 6 ? "Today" : `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`; - const blue = 0.1 + Math.random() * 0.7; - const green = Math.min(blue + 0.05 + Math.random() * 0.1, 0.95); - const sent = Math.round((1 - blue) * 15000); - const reach = Math.round((1 - green) * 15000); - return { label, blue, green, sent: fmt(sent), reach: fmt(reach) }; +/** daily_trend → WeeklyChart props 변환 */ +function mapChart(data: DashboardData) { + const trends = data.daily_trend; + if (trends.length === 0) return []; + + const maxVal = Math.max(...trends.map((t) => Math.max(t.sent, t.success)), 1); + const todayStr = new Date().toISOString().slice(0, 10); + + return trends.map((t) => { + const isToday = t.date === todayStr; + const mm = t.date.slice(5, 7); + const dd = t.date.slice(8, 10); + return { + label: isToday ? "Today" : `${mm}.${dd}`, + blue: 1 - t.sent / maxVal, // Y축 비율 (0=최상단) + green: 1 - t.success / maxVal, + sent: formatNumber(t.sent), + reach: formatNumber(t.success), + }; }); } -/** 최근 발송 내역 랜덤 데이터 생성 */ -function randomMessages() { - const templates = [ - "가을맞이 프로모션 알림", "정기 점검 안내", "비밀번호 변경 알림", - "신규 서비스 런칭", "야간 푸시 마케팅", "결제 완료 알림", - "이벤트 당첨 안내", "서비스 업데이트 공지", "보안 알림", - ]; - const statuses = ["완료", "완료", "완료", "실패", "진행", "예약"] as const; - const hours = ["09:00", "09:15", "10:30", "11:00", "14:00", "15:30", "18:00", "20:00"]; - - return Array.from({ length: 5 }, () => ({ - template: templates[rand(0, templates.length - 1)], - targetCount: fmt(rand(1, 50000)), - status: statuses[rand(0, statuses.length - 1)], - sentAt: `2026-02-${String(rand(10, 27)).padStart(2, "0")} ${hours[rand(0, hours.length - 1)]}`, - })).sort((a, b) => (a.sentAt > b.sentAt ? -1 : 1)); +/** top_messages → RecentMessages props 변환 */ +function mapMessages(data: DashboardData) { + return data.top_messages.map((m) => ({ + template: m.message_name, + targetCount: formatNumber(m.target_count), + status: m.status as "완료" | "실패" | "진행" | "예약", + sentAt: m.sent_at, + })); } -/** 플랫폼 비율 랜덤 데이터 생성 */ -function randomPlatform() { - const ios = rand(25, 75); - return { ios, android: 100 - ios }; +/** platform_ratio → PlatformDonut props 변환 */ +function mapPlatform(data: DashboardData) { + return { + ios: data.platform_ratio.ios, + android: data.platform_ratio.android, + }; } export default function DashboardPage() { const [loading, setLoading] = useState(false); - const [cards, setCards] = useState | undefined>(); - const [chart, setChart] = useState | undefined>(); - const [messages, setMessages] = useState | undefined>(); - const [platform, setPlatform] = useState | undefined>(); + const [error, setError] = useState(false); + const [empty, setEmpty] = useState(false); // API 성공이나 데이터 없음 + const [cards, setCards] = useState | undefined>(); + const [chart, setChart] = useState | undefined>(); + const [messages, setMessages] = useState | undefined>(); + const [platform, setPlatform] = useState | undefined>(); - const handleSearch = useCallback(() => { + // 필터 상태 보관 (초기 로드 + 조회 버튼) + const [filter, setFilter] = useState({ + dateStart: (() => { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().slice(0, 10); + })(), + dateEnd: new Date().toISOString().slice(0, 10), + }); + + const loadDashboard = useCallback(async (f: DashboardFilterValues) => { setLoading(true); - setTimeout(() => { - setCards(randomCards()); - setChart(randomChart()); - setMessages(randomMessages()); - setPlatform(randomPlatform()); + setError(false); + setEmpty(false); + try { + const res = await fetchDashboard( + { start_date: f.dateStart, end_date: f.dateEnd }, + f.serviceCode, + ); + const d = res.data.data; + + // 데이터 비어있는지 판단 + const hasData = + d.kpi.total_sent > 0 || + d.daily_trend.length > 0 || + d.top_messages.length > 0; + + if (!hasData) { + setEmpty(true); + return; + } + + setCards(mapCards(d)); + setChart(mapChart(d)); + setMessages(mapMessages(d)); + setPlatform(mapPlatform(d)); + } catch { + setError(true); + } finally { setLoading(false); - }, 1200); + } }, []); + // 초기 로드 + useEffect(() => { + loadDashboard(filter); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 조회 버튼 핸들러 + const handleSearch = useCallback( + (f: DashboardFilterValues) => { + setFilter(f); + loadDashboard(f); + }, + [loadDashboard], + ); + + // 스켈레톤 표시 조건 + const showSkeleton = loading || error || empty || !cards; + return ( <> {/* 페이지 헤더 */} @@ -120,16 +163,54 @@ export default function DashboardPage() { {/* 필터 */} - {/* 통계 카드 */} - + {/* 데이터 영역 */} +
+ + +
+ + +
- {/* 7일 발송 추이 차트 */} - + {/* API 에러 오버레이 */} + {error && !loading && ( +
+
+ + cloud_off + +

+ 데이터를 불러올 수 없습니다 +

+

+ 네트워크 상태를 확인하거나 다시 조회해 주세요 +

+
+
+ )} - {/* 최근 발송 내역 + 플랫폼 도넛 */} -
- - + {/* 조회 결과 없음 오버레이 */} + {empty && !loading && ( +
+
+ + inbox + +

+ 조회된 데이터가 없습니다 +

+

+ 기간이나 서비스를 변경하여 다시 조회해 주세요 +

+
+
+ )}
); diff --git a/react/src/features/dashboard/types.ts b/react/src/features/dashboard/types.ts index c200449..dd9ad9b 100644 --- a/react/src/features/dashboard/types.ts +++ b/react/src/features/dashboard/types.ts @@ -1 +1,49 @@ -// Dashboard feature 타입 정의 +// 대시보드 API 요청 +export interface DashboardRequest { + start_date: string | null; + end_date: string | null; +} + +// 대시보드 통합 응답 +export interface DashboardData { + kpi: DashboardKpi; + daily_trend: DailyTrend[]; + platform_ratio: PlatformRatio; + top_messages: TopMessage[]; +} + +// KPI 지표 +export interface DashboardKpi { + total_sent: number; + success_rate: number; + device_count: number; + service_count: number; + sent_change_rate: number; // 전일 대비 증감률 (%) +} + +// 일별 발송 추이 +export interface DailyTrend { + date: string; + sent: number; + success: number; +} + +// 플랫폼 비율 +export interface PlatformRatio { + ios: number; + android: number; +} + +// 상위 메시지 +export interface TopMessage { + message_name: string; + target_count: number; + status: string; + sent_at: string; +} + +// 서비스 목록 (필터 드롭다운용) +export interface ServiceOption { + service_code: string; + service_name: string; +} -- 2.45.1