generated from gc/template-java-maven
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info) - PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 - SERVICE_BADGE_VARIANTS 공통 상수 추출 - 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 - 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs) - 타이틀 아이콘 전체 페이지 통일 - 카드 테두리 디자인 통일 (border + rounded-xl) - FHD 1920x1080 최적화
397 lines
17 KiB
TypeScript
397 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import {
|
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
|
} from 'recharts';
|
|
import type { UserStatsResponse, UserRoleDistribution } from '../../types/statistics';
|
|
import { getUserStats } from '../../services/statisticsService';
|
|
import PeriodFilter from '../../components/PeriodFilter';
|
|
import Badge from '../../components/ui/Badge';
|
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
|
import { useTheme } from '../../hooks/useTheme';
|
|
|
|
// 역할별 Badge variant 매핑
|
|
const ROLE_BADGE_VARIANT: Record<string, 'rose' | 'blue' | 'teal' | 'lavender' | 'coral' | 'gold'> = {
|
|
ADMIN: 'rose',
|
|
MANAGER: 'blue',
|
|
USER: 'teal',
|
|
};
|
|
|
|
const ROLE_DONUT_COLORS_HEX = {
|
|
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
|
|
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
|
|
} as const;
|
|
|
|
const successRateClass = (rate: number) => {
|
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
|
return 'text-[var(--color-danger)]';
|
|
};
|
|
|
|
// SVG 도넛 차트 (역할별 분포)
|
|
interface DonutChartProps {
|
|
data: UserRoleDistribution[];
|
|
colors: readonly string[];
|
|
}
|
|
|
|
const DonutChart = ({ data, colors }: DonutChartProps) => {
|
|
const total = data.reduce((sum, d) => sum + d.requestCount, 0);
|
|
const size = 100;
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const r = 36;
|
|
const strokeWidth = 18;
|
|
const circumference = 2 * Math.PI * r;
|
|
|
|
let cumulativePct = 0;
|
|
|
|
return (
|
|
<div className="flex items-center gap-4">
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
|
|
{/* 배경 트랙 */}
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke="var(--color-border)"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{total === 0 ? null : data.map((segment, idx) => {
|
|
const pct = segment.requestCount / total;
|
|
const dashArray = circumference * pct;
|
|
const dashOffset = circumference * (1 - cumulativePct);
|
|
cumulativePct += pct;
|
|
|
|
return (
|
|
<circle
|
|
key={segment.role}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={colors[idx % colors.length]}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={`${dashArray} ${circumference - dashArray}`}
|
|
strokeDashoffset={dashOffset}
|
|
transform={`rotate(-90 ${cx} ${cy})`}
|
|
/>
|
|
);
|
|
})}
|
|
<text x={cx} y={cy - 5} textAnchor="middle" fontSize={10} fill="var(--color-text-tertiary)">전체</text>
|
|
<text x={cx} y={cy + 9} textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--color-text-primary)">
|
|
{total.toLocaleString()}
|
|
</text>
|
|
</svg>
|
|
|
|
{/* 범례 */}
|
|
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
|
{data.map((segment, idx) => {
|
|
const pct = total > 0 ? ((segment.requestCount / total) * 100).toFixed(1) : '0.0';
|
|
return (
|
|
<div key={segment.role} className="flex items-center gap-2">
|
|
<span
|
|
className="shrink-0 w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: colors[idx % colors.length] }}
|
|
/>
|
|
<span className="text-xs text-[var(--color-text-secondary)] min-w-[52px]">{segment.role}</span>
|
|
<span className="text-xs font-medium text-[var(--color-text-primary)] ml-auto">
|
|
{segment.requestCount.toLocaleString()}
|
|
</span>
|
|
<span className="text-[10px] text-[var(--color-text-tertiary)] w-10 text-right shrink-0">
|
|
{pct}%
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// KPI 카드
|
|
interface KpiCardProps {
|
|
icon: 'users' | 'key' | 'activity';
|
|
label: string;
|
|
value: number;
|
|
total?: number;
|
|
}
|
|
|
|
const KpiCard = ({ icon, label, value, total }: KpiCardProps) => {
|
|
const pct = total && total > 0 ? Math.min(100, (value / total) * 100) : null;
|
|
|
|
return (
|
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex items-center justify-center w-8 h-8 rounded-md bg-[var(--color-primary-subtle)]">
|
|
{icon === 'users' && (
|
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
)}
|
|
{icon === 'key' && (
|
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
</svg>
|
|
)}
|
|
{icon === 'activity' && (
|
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
<span className="text-xs text-[var(--color-text-secondary)]">{label}</span>
|
|
</div>
|
|
|
|
<p className="text-3xl font-extrabold text-[var(--color-text-primary)] leading-none">
|
|
{value.toLocaleString()}
|
|
</p>
|
|
|
|
{pct !== null && (
|
|
<div className="flex items-center gap-2 mt-auto">
|
|
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{ width: `${pct}%`, backgroundColor: 'var(--color-primary)' }}
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0 w-10 text-right">
|
|
{pct.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 메인 페이지
|
|
const UserStatsPage = () => {
|
|
const { theme } = useTheme();
|
|
const chartColors = CHART_COLORS_HEX[theme];
|
|
const donutColors = ROLE_DONUT_COLORS_HEX[theme];
|
|
|
|
const [startDate, setStartDate] = useState(() => {
|
|
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
|
|
});
|
|
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
|
|
const [data, setData] = useState<UserStatsResponse | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await getUserStats(startDate, endDate);
|
|
if (res.success && res.data) {
|
|
setData(res.data);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [startDate, endDate]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const maxRequests = useMemo(() => {
|
|
if (!data || data.topUsers.length === 0) return 1;
|
|
return Math.max(...data.topUsers.map((u) => u.requestCount), 1);
|
|
}, [data]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto flex flex-col gap-4">
|
|
{/* 헤더: 제목 + PeriodFilter */}
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">사용자 통계</h1>
|
|
<p className="text-sm text-[var(--color-text-secondary)]">사용자별 API 사용 현황을 분석합니다</p>
|
|
</div>
|
|
</div>
|
|
<PeriodFilter
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartDateChange={setStartDate}
|
|
onEndDateChange={setEndDate}
|
|
onRefresh={fetchData}
|
|
/>
|
|
</div>
|
|
|
|
{!data ? (
|
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
|
) : (
|
|
<>
|
|
{/* 1행: KPI 카드 3개 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<KpiCard
|
|
icon="users"
|
|
label="전체 사용자"
|
|
value={data.totalUsers}
|
|
/>
|
|
<KpiCard
|
|
icon="key"
|
|
label="API Key 보유 사용자"
|
|
value={data.usersWithActiveKey}
|
|
total={data.totalUsers}
|
|
/>
|
|
<KpiCard
|
|
icon="activity"
|
|
label="API 요청 사용자"
|
|
value={data.totalActiveUsers}
|
|
total={data.totalUsers}
|
|
/>
|
|
</div>
|
|
|
|
{/* 2행: 일별 추이(좌 60%) + 역할별 분포(우 40%) */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
{/* 좌: 일별 활성 사용자 추이 */}
|
|
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">일별 API 요청 사용자 추이</h3>
|
|
{data.dailyActiveUsers.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
<AreaChart data={data.dailyActiveUsers}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
|
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
|
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="activeUsers"
|
|
stroke={chartColors[0]}
|
|
fill={chartColors[0]}
|
|
fillOpacity={0.2}
|
|
name="API 요청 사용자"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<p className="text-[var(--color-text-tertiary)] text-sm">데이터가 없습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우: 역할별 요청 분포 (CSS 도넛) */}
|
|
<div className="col-span-2 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">역할별 요청 분포</h3>
|
|
{data.roleDistribution.length > 0 ? (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<DonutChart data={data.roleDistribution} colors={donutColors} />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<p className="text-[var(--color-text-tertiary)] text-sm">데이터가 없습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3행: 상위 사용자 Top 5 테이블 */}
|
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
|
<div className="px-4 py-3 border-b border-[var(--color-border)]">
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">상위 사용자 Top 5</h3>
|
|
</div>
|
|
{data.topUsers.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-[var(--color-bg-base)]">
|
|
<tr className="h-8">
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-16">순위</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">사용자명</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28">역할</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">요청 수</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28">성공률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{data.topUsers.slice(0, 5).map((user, idx) => {
|
|
const rank = idx + 1;
|
|
const rankBg = rank <= 3
|
|
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]'
|
|
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
|
|
const reqPct = maxRequests > 0 ? (user.requestCount / maxRequests) * 100 : 0;
|
|
|
|
return (
|
|
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
|
|
{/* 순위 뱃지 */}
|
|
<td className="px-3 py-1">
|
|
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-md text-xs font-bold ${rankBg}`}>
|
|
{rank}
|
|
</span>
|
|
</td>
|
|
|
|
{/* 사용자명 (마스킹) */}
|
|
<td className="px-3 py-1">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="h-4 w-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
|
|
</svg>
|
|
<span className="font-medium text-[var(--color-text-primary)]">
|
|
{user.userName.length > 0 ? `${user.userName.charAt(0)}***` : '-'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* 역할 Badge pill */}
|
|
<td className="px-3 py-1">
|
|
<Badge
|
|
variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}
|
|
size="sm"
|
|
>
|
|
{user.role}
|
|
</Badge>
|
|
</td>
|
|
|
|
{/* 요청 수 + 프로그레스 바 */}
|
|
<td className="px-3 py-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden min-w-[80px]">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{ width: `${reqPct}%`, backgroundColor: 'var(--color-primary)' }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-medium text-[var(--color-text-primary)] w-16 text-right shrink-0">
|
|
{user.requestCount.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* 성공률 색상 코딩 */}
|
|
<td className="px-3 py-1">
|
|
<span className={`text-xs font-semibold ${successRateClass(user.successRate)}`}>
|
|
{user.successRate.toFixed(1)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="text-[var(--color-text-tertiary)] text-center py-8 text-sm">데이터가 없습니다</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UserStatsPage;
|