- 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
250 lines
8.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|