kcg-ai-monitoring/frontend/src/features/risk-assessment/EnforcementPlan.tsx

234 lines
11 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } 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 { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
/** API 응답 → 화면용 Plan 변환 */
function toPlan(p: EnforcementPlanApi): Plan {
return {
id: p.planUid,
zone: p.areaName ?? p.zoneCode ?? '-',
lat: p.lat ?? 0,
lng: p.lon ?? 0,
risk: p.riskScore ?? 0,
period: p.plannedDate,
ships: `${p.assignedShipCount}`,
crew: p.assignedCrew,
status: p.status,
alert: p.alertStatus ?? '-',
};
}
const cols: DataColumn<Plan>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <Badge intent={getRiskIntent(n)} size="xs">{n}</Badge>; } },
{ key: 'period', label: '단속 시간', width: '160px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
{ key: 'alert', label: '경보', width: '80px', align: 'center',
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
];
export function EnforcementPlan() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [plans, setPlans] = useState<Plan[]>([]);
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
getEnforcementPlans({ size: 100 }),
getEvents({ level: 'CRITICAL', status: 'NEW', size: 20 }).catch(() => null),
])
.then(([planRes, evtRes]) => {
if (!cancelled) {
setPlans(planRes.content.map(toPlan));
setCriticalEvents(evtRes?.content ?? []);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const PLANS = plans;
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
createRadiusLayer(
'ep-radius-confirmed',
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
lat: p.lat,
lng: p.lng,
radius: 20000,
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
})),
0.15,
),
createRadiusLayer(
'ep-radius-planned',
PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({
lat: p.lat,
lng: p.lng,
radius: 20000,
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
})),
0.06,
),
createMarkerLayer(
'ep-markers',
PLANS.map(p => ({
lat: p.lat,
lng: p.lng,
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
radius: 1000,
label: `${p.id} ${p.zone}`,
} as MarkerData)),
),
], [PLANS]);
useMapLayers(mapRef, buildLayers, [PLANS]);
// 통계 요약값
const todayCount = PLANS.length;
const alertCount = PLANS.filter(p => p.alert === '경보 발령' || p.alert === 'ALERT').length;
const totalShips = PLANS.reduce((sum, p) => {
const num = parseInt(p.ships, 10);
return sum + (isNaN(num) ? 0 : num);
}, 0);
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-orange-400"
title={t('enforcementPlan.title')}
description={t('enforcementPlan.desc')}
actions={
<Button variant="primary" size="md" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 로딩/에러 상태 */}
{loading && (
<div className="text-center text-muted-foreground text-sm py-4"> ...</div>
)}
{error && (
<div className="text-center text-red-400 text-sm py-4"> : {error}</div>
)}
<div className="flex gap-2">
{[
{ l: '오늘 계획', v: `${todayCount}`, c: 'text-heading', i: Calendar },
{ l: '경보 발령', v: `${alertCount}`, c: 'text-red-400', i: AlertTriangle },
{ l: '투입 함정', v: `${totalShips}`, c: 'text-cyan-400', i: Ship },
{ l: '투입 인력', v: `${totalCrew}`, c: 'text-green-400', i: Users },
].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
{/* 미배정 CRITICAL 이벤트 */}
{criticalEvents.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> CRITICAL </span>
<Badge intent="critical" size="xs">{criticalEvents.length}</Badge>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{criticalEvents.map((evt) => (
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
{getAlertLevelLabel(evt.level, tc, lang)}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="flex gap-4 text-[10px]">
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
<Badge intent="critical" size="sm">{k}</Badge>
<span className="text-muted-foreground">{v}</span>
</div>
))}
</div>
</CardContent>
</Card>
<DataTable data={PLANS} columns={cols} pageSize={10} searchPlaceholder="구역, 함정명 검색..." searchKeys={['zone', 'ships']} exportFilename="단속계획" />
{/* 단속 구역 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.2, 126.0]} zoom={7} height={420} className="rounded-lg overflow-hidden" />
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="space-y-1">
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-red-500/70 border border-red-500" /><span className="text-[8px] text-muted-foreground"> 80+</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-orange-500/70 border border-orange-500" /><span className="text-[8px] text-muted-foreground"> 60~80</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-yellow-500/70 border border-yellow-500" /><span className="text-[8px] text-muted-foreground"> 40~60</span></div>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border-2 border-orange-500/50 bg-orange-500/15" /><span className="text-[7px] text-hint"></span></div>
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border border-dashed border-orange-500/40 bg-orange-500/5" /><span className="text-[7px] text-hint"></span></div>
</div>
</div>
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-orange-400 font-bold">{PLANS.length}</span>
<span className="text-[9px] text-hint ml-1"> </span>
</div>
</CardContent>
</Card>
</PageContainer>
);
}