kcg-ai-monitoring/frontend/src/features/statistics/Statistics.tsx
htlee 19b1613157 feat: 프론트 전수 mock 정리 + UTC→KST 통일 + i18n 수정 + stats hourly API
## 시간 표시 KST 통일
- shared/utils/dateFormat.ts 공통 유틸 신규 (formatDateTime/formatDate/formatTime/toDateParam)
- 14개 파일에서 인라인 toLocaleString → 공통 유틸 교체

## i18n 'group.parentInference' 사이드바 미번역 수정
- ko/en common.json의 'group' 키 중복 정의를 병합
  (95행 두번째 group 객체가 35행을 덮어써서 parentInference 누락)

## Dashboard/MonitoringDashboard/Statistics 더미→실 API
- 백엔드 GET /api/stats/hourly 신규 (PredictionStatsHourly 엔티티/리포지토리)
- Dashboard: HOURLY_DETECTION/VESSEL_TYPE/AREA_RISK 하드코딩 제거 →
  getHourlyStats(24) + getDailyStats(today) 결과로 useMemo 변환
- MonitoringDashboard: TREND Math.random() 제거 → getHourlyStats 기반
  위험도 가중평균 + 경보 카운트
- Statistics: KPI_DATA 하드코딩 제거 → getKpiMetrics() 결과를 표 행으로

## Store mock 의존성 제거
- eventStore.alerts/MOCK_ALERTS 제거 (MobileService는 events에서 직접 추출)
- enforcementStore.plans 제거 (EnforcementPlan은 이미 직접 API 호출)
- transferStore + MOCK_TRANSFERS 완전 제거
  (ChinaFishing/TransferDetection은 RealTransshipSuspects 컴포넌트 사용)
- mock/events.ts, mock/enforcement.ts, mock/transfers.ts 파일 삭제

## RiskMap 랜덤 격자 제거
- generateGrid() Math.random() 제거 → 빈 배열 + 'AI 분석 데이터 수집 중' 안내
- MTIS 외부 통계 5개 탭에 [MTIS 외부 통계] 배지 추가

## 12개 mock 화면에 '데모 데이터' 노란색 배지 추가
- patrol/PatrolRoute, FleetOptimization
- admin/AdminPanel, DataHub, NoticeManagement, SystemConfig
- ai-operations/AIModelManagement, MLOpsPage
- field-ops/ShipAgent
- statistics/ReportManagement, ExternalService
- surveillance/MapControl

## 백엔드 NUMERIC precision 동기화
- PredictionKpi.deltaPct: 5,2 → 12,2
- PredictionStatsDaily/Monthly.aiAccuracyPct: 5,2 → 12,2
- (V015 마이그레이션과 동기화)

44 files changed, +346 / -787

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:36:38 +09:00

239 lines
6.9 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';
/* 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 className="bg-green-500/20 text-green-400 border-0 text-[9px]">
{v as string}
</Badge>
),
},
];
export function Statistics() {
const { t } = useTranslation('statistics');
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) => (
<div
key={item.type}
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
>
<div className="text-lg font-bold text-heading">
{item.count}
</div>
<div className="text-[10px] text-muted-foreground">
{item.type}
</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>
);
}