kcg-ai-monitoring/frontend/src/shared/components/common/NotificationBanner.tsx
htlee 8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00

161 lines
5.7 KiB
TypeScript

import { useState, useEffect } from 'react';
import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 알림 배너/팝업
* - 시스템 공지, 긴급 알림, 매너 배너 등 공통 노출
* - 노출 기간, 대상(역할) 설정 지원
*/
export type NoticeType = 'info' | 'warning' | 'urgent' | 'maintenance';
export type NoticeDisplay = 'banner' | 'popup' | 'toast';
export interface SystemNotice {
id: string;
type: NoticeType;
display: NoticeDisplay;
title: string;
message: string;
startDate: string; // ISO date
endDate: string; // ISO date
targetRoles: string[]; // 빈 배열 = 전체
dismissible: boolean;
pinned: boolean;
}
const TYPE_STYLES: Record<NoticeType, { icon: React.ElementType; bg: string; border: string; text: string }> = {
info: { icon: Info, bg: 'bg-blue-500/10', border: 'border-blue-500/20', text: 'text-blue-400' },
warning: { icon: AlertTriangle, bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', text: 'text-yellow-400' },
urgent: { icon: Bell, bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-400' },
maintenance: { icon: Megaphone, bg: 'bg-orange-500/10', border: 'border-orange-500/20', text: 'text-orange-400' },
};
interface NotificationBannerProps {
notices: SystemNotice[];
userRole?: string;
}
export function NotificationBanner({ notices, userRole }: NotificationBannerProps) {
const { t } = useTranslation('common');
const [dismissed, setDismissed] = useState<Set<string>>(() => {
const stored = sessionStorage.getItem('dismissed_notices');
return new Set(stored ? JSON.parse(stored) : []);
});
const now = new Date().toISOString();
const activeNotices = notices.filter((n) => {
if (dismissed.has(n.id) && n.dismissible) return false;
if (n.startDate > now || n.endDate < now) return false;
if (n.targetRoles.length > 0 && userRole && !n.targetRoles.includes(userRole)) return false;
return n.display === 'banner';
});
const dismiss = (id: string) => {
const next = new Set(dismissed);
next.add(id);
setDismissed(next);
sessionStorage.setItem('dismissed_notices', JSON.stringify([...next]));
};
if (activeNotices.length === 0) return null;
return (
<div className="space-y-1 px-5 pt-3">
{activeNotices.map((notice) => {
const style = TYPE_STYLES[notice.type];
const Icon = style.icon;
return (
<div
key={notice.id}
className={`flex items-center gap-2.5 px-4 py-2.5 rounded-lg border ${style.bg} ${style.border}`}
>
<Icon className={`w-4 h-4 shrink-0 ${style.text}`} />
<div className="flex-1 min-w-0">
<span className={`text-[11px] font-bold ${style.text}`}>{notice.title}</span>
<span className="text-[11px] text-muted-foreground ml-2">{notice.message}</span>
</div>
<span className="text-[9px] text-hint shrink-0">
~{notice.endDate.slice(0, 10)}
</span>
{notice.dismissible && (
<button
type="button"
aria-label={t('aria.closeNotification')}
onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
);
}
/* ─── 팝업 알림 ─── */
interface NotificationPopupProps {
notices: SystemNotice[];
userRole?: string;
}
export function NotificationPopup({ notices, userRole }: NotificationPopupProps) {
const [visible, setVisible] = useState<SystemNotice | null>(null);
const now = new Date().toISOString();
useEffect(() => {
const popupNotices = notices.filter((n) => {
if (n.startDate > now || n.endDate < now) return false;
if (n.targetRoles.length > 0 && userRole && !n.targetRoles.includes(userRole)) return false;
const shown = sessionStorage.getItem(`popup_shown_${n.id}`);
return n.display === 'popup' && !shown;
});
if (popupNotices.length > 0) {
setVisible(popupNotices[0]);
}
}, [notices, userRole]);
if (!visible) return null;
const style = TYPE_STYLES[visible.type];
const Icon = style.icon;
const close = () => {
sessionStorage.setItem(`popup_shown_${visible.id}`, '1');
setVisible(null);
};
return (
<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-md mx-4 overflow-hidden">
<div className={`flex items-center gap-2 px-5 py-3 ${style.bg} border-b ${style.border}`}>
<Icon className={`w-5 h-5 ${style.text}`} />
<span className={`text-sm font-bold ${style.text}`}>{visible.title}</span>
</div>
<div className="px-5 py-4">
<p className="text-[12px] text-label leading-relaxed">{visible.message}</p>
<div className="flex items-center gap-4 mt-3 text-[9px] text-hint">
<span>: {visible.startDate.slice(0, 10)} ~ {visible.endDate.slice(0, 10)}</span>
{visible.targetRoles.length > 0 && (
<span>: {visible.targetRoles.join(', ')}</span>
)}
</div>
</div>
<div className="px-5 py-3 border-t border-border flex justify-end">
<button
onClick={close}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
);
}