EnforcementHistory:
- eventId 역추적 컬럼 추가 (#{eventId} 클릭 → EventList 이동)
- Record 인터페이스에 eventId 필드 추가
EnforcementPlan:
- 미배정 CRITICAL 이벤트 패널 신설 (NEW 상태 CRITICAL 이벤트 표시)
- getEvents(level=CRITICAL, status=NEW) 연동
MainLayout:
- admin 메뉴 4개 서브그룹 분리 (AI 플랫폼/시스템 운영/사용자 관리/감사·보안)
- NavDivider 타입 도입으로 그룹 내 소제목 라벨 렌더링
- 기존 RBAC 필터링 + collapsed 모드 호환 유지
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
11 KiB
TypeScript
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, STATIC_LAYERS, 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(() => [
|
|
...STATIC_LAYERS,
|
|
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>
|
|
);
|
|
}
|