feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
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:
commit
ad6010320a
|
|
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user