자동 갱신 (30초, 깜박임 없음): - eventStore: silentRefresh() 메서드 추가 (loading 상태 미변경, 데이터만 교체) - EventList: 30초 인터벌로 silentRefresh + loadStats 호출 - DarkVesselDetection: 30초 인터벌로 getDarkVessels silent 갱신 모선추론 자동 연결: - ParentReview CONFIRM → createLabelSession 자동 호출 (학습 데이터 수집 시작) - ParentReview REJECT → excludeForGroup 자동 호출 (잘못된 후보 재추론 방지) - 자동 연결 실패 시 리뷰 자체는 유지 (catch 무시) i18n (ko/en): - darkTier: CRITICAL/HIGH/WATCH/NONE 라벨 - transshipTier: CRITICAL/HIGH/WATCH 라벨 - adminSubGroup: AI 플랫폼/시스템 운영/사용자 관리/감사·보안 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { Button } from '@shared/components/ui/button';
|
|
import { Select } from '@shared/components/ui/select';
|
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
|
import { useAuth } from '@/app/auth/AuthContext';
|
|
import {
|
|
fetchReviewList,
|
|
reviewParent,
|
|
createLabelSession,
|
|
excludeForGroup,
|
|
type ParentResolution,
|
|
} from '@/services/parentInferenceApi';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
/**
|
|
* 모선 확정/거부/리셋 페이지.
|
|
* - 운영자가 prediction이 추론한 모선 후보를 확정/거부.
|
|
* - 권한: parent-inference-workflow:parent-review (READ + UPDATE)
|
|
* - 모든 액션은 백엔드에서 audit_log + review_log에 기록
|
|
*/
|
|
|
|
export function ParentReview() {
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language);
|
|
const { hasPermission } = useAuth();
|
|
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
|
|
const [items, setItems] = useState<ParentResolution[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
|
const [filter, setFilter] = useState<string>('');
|
|
|
|
// 새 그룹 입력 폼 (테스트용)
|
|
const [newGroupKey, setNewGroupKey] = useState('');
|
|
const [newSubCluster, setNewSubCluster] = useState('1');
|
|
const [newMmsi, setNewMmsi] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const res = await fetchReviewList(filter || undefined, 0, 50);
|
|
setItems(res.content);
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'unknown';
|
|
setError(msg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filter]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const handleAction = async (
|
|
item: ParentResolution,
|
|
action: 'CONFIRM' | 'REJECT' | 'RESET',
|
|
selectedMmsi?: string,
|
|
) => {
|
|
if (!canUpdate) return;
|
|
setActionLoading(item.id);
|
|
try {
|
|
await reviewParent(item.groupKey, item.subClusterId, {
|
|
action,
|
|
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
|
|
comment: `${action} via UI`,
|
|
});
|
|
|
|
// CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작)
|
|
if (action === 'CONFIRM') {
|
|
const mmsi = selectedMmsi || item.selectedParentMmsi;
|
|
if (mmsi) {
|
|
await createLabelSession(item.groupKey, item.subClusterId, {
|
|
labelParentMmsi: mmsi,
|
|
}).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ });
|
|
}
|
|
}
|
|
|
|
// REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지)
|
|
if (action === 'REJECT') {
|
|
const mmsi = item.selectedParentMmsi;
|
|
if (mmsi) {
|
|
await excludeForGroup(item.groupKey, item.subClusterId, {
|
|
excludedMmsi: mmsi,
|
|
reason: '운영자 거부',
|
|
}).catch(() => { /* Exclusion 실패는 무시 */ });
|
|
}
|
|
}
|
|
|
|
await load();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'unknown';
|
|
alert('처리 실패: ' + msg);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!canUpdate || !newGroupKey || !newMmsi) return;
|
|
setActionLoading(-1);
|
|
try {
|
|
await reviewParent(newGroupKey, parseInt(newSubCluster, 10), {
|
|
action: 'CONFIRM',
|
|
selectedParentMmsi: newMmsi,
|
|
comment: '운영자 직접 등록',
|
|
});
|
|
setNewGroupKey('');
|
|
setNewMmsi('');
|
|
await load();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'unknown';
|
|
alert('등록 실패: ' + msg);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageContainer size="lg">
|
|
<PageHeader
|
|
icon={GitMerge}
|
|
iconColor="text-purple-400"
|
|
title="모선 확정/거부"
|
|
description="추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)"
|
|
actions={
|
|
<>
|
|
<Select size="sm" title="상태 필터" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
|
<option value="">전체 상태</option>
|
|
<option value="UNRESOLVED">미해결</option>
|
|
<option value="MANUAL_CONFIRMED">확정됨</option>
|
|
<option value="REVIEW_REQUIRED">검토필요</option>
|
|
</Select>
|
|
<Button variant="primary" size="sm" onClick={load}>
|
|
새로고침
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* 신규 등록 폼 (테스트용) */}
|
|
{canUpdate && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-xs text-muted-foreground mb-2">신규 모선 확정 등록 (테스트)</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
aria-label="group_key"
|
|
type="text"
|
|
value={newGroupKey}
|
|
onChange={(e) => setNewGroupKey(e.target.value)}
|
|
placeholder="group_key (예: 渔船A)"
|
|
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
|
/>
|
|
<input
|
|
aria-label="sub_cluster_id"
|
|
type="number"
|
|
value={newSubCluster}
|
|
onChange={(e) => setNewSubCluster(e.target.value)}
|
|
placeholder="sub_cluster_id"
|
|
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
|
/>
|
|
<input
|
|
aria-label="parent MMSI"
|
|
type="text"
|
|
value={newMmsi}
|
|
onChange={(e) => setNewMmsi(e.target.value)}
|
|
placeholder="parent MMSI"
|
|
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreate}
|
|
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
|
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
|
|
>
|
|
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
|
확정 등록
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!canUpdate && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-xs text-yellow-400">
|
|
조회 전용 모드 (UPDATE 권한 없음). 확정/거부/리셋 액션이 비활성화됩니다.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{error && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-xs text-red-400">에러: {error}</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && items.length === 0 && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-hint text-sm">
|
|
등록된 모선 결정이 없습니다. 위의 폼으로 테스트 등록하거나, prediction 백엔드 연결 후 데이터가 채워집니다.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!loading && items.length > 0 && (
|
|
<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">상태</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-center">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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">
|
|
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
|
|
{getParentResolutionLabel(it.status, tc, lang)}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">
|
|
{formatDateTime(it.updatedAt)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button
|
|
type="button"
|
|
disabled={!canUpdate || actionLoading === it.id}
|
|
onClick={() => handleAction(it, 'CONFIRM')}
|
|
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
|
|
title="확정"
|
|
>
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!canUpdate || actionLoading === it.id}
|
|
onClick={() => handleAction(it, 'REJECT')}
|
|
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
|
|
title="거부"
|
|
>
|
|
<XCircle className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!canUpdate || actionLoading === it.id}
|
|
onClick={() => handleAction(it, 'RESET')}
|
|
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
|
|
title="리셋"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|