release: 2026-04-17 (11건 커밋) #72
@ -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"
|
||||
>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user