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')} 추가
This commit is contained in:
htlee 2026-04-16 16:32:37 +09:00
부모 5a57959bd5
커밋 8af693a2df
28개의 변경된 파일262개의 추가작업 그리고 79개의 파일을 삭제

파일 보기

@ -282,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */} {/* 언어 토글 */}
<button <button
onClick={toggleLanguage} onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap" className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'} title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
> >
{language === 'ko' ? 'EN' : '한국어'} {language === 'ko' ? 'EN' : '한국어'}
</button> </button>
@ -338,7 +339,7 @@ export function MainLayout() {
<div className="relative flex items-center"> <div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input <input
aria-label="페이지 내 검색" aria-label={t('aria.searchInPage')}
value={pageSearch} value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)} onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {

파일 보기

@ -94,12 +94,12 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]); }, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => { const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return; if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
try { try {
await unlockUser(userId); await unlockUser(userId);
await loadUsers(); await loadUsers();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };

파일 보기

@ -341,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() { export function DataHub() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal'); const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02'); const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>(''); const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -442,7 +443,7 @@ export function DataHub() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<input <input
aria-label="수신 현황 기준일" aria-label={tc('aria.receiptDate')}
type="date" type="date"
value={selectedDate} value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)} onChange={(e) => setSelectedDate(e.target.value)}

파일 보기

@ -74,6 +74,7 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() { export function NoticeManagement() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE'); const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE'); const canUpdate = hasPermission('admin:notices', 'UPDATE');
@ -265,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading"> <span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'} {editingId ? '알림 수정' : '새 알림 등록'}
</span> </span>
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading"> <button type="button" aria-label={tc('aria.close')} onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
@ -275,7 +276,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="알림 제목" aria-label={tc('aria.noticeTitle')}
value={form.title} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} 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" 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"
@ -287,7 +288,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea <textarea
aria-label="알림 내용" aria-label={tc('aria.noticeContent')}
value={form.message} value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })} onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3} rows={3}
@ -343,7 +344,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="시작일" aria-label={tc('aria.dateFrom')}
type="date" type="date"
value={form.startDate} value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })} onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -353,7 +354,7 @@ export function NoticeManagement() {
<div> <div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label> <label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input <input
aria-label="종료일" aria-label={tc('aria.dateTo')}
type="date" type="date"
value={form.endDate} value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })} onChange={(e) => setForm({ ...form, endDate: e.target.value })}

파일 보기

@ -19,6 +19,7 @@ import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker'; import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi'; import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/** /**
* (wing ). * (wing ).
@ -45,6 +46,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; } function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() { export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE'); const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE'); const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -230,7 +232,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes); await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화 await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`); alert(`${tc('success.permissionUpdated')} (${changes.length})`);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown'); setError(e instanceof Error ? e.message : 'unknown');
} finally { } finally {
@ -247,7 +249,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]); setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.createFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
@ -257,23 +259,23 @@ export function PermissionsPanel() {
await load(); await load();
setEditingColor(null); setEditingColor(null);
} catch (e: unknown) { } catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
const handleDeleteRole = async () => { const handleDeleteRole = async () => {
if (!selectedRole) return; if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') { if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.'); alert(tc('message.builtinRoleCannotDelete'));
return; return;
} }
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return; if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try { try {
await deleteRole(selectedRole.roleSn); await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null); setSelectedRoleSn(null);
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.deleteFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} }
}; };
@ -365,7 +367,7 @@ export function PermissionsPanel() {
</div> </div>
</div> </div>
{error && <div className="text-xs text-heading">: {error}</div>} {error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -394,11 +396,11 @@ export function PermissionsPanel() {
{showCreate && ( {showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5"> <div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())} <input aria-label={tc('aria.roleCode')} value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)" placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)} <input aria-label={tc('aria.roleName')} value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름" placeholder={tc('aria.roleName')}
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} /> <ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1"> <div className="flex gap-1 pt-1">

파일 보기

@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() { export function SystemConfig() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas'); const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState(''); const [majorFilter, setMajorFilter] = useState('');
@ -218,7 +219,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input <input
aria-label="코드 검색" aria-label={tc('aria.searchCode')}
value={query} value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }} onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={ placeholder={
@ -233,7 +234,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" /> <Filter className="w-3.5 h-3.5 text-hint" />
<select <select
aria-label="대분류 필터" aria-label={tc('aria.categoryFilter')}
value={majorFilter} value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }} onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50" className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"

파일 보기

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react'; import { X, Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi'; import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
import { getRoleBadgeStyle } from '@shared/constants/userRoles'; import { getRoleBadgeStyle } from '@shared/constants/userRoles';
@ -11,6 +12,7 @@ interface Props {
} }
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) { export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
const { t: tc } = useTranslation('common');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]); const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -44,7 +46,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
onSaved(); onSaved();
onClose(); onClose();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -60,7 +62,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR ) {user.userAcnt} ({user.userNm}) - (OR )
</div> </div>
</div> </div>
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading"> <button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>

파일 보기

@ -44,6 +44,7 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() { export function AIAssistant() {
const { t } = useTranslation('ai'); const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES); const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1'); const [selectedConv, setSelectedConv] = useState('1');
@ -148,7 +149,7 @@ export function AIAssistant() {
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)" placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
/> />
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors"> <button type="button" aria-label={tc('aria.send')} onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</button> </button>
</div> </div>

파일 보기

@ -1183,7 +1183,7 @@ export function AIModelManagement() {
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span> <span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button> <button type="button" aria-label={tcCommon('aria.copyExampleUrl')} onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div> </div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto"> <pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid {`GET /api/v1/predictions/grid

파일 보기

@ -107,6 +107,7 @@ const WORKERS = [
export function MLOpsPage() { export function MLOpsPage() {
const { t } = useTranslation('ai'); const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard'); const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train'); const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0); const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -505,7 +506,7 @@ export function MLOpsPage() {
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." /> <input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button> <button type="button" aria-label={tc('aria.send')} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
</div> </div>
</CardContent></Card> </CardContent></Card>
</div> </div>

파일 보기

@ -394,7 +394,7 @@ export function ChinaFishing() {
</button> </button>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5"> <div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" /> <Search className="w-3.5 h-3.5 text-hint mr-2" />
<input aria-label="해역 또는 해구 번호 검색" <input aria-label={tcCommon('aria.searchAreaOrZone')}
placeholder="해역 또는 해구 번호 검색" placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none" className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/> />
@ -480,7 +480,7 @@ export function ChinaFishing() {
<span className="text-sm font-bold text-heading"> </span> <span className="text-sm font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge> <Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div> </div>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none"> <select aria-label={tcCommon('aria.areaOfInterestSelect')} className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<option> A</option> <option> A</option>
<option> B</option> <option> B</option>
</select> </select>
@ -748,10 +748,10 @@ export function ChinaFishing() {
))} ))}
</div> </div>
<div className="flex justify-between mt-2"> <div className="flex justify-between mt-2">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors"> <button type="button" aria-label={tcCommon('aria.previous')} className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors"> <button type="button" aria-label={tcCommon('aria.next')} className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>

파일 보기

@ -493,7 +493,7 @@ export function GearDetection() {
{/* 필터 토글 버튼 */} {/* 필터 토글 버튼 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label="필터 설정" <button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
hasActiveFilter hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading' ? 'bg-primary/10 border-primary/40 text-heading'
@ -510,7 +510,7 @@ export function GearDetection() {
{hasActiveFilter && ( {hasActiveFilter && (
<> <>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span> <span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<button type="button" aria-label="필터 초기화" <button type="button" aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }} onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised"> className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -547,12 +547,12 @@ export function GearDetection() {
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span> <span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember} <input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))} value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
aria-label="최소 멤버 수" aria-label={tc('aria.memberCountMin')}
className="flex-1 h-1 accent-primary cursor-pointer" /> className="flex-1 h-1 accent-primary cursor-pointer" />
<input type="range" min={2} max={filterOptions.maxMember} <input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax} value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))} onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label="최대 멤버 수" aria-label={tc('aria.memberCountMax')}
className="flex-1 h-1 accent-primary cursor-pointer" /> className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span> <span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div> </div>
@ -562,7 +562,7 @@ export function GearDetection() {
{/* 패널 내 초기화 */} {/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between"> <div className="pt-2 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span> <span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span>
<button type="button" aria-label="필터 초기화" <button type="button" aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }} onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay"> className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
<X className="w-3 h-3" /> <X className="w-3 h-3" />

파일 보기

@ -62,7 +62,7 @@ export function RealGearGroups() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)} <select aria-label={tc('aria.groupTypeFilter')} value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""></option> <option value=""></option>
<option value="FLEET">FLEET</option> <option value="FLEET">FLEET</option>

파일 보기

@ -118,7 +118,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)} <select aria-label={t('aria.regionFilter')} value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""> </option> <option value=""> </option>
<option value="TERRITORIAL_SEA"></option> <option value="TERRITORIAL_SEA"></option>

파일 보기

@ -6,6 +6,7 @@
*/ */
import { useEffect, useState, useMemo, useCallback } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown'; import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
@ -24,6 +25,7 @@ interface DarkDetailPanelProps {
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) { export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]); const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {}; const features = vessel?.features ?? {};
@ -76,7 +78,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge> <Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span> <span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div> </div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기"> <button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" /> <X className="w-4 h-4 text-hint" />
</button> </button>
</div> </div>

파일 보기

@ -68,6 +68,7 @@ interface GearDetailPanelProps {
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) { export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]); const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false); const [corrLoading, setCorrLoading] = useState(false);
@ -276,7 +277,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{getZoneCodeLabel(gear.zone, t, lang)} {getZoneCodeLabel(gear.zone, t, lang)}
</Badge> </Badge>
</div> </div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기"> <button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" /> <X className="w-4 h-4 text-hint" />
</button> </button>
</div> </div>

파일 보기

@ -5,6 +5,7 @@
* Zustand subscribe DOM React re-render . * Zustand subscribe DOM React re-render .
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { useGearReplayStore } from '@stores/gearReplayStore'; import { useGearReplayStore } from '@stores/gearReplayStore';
import { Play, Pause, X } from 'lucide-react'; import { Play, Pause, X } from 'lucide-react';
@ -27,6 +28,7 @@ function formatEpochTime(epochMs: number): string {
} }
export function GearReplayController({ onClose }: GearReplayControllerProps) { export function GearReplayController({ onClose }: GearReplayControllerProps) {
const { t: tc } = useTranslation('common');
const play = useGearReplayStore((s) => s.play); const play = useGearReplayStore((s) => s.play);
const pause = useGearReplayStore((s) => s.pause); const pause = useGearReplayStore((s) => s.pause);
const seek = useGearReplayStore((s) => s.seek); const seek = useGearReplayStore((s) => s.seek);
@ -133,7 +135,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]" className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick} onClick={handleTrackClick}
role="slider" role="slider"
aria-label="재생 위치" aria-label={tc('aria.replayPosition')}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={Math.round(initialPct)} aria-valuenow={Math.round(initialPct)}
@ -167,7 +169,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
{/* Close */} {/* Close */}
<button <button
type="button" type="button"
aria-label="재생 닫기" aria-label={tc('aria.replayClose')}
onClick={onClose} onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded" className="shrink-0 p-1 hover:bg-surface-raised rounded"
> >

파일 보기

@ -4,6 +4,7 @@
*/ */
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react'; import { Loader2, Ship, Clock, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl'; import { PathLayer, ScatterplotLayer } from 'deck.gl';
@ -33,6 +34,7 @@ function fmt(ts: string | number): string {
} }
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) { export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const { t: tc } = useTranslation('common');
const mapRef = useRef<MapHandle | null>(null); const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null); const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -201,7 +203,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div> </div>
</div> </div>
{onClose && ( {onClose && (
<button type="button" onClick={onClose} aria-label="미니맵 닫기" <button type="button" onClick={onClose} aria-label={tc('aria.miniMapClose')}
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0"> className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>

파일 보기

@ -171,25 +171,25 @@ export function EventList() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isNew && ( {isNew && (
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'} <button type="button" aria-label={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
</button> </button>
)} )}
<button type="button" aria-label="선박 상세" title="선박 상세" <button type="button" aria-label={tc('aria.vesselDetail')} title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400" className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}> onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" /> <Ship className="w-3.5 h-3.5" />
</button> </button>
{isActionable && ( {isActionable && (
<> <>
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'} <button type="button" aria-label={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed" className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}> disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'} <button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" /> <Ban className="w-3.5 h-3.5" />

파일 보기

@ -67,7 +67,7 @@ export function LabelSession() {
setGroupKey(''); setLabelMmsi(''); setGroupKey(''); setLabelMmsi('');
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -75,13 +75,13 @@ export function LabelSession() {
const handleCancel = async (id: number) => { const handleCancel = async (id: number) => {
if (!canUpdate) return; if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return; if (!confirm(tc('dialog.cancelSession'))) return;
setBusy(id); setBusy(id);
try { try {
await cancelLabelSession(id, '운영자 취소'); await cancelLabelSession(id, '운영자 취소');
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -98,7 +98,7 @@ export function LabelSession() {
<> <>
<Select <Select
size="sm" size="sm"
aria-label="상태 필터" aria-label={tc('aria.statusFilter')}
title="상태 필터" title="상태 필터"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
@ -122,11 +122,11 @@ export function LabelSession() {
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>} {!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input aria-label="group_key" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key" <input aria-label={tc('aria.groupKey')} value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="sub_cluster_id" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub" <input aria-label={tc('aria.subClusterId')} type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="정답 parent MMSI" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI" <input aria-label={tc('aria.correctParentMmsi')} value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate} <button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1} disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
@ -138,7 +138,7 @@ export function LabelSession() {
</CardContent> </CardContent>
</Card> </Card>
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && ( {loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground"> <div className="flex items-center justify-center py-12 text-muted-foreground">

파일 보기

@ -14,6 +14,7 @@ import {
type CandidateExclusion, type CandidateExclusion,
} from '@/services/parentInferenceApi'; } from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { useTranslation } from 'react-i18next';
/** /**
* . * .
@ -26,6 +27,7 @@ import { formatDateTime } from '@shared/utils/dateFormat';
*/ */
export function ParentExclusion() { export function ParentExclusion() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE'); const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE'); const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
@ -71,7 +73,7 @@ export function ParentExclusion() {
setGrpKey(''); setGrpMmsi(''); setGrpReason(''); setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -85,7 +87,7 @@ export function ParentExclusion() {
setGlbMmsi(''); setGlbReason(''); setGlbMmsi(''); setGlbReason('');
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -98,7 +100,7 @@ export function ParentExclusion() {
await releaseExclusion(id, '운영자 해제'); await releaseExclusion(id, '운영자 해제');
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -115,7 +117,7 @@ export function ParentExclusion() {
<> <>
<Select <Select
size="sm" size="sm"
aria-label="스코프 필터" aria-label={tc('aria.scopeFilter')}
title="스코프 필터" title="스코프 필터"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')} onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
@ -139,13 +141,13 @@ export function ParentExclusion() {
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>} {!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input aria-label="group_key" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key" <input aria-label={tc('aria.groupKey')} value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="sub_cluster_id" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub" <input aria-label={tc('aria.subClusterId')} type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="excluded MMSI" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI" <input aria-label={tc('aria.excludedMmsi')} value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="제외 사유" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유" <input aria-label={tc('aria.exclusionReason')} value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<button type="button" onClick={handleAddGroup} <button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1} disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
@ -165,9 +167,9 @@ export function ParentExclusion() {
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>} {!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input aria-label="excluded MMSI (전역)" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI" <input aria-label={tc('aria.excludedMmsi')} value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<input aria-label="전역 제외 사유" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유" <input aria-label={tc('aria.globalExclusionReason')} value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<button type="button" onClick={handleAddGlobal} <button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2} disabled={!canCreateGlobal || !glbMmsi || busy === -2}
@ -179,7 +181,7 @@ export function ParentExclusion() {
</CardContent> </CardContent>
</Card> </Card>
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && ( {loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground"> <div className="flex items-center justify-center py-12 text-muted-foreground">

파일 보기

@ -97,7 +97,7 @@ export function ParentReview() {
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown'; const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg); alert(tc('error.processFailed', { msg }));
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@ -117,7 +117,7 @@ export function ParentReview() {
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown'; const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg); alert(tc('error.registerFailed', { msg }));
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@ -152,7 +152,7 @@ export function ParentReview() {
<div className="text-xs text-muted-foreground mb-2"> ()</div> <div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
aria-label="group_key" aria-label={tc('aria.groupKey')}
type="text" type="text"
value={newGroupKey} value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)} onChange={(e) => setNewGroupKey(e.target.value)}
@ -160,7 +160,7 @@ export function ParentReview() {
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/> />
<input <input
aria-label="sub_cluster_id" aria-label={tc('aria.subClusterId')}
type="number" type="number"
value={newSubCluster} value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)} onChange={(e) => setNewSubCluster(e.target.value)}
@ -202,7 +202,7 @@ export function ParentReview() {
{error && ( {error && (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-xs text-red-400">: {error}</div> <div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}

파일 보기

@ -32,6 +32,7 @@ const reports: Report[] = [
export function ReportManagement() { export function ReportManagement() {
const { t } = useTranslation('statistics'); const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]); const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
@ -81,7 +82,7 @@ export function ReportManagement() {
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span> <span className="text-[11px] text-label font-bold"> (··)</span>
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button> <button type="button" aria-label={tc('aria.uploadPanelClose')} onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div> </div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} /> <FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3"> <div className="flex justify-end mt-3">

파일 보기

@ -201,11 +201,11 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2> <h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span> <span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)} <input aria-label={tc('aria.queryFrom')} value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" /> placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span> <span className="text-hint text-[10px]">~</span>
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)} <input aria-label={tc('aria.queryTo')} value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" /> placeholder="YYYY-MM-DD HH:mm" />
</div> </div>

파일 보기

@ -250,5 +250,84 @@
"statistics": "Statistics", "statistics": "Statistics",
"aiOps": "AI Ops", "aiOps": "AI Ops",
"admin": "Admin" "admin": "Admin"
},
"aria": {
"close": "Close",
"closeDialog": "Close dialog",
"closeNotification": "Close notification",
"edit": "Edit",
"delete": "Delete",
"search": "Search",
"clearSearch": "Clear search",
"searchInPage": "Search in page",
"refresh": "Refresh",
"filter": "Filter",
"filterToggle": "Toggle filter",
"filterReset": "Reset filter",
"statusFilter": "Status filter",
"scopeFilter": "Scope filter",
"groupTypeFilter": "Group type filter",
"categoryFilter": "Category filter",
"regionFilter": "Region filter",
"previous": "Previous",
"next": "Next",
"send": "Send",
"confirmAction": "Confirm",
"dateFrom": "Start date",
"dateTo": "End date",
"queryFrom": "Query start time",
"queryTo": "Query end time",
"roleCode": "Role code",
"roleName": "Role name",
"roleDesc": "Role description",
"groupKey": "Group key",
"subClusterId": "Sub cluster ID",
"excludedMmsi": "Excluded MMSI",
"exclusionReason": "Exclusion reason",
"globalExclusionReason": "Global exclusion reason",
"correctParentMmsi": "Correct parent MMSI",
"uploadPanelClose": "Close upload panel",
"noticeTitle": "Notice title",
"noticeContent": "Notice content",
"languageToggle": "Language toggle",
"searchCode": "Search code",
"searchAreaOrZone": "Search area or zone",
"areaOfInterestSelect": "Select area of interest",
"replayPosition": "Replay position",
"replayClose": "Close replay",
"miniMapClose": "Close mini map",
"memberCountMin": "Min members",
"memberCountMax": "Max members",
"receiptDate": "Receipt reference date",
"copyExampleUrl": "Copy example URL",
"vesselDetail": "Vessel detail",
"enforcementRegister": "Register enforcement",
"falsePositiveProcess": "Mark false positive"
},
"error": {
"operationFailed": "Operation failed: {{msg}}",
"createFailed": "Create failed: {{msg}}",
"updateFailed": "Update failed: {{msg}}",
"deleteFailed": "Delete failed: {{msg}}",
"registerFailed": "Register failed: {{msg}}",
"processFailed": "Process failed: {{msg}}",
"errorPrefix": "Error: {{msg}}"
},
"dialog": {
"cancelSession": "Cancel this session?",
"deleteRole": "Delete this role?",
"genericDelete": "Delete?",
"genericRemove": "Remove?"
},
"success": {
"permissionUpdated": "Permissions updated",
"saved": "Saved"
},
"message": {
"noPermission": "No access permission",
"loading": "Loading...",
"builtinRoleCannotDelete": "Built-in role cannot be deleted",
"switchToEnglish": "Switch to English",
"switchToKorean": "Switch to Korean"
} }
} }

파일 보기

@ -250,5 +250,84 @@
"statistics": "통계·보고", "statistics": "통계·보고",
"aiOps": "AI 운영", "aiOps": "AI 운영",
"admin": "시스템 관리" "admin": "시스템 관리"
},
"aria": {
"close": "닫기",
"closeDialog": "대화상자 닫기",
"closeNotification": "알림 닫기",
"edit": "편집",
"delete": "삭제",
"search": "검색",
"clearSearch": "검색어 지우기",
"searchInPage": "페이지 내 검색",
"refresh": "새로고침",
"filter": "필터",
"filterToggle": "필터 설정",
"filterReset": "필터 초기화",
"statusFilter": "상태 필터",
"scopeFilter": "스코프 필터",
"groupTypeFilter": "그룹 유형 필터",
"categoryFilter": "대분류 필터",
"regionFilter": "해역 필터",
"previous": "이전",
"next": "다음",
"send": "전송",
"confirmAction": "확인",
"dateFrom": "시작일",
"dateTo": "종료일",
"queryFrom": "조회 시작 시각",
"queryTo": "조회 종료 시각",
"roleCode": "역할 코드",
"roleName": "역할 이름",
"roleDesc": "역할 설명",
"groupKey": "그룹 키",
"subClusterId": "서브 클러스터 ID",
"excludedMmsi": "제외 MMSI",
"exclusionReason": "제외 사유",
"globalExclusionReason": "전역 제외 사유",
"correctParentMmsi": "정답 parent MMSI",
"uploadPanelClose": "업로드 패널 닫기",
"noticeTitle": "알림 제목",
"noticeContent": "알림 내용",
"languageToggle": "언어 전환",
"searchCode": "코드 검색",
"searchAreaOrZone": "해역 또는 해구 번호 검색",
"areaOfInterestSelect": "관심영역 선택",
"replayPosition": "재생 위치",
"replayClose": "재생 닫기",
"miniMapClose": "미니맵 닫기",
"memberCountMin": "최소 멤버 수",
"memberCountMax": "최대 멤버 수",
"receiptDate": "수신 현황 기준일",
"copyExampleUrl": "예시 URL 복사",
"vesselDetail": "선박 상세",
"enforcementRegister": "단속 등록",
"falsePositiveProcess": "오탐 처리"
},
"error": {
"operationFailed": "작업 실패: {{msg}}",
"createFailed": "생성 실패: {{msg}}",
"updateFailed": "갱신 실패: {{msg}}",
"deleteFailed": "삭제 실패: {{msg}}",
"registerFailed": "등록 실패: {{msg}}",
"processFailed": "처리 실패: {{msg}}",
"errorPrefix": "에러: {{msg}}"
},
"dialog": {
"cancelSession": "세션을 취소하시겠습니까?",
"deleteRole": "해당 역할을 삭제하시겠습니까?",
"genericDelete": "삭제하시겠습니까?",
"genericRemove": "제거하시겠습니까?"
},
"success": {
"permissionUpdated": "권한 갱신",
"saved": "저장되었습니다"
},
"message": {
"noPermission": "접근 권한이 없습니다",
"loading": "로딩 중...",
"builtinRoleCannotDelete": "내장 역할은 삭제할 수 없습니다",
"switchToEnglish": "Switch to English",
"switchToKorean": "한국어로 전환"
} }
} }

파일 보기

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react'; import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/* /*
* SFR-02 공통컴포넌트: 알림 / * SFR-02 공통컴포넌트: 알림 /
@ -36,6 +37,7 @@ interface NotificationBannerProps {
} }
export function NotificationBanner({ notices, userRole }: NotificationBannerProps) { export function NotificationBanner({ notices, userRole }: NotificationBannerProps) {
const { t } = useTranslation('common');
const [dismissed, setDismissed] = useState<Set<string>>(() => { const [dismissed, setDismissed] = useState<Set<string>>(() => {
const stored = sessionStorage.getItem('dismissed_notices'); const stored = sessionStorage.getItem('dismissed_notices');
return new Set(stored ? JSON.parse(stored) : []); return new Set(stored ? JSON.parse(stored) : []);
@ -80,7 +82,7 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
{notice.dismissible && ( {notice.dismissible && (
<button <button
type="button" type="button"
aria-label="알림 닫기" aria-label={t('aria.closeNotification')}
onClick={() => dismiss(notice.id)} onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0" className="text-hint hover:text-muted-foreground shrink-0"
> >

파일 보기

@ -1,4 +1,5 @@
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/* /*
* SFR-02 공통컴포넌트: 검색 * SFR-02 공통컴포넌트: 검색
@ -11,22 +12,24 @@ interface SearchInputProps {
className?: string; className?: string;
} }
export function SearchInput({ value, onChange, placeholder = '검색...', className = '' }: SearchInputProps) { export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
const { t } = useTranslation('common');
const effectivePlaceholder = placeholder ?? `${t('action.search')}...`;
return ( return (
<div className={`relative ${className}`}> <div className={`relative ${className}`}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input <input
type="text" type="text"
aria-label={placeholder} aria-label={effectivePlaceholder}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={effectivePlaceholder}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-8 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-8 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
/> />
{value && ( {value && (
<button <button
type="button" type="button"
aria-label="검색어 지우기" aria-label={t('aria.clearSearch')}
onClick={() => onChange('')} onClick={() => onChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
> >