feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33) #34

Merged
seonkyu.kim merged 1 commits from feature/SPMS-33-dashboard-api-type-fix into develop 2026-03-01 12:02:08 +00:00
5 changed files with 102 additions and 117 deletions
Showing only changes of commit aef2890474 - Show all commits

View File

@ -1,6 +1,6 @@
import { apiClient } from "./client";
import type { ApiResponse, PaginatedResponse } from "@/types/api";
import type { DashboardRequest, DashboardData, ServiceOption } from "@/features/dashboard/types";
import type { ApiResponse } from "@/types/api";
import type { DashboardRequest, DashboardData } from "@/features/dashboard/types";
/** 대시보드 통합 조회 */
export function fetchDashboard(data: DashboardRequest, serviceCode?: string) {
@ -10,11 +10,3 @@ export function fetchDashboard(data: DashboardRequest, serviceCode?: string) {
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,13 +1,10 @@
import { useState, useEffect } from "react";
import FilterDropdown from "@/components/common/FilterDropdown";
import { useState } from "react";
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 {
@ -15,8 +12,6 @@ interface DashboardFilterProps {
loading?: boolean;
}
const ALL_SERVICES_LABEL = "전체 서비스";
/** 오늘 날짜 YYYY-MM-DD */
function today() {
return new Date().toISOString().slice(0, 10);
@ -31,41 +26,16 @@ function thirtyDaysAgo() {
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
const [dateEnd, setDateEnd] = useState(today);
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(ALL_SERVICES_LABEL);
};
// 조회
const handleSearch = () => {
const serviceCode = serviceCodeMap[selectedService]; // 전체 서비스면 undefined
onSearch?.({ dateStart, dateEnd, serviceCode });
onSearch?.({ dateStart, dateEnd });
};
return (
@ -80,16 +50,6 @@ export default function DashboardFilter({ onSearch, loading }: DashboardFilterPr
disabled={loading}
/>
{/* 서비스 드롭다운 */}
<FilterDropdown
label="서비스"
value={selectedService}
options={serviceOptions}
onChange={setSelectedService}
className="flex-1"
disabled={loading}
/>
{/* 초기화 */}
<FilterResetButton onClick={handleReset} disabled={loading} />

View File

@ -13,72 +13,78 @@ 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_sent),
badge: { type: "trend" as const, value: `${Math.abs(kpi.sent_change_rate)}%` },
value: formatNumber(kpi.total_send),
badge: { type: "trend" as const, value: `${Math.abs(kpi.today_sent_change_rate)}%` },
link: "/statistics",
},
{
label: "성공률",
value: kpi.success_rate.toFixed(1),
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.device_count),
value: formatNumber(kpi.total_devices),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(kpi.service_count),
value: String(kpi.active_service_count),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** daily_trend → WeeklyChart props 변환 */
/** daily → WeeklyChart props 변환 */
function mapChart(data: DashboardData) {
const trends = data.daily_trend;
const trends = data.daily ?? [];
if (trends.length === 0) return [];
const maxVal = Math.max(...trends.map((t) => Math.max(t.sent, t.success)), 1);
const maxVal = Math.max(...trends.map((t) => Math.max(t.send_count, t.success_count)), 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);
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.sent / maxVal, // Y축 비율 (0=최상단)
green: 1 - t.success / maxVal,
sent: formatNumber(t.sent),
reach: formatNumber(t.success),
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.message_name,
targetCount: formatNumber(m.target_count),
status: m.status as "완료" | "실패" | "진행" | "예약",
sentAt: m.sent_at,
return (data.top_messages ?? []).map((m) => ({
template: m.title ?? "",
targetCount: formatNumber(m.total_send_count),
status: (m.status ?? "") as "완료" | "실패" | "진행" | "예약",
sentAt: "",
}));
}
/** platform_ratio → PlatformDonut props 변환 */
/** platform_share → PlatformDonut props 변환 */
function mapPlatform(data: DashboardData) {
const shares = data.platform_share ?? [];
return {
ios: data.platform_ratio.ios,
android: data.platform_ratio.android,
ios: shares.find((p) => p.platform?.toLowerCase() === "ios")?.ratio ?? 0,
android: shares.find((p) => p.platform?.toLowerCase() === "android")?.ratio ?? 0,
};
}
@ -108,15 +114,16 @@ export default function DashboardPage() {
try {
const res = await fetchDashboard(
{ start_date: f.dateStart, end_date: f.dateEnd },
f.serviceCode,
);
const d = res.data.data;
// 데이터 비어있는지 판단
// 데이터 비어있는지 판단 (서비스·기기가 있으면 KPI 표시)
const hasData =
d.kpi.total_sent > 0 ||
d.daily_trend.length > 0 ||
d.top_messages.length > 0;
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);

View File

@ -4,49 +4,71 @@ export interface DashboardRequest {
end_date: string | null;
}
// 대시보드 통합 응답
export interface DashboardData {
kpi: DashboardKpi;
daily_trend: DailyTrend[];
platform_ratio: PlatformRatio;
top_messages: TopMessage[];
// 기간별 통계
export interface PeriodStat {
send_count: number;
success_count: number;
open_count: number;
ctr: number;
}
// KPI 지표
export interface DashboardKpi {
total_sent: number;
success_rate: number;
device_count: number;
service_count: number;
sent_change_rate: number; // 전일 대비 증감률 (%)
success_rate_change: number; // 성공률 전일 대비 변화분 (pp)
device_count_change: number; // 등록 기기 수 전일 대비 변화량
today_sent_change_rate: number; // 오늘 발송 전일 대비 증감률 (%)
total_devices: number;
active_devices: number;
total_messages: number;
total_send: number;
total_success: number;
total_open: number;
avg_ctr: number;
active_service_count: number;
success_rate_change: number;
device_count_change: number;
today_sent_change_rate: number;
today: PeriodStat | null;
this_month: PeriodStat | null;
}
// 일별 발송 추이
export interface DailyTrend {
date: string;
sent: number;
success: number;
// 일별 통계
export interface DailyStat {
stat_date: string | null;
send_count: number;
success_count: number;
fail_count: number;
open_count: number;
ctr: number;
}
// 플랫폼 비율
export interface PlatformRatio {
ios: number;
android: number;
// 시간대별 통계
export interface HourlyStat {
hour: number;
send_count: number;
open_count: number;
ctr: number;
}
// 플랫폼별 통계
export interface PlatformStat {
platform: string | null;
count: number;
ratio: number;
}
// 상위 메시지
export interface TopMessage {
message_name: string;
target_count: number;
status: string;
sent_at: string;
message_code: string | null;
title: string | null;
service_name: string | null;
total_send_count: number;
success_count: number;
status: string | null;
}
// 서비스 목록 (필터 드롭다운용)
export interface ServiceOption {
service_code: string;
service_name: string;
}
// 대시보드 통합 응답
export interface DashboardData {
kpi: DashboardKpi;
daily: DailyStat[] | null;
hourly: HourlyStat[] | null;
platform_share: PlatformStat[] | null;
top_messages: TopMessage[] | null;
}

View File

@ -154,8 +154,12 @@ export default function ServiceDetailPage() {
);
}
// 오늘 발송 = daily_trend의 오늘 데이터 또는 kpi.total_sent (서비스별 조회 시 오늘 기간)
const todaySent = stats?.total_sent ?? 0;
// 오늘 발송 = kpi.total_send (서비스별 조회 시 오늘 기간)
const todaySent = stats?.total_send ?? 0;
const successRate =
stats && stats.total_send > 0
? +(stats.total_success / stats.total_send * 100).toFixed(1)
: 0;
return (
<div>
@ -167,11 +171,11 @@ export default function ServiceDetailPage() {
/>
<ServiceStatsCards
totalSent={stats?.total_sent ?? 0}
successRate={stats?.success_rate ?? 0}
totalSent={stats?.total_send ?? 0}
successRate={successRate}
deviceCount={service.deviceCount}
todaySent={todaySent}
sentChangeRate={stats?.sent_change_rate}
sentChangeRate={stats?.today_sent_change_rate}
successRateChange={stats?.success_rate_change}
deviceCountChange={stats?.device_count_change}
todaySentChangeRate={stats?.today_sent_change_rate}