kcg-ai-monitoring/frontend/src/features/admin/NoticeManagement.tsx
htlee 2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에
  intent 필드 추가 + getXxxIntent() 헬퍼 신규
- statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑
  + getRiskIntent(0-100) 점수 기반 매핑
- 모든 Badge className="..." 패턴을 intent prop으로 치환:
  - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub)
  - ai-operations (AIModelManagement/MLOpsPage)
  - enforcement (EventList/EnforcementHistory)
  - field-ops (AIAlert)
  - detection (GearIdentification)
  - patrol (PatrolRoute/FleetOptimization)
  - parent-inference (ParentExclusion)
  - statistics (ExternalService/ReportManagement)
  - surveillance (MapControl)
  - risk-assessment (EnforcementPlan)
  - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토)
  - dashboard (Dashboard PatrolStatusBadge)

이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며,
쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00

422 lines
20 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } 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 type { BadgeIntent } from '@lib/theme/variants';
import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
Users, Megaphone, AlertTriangle, Info, Search, Filter,
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
} from 'lucide-react';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { toDateParam } from '@shared/utils/dateFormat';
import { SaveButton } from '@shared/components/common/SaveButton';
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
/*
* SFR-02: 공통알림(팝업, 배너, 시스템 공지 등) 관리
* - 노출 기간 설정 (시작일 ~ 종료일)
* - 대상 설정 (역할 기반)
* - 알림 유형: 정보, 경고, 긴급, 점검
* - 표시 방식: 배너, 팝업, 토스트
*/
// ─── 시뮬레이션 데이터 ──────────────────
const INITIAL_NOTICES: SystemNotice[] = [
{
id: 'N-001', type: 'urgent', display: 'banner', title: '서해 NLL 인근 경보 강화',
message: '2026-04-03부터 서해 NLL 인근 해역에 대한 경계 경보가 강화되었습니다. 모든 상황실 운영자는 경계 태세를 유지하시기 바랍니다.',
startDate: '2026-04-03', endDate: '2026-04-10', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: true, pinned: true,
},
{
id: 'N-002', type: 'maintenance', display: 'popup', title: '정기 시스템 점검 안내',
message: '2026-04-05(토) 02:00~06:00 시스템 정기점검이 예정되어 있습니다. 점검 중 서비스 이용이 제한될 수 있습니다.',
startDate: '2026-04-03', endDate: '2026-04-05', targetRoles: [], dismissible: true, pinned: false,
},
{
id: 'N-003', type: 'info', display: 'banner', title: 'AI 탐지 모델 v2.3 업데이트',
message: '다크베셀 탐지 정확도가 89% → 93%로 개선되었습니다. 환적 탐지 알고리즘이 업데이트되었습니다.',
startDate: '2026-04-01', endDate: '2026-04-15', targetRoles: ['ADMIN', 'ANALYST'], dismissible: true, pinned: false,
},
{
id: 'N-004', type: 'warning', display: 'banner', title: '비밀번호 변경 권고',
message: '비밀번호 변경 주기(90일)가 도래한 사용자는 보안정책에 따라 비밀번호를 변경해 주세요.',
startDate: '2026-03-25', endDate: '2026-04-25', targetRoles: [], dismissible: true, pinned: false,
},
{
id: 'N-005', type: 'info', display: 'toast', title: 'S-57 해도 데이터 갱신',
message: '해경GIS통합위치정보시스템 S-57 전자해도 데이터가 2026-04-01 기준으로 갱신되었습니다.',
startDate: '2026-04-01', endDate: '2026-04-07', targetRoles: ['ADMIN', 'OPERATOR', 'ANALYST'], dismissible: true, pinned: false,
},
{
id: 'N-006', type: 'urgent', display: 'popup', title: '중국어선 대규모 출항 정보',
message: '중국 산동성 위해·영성 항에서 대규모 어선단(약 300척) 출항이 감지되었습니다. 서해 EEZ 진입 예상시간: 2026-04-04 06:00경.',
startDate: '2026-04-03', endDate: '2026-04-06', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: false, pinned: true,
},
];
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
];
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
{ key: 'banner', label: '배너', icon: Monitor },
{ key: 'popup', label: '팝업', icon: MessageSquare },
{ key: 'toast', label: '토스트', icon: Bell },
];
const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<SystemNotice>({
id: '', type: 'info', display: 'banner', title: '', message: '',
startDate: toDateParam(),
endDate: toDateParam(new Date(Date.now() + 7 * 86400000)),
targetRoles: [], dismissible: true, pinned: false,
});
const now = toDateParam();
const openNew = () => {
setForm({
id: `N-${String(notices.length + 1).padStart(3, '0')}`,
type: 'info', display: 'banner', title: '', message: '',
startDate: now,
endDate: toDateParam(new Date(Date.now() + 7 * 86400000)),
targetRoles: [], dismissible: true, pinned: false,
});
setEditingId(null);
setShowForm(true);
};
const openEdit = (notice: SystemNotice) => {
setForm({ ...notice });
setEditingId(notice.id);
setShowForm(true);
};
const handleSave = () => {
if (editingId) {
setNotices((prev) => prev.map((n) => n.id === editingId ? form : n));
} else {
setNotices((prev) => [form, ...prev]);
}
setShowForm(false);
};
const handleDelete = (id: string) => {
setNotices((prev) => prev.filter((n) => n.id !== id));
};
const toggleRole = (role: string) => {
setForm((prev) => ({
...prev,
targetRoles: prev.targetRoles.includes(role)
? prev.targetRoles.filter((r) => r !== role)
: [...prev.targetRoles, role],
}));
};
const getStatus = (n: SystemNotice): { label: string; intent: BadgeIntent } => {
if (n.endDate < now) return { label: '종료', intent: 'muted' };
if (n.startDate > now) return { label: '예약', intent: 'info' };
return { label: '노출 중', intent: 'success' };
};
// KPI
const activeCount = notices.filter((n) => n.startDate <= now && n.endDate >= now).length;
const scheduledCount = notices.filter((n) => n.startDate > now).length;
const urgentCount = notices.filter((n) => n.type === 'urgent' && n.startDate <= now && n.endDate >= now).length;
return (
<PageContainer>
<PageHeader
icon={Bell}
iconColor="text-yellow-400"
title={t('notices.title')}
description={t('notices.desc')}
demo
actions={
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* KPI — 가로 한 줄 */}
<div className="flex gap-2">
{[
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
</div>
<span className={`text-base font-bold ${kpi.color}`}>{kpi.count}</span>
<span className="text-[9px] text-hint">{kpi.label}</span>
</div>
))}
</div>
{/* 알림 목록 */}
<Card>
<CardContent className="p-0">
<table className="w-full text-[11px] table-fixed">
<colgroup>
<col style={{ width: '6%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '4%' }} />
<col style={{ width: '6%' }} />
</colgroup>
<thead>
<tr className="border-b border-border text-hint">
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-2 py-2 text-left font-medium"></th>
<th className="px-1 py-2 text-center font-medium"></th>
<th className="px-1 py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{notices.map((n) => {
const status = getStatus(n);
const typeOpt = TYPE_OPTIONS.find((t) => t.key === n.type)!;
const dispOpt = DISPLAY_OPTIONS.find((d) => d.key === n.display)!;
return (
<tr key={n.id} className="border-b border-border hover:bg-surface-overlay">
<td className="px-2 py-1.5">
<Badge intent={status.intent} size="xs">{status.label}</Badge>
</td>
<td className="px-2 py-1.5">
<span className={`inline-flex items-center gap-1 text-[10px] ${typeOpt.color}`}>
<typeOpt.icon className="w-3 h-3" />
{typeOpt.label}
</span>
</td>
<td className="px-2 py-1.5">
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
<dispOpt.icon className="w-3 h-3" />
{dispOpt.label}
</span>
</td>
<td className="px-2 py-1.5 truncate">
<span className="text-heading font-medium">{n.title}</span>
<span className="text-hint text-[10px] ml-2">{n.message.slice(0, 50)}</span>
</td>
<td className="px-2 py-1.5 text-muted-foreground font-mono text-[10px] whitespace-nowrap">
{n.startDate.slice(5)} ~ {n.endDate.slice(5)}
</td>
<td className="px-2 py-1.5 truncate">
{n.targetRoles.length === 0 ? (
<span className="text-hint text-[10px]"></span>
) : (
<span className="text-[9px] text-label">{n.targetRoles.join(' · ')}</span>
)}
</td>
<td className="px-1 py-1.5 text-center">
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />}
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<Trash2 className="w-3 h-3" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
{/* ── 등록/수정 폼 모달 ── */}
{showForm && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</span>
<button onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* 제목 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
placeholder="알림 제목 입력"
/>
</div>
{/* 내용 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50 resize-none"
placeholder="알림 내용 입력"
/>
</div>
{/* 유형 + 표시방식 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setForm({ ...form, type: opt.key })}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] transition-colors ${
form.type === opt.key
? `bg-switch-background/50 ${opt.color} font-bold`
: 'text-hint hover:bg-surface-overlay'
}`}
>
<opt.icon className="w-3 h-3" />
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{DISPLAY_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setForm({ ...form, display: opt.key })}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.display === opt.key
? 'bg-blue-600/20 text-blue-400 font-bold'
: 'text-hint hover:bg-surface-overlay'
}`}
>
<opt.icon className="w-3 h-3" />
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* 노출기간 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading focus:outline-none focus:border-blue-500/50"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading focus:outline-none focus:border-blue-500/50"
/>
</div>
</div>
{/* 대상 역할 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">
<span className="text-hint">( )</span>
</label>
<div className="flex gap-1.5">
{ROLE_OPTIONS.map((role) => (
<button
key={role}
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.targetRoles.includes(role)
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold'
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
}`}
>
{role}
</button>
))}
</div>
</div>
{/* 옵션 */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.dismissible}
onChange={(e) => setForm({ ...form, dismissible: e.target.checked })}
className="w-3.5 h-3.5 rounded border-slate-600 bg-secondary text-blue-600 focus:ring-blue-500/30"
/>
<span className="text-[11px] text-muted-foreground"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.pinned}
onChange={(e) => setForm({ ...form, pinned: e.target.checked })}
className="w-3.5 h-3.5 rounded border-slate-600 bg-secondary text-blue-600 focus:ring-blue-500/30"
/>
<span className="text-[11px] text-muted-foreground"> </span>
</label>
</div>
</div>
{/* 하단 버튼 */}
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2">
<button
onClick={() => setShowForm(false)}
className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors"
>
</button>
<SaveButton
onClick={handleSave}
label={editingId ? '수정' : '등록'}
disabled={!form.title.trim() || !form.message.trim()}
/>
</div>
</div>
</div>
)}
</PageContainer>
);
}