kcg-ai-monitoring/frontend/src/features/field-ops/AIAlert.tsx
htlee 2976796652 refactor(frontend): enforcement/field-ops/patrol PageContainer/PageHeader 적용
- EnforcementHistory/EventList/EnforcementPlan: primary Button 액션
- EventList: Select 공통 컴포넌트로 등급 필터 치환
- AIAlert/ShipAgent/MobileService: PageContainer + PageHeader(demo)
- PatrolRoute/FleetOptimization: primary Button 액션 2개씩

Phase B-3 완료. 총 10개 파일.
2026-04-08 11:57:01 +09:00

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>
);
}