feat: 대시보드 API 연동 (#29)
- 대시보드 API 타입 정의 (DashboardRequest, DashboardData 등) - API 함수 생성 (fetchDashboard, fetchServiceList) - DashboardFilter 서비스 드롭다운 API 연동 (하드코딩 제거) - DashboardPage 랜덤 더미 데이터 → API 호출로 전환 - 에러/빈 데이터 오버레이 분리 (API 에러 vs 조회 결과 없음) Closes #29
This commit is contained in:
parent
ca88d5ba08
commit
cf7076f525
20
react/src/api/dashboard.api.ts
Normal file
20
react/src/api/dashboard.api.ts
Normal file
|
|
@ -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<ApiResponse<DashboardData>>(
|
||||||
|
"/v1/in/stats/dashboard",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서비스 목록 조회 (필터 드롭다운용) */
|
||||||
|
export function fetchServiceList() {
|
||||||
|
return apiClient.post<PaginatedResponse<ServiceOption>>(
|
||||||
|
"/v1/in/service/list",
|
||||||
|
{ page: 1, pageSize: 100 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import FilterDropdown from "@/components/common/FilterDropdown";
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
import DateRangeInput from "@/components/common/DateRangeInput";
|
import DateRangeInput from "@/components/common/DateRangeInput";
|
||||||
import FilterResetButton from "@/components/common/FilterResetButton";
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
import { fetchServiceList } from "@/api/dashboard.api";
|
||||||
|
|
||||||
|
export interface DashboardFilterValues {
|
||||||
|
dateStart: string;
|
||||||
|
dateEnd: string;
|
||||||
|
serviceCode?: string; // 미지정 시 전체 서비스
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardFilterProps {
|
interface DashboardFilterProps {
|
||||||
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
|
onSearch?: (filter: DashboardFilterValues) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
|
const ALL_SERVICES_LABEL = "전체 서비스";
|
||||||
|
|
||||||
/** 오늘 날짜 YYYY-MM-DD */
|
/** 오늘 날짜 YYYY-MM-DD */
|
||||||
function today() {
|
function today() {
|
||||||
|
|
@ -24,18 +31,41 @@ function thirtyDaysAgo() {
|
||||||
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
|
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
|
||||||
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
||||||
const [dateEnd, setDateEnd] = useState(today);
|
const [dateEnd, setDateEnd] = useState(today);
|
||||||
const [selectedService, setSelectedService] = useState(SERVICES[0]);
|
const [selectedService, setSelectedService] = useState(ALL_SERVICES_LABEL);
|
||||||
|
|
||||||
|
// 서비스 목록: label 배열 + code 매핑
|
||||||
|
const [serviceOptions, setServiceOptions] = useState<string[]>([ALL_SERVICES_LABEL]);
|
||||||
|
const [serviceCodeMap, setServiceCodeMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 서비스 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServiceList()
|
||||||
|
.then((res) => {
|
||||||
|
const items = res.data.data?.items ?? [];
|
||||||
|
const labels = [ALL_SERVICES_LABEL, ...items.map((s) => s.service_name)];
|
||||||
|
const codeMap: Record<string, string> = {};
|
||||||
|
items.forEach((s) => {
|
||||||
|
codeMap[s.service_name] = s.service_code;
|
||||||
|
});
|
||||||
|
setServiceOptions(labels);
|
||||||
|
setServiceCodeMap(codeMap);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 서비스 목록 로드 실패 시 "전체 서비스"만 유지
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 초기화
|
// 초기화
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDateStart(thirtyDaysAgo());
|
setDateStart(thirtyDaysAgo());
|
||||||
setDateEnd(today());
|
setDateEnd(today());
|
||||||
setSelectedService(SERVICES[0]);
|
setSelectedService(ALL_SERVICES_LABEL);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 조회
|
// 조회
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
onSearch?.({ dateStart, dateEnd, service: selectedService });
|
const serviceCode = serviceCodeMap[selectedService]; // 전체 서비스면 undefined
|
||||||
|
onSearch?.({ dateStart, dateEnd, serviceCode });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -54,7 +84,7 @@ export default function DashboardFilter({ onSearch, loading }: DashboardFilterPr
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="서비스"
|
label="서비스"
|
||||||
value={selectedService}
|
value={selectedService}
|
||||||
options={SERVICES}
|
options={serviceOptions}
|
||||||
onChange={setSelectedService}
|
onChange={setSelectedService}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,157 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import DashboardFilter from "../components/DashboardFilter";
|
import DashboardFilter from "../components/DashboardFilter";
|
||||||
|
import type { DashboardFilterValues } from "../components/DashboardFilter";
|
||||||
import StatsCards from "../components/StatsCards";
|
import StatsCards from "../components/StatsCards";
|
||||||
import WeeklyChart from "../components/WeeklyChart";
|
import WeeklyChart from "../components/WeeklyChart";
|
||||||
import RecentMessages from "../components/RecentMessages";
|
import RecentMessages from "../components/RecentMessages";
|
||||||
import PlatformDonut from "../components/PlatformDonut";
|
import PlatformDonut from "../components/PlatformDonut";
|
||||||
|
import { fetchDashboard } from "@/api/dashboard.api";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import type { DashboardData } from "../types";
|
||||||
|
|
||||||
/** 랜덤 정수 (min~max) */
|
/** KPI → StatsCards props 변환 */
|
||||||
function rand(min: number, max: number) {
|
function mapCards(data: DashboardData) {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
const { kpi } = data;
|
||||||
}
|
|
||||||
|
|
||||||
/** 숫자를 천단위 콤마로 포맷 */
|
|
||||||
function fmt(n: number) {
|
|
||||||
return n.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 통계 카드 랜덤 데이터 생성 */
|
|
||||||
function randomCards() {
|
|
||||||
const sent = rand(5000, 30000);
|
|
||||||
const rate = (95 + Math.random() * 4.9).toFixed(1);
|
|
||||||
const devices = rand(20000, 80000);
|
|
||||||
const services = rand(3, 15);
|
|
||||||
const trend = rand(1, 30);
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "오늘 발송 수",
|
label: "오늘 발송 수",
|
||||||
value: fmt(sent),
|
value: formatNumber(kpi.total_sent),
|
||||||
badge: { type: "trend" as const, value: `${trend}%` },
|
badge: { type: "trend" as const, value: `${Math.abs(kpi.sent_change_rate)}%` },
|
||||||
link: "/statistics",
|
link: "/statistics",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "성공률",
|
label: "성공률",
|
||||||
value: rate,
|
value: kpi.success_rate.toFixed(1),
|
||||||
unit: "%",
|
unit: "%",
|
||||||
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
|
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||||
link: "/statistics",
|
link: "/statistics",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "등록 기기 수",
|
label: "등록 기기 수",
|
||||||
value: fmt(devices),
|
value: formatNumber(kpi.device_count),
|
||||||
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
|
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
|
||||||
link: "/devices",
|
link: "/devices",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "활성 서비스",
|
label: "활성 서비스",
|
||||||
value: String(services),
|
value: String(kpi.service_count),
|
||||||
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||||
link: "/services",
|
link: "/services",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 주간 차트 랜덤 데이터 생성 */
|
/** daily_trend → WeeklyChart props 변환 */
|
||||||
function randomChart() {
|
function mapChart(data: DashboardData) {
|
||||||
const now = new Date();
|
const trends = data.daily_trend;
|
||||||
return Array.from({ length: 7 }, (_, i) => {
|
if (trends.length === 0) return [];
|
||||||
const d = new Date(now);
|
|
||||||
d.setDate(d.getDate() - (6 - i));
|
const maxVal = Math.max(...trends.map((t) => Math.max(t.sent, t.success)), 1);
|
||||||
const label = i === 6 ? "Today" : `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
const blue = 0.1 + Math.random() * 0.7;
|
|
||||||
const green = Math.min(blue + 0.05 + Math.random() * 0.1, 0.95);
|
return trends.map((t) => {
|
||||||
const sent = Math.round((1 - blue) * 15000);
|
const isToday = t.date === todayStr;
|
||||||
const reach = Math.round((1 - green) * 15000);
|
const mm = t.date.slice(5, 7);
|
||||||
return { label, blue, green, sent: fmt(sent), reach: fmt(reach) };
|
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),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 최근 발송 내역 랜덤 데이터 생성 */
|
/** top_messages → RecentMessages props 변환 */
|
||||||
function randomMessages() {
|
function mapMessages(data: DashboardData) {
|
||||||
const templates = [
|
return data.top_messages.map((m) => ({
|
||||||
"가을맞이 프로모션 알림", "정기 점검 안내", "비밀번호 변경 알림",
|
template: m.message_name,
|
||||||
"신규 서비스 런칭", "야간 푸시 마케팅", "결제 완료 알림",
|
targetCount: formatNumber(m.target_count),
|
||||||
"이벤트 당첨 안내", "서비스 업데이트 공지", "보안 알림",
|
status: m.status as "완료" | "실패" | "진행" | "예약",
|
||||||
];
|
sentAt: m.sent_at,
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 플랫폼 비율 랜덤 데이터 생성 */
|
/** platform_ratio → PlatformDonut props 변환 */
|
||||||
function randomPlatform() {
|
function mapPlatform(data: DashboardData) {
|
||||||
const ios = rand(25, 75);
|
return {
|
||||||
return { ios, android: 100 - ios };
|
ios: data.platform_ratio.ios,
|
||||||
|
android: data.platform_ratio.android,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [cards, setCards] = useState<ReturnType<typeof randomCards> | undefined>();
|
const [error, setError] = useState(false);
|
||||||
const [chart, setChart] = useState<ReturnType<typeof randomChart> | undefined>();
|
const [empty, setEmpty] = useState(false); // API 성공이나 데이터 없음
|
||||||
const [messages, setMessages] = useState<ReturnType<typeof randomMessages> | undefined>();
|
const [cards, setCards] = useState<ReturnType<typeof mapCards> | undefined>();
|
||||||
const [platform, setPlatform] = useState<ReturnType<typeof randomPlatform> | undefined>();
|
const [chart, setChart] = useState<ReturnType<typeof mapChart> | undefined>();
|
||||||
|
const [messages, setMessages] = useState<ReturnType<typeof mapMessages> | undefined>();
|
||||||
|
const [platform, setPlatform] = useState<ReturnType<typeof mapPlatform> | undefined>();
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
// 필터 상태 보관 (초기 로드 + 조회 버튼)
|
||||||
|
const [filter, setFilter] = useState<DashboardFilterValues>({
|
||||||
|
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);
|
setLoading(true);
|
||||||
setTimeout(() => {
|
setError(false);
|
||||||
setCards(randomCards());
|
setEmpty(false);
|
||||||
setChart(randomChart());
|
try {
|
||||||
setMessages(randomMessages());
|
const res = await fetchDashboard(
|
||||||
setPlatform(randomPlatform());
|
{ 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);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
|
|
@ -120,16 +163,54 @@ export default function DashboardPage() {
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
<DashboardFilter onSearch={handleSearch} loading={loading} />
|
<DashboardFilter onSearch={handleSearch} loading={loading} />
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 데이터 영역 */}
|
||||||
<StatsCards cards={cards} loading={loading} />
|
<div className="relative">
|
||||||
|
<StatsCards cards={showSkeleton ? undefined : cards} loading={showSkeleton} />
|
||||||
{/* 7일 발송 추이 차트 */}
|
<WeeklyChart data={showSkeleton ? undefined : chart} loading={showSkeleton} />
|
||||||
<WeeklyChart data={chart} loading={loading} />
|
|
||||||
|
|
||||||
{/* 최근 발송 내역 + 플랫폼 도넛 */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<RecentMessages messages={messages} loading={loading} />
|
<RecentMessages messages={showSkeleton ? undefined : messages} loading={showSkeleton} />
|
||||||
<PlatformDonut data={platform} loading={loading} />
|
<PlatformDonut data={showSkeleton ? undefined : platform} loading={showSkeleton} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 에러 오버레이 */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400"
|
||||||
|
style={{ fontSize: "48px" }}
|
||||||
|
>
|
||||||
|
cloud_off
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-gray-600">
|
||||||
|
데이터를 불러올 수 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
네트워크 상태를 확인하거나 다시 조회해 주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 조회 결과 없음 오버레이 */}
|
||||||
|
{empty && !loading && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400"
|
||||||
|
style={{ fontSize: "48px" }}
|
||||||
|
>
|
||||||
|
inbox
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-gray-600">
|
||||||
|
조회된 데이터가 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
기간이나 서비스를 변경하여 다시 조회해 주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user