- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환 - 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid - 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정) - ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/ PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출 - PermissionsPanel 역할 생성/수정에 ColorPicker 통합 - MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동) - Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리) - ReportManagement, TransferDetection p-5 space-y-4 padding 복구 - EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소 - timeline 시간 formatDateTime 적용 (ISO T 구분자 처리) - 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
|
import { BarChart3, Download } from 'lucide-react';
|
|
import { BarChart, AreaChart } from '@lib/charts';
|
|
import {
|
|
getKpiMetrics,
|
|
getMonthlyStats,
|
|
toMonthlyTrend,
|
|
toViolationTypes,
|
|
type PredictionKpi,
|
|
type PredictionStatsMonthly,
|
|
} from '@/services/kpi';
|
|
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
|
import { toDateParam } from '@shared/utils/dateFormat';
|
|
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
|
|
/* SFR-13: 통계·지표·성과 분석 */
|
|
|
|
interface KpiRow {
|
|
id: string;
|
|
name: string;
|
|
target: string;
|
|
current: string;
|
|
status: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const kpiCols: DataColumn<KpiRow>[] = [
|
|
{
|
|
key: 'id',
|
|
label: 'ID',
|
|
width: '70px',
|
|
render: (v) => (
|
|
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
label: '지표명',
|
|
sortable: true,
|
|
render: (v) => (
|
|
<span className="text-heading font-medium">{v as string}</span>
|
|
),
|
|
},
|
|
{ key: 'target', label: '목표', width: '80px', align: 'center' },
|
|
{
|
|
key: 'current',
|
|
label: '현재',
|
|
width: '80px',
|
|
align: 'center',
|
|
render: (v) => (
|
|
<span className="text-cyan-400 font-bold">{v as string}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
width: '60px',
|
|
align: 'center',
|
|
render: (v) => (
|
|
<Badge intent="success" size="sm">
|
|
{v as string}
|
|
</Badge>
|
|
),
|
|
},
|
|
];
|
|
|
|
export function Statistics() {
|
|
const { t } = useTranslation('statistics');
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language);
|
|
|
|
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
|
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
|
const [kpiMetrics, setKpiMetrics] = useState<PredictionKpi[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadStats() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const now = new Date();
|
|
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
|
|
|
const [data, kpiData] = await Promise.all([
|
|
getMonthlyStats(toDateParam(from), toDateParam(now)),
|
|
getKpiMetrics().catch(() => [] as PredictionKpi[]),
|
|
]);
|
|
|
|
if (cancelled) return;
|
|
|
|
setMonthly(data.map(toMonthlyTrend));
|
|
setViolationTypes(toViolationTypes(data));
|
|
setKpiMetrics(kpiData);
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setError(
|
|
err instanceof Error ? err.message : '통계 데이터 로드 실패',
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
loadStats();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const MONTHLY = monthly.map((m) => ({
|
|
m: m.month,
|
|
enforce: m.enforce,
|
|
detect: m.detect,
|
|
accuracy: m.accuracy,
|
|
}));
|
|
|
|
const BY_TYPE = violationTypes;
|
|
|
|
const KPI_DATA: KpiRow[] = kpiMetrics.map((k, i) => {
|
|
const trendLabel =
|
|
k.trend === 'up' ? '상승' : k.trend === 'down' ? '하락' : k.trend === 'flat' ? '유지' : '-';
|
|
const deltaLabel = k.deltaPct != null ? ` (${k.deltaPct > 0 ? '+' : ''}${k.deltaPct}%)` : '';
|
|
return {
|
|
id: `KPI-${String(i + 1).padStart(2, '0')}`,
|
|
name: k.kpiLabel,
|
|
target: '-',
|
|
current: String(k.value),
|
|
status: `${trendLabel}${deltaLabel}`,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div className="p-5 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
|
<BarChart3 className="w-5 h-5 text-purple-400" />
|
|
{t('statistics.title')}
|
|
</h2>
|
|
<p className="text-[10px] text-hint mt-0.5">
|
|
{t('statistics.desc')}
|
|
</p>
|
|
</div>
|
|
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading">
|
|
<Download className="w-3 h-3" />
|
|
보고서 생성
|
|
</button>
|
|
</div>
|
|
|
|
{loading && (
|
|
<div className="text-center py-10 text-muted-foreground text-sm">
|
|
데이터를 불러오는 중...
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-center py-10 text-red-400 text-sm">{error}</div>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-label mb-3">
|
|
월별 단속·탐지 추이
|
|
</div>
|
|
<BarChart
|
|
data={MONTHLY}
|
|
xKey="m"
|
|
height={200}
|
|
series={[
|
|
{ key: 'enforce', name: '단속', color: '#3b82f6' },
|
|
{ key: 'detect', name: '탐지', color: '#8b5cf6' },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-label mb-3">
|
|
AI 정확도 추이
|
|
</div>
|
|
<AreaChart
|
|
data={MONTHLY}
|
|
xKey="m"
|
|
height={200}
|
|
yAxisDomain={[75, 100]}
|
|
series={[
|
|
{ key: 'accuracy', name: '정확도 %', color: '#22c55e' },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-label mb-3">
|
|
위반 유형별 분포
|
|
</div>
|
|
<div className="flex gap-3">
|
|
{BY_TYPE.map((item) => {
|
|
const color = getViolationColor(item.type);
|
|
const label = getViolationLabel(item.type, tc, lang);
|
|
return (
|
|
<div
|
|
key={item.type}
|
|
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg border-l-4"
|
|
style={{ borderLeftColor: color }}
|
|
>
|
|
<div className="text-lg font-bold tabular-nums" style={{ color }}>
|
|
{item.count}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
<div className="text-[9px] text-hint">{item.pct}%</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
<DataTable
|
|
data={KPI_DATA}
|
|
columns={kpiCols}
|
|
pageSize={10}
|
|
title="핵심 성과 지표 (KPI)"
|
|
searchPlaceholder="지표명 검색..."
|
|
exportFilename="성과지표"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|