## 시간 표시 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>
187 lines
8.1 KiB
TypeScript
187 lines
8.1 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { Tag, X, Loader2 } from 'lucide-react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { useAuth } from '@/app/auth/AuthContext';
|
|
import {
|
|
fetchLabelSessions,
|
|
createLabelSession,
|
|
cancelLabelSession,
|
|
type LabelSession as LabelSessionType,
|
|
} from '@/services/parentInferenceApi';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
|
|
/**
|
|
* 모선 추론 학습 세션 페이지.
|
|
* 운영자가 정답 라벨링 → prediction 모델 학습 데이터로 활용.
|
|
*
|
|
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
|
|
*/
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
ACTIVE: 'bg-green-500/20 text-green-400',
|
|
CANCELLED: 'bg-gray-500/20 text-gray-400',
|
|
COMPLETED: 'bg-blue-500/20 text-blue-400',
|
|
};
|
|
|
|
export function LabelSession() {
|
|
const { hasPermission } = useAuth();
|
|
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
|
|
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
|
|
|
|
const [items, setItems] = useState<LabelSessionType[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [filter, setFilter] = useState<string>('');
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
|
|
// 신규 세션
|
|
const [groupKey, setGroupKey] = useState('');
|
|
const [subCluster, setSubCluster] = useState('1');
|
|
const [labelMmsi, setLabelMmsi] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError('');
|
|
try {
|
|
const res = await fetchLabelSessions(filter || undefined, 0, 50);
|
|
setItems(res.content);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'unknown');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filter]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!canCreate || !groupKey || !labelMmsi) return;
|
|
setBusy(-1);
|
|
try {
|
|
await createLabelSession(groupKey, parseInt(subCluster, 10), {
|
|
labelParentMmsi: labelMmsi,
|
|
anchorSnapshot: { source: 'manual', timestamp: new Date().toISOString() },
|
|
});
|
|
setGroupKey(''); setLabelMmsi('');
|
|
await load();
|
|
} catch (e: unknown) {
|
|
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleCancel = async (id: number) => {
|
|
if (!canUpdate) return;
|
|
if (!confirm('세션을 취소하시겠습니까?')) return;
|
|
setBusy(id);
|
|
try {
|
|
await cancelLabelSession(id, '운영자 취소');
|
|
await load();
|
|
} catch (e: unknown) {
|
|
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-heading">학습 세션</h1>
|
|
<p className="text-xs text-hint mt-1">정답 라벨링 → prediction 모델 학습 데이터로 활용</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<select value={filter} onChange={(e) => setFilter(e.target.value)}
|
|
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
|
|
<option value="">전체 상태</option>
|
|
<option value="ACTIVE">ACTIVE</option>
|
|
<option value="CANCELLED">CANCELLED</option>
|
|
<option value="COMPLETED">COMPLETED</option>
|
|
</select>
|
|
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded">새로고침</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
|
|
<Tag className="w-3.5 h-3.5" /> 신규 학습 세션 등록
|
|
{!canCreate && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
|
|
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
|
<input type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
|
|
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
|
<input value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
|
|
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
|
<button type="button" onClick={handleCreate}
|
|
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
|
|
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
|
|
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
|
|
세션 생성
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && (
|
|
<Card>
|
|
<CardContent className="p-0 overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-surface-overlay text-hint">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left">ID</th>
|
|
<th className="px-3 py-2 text-left">Group Key</th>
|
|
<th className="px-3 py-2 text-center">Sub</th>
|
|
<th className="px-3 py-2 text-left">정답 MMSI</th>
|
|
<th className="px-3 py-2 text-left">상태</th>
|
|
<th className="px-3 py-2 text-left">생성자</th>
|
|
<th className="px-3 py-2 text-left">시작</th>
|
|
<th className="px-3 py-2 text-center">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.length === 0 && (
|
|
<tr><td colSpan={8} className="px-3 py-8 text-center text-hint">학습 세션이 없습니다.</td></tr>
|
|
)}
|
|
{items.map((it) => (
|
|
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
|
|
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
|
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
|
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
|
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
|
<td className="px-3 py-2">
|
|
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{it.status === 'ACTIVE' && (
|
|
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
|
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|