feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33) #34
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user