kcg-ai-monitoring/frontend/src/features/field-ops/AIAlert.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00

197 lines
5.8 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-600 dark: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-600 dark: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-600 dark:text-red-400' : n > 0.8 ? 'text-orange-600 dark:text-orange-400' : 'text-yellow-600 dark: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 intent: 'success' | 'info' | 'critical' =
s === 'DELIVERED' ? 'success' : s === 'SENT' ? 'info' : 'critical';
return (
<Badge intent={intent} size="xs">{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-600 dark: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-600 dark:text-cyan-400">
</button>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-600 dark: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-600 dark:text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-600 dark: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>
);
}