feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33)
All checks were successful
SPMS_BO/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/34
This commit is contained in:
김선규 2026-03-01 12:02:06 +00:00
commit ad6010320a
5 changed files with 102 additions and 117 deletions

View File

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

View File

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

View File

@ -4,49 +4,71 @@ export interface DashboardRequest {
end_date: string | null; end_date: string | null;
} }
// 대시보드 통합 응답 // 기간별 통계
export interface DashboardData { export interface PeriodStat {
kpi: DashboardKpi; send_count: number;
daily_trend: DailyTrend[]; success_count: number;
platform_ratio: PlatformRatio; open_count: number;
top_messages: TopMessage[]; ctr: number;
} }
// KPI 지표 // KPI 지표
export interface DashboardKpi { export interface DashboardKpi {
total_sent: number; total_devices: number;
success_rate: number; active_devices: number;
device_count: number; total_messages: number;
service_count: number; total_send: number;
sent_change_rate: number; // 전일 대비 증감률 (%) total_success: number;
success_rate_change: number; // 성공률 전일 대비 변화분 (pp) total_open: number;
device_count_change: number; // 등록 기기 수 전일 대비 변화량 avg_ctr: number;
today_sent_change_rate: 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 { export interface DailyStat {
date: string; stat_date: string | null;
sent: number; send_count: number;
success: number; success_count: number;
fail_count: number;
open_count: number;
ctr: number;
} }
// 플랫폼 비율 // 시간대별 통계
export interface PlatformRatio { export interface HourlyStat {
ios: number; hour: number;
android: number; send_count: number;
open_count: number;
ctr: number;
}
// 플랫폼별 통계
export interface PlatformStat {
platform: string | null;
count: number;
ratio: number;
} }
// 상위 메시지 // 상위 메시지
export interface TopMessage { export interface TopMessage {
message_name: string; message_code: string | null;
target_count: number; title: string | null;
status: string; service_name: string | null;
sent_at: string; total_send_count: number;
success_count: number;
status: string | null;
} }
// 서비스 목록 (필터 드롭다운용) // 대시보드 통합 응답
export interface ServiceOption { export interface DashboardData {
service_code: string; kpi: DashboardKpi;
service_name: string; 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 (서비스별 조회 시 오늘 기간) // 오늘 발송 = kpi.total_send (서비스별 조회 시 오늘 기간)
const todaySent = stats?.total_sent ?? 0; const todaySent = stats?.total_send ?? 0;
const successRate =
stats && stats.total_send > 0
? +(stats.total_success / stats.total_send * 100).toFixed(1)
: 0;
return ( return (
<div> <div>
@ -167,11 +171,11 @@ export default function ServiceDetailPage() {
/> />
<ServiceStatsCards <ServiceStatsCards
totalSent={stats?.total_sent ?? 0} totalSent={stats?.total_send ?? 0}
successRate={stats?.success_rate ?? 0} successRate={successRate}
deviceCount={service.deviceCount} deviceCount={service.deviceCount}
todaySent={todaySent} todaySent={todaySent}
sentChangeRate={stats?.sent_change_rate} sentChangeRate={stats?.today_sent_change_rate}
successRateChange={stats?.success_rate_change} successRateChange={stats?.success_rate_change}
deviceCountChange={stats?.device_count_change} deviceCountChange={stats?.device_count_change}
todaySentChangeRate={stats?.today_sent_change_rate} todaySentChangeRate={stats?.today_sent_change_rate}