diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 65eff20..e6ba47e 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -6,12 +6,12 @@ import { FileText, Settings, LogOut, ChevronLeft, ChevronRight, Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, Download, FileSpreadsheet, Printer, Wifi, Brain, Activity, - ChevronsLeft, ChevronsRight, Navigation, Users, EyeOff, BarChart3, Globe, Smartphone, Monitor, Send, Cpu, MessageSquare, GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound, } from 'lucide-react'; import { useAuth, type UserRole } from '@/app/auth/AuthContext'; +import { getRoleColorHex } from '@shared/constants/userRoles'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { useSettingsStore } from '@stores/settingsStore'; @@ -27,13 +27,6 @@ import { useSettingsStore } from '@stores/settingsStore'; * - 모든 페이지 하단: 페이지네이션 */ -const ROLE_COLORS: Record = { - ADMIN: 'text-red-400', - OPERATOR: 'text-blue-400', - ANALYST: 'text-purple-400', - FIELD: 'text-green-400', - VIEWER: 'text-yellow-400', -}; const AUTH_METHOD_LABELS: Record = { password: 'ID/PW', @@ -51,7 +44,7 @@ const NAV_ENTRIES: NavEntry[] = [ // ── 상황판·감시 ── { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, - { to: '/events', icon: Radar, labelKey: 'nav.eventList' }, + { to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' }, { to: '/map-control', icon: Map, labelKey: 'nav.riskMap' }, // ── 위험도·단속 ── { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, @@ -114,40 +107,6 @@ function formatRemaining(seconds: number) { return `${m}:${String(s).padStart(2, '0')}`; } -// ─── 공통 페이지네이션 (간소형) ───────────── -function PagePagination({ page, totalPages, onPageChange }: { - page: number; totalPages: number; onPageChange: (p: number) => void; -}) { - if (totalPages <= 1) return null; - const range: number[] = []; - const maxVis = 5; - let s = Math.max(0, page - Math.floor(maxVis / 2)); - const e = Math.min(totalPages - 1, s + maxVis - 1); - if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1); - for (let i = s; i <= e; i++) range.push(i); - - const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors"; - - return ( -
- - - {range.map((p) => ( - - ))} - - - {page + 1} / {totalPages} -
- ); -} - export function MainLayout() { const { t } = useTranslation('common'); const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore(); @@ -166,33 +125,6 @@ export function MainLayout() { // 공통 검색 const [pageSearch, setPageSearch] = useState(''); - // 공통 스크롤 페이징 (페이지 단위 스크롤) - const [scrollPage, setScrollPage] = useState(0); - const scrollPageSize = 800; // px per page - - const handleScrollPageChange = (p: number) => { - setScrollPage(p); - if (contentRef.current) { - contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' }); - } - }; - - // 스크롤 이벤트로 현재 페이지 추적 - const handleScroll = () => { - if (contentRef.current) { - const { scrollTop, scrollHeight, clientHeight } = contentRef.current; - const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); - const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1); - setScrollPage(currentPage); - } - }; - - const getTotalScrollPages = () => { - if (!contentRef.current) return 1; - const { scrollHeight, clientHeight } = contentRef.current; - return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); - }; - // 인쇄 const handlePrint = () => { const el = contentRef.current; @@ -257,7 +189,7 @@ export function MainLayout() { }); // RBAC - const roleColor = user ? ROLE_COLORS[user.role] : null; + const roleColor = user ? getRoleColorHex(user.role) : null; const isSessionWarning = sessionRemaining <= 5 * 60; // SFR-02: 공통알림 데이터 @@ -310,7 +242,7 @@ export function MainLayout() {
- {t(`role.${user.role}`)} + {t(`role.${user.role}`)}
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod} @@ -485,7 +417,7 @@ export function MainLayout() {
{user.org}
{roleColor && ( - + {user.role} )} @@ -522,7 +454,7 @@ export function MainLayout() { (window as unknown as { find: (s: string) => boolean }).find?.(pageSearch); } }} - className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors" + className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors" > {t('action.search')} @@ -559,19 +491,9 @@ export function MainLayout() {
- - {/* SFR-02: 공통 페이지네이션 (하단) */} -
- -
{/* SFR-02: 공통알림 팝업 */} diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index bfa4716..2b6d5f9 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -18,6 +18,9 @@ import { type AuditStats, } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getRoleBadgeStyle } from '@shared/constants/userRoles'; +import { getUserAccountStatusIntent, getUserAccountStatusLabel } from '@shared/constants/userAccountStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; import { PermissionsPanel } from './PermissionsPanel'; import { UserRoleAssignDialog } from './UserRoleAssignDialog'; @@ -31,32 +34,12 @@ import { UserRoleAssignDialog } from './UserRoleAssignDialog'; * 4) 보안 정책 - 정적 정보 */ -const ROLE_COLORS: Record = { - ADMIN: 'bg-red-500/20 text-red-400', - OPERATOR: 'bg-blue-500/20 text-blue-400', - ANALYST: 'bg-purple-500/20 text-purple-400', - FIELD: 'bg-green-500/20 text-green-400', - VIEWER: 'bg-yellow-500/20 text-yellow-400', -}; - -const STATUS_COLORS: Record = { - ACTIVE: 'bg-green-500/20 text-green-400', - LOCKED: 'bg-red-500/20 text-red-400', - INACTIVE: 'bg-gray-500/20 text-gray-400', - PENDING: 'bg-yellow-500/20 text-yellow-400', -}; - -const STATUS_LABELS: Record = { - ACTIVE: '활성', - LOCKED: '잠금', - INACTIVE: '비활성', - PENDING: '승인대기', -}; - type Tab = 'roles' | 'users' | 'audit' | 'policy'; export function AccessControl() { const { t } = useTranslation('admin'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [tab, setTab] = useState('roles'); // 공통 상태 @@ -135,7 +118,7 @@ export function AccessControl() { return (
{list.map((r) => ( - {r} + {r} ))}
); @@ -144,7 +127,7 @@ export function AccessControl() { { key: 'userSttsCd', label: '상태', width: '70px', sortable: true, render: (v) => { const s = v as string; - return {STATUS_LABELS[s] || s}; + return {getUserAccountStatusLabel(s, tc, lang)}; }, }, { key: 'failCnt', label: '실패', width: '50px', align: 'center', @@ -241,7 +224,7 @@ export function AccessControl() { type="button" onClick={() => setTab(tt.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === tt.key ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index e660abe..329417e 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes'; /** * 접근 이력 조회 + 메트릭 카드. @@ -30,11 +31,6 @@ export function AccessLogs() { useEffect(() => { load(); }, [load]); - const statusColor = (s: number) => - s >= 500 ? 'bg-red-500/20 text-red-400' - : s >= 400 ? 'bg-orange-500/20 text-orange-400' - : 'bg-green-500/20 text-green-400'; - return (
@@ -113,7 +109,7 @@ export function AccessLogs() { {it.httpMethod} {it.requestPath} - {it.statusCode} + {it.statusCode} {it.durationMs} {it.ipAddress || '-'} diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index de4376f..713376e 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { SaveButton } from '@shared/components/common/SaveButton'; +import { getConnectionStatusHex } from '@shared/constants/connectionStatuses'; import { Database, RefreshCw, Calendar, Wifi, WifiOff, Radio, Activity, Server, ArrowDownToLine, Clock, AlertTriangle, @@ -43,11 +44,7 @@ const SIGNAL_SOURCES: SignalSource[] = [ { name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() }, ]; -const SIGNAL_COLORS: Record = { - ok: '#22c55e', - warn: '#eab308', - error: '#ef4444', -}; +// SIGNAL_COLORS는 connectionStatuses 카탈로그에서 가져옴 (getConnectionStatusHex) const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}시`); @@ -111,7 +108,7 @@ const channelColumns: DataColumn[] = [ { key: 'linkInfo', label: '연계정보', width: '65px' }, { key: 'storage', label: '저장장소', render: (v) => {v as string} }, { key: 'linkMethod', label: '연계방식', width: '70px', align: 'center', - render: (v) => {v as string}, + render: (v) => {v as string}, }, { key: 'cycle', label: '수집주기', width: '80px', align: 'center', render: (v) => { @@ -129,7 +126,7 @@ const channelColumns: DataColumn[] = [ const on = v === 'ON'; return (
- + {v as string} {row.lastUpdate && ( @@ -163,7 +160,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
))} @@ -274,7 +271,7 @@ const LOAD_JOBS: LoadJob[] = [ const loadColumns: DataColumn[] = [ { key: 'id', label: 'ID', width: '80px', render: (v) => {v as string} }, { key: 'name', label: '작업명', sortable: true, render: (v) => {v as string} }, - { key: 'sourceJob', label: '수집원', width: '80px', render: (v) => {v as string} }, + { key: 'sourceJob', label: '수집원', width: '80px', render: (v) => {v as string} }, { key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => {v as string} }, { key: 'targetDb', label: 'DB', width: '70px', align: 'center' }, { key: 'status', label: '상태', width: '80px', align: 'center', sortable: true, @@ -439,7 +436,7 @@ export function DataHub() { key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > @@ -541,7 +538,7 @@ export function DataHub() { onClick={() => setStatusFilter(f)} className={`px-2.5 py-1 rounded text-[10px] transition-colors ${ statusFilter === f - ? 'bg-cyan-600 text-heading font-bold' + ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay hover:text-label' }`} > @@ -570,16 +567,16 @@ export function DataHub() { 서버 타입: {(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => ( ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))} -
@@ -595,14 +592,14 @@ export function DataHub() { 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))}
-
@@ -619,13 +616,13 @@ export function DataHub() { 종류: {(['', '수집', '적재'] as const).map((f) => ( ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))}
{showCreate && ( -
+
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" /> setNewRoleNm(e.target.value)} placeholder="역할 이름" className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> -
+ +
+
+ {r.builtinYn === 'Y' && BUILT-IN} + {canUpdatePerm && ( + + )} +
-
{r.roleNm}
-
권한 {r.permissions.length}건
- + + {isEditingColor && ( +
+ handleUpdateColor(r.roleSn, hex)} + /> +
+ )} +
); })}
diff --git a/frontend/src/features/admin/SystemConfig.tsx b/frontend/src/features/admin/SystemConfig.tsx index 3104293..9b55e59 100644 --- a/frontend/src/features/admin/SystemConfig.tsx +++ b/frontend/src/features/admin/SystemConfig.tsx @@ -203,7 +203,7 @@ export function SystemConfig() { key={t.key} onClick={() => changeTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > @@ -321,7 +321,7 @@ export function SystemConfig() { > {s.code} - {s.major} + {s.major} {s.mid} {s.name} diff --git a/frontend/src/features/admin/UserRoleAssignDialog.tsx b/frontend/src/features/admin/UserRoleAssignDialog.tsx index 4414d3d..88c7ca4 100644 --- a/frontend/src/features/admin/UserRoleAssignDialog.tsx +++ b/frontend/src/features/admin/UserRoleAssignDialog.tsx @@ -2,14 +2,7 @@ import { useEffect, useState } from 'react'; import { X, Check, Loader2 } from 'lucide-react'; import { Badge } from '@shared/components/ui/badge'; import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi'; - -const ROLE_COLORS: Record = { - ADMIN: 'bg-red-500/20 text-red-400', - OPERATOR: 'bg-blue-500/20 text-blue-400', - ANALYST: 'bg-purple-500/20 text-purple-400', - FIELD: 'bg-green-500/20 text-green-400', - VIEWER: 'bg-yellow-500/20 text-yellow-400', -}; +import { getRoleBadgeStyle } from '@shared/constants/userRoles'; interface Props { user: AdminUser; @@ -91,7 +84,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) { }`}> {isSelected && }
- + {r.roleCd}
diff --git a/frontend/src/features/ai-operations/AIAssistant.tsx b/frontend/src/features/ai-operations/AIAssistant.tsx index f1d5333..a25d190 100644 --- a/frontend/src/features/ai-operations/AIAssistant.tsx +++ b/frontend/src/features/ai-operations/AIAssistant.tsx @@ -144,7 +144,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" /> -
diff --git a/frontend/src/features/ai-operations/AIModelManagement.tsx b/frontend/src/features/ai-operations/AIModelManagement.tsx index 56898d9..ed45269 100644 --- a/frontend/src/features/ai-operations/AIModelManagement.tsx +++ b/frontend/src/features/ai-operations/AIModelManagement.tsx @@ -11,6 +11,8 @@ import { FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink, } from 'lucide-react'; import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts'; +import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities'; +import { useSettingsStore } from '@stores/settingsStore'; /* * SFR-04: AI 불법조업 예측 모델 관리 @@ -237,6 +239,8 @@ const ALARM_SEVERITY = [ export function AIModelManagement() { const { t } = useTranslation('ai'); + const { t: tcCommon } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [tab, setTab] = useState('registry'); const [rules, setRules] = useState(defaultRules); @@ -301,7 +305,7 @@ export function AIModelManagement() { { key: 'api' as Tab, icon: Globe, label: '예측 결과 API' }, ].map((t) => ( ))} @@ -319,7 +323,7 @@ export function AIModelManagement() {
정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화
- + @@ -340,7 +344,7 @@ export function AIModelManagement() {
{rule.name} - {rule.model} + {rule.model}
{rule.desc}
@@ -628,7 +632,6 @@ export function AIModelManagement() { {/* 7대 엔진 카드 */}
{DETECTION_ENGINES.map((eng) => { - const sevColor = eng.severity.includes('CRITICAL') ? 'text-red-400 bg-red-500/15' : eng.severity.includes('HIGH') ? 'text-orange-400 bg-orange-500/15' : eng.severity === 'MEDIUM~CRITICAL' ? 'text-yellow-400 bg-yellow-500/15' : 'text-hint bg-muted'; const stColor = eng.status === '운영중' ? 'bg-green-500/20 text-green-400' : eng.status === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'; return ( @@ -654,7 +657,9 @@ export function AIModelManagement() {
심각도
- {eng.severity} + + {getEngineSeverityLabel(eng.severity, tcCommon, lang)} +
쿨다운
@@ -948,7 +953,7 @@ export function AIModelManagement() { ].map((s) => (
- {s.sfr} + {s.sfr} {s.name}
{s.desc}
diff --git a/frontend/src/features/ai-operations/MLOpsPage.tsx b/frontend/src/features/ai-operations/MLOpsPage.tsx index 5978dde..4091cba 100644 --- a/frontend/src/features/ai-operations/MLOpsPage.tsx +++ b/frontend/src/features/ai-operations/MLOpsPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses'; import { Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield, FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square, @@ -109,10 +110,6 @@ export function MLOpsPage() { const [selectedTmpl, setSelectedTmpl] = useState(0); const [selectedLLM, setSelectedLLM] = useState(0); - const stColor = (s: string) => s === 'DEPLOYED' ? 'bg-green-500/20 text-green-400 border-green-500' : s === 'APPROVED' ? 'bg-blue-500/20 text-blue-400 border-blue-500' : s === 'TESTING' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500' : 'bg-muted text-muted-foreground border-slate-600'; - const gateColor = (s: string) => s === 'pass' ? 'bg-green-500/20 text-green-400' : s === 'fail' ? 'bg-red-500/20 text-red-400' : s === 'run' ? 'bg-yellow-500/20 text-yellow-400 animate-pulse' : 'bg-switch-background/50 text-hint'; - const expColor = (s: string) => s === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : s === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'; - return (
@@ -161,7 +158,7 @@ export function MLOpsPage() {
배포 모델 현황
{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
- DEPLOYED + DEPLOYED {m.name} {m.ver} F1 {m.f1}% @@ -172,7 +169,7 @@ export function MLOpsPage() {
진행 중 실험
{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
- 실행중 + 실행중 {e.name}
{e.progress}% @@ -202,14 +199,14 @@ export function MLOpsPage() {
실험 목록
- +
{EXPERIMENTS.map(e => (
{e.id} {e.name} - {e.status} + {e.status}
{e.epoch} {e.time} @@ -228,7 +225,7 @@ export function MLOpsPage() {
{m.name}
{m.ver}
- {m.status} + {MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}
{/* 성능 지표 */} {m.accuracy > 0 && ( @@ -245,7 +242,7 @@ export function MLOpsPage() {
Quality Gates
{m.gates.map((g, i) => ( - {g} + {g} ))}
@@ -283,8 +280,8 @@ export function MLOpsPage() {
카나리 / A·B 테스트
위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)
-
v2.1.0 80%
-
v2.0.3 20%
+
v2.1.0 80%
+
v2.0.3 20%
@@ -293,7 +290,7 @@ export function MLOpsPage() { {MODELS.filter(m => m.status === 'APPROVED').map(m => (
{m.name} {m.ver} - +
))}
@@ -318,7 +315,7 @@ export function MLOpsPage() { "version": "v2.1.0" }`} />
- +
@@ -386,7 +383,7 @@ export function MLOpsPage() {
{k}
{v}
))}
- +
@@ -422,7 +419,7 @@ export function MLOpsPage() {
{k}{v}
))}
- +
HPS 시도 결과
Best: Trial #3 (F1=0.912)
@@ -436,7 +433,7 @@ export function MLOpsPage() { {t.dropout} {t.hidden} {t.f1.toFixed(3)} - {t.best && BEST} + {t.best && BEST} ))} @@ -503,14 +500,14 @@ export function MLOpsPage() {

2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부

3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인

- 배타적경제수역법 §5 - 한중어업협정 §6 + 배타적경제수역법 §5 + 한중어업협정 §6
- +
diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index a8d7f8b..e9d80a6 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -195,7 +195,7 @@ export function LoginPage() { : {l}; } }, -]; - export function DarkVesselDetection() { const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, + { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, + render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, + { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', render: v => {v as string} }, + { key: 'flag', label: '국적', width: '50px' }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, + { key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => {v as string} }, + { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, + render: v => {getVesselSurveillanceLabel(v as string, tc, lang)} }, + { key: 'label', label: '라벨', width: '60px', align: 'center', + render: v => { const l = v as string; return l === '-' ? : {l}; } }, + ], [tc, lang]); + const [darkItems, setDarkItems] = useState([]); const [serviceAvailable, setServiceAvailable] = useState(true); const [loading, setLoading] = useState(false); @@ -128,7 +128,7 @@ export function DarkVesselDetection() { lat: d.lat, lng: d.lng, radius: 10000, - color: PATTERN_COLORS[d.pattern] || '#ef4444', + color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', })), 0.08, ), @@ -137,7 +137,7 @@ export function DarkVesselDetection() { DATA.map(d => ({ lat: d.lat, lng: d.lng, - color: PATTERN_COLORS[d.pattern] || '#ef4444', + color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', radius: d.risk > 80 ? 1200 : 800, label: `${d.id} ${d.name}`, } as MarkerData)), @@ -193,12 +193,16 @@ export function DarkVesselDetection() {
탐지 패턴
- {Object.entries(PATTERN_COLORS).map(([p, c]) => ( -
-
- {p} -
- ))} + {(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => { + const meta = getDarkVesselPatternMeta(p); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
EEZ
diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 7212364..cd3f734 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -8,14 +8,18 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay import type { MarkerData } from '@lib/map'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { formatDate } from '@shared/utils/dateFormat'; +import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; +import { getAlertLevelHex } from '@shared/constants/alertLevels'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-10: 불법 어망·어구 탐지 및 관리 */ type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; }; -const RISK_COLORS: Record = { - '고위험': '#ef4444', - '중위험': '#eab308', +// 한글 위험도 → AlertLevel hex 매핑 +const RISK_HEX: Record = { + '고위험': getAlertLevelHex('CRITICAL'), + '중위험': getAlertLevelHex('MEDIUM'), '안전': '#22c55e', }; @@ -50,22 +54,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear { }; } -const cols: DataColumn[] = [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => {v as string} }, - { key: 'owner', label: '소유 선박', sortable: true, render: v => {v as string} }, - { key: 'zone', label: '설치 해역', width: '90px', sortable: true }, - { key: 'permit', label: '허가 상태', width: '80px', align: 'center', - render: v => { const p = v as string; const c = p === '유효' ? 'bg-green-500/20 text-green-400' : p === '무허가' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'; return {p}; } }, - { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, - render: v => { const s = v as string; const c = s.includes('불법') ? 'bg-red-500/20 text-red-400' : s === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'; return {s}; } }, - { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, - render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return {r}; } }, - { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, -]; - export function GearDetection() { const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, + { key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => {v as string} }, + { key: 'owner', label: '소유 선박', sortable: true, render: v => {v as string} }, + { key: 'zone', label: '설치 해역', width: '90px', sortable: true }, + { key: 'permit', label: '허가 상태', width: '80px', align: 'center', + render: v => {getPermitStatusLabel(v as string, tc, lang)} }, + { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, + render: v => {v as string} }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return {r}; } }, + { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, + ], [tc, lang]); + const [groups, setGroups] = useState([]); const [serviceAvailable, setServiceAvailable] = useState(true); const [loading, setLoading] = useState(false); @@ -105,7 +112,7 @@ export function GearDetection() { lat: g.lat, lng: g.lng, radius: 6000, - color: RISK_COLORS[g.risk] || '#64748b', + color: RISK_HEX[g.risk] || "#64748b", })), 0.1, ), @@ -114,7 +121,7 @@ export function GearDetection() { DATA.map(g => ({ lat: g.lat, lng: g.lng, - color: RISK_COLORS[g.risk] || '#64748b', + color: RISK_HEX[g.risk] || "#64748b", radius: g.risk === '고위험' ? 1200 : 800, label: `${g.id} ${g.type}`, } as MarkerData)), diff --git a/frontend/src/features/detection/GearIdentification.tsx b/frontend/src/features/detection/GearIdentification.tsx index 5de1084..828b6b3 100644 --- a/frontend/src/features/detection/GearIdentification.tsx +++ b/frontend/src/features/detection/GearIdentification.tsx @@ -781,7 +781,7 @@ export function GearIdentification() {
+ + className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-on-vivid text-[10px] font-bold rounded-lg">최종 승인
diff --git a/frontend/src/features/patrol/PatrolRoute.tsx b/frontend/src/features/patrol/PatrolRoute.tsx index 500af52..f5f27cc 100644 --- a/frontend/src/features/patrol/PatrolRoute.tsx +++ b/frontend/src/features/patrol/PatrolRoute.tsx @@ -106,7 +106,7 @@ export function PatrolRoute() {

{t('patrolRoute.desc')}

- +
diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index d2ed00e..5c37d28 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -39,7 +39,7 @@ const cols: DataColumn[] = [ { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, render: v => { const s = v as string; return {s}; } }, { key: 'alert', label: '경보', width: '80px', align: 'center', - render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? {a} : {a}; } }, + render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? {a} : {a}; } }, ]; export function EnforcementPlan() { @@ -124,7 +124,7 @@ export function EnforcementPlan() {

{t('enforcementPlan.title')}

{t('enforcementPlan.desc')}

- +
{/* 로딩/에러 상태 */} @@ -153,7 +153,7 @@ export function EnforcementPlan() {
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
- {k} + {k} {v}
))} diff --git a/frontend/src/features/statistics/ExternalService.tsx b/frontend/src/features/statistics/ExternalService.tsx index bdc24a0..546b416 100644 --- a/frontend/src/features/statistics/ExternalService.tsx +++ b/frontend/src/features/statistics/ExternalService.tsx @@ -18,7 +18,7 @@ const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'name', label: '서비스명', sortable: true, render: v => {v as string} }, { key: 'target', label: '제공 대상', width: '80px', sortable: true }, - { key: 'type', label: '방식', width: '50px', align: 'center', render: v => {v as string} }, + { key: 'type', label: '방식', width: '50px', align: 'center', render: v => {v as string} }, { key: 'format', label: '포맷', width: '60px', align: 'center' }, { key: 'cycle', label: '갱신주기', width: '70px' }, { key: 'privacy', label: '정보등급', width: '70px', align: 'center', diff --git a/frontend/src/features/statistics/ReportManagement.tsx b/frontend/src/features/statistics/ReportManagement.tsx index b6b1277..f9fee79 100644 --- a/frontend/src/features/statistics/ReportManagement.tsx +++ b/frontend/src/features/statistics/ReportManagement.tsx @@ -37,7 +37,7 @@ export function ReportManagement() { ); return ( -
+

@@ -66,7 +66,7 @@ export function ReportManagement() { > 증거 업로드 -

@@ -116,7 +116,7 @@ export function ReportManagement() {
증거 {r.evidence}건
- +
@@ -131,7 +131,7 @@ export function ReportManagement() {
보고서 미리보기
-
diff --git a/frontend/src/features/statistics/Statistics.tsx b/frontend/src/features/statistics/Statistics.tsx index 231357a..099d29b 100644 --- a/frontend/src/features/statistics/Statistics.tsx +++ b/frontend/src/features/statistics/Statistics.tsx @@ -15,6 +15,8 @@ import { } from '@/services/kpi'; import type { MonthlyTrend, ViolationType } from '@data/mock/kpi'; import { toDateParam } from '@shared/utils/dateFormat'; +import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-13: 통계·지표·성과 분석 */ @@ -60,7 +62,7 @@ const kpiCols: DataColumn[] = [ width: '60px', align: 'center', render: (v) => ( - + {v as string} ), @@ -69,6 +71,8 @@ const kpiCols: DataColumn[] = [ export function Statistics() { const { t } = useTranslation('statistics'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [monthly, setMonthly] = useState([]); const [violationTypes, setViolationTypes] = useState([]); @@ -205,20 +209,25 @@ export function Statistics() { 위반 유형별 분포
- {BY_TYPE.map((item) => ( -
-
- {item.count} + {BY_TYPE.map((item) => { + const color = getViolationColor(item.type); + const label = getViolationLabel(item.type, tc, lang); + return ( +
+
+ {item.count} +
+
+ {label} +
+
{item.pct}%
-
- {item.type} -
-
{item.pct}%
-
- ))} + ); + })}
diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index 69103aa..be54d76 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -14,13 +14,7 @@ import { type PredictionEvent, } from '@/services/event'; -// ─── 위험도 레벨 → 마커 색상 ───────────────── -const RISK_MARKER_COLOR: Record = { - CRITICAL: '#ef4444', - HIGH: '#f97316', - MEDIUM: '#3b82f6', - LOW: '#6b7280', -}; +import { getAlertLevelHex } from '@shared/constants/alertLevels'; interface MapEvent { id: string; @@ -171,7 +165,7 @@ export function LiveMapView() { 'ais-vessels', vesselMarkers.map((v): MarkerData => { const level = v.item.algorithms.riskScore.level; - const color = RISK_MARKER_COLOR[level] ?? '#6b7280'; + const color = getAlertLevelHex(level); return { lat: v.lat, lng: v.lng, @@ -367,7 +361,7 @@ export function LiveMapView() {
AI 판단 근거 - 신뢰도: High + 신뢰도: High
diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx index 1b4a46d..7f1bde1 100644 --- a/frontend/src/features/surveillance/MapControl.tsx +++ b/frontend/src/features/surveillance/MapControl.tsx @@ -4,6 +4,7 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Map, Shield, Crosshair, AlertTriangle, Eye, Anchor, Ship, Filter, Layers, Target, Clock, MapPin, Bell, Navigation, Info } from 'lucide-react'; +import { getTrainingZoneIntent, getTrainingZoneHex, getTrainingZoneMeta } from '@shared/constants/trainingZoneTypes'; /* * 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영 @@ -140,20 +141,12 @@ const ntmColumns: DataColumn[] = [ render: v => {v as string} }, ]; -// ─── 범례 색상 ────────────────────────── - -const TYPE_COLORS: Record = { - '해군': { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: '해군 훈련 구역', mapColor: '#eab308' }, - '공군': { bg: 'bg-pink-500/20', text: 'text-pink-400', label: '공군 훈련 구역', mapColor: '#ec4899' }, - '육군': { bg: 'bg-green-500/20', text: 'text-green-400', label: '육군 훈련 구역', mapColor: '#22c55e' }, - '국과연': { bg: 'bg-blue-500/20', text: 'text-blue-400', label: '국방과학연구소', mapColor: '#3b82f6' }, - '해경': { bg: 'bg-purple-500/20', text: 'text-purple-400', label: '해양경찰청 훈련구역', mapColor: '#a855f7' }, -}; +// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup const columns: DataColumn[] = [ { key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => {v as string} }, { key: 'type', label: '구분', width: '60px', align: 'center', sortable: true, - render: v => { const t = TYPE_COLORS[v as string]; return {v as string}; } }, + render: v => {v as string} }, { key: 'sea', label: '해역', width: '60px', sortable: true }, { key: 'lat', label: '위도', width: '110px', render: v => {v as string} }, { key: 'lng', label: '경도', width: '110px', render: v => {v as string} }, @@ -214,7 +207,7 @@ export function MapControl() { const lat = parseDMS(z.lat); const lng = parseDMS(z.lng); if (lat === null || lng === null) return; - const color = TYPE_COLORS[z.type]?.mapColor || '#6b7280'; + const color = getTrainingZoneHex(z.type); const radiusM = parseRadius(z.radius); const isActive = z.status === '활성'; parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z }); @@ -285,12 +278,16 @@ export function MapControl() { {/* 범례 */}
범례: - {Object.entries(TYPE_COLORS).map(([type, c]) => ( -
-
- {c.label} -
- ))} + {(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { + const meta = getTrainingZoneMeta(type); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
{/* 탭 + 해역 필터 */} @@ -315,7 +312,7 @@ export function MapControl() { {['', '서해', '남해', '동해', '제주'].map(s => ( ))} @@ -346,7 +343,7 @@ export function MapControl() { 구분: {NTM_CATEGORIES.map(c => ( + className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c} ))}
@@ -358,7 +355,7 @@ export function MapControl() {
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
- {n.category} + {n.category}
{n.title}
{n.detail}
@@ -397,12 +394,16 @@ export function MapControl() {
훈련구역 범례
- {Object.entries(TYPE_COLORS).map(([type, c]) => ( -
-
- {c.label} -
- ))} + {(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { + const meta = getTrainingZoneMeta(type); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
EEZ
diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index 3c3da5f..bb962e5 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -3,7 +3,7 @@ import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis'; export function TransferDetection() { return ( -
+

환적·접촉 탐지

선박 간 근접 접촉 및 환적 의심 행위 분석

diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index 4f73499..ccd3d6c 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -14,6 +14,9 @@ import { } from '@/services/vesselAnalysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; +import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; // ─── 허가 정보 타입 ────────────────────── interface VesselPermitData { @@ -47,14 +50,6 @@ async function fetchVesselPermit(mmsi: string): Promise } } -// ─── 위험도 레벨 → 색상 매핑 ────────────── -const RISK_LEVEL_CONFIG: Record = { - CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' }, - HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' }, - MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' }, - LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' }, -}; - const RIGHT_TOOLS = [ { icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' }, { icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' }, @@ -152,10 +147,14 @@ export function VesselDetail() { useMapLayers(mapRef, buildLayers, []); + // i18n + 카탈로그 + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + // 위험도 점수 바 const riskScore = vessel?.algorithms.riskScore.score ?? 0; - const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW'; - const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW; + const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel; + const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW; return (
@@ -280,12 +279,12 @@ export function VesselDetail() {
위험도 - - {riskConfig.label} + + {getAlertLevelLabel(riskLevel, tc, lang)}
- + {Math.round(riskScore * 100)} /100 @@ -341,15 +340,14 @@ export function VesselDetail() { ) : (
{events.map((evt) => { - const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW; return (
- - {evt.level} + + {getAlertLevelLabel(evt.level, tc, lang)} {evt.title} - + {evt.status}
@@ -378,8 +376,8 @@ export function VesselDetail() { MMSI: {mmsiParam} {vessel && ( - - 위험도: {riskConfig.label} + + 위험도: {getAlertLevelLabel(riskLevel, tc, lang)} )}