- 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 사용처에 일관되게 반영됨.
422 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|