feat: 대시보드 API 연동 (#29)
All checks were successful
SPMS_BO/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/30
This commit is contained in:
김선규 2026-02-28 08:12:15 +00:00
commit cff57b5fad
4 changed files with 261 additions and 82 deletions

View 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 },
);
}

View File

@ -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<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 = () => {
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
<FilterDropdown
label="서비스"
value={selectedService}
options={SERVICES}
options={serviceOptions}
onChange={setSelectedService}
className="flex-1"
disabled={loading}

View File

@ -1,114 +1,157 @@
import { useState, useCallback } from "react";
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";
/** 랜덤 정수 (min~max) */
function rand(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/** 숫자를 천단위 콤마로 포맷 */
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);
/** KPI → StatsCards props 변환 */
function mapCards(data: DashboardData) {
const { kpi } = data;
return [
{
label: "오늘 발송 수",
value: fmt(sent),
badge: { type: "trend" as const, value: `${trend}%` },
value: formatNumber(kpi.total_sent),
badge: { type: "trend" as const, value: `${Math.abs(kpi.sent_change_rate)}%` },
link: "/statistics",
},
{
label: "성공률",
value: rate,
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: fmt(devices),
value: formatNumber(kpi.device_count),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(services),
value: String(kpi.service_count),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** 주간 차트 랜덤 데이터 생성 */
function randomChart() {
const now = new Date();
return Array.from({ length: 7 }, (_, i) => {
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<ReturnType<typeof randomCards> | undefined>();
const [chart, setChart] = useState<ReturnType<typeof randomChart> | undefined>();
const [messages, setMessages] = useState<ReturnType<typeof randomMessages> | undefined>();
const [platform, setPlatform] = useState<ReturnType<typeof randomPlatform> | undefined>();
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 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);
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() {
{/* 필터 */}
<DashboardFilter onSearch={handleSearch} loading={loading} />
{/* 통계 카드 */}
<StatsCards cards={cards} 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>
{/* 7일 발송 추이 차트 */}
<WeeklyChart data={chart} loading={loading} />
{/* 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>
)}
{/* 최근 발송 내역 + 플랫폼 도넛 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentMessages messages={messages} loading={loading} />
<PlatformDonut data={platform} loading={loading} />
{/* 조회 결과 없음 오버레이 */}
{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>
</>
);

View File

@ -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;
}