SPMS_WEB/react/src/features/dashboard/pages/DashboardPage.tsx
SEAN 32b48af34c fix: 대시보드 및 기기 관리 화면 버그 수정 (#47)
- StatusBadge: 매핑 실패 시 undefined variant → default 폴백 처리
- RecentMessages: STATUS_MAP 미등록 status 값 → default 폴백 처리
- DashboardPage: mapChart에서 최근 7일 날짜 항상 채우기 (빈 날짜 0으로)
- DeviceListPage/DeviceSlidePanel: 플랫폼 비교 toLowerCase() 처리
- DeviceSlidePanel: 플랫폼 텍스트 iOS/Android 정규화, 날짜 formatDate 적용
- PlatformBadge: Android 아이콘 lineHeight: 1 추가로 수직 정렬 수정
- formatDate: 서버 기본값(0001-01-01) → "-" 반환
- SecretToggleCell: position fixed로 테이블 overflow 탈출

Closes #47
2026-03-18 10:43:08 +09:00

250 lines
8.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;
const successRate =
kpi.total_send > 0
? +(kpi.total_success / kpi.total_send * 100).toFixed(1)
: 0;
return [
{
label: "오늘 발송 수",
value: formatNumber(kpi.total_send),
badge: { type: "trend" as const, value: `${Math.abs(kpi.today_sent_change_rate)}%` },
link: "/statistics",
},
{
label: "성공률",
value: successRate.toFixed(1),
unit: "%",
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: formatNumber(kpi.total_devices),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(kpi.active_service_count),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** daily → WeeklyChart props 변환 (최근 7일 기준, 없는 날짜는 0으로 채움) */
function mapChart(data: DashboardData) {
const allTrends = data.daily ?? [];
// 최근 7일 날짜 목록 생성 (오늘 포함)
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
const last7Days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - 6 + i);
return d.toISOString().slice(0, 10);
});
const dataMap = new Map(allTrends.map((t) => [t.stat_date, t]));
// 없는 날짜는 send_count 0으로 채움
const trends = last7Days.map(
(date) =>
dataMap.get(date) ?? {
stat_date: date,
send_count: 0,
success_count: 0,
fail_count: 0,
open_count: 0,
ctr: 0,
},
);
const maxVal = Math.max(
...trends.map((t) => Math.max(t.send_count, t.success_count)),
1,
);
return trends.map((t) => {
const dateStr = t.stat_date ?? "";
const isToday = dateStr === todayStr;
const mm = dateStr.slice(5, 7);
const dd = dateStr.slice(8, 10);
return {
label: isToday ? "Today" : `${mm}.${dd}`,
blue: 1 - t.send_count / maxVal, // Y축 비율 (0=최상단)
green: 1 - t.success_count / maxVal,
sent: formatNumber(t.send_count),
reach: formatNumber(t.success_count),
};
});
}
/** top_messages → RecentMessages props 변환 */
function mapMessages(data: DashboardData) {
return (data.top_messages ?? []).map((m) => ({
template: m.title ?? "",
targetCount: formatNumber(m.total_send_count),
status: (m.status ?? "") as "완료" | "실패" | "진행" | "예약",
sentAt: "",
}));
}
/** platform_share → PlatformDonut props 변환 */
function mapPlatform(data: DashboardData) {
const shares = data.platform_share ?? [];
return {
ios: shares.find((p) => p.platform?.toLowerCase() === "ios")?.ratio ?? 0,
android: shares.find((p) => p.platform?.toLowerCase() === "android")?.ratio ?? 0,
};
}
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 },
);
const d = res.data.data;
// 데이터 비어있는지 판단 (서비스·기기가 있으면 KPI 표시)
const hasData =
d.kpi.total_send > 0 ||
d.kpi.total_devices > 0 ||
d.kpi.active_service_count > 0 ||
(d.daily ?? []).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>
</>
);
}