- EnforcementHistory/EventList/EnforcementPlan: primary Button 액션 - EventList: Select 공통 컴포넌트로 등급 필터 치환 - AIAlert/ShipAgent/MobileService: PageContainer + PageHeader(demo) - PatrolRoute/FleetOptimization: primary Button 액션 2개씩 Phase B-3 완료. 총 10개 파일.
201 lines
5.6 KiB
TypeScript
201 lines
5.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
|
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
|
import { getAlerts, type PredictionAlert } from '@/services/event';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
|
|
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
|
|
|
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 intent="info" size="sm">{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, setAlerts] = useState<PredictionAlert[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [totalElements, setTotalElements] = useState(0);
|
|
|
|
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(
|
|
() =>
|
|
alerts.map((a) => ({
|
|
id: a.id,
|
|
eventId: a.eventId,
|
|
time: formatDateTime(a.sentAt),
|
|
channel: a.channel ?? '-',
|
|
recipient: a.recipient ?? '-',
|
|
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',
|
|
status: a.deliveryStatus,
|
|
})),
|
|
[alerts],
|
|
);
|
|
|
|
const deliveredCount = alerts.filter((a) => a.deliveryStatus === 'DELIVERED').length;
|
|
const failedCount = alerts.filter((a) => a.deliveryStatus === 'FAILED').length;
|
|
|
|
if (loading) {
|
|
return (
|
|
<PageContainer>
|
|
<div className="flex items-center justify-center gap-2 text-muted-foreground py-8">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
<span>알림 데이터 로딩 중...</span>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<PageContainer>
|
|
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
|
|
<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>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Send}
|
|
iconColor="text-yellow-400"
|
|
title={t('aiAlert.title')}
|
|
description={t('aiAlert.desc')}
|
|
/>
|
|
<div className="flex gap-2">
|
|
{[
|
|
{ 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={PAGE_SIZE}
|
|
searchPlaceholder="채널, 수신대상 검색..."
|
|
searchKeys={['channel', 'recipient']}
|
|
exportFilename="AI알림이력"
|
|
/>
|
|
</PageContainer>
|
|
);
|
|
}
|