feat: S4 alerts API + AIAlert/Dashboard 위험선박 실데이터 전환
백엔드: - PredictionAlert 엔티티 + Repository - AlertController: GET /api/alerts (페이징 + eventId 필터) 프론트: - AIAlert: mock alerts → GET /api/alerts 실제 호출 - Dashboard 위험선박: vesselStore mock → fetchVesselAnalysis() API - riskScore TOP 8 선박, 다크/GPS변조/전재 배지 표시 - Dashboard 이벤트 타임라인: eventStore API 기반 동작 확인 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
474e672683
커밋
cc1b1e20df
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 알림 조회 API.
|
||||
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/alerts")
|
||||
@RequiredArgsConstructor
|
||||
public class AlertController {
|
||||
|
||||
private final PredictionAlertRepository alertRepository;
|
||||
|
||||
/**
|
||||
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
||||
*/
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||
public Object getAlerts(
|
||||
@RequestParam(required = false) Long eventId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
if (eventId != null) {
|
||||
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
|
||||
}
|
||||
return alertRepository.findAllByOrderBySentAtDesc(
|
||||
PageRequest.of(page, size)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 예측 알림.
|
||||
* 이벤트 발생 시 발송된 알림(SMS, 푸시 등) 이력을 저장.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "prediction_alerts", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PredictionAlert {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "event_id")
|
||||
private Long eventId;
|
||||
|
||||
@Column(name = "channel", length = 20)
|
||||
private String channel;
|
||||
|
||||
@Column(name = "recipient", length = 200)
|
||||
private String recipient;
|
||||
|
||||
@Column(name = "sent_at")
|
||||
private OffsetDateTime sentAt;
|
||||
|
||||
@Column(name = "delivery_status", nullable = false, length = 20)
|
||||
private String deliveryStatus;
|
||||
|
||||
@Column(name = "ai_confidence", precision = 5, scale = 4)
|
||||
private BigDecimal aiConfidence;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "event_id", insertable = false, updatable = false)
|
||||
private PredictionEvent event;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (deliveryStatus == null) deliveryStatus = "SENT";
|
||||
if (sentAt == null) sentAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PredictionAlertRepository extends JpaRepository<PredictionAlert, Long> {
|
||||
|
||||
List<PredictionAlert> findByEventIdOrderBySentAtDesc(Long eventId);
|
||||
|
||||
Page<PredictionAlert> findAllByOrderBySentAtDesc(Pageable pageable);
|
||||
}
|
||||
@ -15,7 +15,7 @@ import { AreaChart, PieChart } from '@lib/charts';
|
||||
import { useKpiStore } from '@stores/kpiStore';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { usePatrolStore } from '@stores/patrolStore';
|
||||
import { useVesselStore } from '@stores/vesselStore';
|
||||
import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
|
||||
// ─── 작전 경보 등급 ─────────────────────
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
@ -44,6 +44,7 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
};
|
||||
|
||||
|
||||
// TODO: /api/risk-grid 연동 예정
|
||||
const AREA_RISK_DATA = [
|
||||
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
|
||||
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
|
||||
@ -54,12 +55,14 @@ const AREA_RISK_DATA = [
|
||||
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
|
||||
];
|
||||
|
||||
// TODO: /api/stats/daily 연동 예정
|
||||
const HOURLY_DETECTION = [
|
||||
{ hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 },
|
||||
{ hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 },
|
||||
{ hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 },
|
||||
];
|
||||
|
||||
// TODO: /api/stats/daily 연동 예정
|
||||
const VESSEL_TYPE_DATA = [
|
||||
{ name: 'EEZ 침범', value: 18, color: '#ef4444' },
|
||||
{ name: '다크베셀', value: 12, color: '#f97316' },
|
||||
@ -68,6 +71,7 @@ const VESSEL_TYPE_DATA = [
|
||||
{ name: '고속도주', value: 4, color: '#06b6d4' },
|
||||
];
|
||||
|
||||
// TODO: /api/weather 연동 예정
|
||||
const WEATHER_DATA = {
|
||||
wind: { speed: 12, direction: 'NW', gust: 18 },
|
||||
wave: { height: 1.8, period: 6 },
|
||||
@ -175,6 +179,7 @@ function FuelGauge({ percent }: { percent: number }) {
|
||||
|
||||
// ─── 해역 위협 미니맵 (Leaflet) ───────────────────
|
||||
|
||||
// TODO: /api/risk-grid 연동 예정
|
||||
const THREAT_AREAS = [
|
||||
{ name: '서해 NLL', lat: 37.80, lng: 124.90, risk: 95, vessels: 8 },
|
||||
{ name: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 91, vessels: 14 },
|
||||
@ -284,14 +289,26 @@ export function Dashboard() {
|
||||
|
||||
const kpiStore = useKpiStore();
|
||||
const eventStore = useEventStore();
|
||||
const vesselStore = useVesselStore();
|
||||
const patrolStore = usePatrolStore();
|
||||
|
||||
const [riskVessels, setRiskVessels] = useState<VesselAnalysisItem[]>([]);
|
||||
|
||||
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
||||
useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
|
||||
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVesselAnalysis()
|
||||
.then((res) => {
|
||||
if (!res.serviceAvailable) { setRiskVessels([]); return; }
|
||||
const sorted = [...res.items].sort(
|
||||
(a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score,
|
||||
);
|
||||
setRiskVessels(sorted.slice(0, 8));
|
||||
})
|
||||
.catch(() => setRiskVessels([]));
|
||||
}, []);
|
||||
|
||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
|
||||
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
|
||||
return {
|
||||
@ -313,19 +330,21 @@ export function Dashboard() {
|
||||
area: e.area ?? '-',
|
||||
})), [eventStore.events]);
|
||||
|
||||
const TOP_RISK_VESSELS = useMemo(() => vesselStore.suspects.slice(0, 8).map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
risk: v.risk / 100,
|
||||
type: v.pattern ?? v.type,
|
||||
flag: v.flag === 'CN' ? '중국' : v.flag === 'KR' ? '한국' : '미상',
|
||||
tonnage: v.tonnage ?? null,
|
||||
speed: v.speed != null ? `${v.speed}kt` : '-',
|
||||
heading: v.heading != null ? `${v.heading}°` : '-',
|
||||
lastAIS: v.lastSignal ?? '-',
|
||||
location: `N${v.lat.toFixed(2)} E${v.lng.toFixed(2)}`,
|
||||
pattern: v.status,
|
||||
})), [vesselStore.suspects]);
|
||||
const TOP_RISK_VESSELS = useMemo(() => riskVessels.map((v) => {
|
||||
const risk = v.algorithms.riskScore;
|
||||
return {
|
||||
id: v.mmsi,
|
||||
name: v.mmsi,
|
||||
risk: risk.score,
|
||||
type: v.classification.vesselType,
|
||||
riskLevel: risk.level,
|
||||
zone: v.algorithms.location.zone,
|
||||
isDark: v.algorithms.darkVessel.isDark,
|
||||
activity: v.algorithms.activity.state,
|
||||
isSpoofing: v.algorithms.gpsSpoofing.spoofingScore >= 0.3,
|
||||
isTransship: v.algorithms.transship.isSuspect,
|
||||
};
|
||||
}), [riskVessels]);
|
||||
|
||||
const PATROL_SHIPS = useMemo(() => patrolStore.ships.map((s) => ({
|
||||
name: s.name,
|
||||
@ -583,52 +602,35 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-2">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<span>#</span>
|
||||
<span>선박명 / ID</span>
|
||||
<span>위반 유형</span>
|
||||
<span>국적/지역</span>
|
||||
<span>속력/침로</span>
|
||||
<span>AIS 상태</span>
|
||||
<span>행동패턴</span>
|
||||
<span>위치</span>
|
||||
<span>MMSI</span>
|
||||
<span>선종</span>
|
||||
<span>해역</span>
|
||||
<span>활동 상태</span>
|
||||
<span>특이사항</span>
|
||||
<span>위험도</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{TOP_RISK_VESSELS.map((vessel, index) => (
|
||||
<div
|
||||
key={vessel.id}
|
||||
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
>
|
||||
<span className="text-hint text-xs font-bold">#{index + 1}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.8 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<span className="text-heading text-[11px] font-bold">{vessel.name}</span>
|
||||
</div>
|
||||
<span className="text-[8px] text-hint ml-5">{vessel.id}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
|
||||
</div>
|
||||
<Badge className={`text-[8px] px-1.5 py-0 border ${
|
||||
vessel.type.includes('침범') || vessel.type.includes('선단') ? 'bg-red-500/15 text-red-400 border-red-500/30'
|
||||
: vessel.type.includes('다크') ? 'bg-orange-500/15 text-orange-400 border-orange-500/30'
|
||||
: vessel.type.includes('환적') ? 'bg-purple-500/15 text-purple-400 border-purple-500/30'
|
||||
: vessel.type.includes('MMSI') ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
|
||||
: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
||||
}`}>{vessel.type}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.flag}</span>
|
||||
<div>
|
||||
<div className="text-[10px] text-heading">{vessel.speed}</div>
|
||||
<div className="text-[8px] text-hint">{vessel.heading}</div>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0">전재</Badge>}
|
||||
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
|
||||
</div>
|
||||
<span className={`text-[10px] ${vessel.lastAIS === '소실' ? 'text-red-400 font-bold' : 'text-muted-foreground'}`}>{vessel.lastAIS}</span>
|
||||
<Badge className={`text-[8px] px-1 py-0 border-0 ${
|
||||
vessel.pattern === '도주' ? 'bg-red-500/20 text-red-400'
|
||||
: vessel.pattern === '조업 중' ? 'bg-orange-500/20 text-orange-400'
|
||||
: vessel.pattern === '정박/접현' ? 'bg-purple-500/20 text-purple-400'
|
||||
: vessel.pattern === '고속이동' ? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>{vessel.pattern}</Badge>
|
||||
<span className="text-[9px] text-hint tabular-nums">{vessel.location}</span>
|
||||
<RiskBar value={vessel.risk} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,61 +1,195 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } 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 { Bell, Send, CheckCircle, XCircle, Clock, MapPin, AlertTriangle, Ship } from 'lucide-react';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { getAlerts, type PredictionAlert } from '@/services/event';
|
||||
|
||||
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
||||
|
||||
interface Alert { id: string; time: string; type: string; location: string; confidence: number; target: string; status: string; received: string; [key: string]: unknown; }
|
||||
const cols: DataColumn<Alert>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'time', label: '탐지 시각', width: '80px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '탐지 유형', width: '80px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'location', label: '위치좌표', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'confidence', label: '신뢰도', width: '60px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 90 ? 'text-red-400' : n > 80 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}%</span>; } },
|
||||
{ key: 'target', label: '수신 대상', render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'status', label: '발송 상태', width: '80px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s === '수신확인' ? 'bg-green-500/20 text-green-400' : s === '발송완료' ? 'bg-blue-500/20 text-blue-400' : 'bg-red-500/20 text-red-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'received', label: '수신 시각', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
interface AlertRow {
|
||||
id: number;
|
||||
eventId: number;
|
||||
time: string;
|
||||
channel: string;
|
||||
recipient: string;
|
||||
confidence: string;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
SENT: '발송완료',
|
||||
DELIVERED: '수신확인',
|
||||
FAILED: '발송실패',
|
||||
};
|
||||
|
||||
const cols: DataColumn<AlertRow>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '70px',
|
||||
render: (v) => <span className="text-hint font-mono text-[10px]">{v as number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'eventId',
|
||||
label: '이벤트',
|
||||
width: '80px',
|
||||
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '발송 시각',
|
||||
width: '130px',
|
||||
sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'channel',
|
||||
label: '채널',
|
||||
width: '80px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recipient',
|
||||
label: '수신 대상',
|
||||
render: (v) => <span className="text-cyan-400">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'confidence',
|
||||
label: '신뢰도',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
if (!s) return <span className="text-hint">-</span>;
|
||||
const n = parseFloat(s);
|
||||
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
|
||||
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
width: '80px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
const c =
|
||||
s === 'DELIVERED'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: s === 'SENT'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-red-500/20 text-red-400';
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{STATUS_LABEL[s] ?? s}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function AIAlert() {
|
||||
const { t } = useTranslation('fieldOps');
|
||||
const { alerts: storeAlerts, load } = useEventStore();
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const [alerts, setAlerts] = useState<PredictionAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalElements, setTotalElements] = useState(0);
|
||||
|
||||
const DATA: Alert[] = useMemo(
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getAlerts({ page: 0, size: 100 });
|
||||
setAlerts(res.content);
|
||||
setTotalElements(res.totalElements);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, [fetchAlerts]);
|
||||
|
||||
const data: AlertRow[] = useMemo(
|
||||
() =>
|
||||
storeAlerts.map((a) => ({
|
||||
alerts.map((a) => ({
|
||||
id: a.id,
|
||||
time: a.time,
|
||||
type: a.type,
|
||||
location: a.location,
|
||||
confidence: a.confidence,
|
||||
target: a.target,
|
||||
status: a.status,
|
||||
received: a.status === '수신확인' ? a.time.replace(/:\d{2}$/, (m) => `:${String(Number(m.slice(1)) + 3).padStart(2, '0')}`) : '-',
|
||||
eventId: a.eventId,
|
||||
time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-',
|
||||
channel: a.channel ?? '-',
|
||||
recipient: a.recipient ?? '-',
|
||||
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',
|
||||
status: a.deliveryStatus,
|
||||
})),
|
||||
[storeAlerts],
|
||||
[alerts],
|
||||
);
|
||||
|
||||
const deliveredCount = alerts.filter((a) => a.deliveryStatus === 'DELIVERED').length;
|
||||
const failedCount = alerts.filter((a) => a.deliveryStatus === 'FAILED').length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-5 flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>알림 데이터 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5 flex items-center justify-center gap-2 text-red-400">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>알림 조회 실패: {error}</span>
|
||||
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Send className="w-5 h-5 text-yellow-400" />{t('aiAlert.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-yellow-400" />
|
||||
{t('aiAlert.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('aiAlert.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[{ l: '총 발송', v: DATA.length, c: 'text-heading' }, { l: '수신확인', v: DATA.filter(d => d.status === '수신확인').length, c: 'text-green-400' }, { l: '미수신', v: DATA.filter(d => d.status === '미수신').length, c: 'text-red-400' }].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">
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||
{[
|
||||
{ l: '총 발송', v: totalElements, c: 'text-heading' },
|
||||
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
|
||||
{ l: '실패', v: failedCount, c: 'text-red-400' },
|
||||
].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"
|
||||
>
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
|
||||
<span className="text-[9px] text-hint">{k.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="유형, 대상, 좌표 검색..." searchKeys={['type', 'target', 'location']} exportFilename="AI알림이력" />
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={cols}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchPlaceholder="채널, 수신대상 검색..."
|
||||
searchKeys={['channel', 'recipient']}
|
||||
exportFilename="AI알림이력"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -100,6 +100,40 @@ export async function getEventStats(): Promise<EventStats> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 알림 API ────────────────────────────────────
|
||||
|
||||
export interface PredictionAlert {
|
||||
id: number;
|
||||
eventId: number;
|
||||
channel: string;
|
||||
recipient: string | null;
|
||||
sentAt: string;
|
||||
deliveryStatus: string;
|
||||
aiConfidence: number | null;
|
||||
}
|
||||
|
||||
export interface AlertPageResponse {
|
||||
content: PredictionAlert[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function getAlerts(params?: {
|
||||
eventId?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AlertPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.eventId != null) query.set('eventId', String(params.eventId));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/alerts?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 하위 호환 헬퍼 (기존 EventRecord 형식 → PredictionEvent 매핑) ──
|
||||
|
||||
/** @deprecated PredictionEvent를 직접 사용하세요 */
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user