kcg-ai-monitoring/frontend/src/features/admin/NoticeManagement.tsx
htlee 234169d540 refactor(frontend): admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)
129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환:
- text-cyan-400 (45건) → text-label
- text-green-400/500 (51건) → text-label + Badge intent="success"
- text-red-400/500 (31건) → text-heading + Badge intent="critical"
- text-blue-400 (33건) → text-label + Badge intent="info"
- text-purple-400 (20건) → text-heading
- text-yellow/orange/amber (32건) → text-heading + Badge intent="warning"

raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등)
미사용 import 정리 (SaveButton/DataTable/lucide 아이콘)

대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs,
AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement,
PermissionsPanel, SystemConfig

검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
2026-04-16 11:25:51 +09:00

430 lines
20 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } 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 { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Bell, Plus, Edit2, Trash2, Eye,
Megaphone, AlertTriangle, Info,
Clock, Pin, Monitor, MessageSquare, X,
} from 'lucide-react';
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-label' },
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
];
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 { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
const canDelete = hasPermission('admin:notices', 'DELETE');
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-label"
title={t('notices.title')}
description={t('notices.desc')}
demo
actions={
<Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} 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-label', bg: 'bg-green-500/10' },
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', 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-label inline" />}
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<Edit2 className="w-3 h-3" />
</button>
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<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 type="button" aria-label="닫기" 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
aria-label="알림 제목"
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
aria-label="알림 내용"
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 type="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 type="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-label 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
aria-label="시작일"
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
aria-label="종료일"
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 type="button"
key={role}
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.targetRoles.includes(role)
? 'bg-surface-overlay text-heading border border-border 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 type="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() || (editingId ? !canUpdate : !canCreate)}
/>
</div>
</div>
</div>
)}
</PageContainer>
);
}