공통 번역 리소스 확장:
- 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')} 추가
161 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|