Compare commits

...

3 커밋

작성자 SHA1 메시지 날짜
2c23049c8e Merge pull request 'refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거' (#69) from refactor/i18n-alert-aria-confirm into develop 2026-04-16 16:33:22 +09:00
03f2ea08db docs: 릴리즈 노트 업데이트 (PR #B i18n 정비) 2026-04-16 16:32:53 +09:00
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
29개의 변경된 파일263개의 추가작업 그리고 79개의 파일을 삭제

파일 보기

@ -5,6 +5,7 @@
## [Unreleased]
### 변경
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집

파일 보기

@ -282,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
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"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -338,7 +339,7 @@ export function MainLayout() {
<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" />
<input
aria-label="페이지 내 검색"
aria-label={t('aria.searchInPage')}
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {

파일 보기

@ -94,12 +94,12 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
try {
await unlockUser(userId);
await loadUsers();
} 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() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -442,7 +443,7 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label="수신 현황 기준일"
aria-label={tc('aria.receiptDate')}
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}

파일 보기

@ -74,6 +74,7 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
@ -265,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</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" />
</button>
</div>
@ -275,7 +276,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="알림 제목"
aria-label={tc('aria.noticeTitle')}
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"
@ -287,7 +288,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label="알림 내용"
aria-label={tc('aria.noticeContent')}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -343,7 +344,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="시작일"
aria-label={tc('aria.dateFrom')}
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -353,7 +354,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="종료일"
aria-label={tc('aria.dateTo')}
type="date"
value={form.endDate}
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 { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/**
* (wing ).
@ -45,6 +46,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -230,7 +232,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`);
alert(`${tc('success.permissionUpdated')} (${changes.length})`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
@ -247,7 +249,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} 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();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.');
alert(tc('message.builtinRoleCannotDelete'));
return;
}
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} 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>
{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>}
@ -394,11 +396,11 @@ export function PermissionsPanel() {
{showCreate && (
<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 (대문자)"
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)}
placeholder="역할 이름"
<input aria-label={tc('aria.roleName')} value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">

파일 보기

@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState('');
@ -218,7 +219,7 @@ export function SystemConfig() {
<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" />
<input
aria-label="코드 검색"
aria-label={tc('aria.searchCode')}
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={
@ -233,7 +234,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
aria-label="대분류 필터"
aria-label={tc('aria.categoryFilter')}
value={majorFilter}
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"

파일 보기

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

파일 보기

@ -44,6 +44,7 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
@ -148,7 +149,7 @@ export function AIAssistant() {
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"
/>
<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" />
</button>
</div>

파일 보기

@ -1183,7 +1183,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<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>
<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

파일 보기

@ -107,6 +107,7 @@ const WORKERS = [
export function MLOpsPage() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -505,7 +506,7 @@ export function MLOpsPage() {
</div>
<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="질의를 입력하세요..." />
<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>
</CardContent></Card>
</div>

파일 보기

@ -394,7 +394,7 @@ export function ChinaFishing() {
</button>
<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" />
<input aria-label="해역 또는 해구 번호 검색"
<input aria-label={tcCommon('aria.searchAreaOrZone')}
placeholder="해역 또는 해구 번호 검색"
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>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</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> B</option>
</select>
@ -748,10 +748,10 @@ export function ChinaFishing() {
))}
</div>
<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" />
</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" />
</button>
</div>

파일 보기

@ -493,7 +493,7 @@ export function GearDetection() {
{/* 필터 토글 버튼 */}
<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 ${
hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading'
@ -510,7 +510,7 @@ export function GearDetection() {
{hasActiveFilter && (
<>
<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); }}
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" />
@ -547,12 +547,12 @@ export function GearDetection() {
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember}
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" />
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label="최대 멤버 수"
aria-label={tc('aria.memberCountMax')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
@ -562,7 +562,7 @@ export function GearDetection() {
{/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<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); }}
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" />

파일 보기

@ -62,7 +62,7 @@ export function RealGearGroups() {
</div>
</div>
<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">
<option value=""></option>
<option value="FLEET">FLEET</option>

파일 보기

@ -118,7 +118,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
</div>
</div>
<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">
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>

파일 보기

@ -6,6 +6,7 @@
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
@ -24,6 +25,7 @@ interface DarkDetailPanelProps {
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
@ -76,7 +78,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</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" />
</button>
</div>

파일 보기

@ -68,6 +68,7 @@ interface GearDetailPanelProps {
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate();
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
@ -276,7 +277,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</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" />
</button>
</div>

파일 보기

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

파일 보기

@ -4,6 +4,7 @@
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
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) {
const { t: tc } = useTranslation('common');
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
@ -201,7 +203,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div>
</div>
{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">
<X className="w-3.5 h-3.5" />
</button>

파일 보기

@ -171,25 +171,25 @@ export function EventList() {
return (
<div className="flex items-center gap-1">
{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"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</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"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{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"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</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"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />

파일 보기

@ -67,7 +67,7 @@ export function LabelSession() {
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -75,13 +75,13 @@ export function LabelSession() {
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
if (!confirm(tc('dialog.cancelSession'))) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +98,7 @@ export function LabelSession() {
<>
<Select
size="sm"
aria-label="상태 필터"
aria-label={tc('aria.statusFilter')}
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
@ -122,11 +122,11 @@ export function LabelSession() {
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<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} />
<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} />
<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} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
@ -138,7 +138,7 @@ export function LabelSession() {
</CardContent>
</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 && (
<div className="flex items-center justify-center py-12 text-muted-foreground">

파일 보기

@ -14,6 +14,7 @@ import {
type CandidateExclusion,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useTranslation } from 'react-i18next';
/**
* .
@ -26,6 +27,7 @@ import { formatDateTime } from '@shared/utils/dateFormat';
*/
export function ParentExclusion() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
@ -71,7 +73,7 @@ export function ParentExclusion() {
setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -85,7 +87,7 @@ export function ParentExclusion() {
setGlbMmsi(''); setGlbReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +100,7 @@ export function ParentExclusion() {
await releaseExclusion(id, '운영자 해제');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -115,7 +117,7 @@ export function ParentExclusion() {
<>
<Select
size="sm"
aria-label="스코프 필터"
aria-label={tc('aria.scopeFilter')}
title="스코프 필터"
value={filter}
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>}
</div>
<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} />
<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} />
<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} />
<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} />
<button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
@ -165,9 +167,9 @@ export function ParentExclusion() {
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<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} />
<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} />
<button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
@ -179,7 +181,7 @@ export function ParentExclusion() {
</CardContent>
</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 && (
<div className="flex items-center justify-center py-12 text-muted-foreground">

파일 보기

@ -97,7 +97,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg);
alert(tc('error.processFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -117,7 +117,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg);
alert(tc('error.registerFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -152,7 +152,7 @@ export function ParentReview() {
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
aria-label={tc('aria.groupKey')}
type="text"
value={newGroupKey}
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"
/>
<input
aria-label="sub_cluster_id"
aria-label={tc('aria.subClusterId')}
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
@ -202,7 +202,7 @@ export function ParentReview() {
{error && (
<Card>
<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>
</Card>
)}

파일 보기

@ -32,6 +32,7 @@ const reports: Report[] = [
export function ReportManagement() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState('');
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="flex items-center justify-between mb-2">
<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>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">

파일 보기

@ -201,11 +201,11 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<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"
placeholder="YYYY-MM-DD HH:mm" />
<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"
placeholder="YYYY-MM-DD HH:mm" />
</div>

파일 보기

@ -250,5 +250,84 @@
"statistics": "Statistics",
"aiOps": "AI Ops",
"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": "통계·보고",
"aiOps": "AI 운영",
"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 { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 알림 /
@ -36,6 +37,7 @@ interface NotificationBannerProps {
}
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) : []);
@ -80,7 +82,7 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
{notice.dismissible && (
<button
type="button"
aria-label="알림 닫기"
aria-label={t('aria.closeNotification')}
onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0"
>

파일 보기

@ -1,4 +1,5 @@
import { Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 검색
@ -11,22 +12,24 @@ interface SearchInputProps {
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 (
<div className={`relative ${className}`}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text"
aria-label={placeholder}
aria-label={effectivePlaceholder}
value={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"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
aria-label={t('aria.clearSearch')}
onClick={() => onChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
>