kcg-ai-monitoring/frontend/src/features/statistics/Statistics.tsx
htlee 64e24cea71 refactor(frontend): statistics/ai-ops/parent-inference PageContainer 적용
statistics:
- Statistics: icon=BarChart3, secondary 보고서 생성 Button
- ReportManagement: destructive '새 보고서' + Button 그룹 + demo
- ExternalService: demo

ai-operations:
- AIAssistant: PageContainer + h-full flex 조합 (chat 레이아웃)
- AIModelManagement: 운영 모델 상태 뱃지는 actions 슬롯 유지
- MLOpsPage: demo

parent-inference:
- ParentReview/LabelSession/ParentExclusion: size=lg + Select + primary Button

Phase B-4 완료. 총 9개 파일.
2026-04-08 12:04:05 +09:00

246 lines
7.2 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 { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
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 (
<PageContainer>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title={t('statistics.title')}
description={t('statistics.desc')}
actions={
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
}
/>
{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="성과지표"
/>
</PageContainer>
);
}