SPMS_WEB/react/src/features/dashboard/pages/DashboardPage.tsx
SEAN cf7076f525 feat: 대시보드 API 연동 (#29)
- 대시보드 API 타입 정의 (DashboardRequest, DashboardData 등)
- API 함수 생성 (fetchDashboard, fetchServiceList)
- DashboardFilter 서비스 드롭다운 API 연동 (하드코딩 제거)
- DashboardPage 랜덤 더미 데이터 → API 호출로 전환
- 에러/빈 데이터 오버레이 분리 (API 에러 vs 조회 결과 없음)

Closes #29
2026-02-28 17:07:42 +09:00

218 lines
7.1 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import PageHeader from "@/components/common/PageHeader";
import DashboardFilter from "../components/DashboardFilter";
import type { DashboardFilterValues } from "../components/DashboardFilter";
import StatsCards from "../components/StatsCards";
import WeeklyChart from "../components/WeeklyChart";
import RecentMessages from "../components/RecentMessages";
import PlatformDonut from "../components/PlatformDonut";
import { fetchDashboard } from "@/api/dashboard.api";
import { formatNumber } from "@/utils/format";
import type { DashboardData } from "../types";
/** KPI → StatsCards props 변환 */
function mapCards(data: DashboardData) {
const { kpi } = data;
return [
{
label: "오늘 발송 수",
value: formatNumber(kpi.total_sent),
badge: { type: "trend" as const, value: `${Math.abs(kpi.sent_change_rate)}%` },
link: "/statistics",
},
{
label: "성공률",
value: kpi.success_rate.toFixed(1),
unit: "%",
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: formatNumber(kpi.device_count),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(kpi.service_count),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** 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),
};
});
}
/** 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,
}));
}
/** 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 [error, setError] = useState(false);
const [empty, setEmpty] = useState(false); // API 성공이나 데이터 없음
const [cards, setCards] = useState<ReturnType<typeof mapCards> | 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 [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);
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);
}
}, []);
// 초기 로드
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 (
<>
{/* 페이지 헤더 */}
<PageHeader
title="대시보드"
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
/>
{/* 필터 */}
<DashboardFilter onSearch={handleSearch} loading={loading} />
{/* 데이터 영역 */}
<div className="relative">
<StatsCards cards={showSkeleton ? undefined : cards} loading={showSkeleton} />
<WeeklyChart data={showSkeleton ? undefined : chart} loading={showSkeleton} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentMessages messages={showSkeleton ? undefined : messages} loading={showSkeleton} />
<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>
</>
);
}