feat(frontend): 40+ 페이지 Badge/시맨틱 토큰 마이그레이션
- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환 - 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid - 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정) - ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/ PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출 - PermissionsPanel 역할 생성/수정에 ColorPicker 통합 - MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동) - Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리) - ReportManagement, TransferDetection p-5 space-y-4 padding 복구 - EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소 - timeline 시간 formatDateTime 적용 (ISO T 구분자 처리) - 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
This commit is contained in:
부모
5812d9dea3
커밋
a07c745cbc
@ -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<UserRole, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
|
||||
{range.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
|
||||
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
|
||||
}`}
|
||||
>{p + 1}</button>
|
||||
))}
|
||||
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
|
||||
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lock className="w-3 h-3 text-hint" />
|
||||
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
|
||||
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
|
||||
</div>
|
||||
<div className="text-[8px] text-hint mt-0.5">
|
||||
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
||||
@ -485,7 +417,7 @@ export function MainLayout() {
|
||||
<div className="text-[8px] text-hint">{user.org}</div>
|
||||
</div>
|
||||
{roleColor && (
|
||||
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
|
||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
@ -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"
|
||||
>
|
||||
<Search className="w-3 h-3" />
|
||||
{t('action.search')}
|
||||
@ -559,19 +491,9 @@ export function MainLayout() {
|
||||
<main
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* SFR-02: 공통 페이지네이션 (하단) */}
|
||||
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
|
||||
<PagePagination
|
||||
page={scrollPage}
|
||||
totalPages={getTotalScrollPages()}
|
||||
onPageChange={handleScrollPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SFR-02: 공통알림 팝업 */}
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Tab>('roles');
|
||||
|
||||
// 공통 상태
|
||||
@ -135,7 +118,7 @@ export function AccessControl() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{list.map((r) => (
|
||||
<Badge key={r} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
|
||||
<Badge key={r} size="sm" style={getRoleBadgeStyle(r)}>{r}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -144,7 +127,7 @@ export function AccessControl() {
|
||||
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</Badge>;
|
||||
return <Badge intent={getUserAccountStatusIntent(s)} size="sm">{getUserAccountStatusLabel(s, tc, lang)}</Badge>;
|
||||
},
|
||||
},
|
||||
{ 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'
|
||||
}`}
|
||||
>
|
||||
<tt.icon className="w-3.5 h-3.5" />
|
||||
|
||||
@ -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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -113,7 +109,7 @@ export function AccessLogs() {
|
||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
|
||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
|
||||
@ -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<SignalStatus, string> = {
|
||||
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<ChannelRecord>[] = [
|
||||
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
||||
{ key: 'linkMethod', label: '연계방식', width: '70px', align: 'center',
|
||||
render: (v) => <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v as string}</Badge>,
|
||||
render: (v) => <Badge intent="purple" size="sm">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
|
||||
render: (v) => {
|
||||
@ -129,7 +126,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
const on = v === 'ON';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-heading' : 'bg-red-500 text-heading'}`}>
|
||||
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-on-vivid' : 'bg-red-500 text-on-vivid'}`}>
|
||||
{v as string}
|
||||
</Badge>
|
||||
{row.lastUpdate && (
|
||||
@ -163,7 +160,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-5 rounded-[1px]"
|
||||
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
|
||||
style={{ backgroundColor: getConnectionStatusHex(status), minWidth: '2px' }}
|
||||
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')} — ${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
|
||||
/>
|
||||
))}
|
||||
@ -274,7 +271,7 @@ const LOAD_JOBS: LoadJob[] = [
|
||||
const loadColumns: DataColumn<LoadJob>[] = [
|
||||
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge className="bg-cyan-500/15 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ 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'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -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() {
|
||||
<span className="text-[10px] text-hint">서버 타입:</span>
|
||||
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setCollectTypeFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setCollectStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
</div>
|
||||
@ -595,14 +592,14 @@ export function DataHub() {
|
||||
<span className="text-[10px] text-hint">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setLoadStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
<FolderOpen className="w-3 h-3" />스토리지 관리
|
||||
</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
</div>
|
||||
@ -619,13 +616,13 @@ export function DataHub() {
|
||||
<span className="text-[10px] text-hint">종류:</span>
|
||||
{(['', '수집', '적재'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setAgentRoleFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setAgentStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
|
||||
@ -4,12 +4,17 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
|
||||
import { formatDateTime, formatDate } from '@shared/utils/dateFormat';
|
||||
import { getLoginResultIntent, getLoginResultLabel } from '@shared/constants/loginResultStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 로그인 이력 조회 + 메트릭 카드.
|
||||
* 권한: admin:login-history (READ)
|
||||
*/
|
||||
export function LoginHistoryView() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [items, setItems] = useState<LoginHistory[]>([]);
|
||||
const [stats, setStats] = useState<LoginStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -30,12 +35,6 @@ export function LoginHistoryView() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const resultColor = (r: string) => {
|
||||
if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400';
|
||||
if (r === 'LOCKED') return 'bg-red-500/20 text-red-400';
|
||||
return 'bg-orange-500/20 text-orange-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -122,7 +121,7 @@ export function LoginHistoryView() {
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
||||
|
||||
@ -152,7 +152,7 @@ export function NoticeManagement() {
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
새 알림 등록
|
||||
|
||||
@ -13,6 +13,9 @@ import {
|
||||
type Operation, type TreeNode, type PermRow,
|
||||
} from '@/lib/permission/permResolver';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
||||
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
||||
|
||||
/**
|
||||
* 트리 기반 권한 관리 패널 (wing 패턴).
|
||||
@ -34,14 +37,6 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
|
||||
*/
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
OPERATOR: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
ANALYST: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
FIELD: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
VIEWER: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
};
|
||||
|
||||
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
|
||||
|
||||
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
||||
@ -65,6 +60,8 @@ export function PermissionsPanel() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newRoleCd, setNewRoleCd] = useState('');
|
||||
const [newRoleNm, setNewRoleNm] = useState('');
|
||||
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
|
||||
const [editingColor, setEditingColor] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
@ -233,15 +230,26 @@ export function PermissionsPanel() {
|
||||
const handleCreateRole = async () => {
|
||||
if (!newRoleCd || !newRoleNm) return;
|
||||
try {
|
||||
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm });
|
||||
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
|
||||
setShowCreate(false);
|
||||
setNewRoleCd(''); setNewRoleNm('');
|
||||
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateColor = async (roleSn: number, hex: string) => {
|
||||
try {
|
||||
await apiUpdateRole(roleSn, { colorHex: hex });
|
||||
await load();
|
||||
setEditingColor(null);
|
||||
} catch (e: unknown) {
|
||||
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async () => {
|
||||
if (!selectedRole) return;
|
||||
if (selectedRole.builtinYn === 'Y') {
|
||||
@ -364,14 +372,15 @@ export function PermissionsPanel() {
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1">
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||||
<input 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 value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
placeholder="역할 이름"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<div className="flex gap-1">
|
||||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||||
<div className="flex gap-1 pt-1">
|
||||
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
|
||||
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded">생성</button>
|
||||
<button type="button" onClick={() => setShowCreate(false)}
|
||||
@ -383,26 +392,55 @@ export function PermissionsPanel() {
|
||||
<div className="space-y-1">
|
||||
{roles.map((r) => {
|
||||
const selected = r.roleSn === selectedRoleSn;
|
||||
const isEditingColor = editingColor === String(r.roleSn);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={r.roleSn}
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded border transition-colors ${
|
||||
className={`px-2 py-1.5 rounded border transition-colors ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-heading'
|
||||
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border text-[9px]`}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
title="역할 선택"
|
||||
>
|
||||
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
{canUpdatePerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||||
className="text-[8px] text-hint hover:text-blue-400"
|
||||
title="색상 변경"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
{isEditingColor && (
|
||||
<div className="mt-2 p-2 bg-background rounded border border-border">
|
||||
<ColorPicker
|
||||
label="배지 색상"
|
||||
value={r.colorHex}
|
||||
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -321,7 +321,7 @@ export function SystemConfig() {
|
||||
>
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{s.major}</Badge>
|
||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
|
||||
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 && <Check className="w-3.5 h-3.5 text-white" />}
|
||||
</div>
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px]`}>
|
||||
<Badge size="md" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
<div className="text-left">
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-heading rounded-xl transition-colors">
|
||||
<button 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>
|
||||
|
||||
@ -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<Tab>('registry');
|
||||
const [rules, setRules] = useState(defaultRules);
|
||||
|
||||
@ -301,7 +305,7 @@ export function AIModelManagement() {
|
||||
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
|
||||
].map((t) => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
@ -319,7 +323,7 @@ export function AIModelManagement() {
|
||||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
<button className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
</div>
|
||||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||
</div>
|
||||
@ -340,7 +344,7 @@ export function AIModelManagement() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[8px]">{rule.model}</Badge>
|
||||
<Badge intent="purple" size="xs">{rule.model}</Badge>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
|
||||
</div>
|
||||
@ -628,7 +632,6 @@ export function AIModelManagement() {
|
||||
{/* 7대 엔진 카드 */}
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<Card key={eng.id} className="bg-surface-raised border-border">
|
||||
@ -654,7 +657,9 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">심각도</div>
|
||||
<Badge className={`border-0 text-[9px] ${sevColor}`}>{eng.severity}</Badge>
|
||||
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
|
||||
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">쿨다운</div>
|
||||
@ -948,7 +953,7 @@ export function AIModelManagement() {
|
||||
].map((s) => (
|
||||
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge className="border-0 text-[9px] font-bold" style={{ backgroundColor: `${s.color}20`, color: s.color }}>{s.sfr}</Badge>
|
||||
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
|
||||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -161,7 +158,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">배포 모델 현황</div>
|
||||
<div className="space-y-2">{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEPLOYED</Badge>
|
||||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||||
<span className="text-[10px] text-hint">{m.ver}</span>
|
||||
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
|
||||
@ -172,7 +169,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">진행 중 실험</div>
|
||||
<div className="space-y-2">{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px] animate-pulse">실행중</Badge>
|
||||
<Badge intent="info" size="sm" className="animate-pulse">실행중</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.name}</span>
|
||||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground">{e.progress}%</span>
|
||||
@ -202,14 +199,14 @@ export function MLOpsPage() {
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EXPERIMENTS.map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-16">{e.id}</span>
|
||||
<span className="text-[11px] text-heading font-medium w-40 truncate">{e.name}</span>
|
||||
<Badge className={`border-0 text-[9px] w-14 text-center ${expColor(e.status)}`}>{e.status}</Badge>
|
||||
<Badge intent={getExperimentIntent(e.status)} size="sm" className={`w-14 ${EXPERIMENT_STATUSES[e.status as keyof typeof EXPERIMENT_STATUSES]?.pulse ? 'animate-pulse' : ''}`}>{e.status}</Badge>
|
||||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||
@ -228,7 +225,7 @@ export function MLOpsPage() {
|
||||
<Card key={m.name + m.ver} className="bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div><div className="text-[13px] font-bold text-heading">{m.name}</div><div className="text-[9px] text-hint mt-0.5">{m.ver}</div></div>
|
||||
<Badge className={`border text-[10px] font-bold ${stColor(m.status)}`}>{m.status}</Badge>
|
||||
<Badge intent={getModelStatusIntent(m.status)} size="md" className="font-bold">{MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}</Badge>
|
||||
</div>
|
||||
{/* 성능 지표 */}
|
||||
{m.accuracy > 0 && (
|
||||
@ -245,7 +242,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[9px] text-hint mb-1.5 font-bold">Quality Gates</div>
|
||||
<div className="flex gap-1">
|
||||
{m.gates.map((g, i) => (
|
||||
<Badge key={g} className={`border-0 text-[8px] ${gateColor(m.gateStatus[i])}`}>{g}</Badge>
|
||||
<Badge key={g} intent={getQualityGateIntent(m.gateStatus[i])} size="xs" className={QUALITY_GATE_STATUSES[m.gateStatus[i] as keyof typeof QUALITY_GATE_STATUSES]?.pulse ? 'animate-pulse' : ''}>{g}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -283,8 +280,8 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-heading mb-2">카나리 / A·B 테스트</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-3">위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)</div>
|
||||
<div className="h-5 bg-background rounded-lg overflow-hidden flex">
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -293,7 +290,7 @@ export function MLOpsPage() {
|
||||
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-heading text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -318,7 +315,7 @@ export function MLOpsPage() {
|
||||
"version": "v2.1.0"
|
||||
}`} />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -386,7 +383,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -422,7 +419,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
</CardContent></Card>
|
||||
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||||
@ -436,7 +433,7 @@ export function MLOpsPage() {
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.dropout}</td>
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.hidden}</td>
|
||||
<td className="py-2 px-2 text-heading font-bold">{t.f1.toFixed(3)}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge className="bg-green-500/20 text-green-400 border-0 text-[8px]">BEST</Badge>}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge intent="success" size="xs">BEST</Badge>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
@ -503,14 +500,14 @@ export function MLOpsPage() {
|
||||
<p className="text-[10px] text-muted-foreground">2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부</p>
|
||||
<p className="text-[10px] text-muted-foreground">3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인</p>
|
||||
<div className="mt-2 pt-2 border-t border-border flex gap-1">
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">배타적경제수역법 §5</Badge>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">한중어업협정 §6</Badge>
|
||||
<Badge intent="success" size="xs">배타적경제수역법 §5</Badge>
|
||||
<Badge intent="success" size="xs">한중어업협정 §6</Badge>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input 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 className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading rounded-xl"><Send className="w-4 h-4" /></button>
|
||||
<button 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>
|
||||
|
||||
@ -195,7 +195,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@ -22,45 +22,13 @@ import {
|
||||
type PredictionStatsDaily,
|
||||
type PredictionStatsHourly,
|
||||
} from '@/services/kpi';
|
||||
import { toDateParam } from '@shared/utils/dateFormat';
|
||||
import { toDateParam, formatDate, formatTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
||||
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getPatrolStatusClasses, getPatrolStatusLabel } from '@shared/constants/patrolStatuses';
|
||||
import { getKpiUi } from '@shared/constants/kpiUiMap';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
// ─── 작전 경보 등급 ─────────────────────
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
const ALERT_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
|
||||
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400', border: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/30', dot: 'bg-orange-500' },
|
||||
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', border: 'border-yellow-500/30', dot: 'bg-yellow-500' },
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' },
|
||||
};
|
||||
|
||||
// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ─────────
|
||||
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
// kpiKey 기반 매핑 (백엔드 API 응답)
|
||||
realtime_detection: { icon: Radar, color: '#3b82f6' },
|
||||
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
|
||||
dark_vessel: { icon: Eye, color: '#f97316' },
|
||||
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
|
||||
tracking: { icon: Crosshair, color: '#06b6d4' },
|
||||
enforcement: { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
|
||||
|
||||
// 위반 유형/어구 → 차트 색상 매핑
|
||||
const VESSEL_TYPE_COLORS: Record<string, string> = {
|
||||
'EEZ 침범': '#ef4444',
|
||||
'다크베셀': '#f97316',
|
||||
'불법환적': '#a855f7',
|
||||
'MMSI변조': '#eab308',
|
||||
'고속도주': '#06b6d4',
|
||||
'어구 불법': '#6b7280',
|
||||
};
|
||||
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||
|
||||
// TODO: /api/weather 연동 예정
|
||||
const WEATHER_DATA = {
|
||||
@ -83,9 +51,10 @@ function PulsingDot({ color }: { color: string }) {
|
||||
}
|
||||
|
||||
function RiskBar({ value, size = 'default' }: { value: number; size?: 'default' | 'sm' }) {
|
||||
const pct = value * 100;
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 80 ? 'bg-orange-500' : pct > 70 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
const textColor = pct > 90 ? 'text-red-400' : pct > 80 ? 'text-orange-400' : pct > 70 ? 'text-yellow-400' : 'text-blue-400';
|
||||
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
|
||||
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
|
||||
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
|
||||
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -122,22 +91,27 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
|
||||
|
||||
interface TimelineEvent { time: string; level: AlertLevel; title: string; detail: string; vessel: string; area: string }
|
||||
function TimelineItem({ event }: { event: TimelineEvent }) {
|
||||
const c = ALERT_COLORS[event.level];
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const c = ALERT_LEVELS[event.level].classes;
|
||||
return (
|
||||
<div className={`flex gap-3 p-2.5 rounded-lg ${c.bg} border ${c.border} hover:brightness-110 transition-all cursor-pointer group`}>
|
||||
<div className="flex flex-col items-center gap-1 pt-0.5 shrink-0">
|
||||
<div className="flex flex-col items-center gap-0.5 pt-0.5 shrink-0 min-w-[68px]">
|
||||
<PulsingDot color={c.dot} />
|
||||
<span className="text-[9px] text-hint tabular-nums">{event.time}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatDate(event.time)}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatTime(event.time)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-xs font-bold ${c.text}`}>{event.title}</span>
|
||||
<Badge className={`${c.bg} ${c.text} text-[8px] px-1 py-0 border-0`}>{event.level}</Badge>
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="xs">
|
||||
{getAlertLevelLabel(event.level, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed truncate">{event.detail}</p>
|
||||
<p className="text-[0.75rem] text-label leading-relaxed truncate">{event.detail}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
|
||||
@ -146,14 +120,13 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
|
||||
}
|
||||
|
||||
function PatrolStatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'추적 중': 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
'검문 중': 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
'초계 중': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
'귀항 중': 'bg-muted text-muted-foreground border-slate-500/30',
|
||||
'대기': 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
};
|
||||
return <Badge className={`${styles[status] || 'bg-muted text-muted-foreground'} text-[9px] border px-1.5 py-0`}>{status}</Badge>;
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
return (
|
||||
<Badge className={`${getPatrolStatusClasses(status)} text-[9px] border px-1.5 py-0`}>
|
||||
{getPatrolStatusLabel(status, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FuelGauge({ percent }: { percent: number }) {
|
||||
@ -276,6 +249,8 @@ const MemoSeaAreaMap = memo(SeaAreaMap);
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [defconLevel] = useState(2);
|
||||
|
||||
const kpiStore = useKpiStore();
|
||||
@ -309,7 +284,7 @@ export function Dashboard() {
|
||||
}, []);
|
||||
|
||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
|
||||
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
|
||||
const ui = getKpiUi(m.id) ?? getKpiUi(m.label);
|
||||
return {
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
@ -321,7 +296,7 @@ export function Dashboard() {
|
||||
}), [kpiStore.metrics]);
|
||||
|
||||
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
|
||||
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||
time: e.time,
|
||||
level: e.level,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
@ -370,12 +345,13 @@ export function Dashboard() {
|
||||
};
|
||||
}), [hourlyStats]);
|
||||
|
||||
// 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory
|
||||
// 위반 유형 분포: daily byCategory(위반 enum) 우선, 없으면 byGearType
|
||||
// 라벨/색상은 공통 카탈로그(violationTypes)에서 일괄 lookup
|
||||
const VESSEL_TYPE_DATA = useMemo(() => {
|
||||
if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[];
|
||||
const totals: Record<string, number> = {};
|
||||
dailyStats.forEach((d) => {
|
||||
const src = d.byGearType ?? d.byCategory ?? null;
|
||||
const src = d.byCategory ?? d.byGearType ?? null;
|
||||
if (src) {
|
||||
Object.entries(src).forEach(([k, v]) => {
|
||||
totals[k] = (totals[k] ?? 0) + (Number(v) || 0);
|
||||
@ -384,12 +360,12 @@ export function Dashboard() {
|
||||
});
|
||||
return Object.entries(totals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, value], i) => ({
|
||||
name,
|
||||
.map(([code, value]) => ({
|
||||
name: getViolationLabel(code, tc, lang),
|
||||
value,
|
||||
color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length],
|
||||
color: getViolationColor(code),
|
||||
}));
|
||||
}, [dailyStats]);
|
||||
}, [dailyStats, tc, lang]);
|
||||
|
||||
// 해역별 위험도: daily byZone → 표 데이터
|
||||
const AREA_RISK_DATA = useMemo(() => {
|
||||
@ -511,10 +487,10 @@ export function Dashboard() {
|
||||
실시간 상황 타임라인
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="critical" size="xs" className="px-1.5 py-0">
|
||||
긴급 {TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length}
|
||||
</Badge>
|
||||
<Badge className="bg-orange-500/15 text-orange-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="high" size="xs" className="px-1.5 py-0">
|
||||
경고 {TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -538,7 +514,7 @@ export function Dashboard() {
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-cyan-500" />
|
||||
함정 배치 현황
|
||||
<Badge className="bg-cyan-500/15 text-cyan-400 text-[8px] border-0 ml-auto px-1.5 py-0">
|
||||
<Badge intent="cyan" size="xs" className="ml-auto px-1.5 py-0">
|
||||
{PATROL_SHIPS.length}척 운용 중
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
@ -654,12 +630,12 @@ export function Dashboard() {
|
||||
<Crosshair className="w-3.5 h-3.5 text-red-500" />
|
||||
고위험 선박 추적 현황 (AI 우선순위)
|
||||
</CardTitle>
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[9px] border-0">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
<Badge intent="critical" size="sm">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-2">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<div className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<span>#</span>
|
||||
<span>MMSI</span>
|
||||
<span>선종</span>
|
||||
@ -672,20 +648,20 @@ export function Dashboard() {
|
||||
{TOP_RISK_VESSELS.map((vessel, index) => (
|
||||
<div
|
||||
key={vessel.id}
|
||||
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
>
|
||||
<span className="text-hint text-xs font-bold">#{index + 1}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<PulsingDot color={vessel.risk > 70 ? 'bg-red-500' : vessel.risk > 50 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0">전재</Badge>}
|
||||
{vessel.isDark && <Badge intent="high" size="xs" className="px-1 py-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge intent="warning" size="xs" className="px-1 py-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge intent="purple" size="xs" className="px-1 py-0">전재</Badge>}
|
||||
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
|
||||
</div>
|
||||
<RiskBar value={vessel.risk} />
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GearIdentification } from './GearIdentification';
|
||||
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
||||
import { PieChart as EcPieChart } from '@lib/charts';
|
||||
@ -130,12 +134,8 @@ function CircleGauge({ value, label }: { value: number; label: string }) {
|
||||
}
|
||||
|
||||
function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) {
|
||||
const colors: Record<VesselStatus, { ring: string; bg: string; text: string }> = {
|
||||
'의심': { ring: '#f97316', bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
||||
'양호': { ring: '#10b981', bg: 'bg-green-500/10', text: 'text-green-400' },
|
||||
'경고': { ring: '#ef4444', bg: 'bg-red-500/10', text: 'text-red-400' },
|
||||
};
|
||||
const c = colors[status];
|
||||
const meta = getVesselRingMeta(status);
|
||||
const c = { ring: meta.hex, text: `text-${meta.intent === 'critical' ? 'red' : meta.intent === 'high' ? 'orange' : 'green'}-400` };
|
||||
const circumference = 2 * Math.PI * 18;
|
||||
const offset = circumference - (riskPct / 100) * circumference;
|
||||
|
||||
@ -196,6 +196,8 @@ function TransferView() {
|
||||
// ─── 메인 페이지 ──────────────────────
|
||||
|
||||
export function ChinaFishing() {
|
||||
const { t: tcCommon } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||
@ -294,7 +296,7 @@ export function ChinaFishing() {
|
||||
onClick={() => setMode(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
|
||||
mode === tab.key
|
||||
? 'bg-blue-600 text-heading'
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
@ -501,7 +503,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
|
||||
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>{v.country}</span>
|
||||
@ -567,10 +569,14 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 text-[8px]">
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => (
|
||||
<div key={lv} className="flex items-center gap-1">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[lv].classes.dot}`} />
|
||||
<span className="text-hint">
|
||||
{getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
|
||||
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||
|
||||
@ -62,30 +65,27 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
||||
};
|
||||
}
|
||||
|
||||
const PATTERN_COLORS: Record<string, string> = {
|
||||
'AIS 완전차단': '#ef4444',
|
||||
'MMSI 변조 의심': '#f97316',
|
||||
'장기소실': '#eab308',
|
||||
'신호 간헐송출': '#a855f7',
|
||||
};
|
||||
|
||||
const cols: DataColumn<Suspect>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s === '추적중' ? 'bg-red-500/20 text-red-400' : s === '감시중' ? 'bg-yellow-500/20 text-yellow-400' : s === '확인중' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge className={`border-0 text-[8px] ${l === '불법' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'}`}>{l}</Badge>; } },
|
||||
];
|
||||
|
||||
export function DarkVesselDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
|
||||
], [tc, lang]);
|
||||
|
||||
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||
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() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">탐지 패턴</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(PATTERN_COLORS).map(([p, c]) => (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c }} />
|
||||
<span className="text-[8px] text-muted-foreground">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
|
||||
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||
|
||||
@ -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<string, string> = {
|
||||
'고위험': '#ef4444',
|
||||
'중위험': '#eab308',
|
||||
// 한글 위험도 → AlertLevel hex 매핑
|
||||
const RISK_HEX: Record<string, string> = {
|
||||
'고위험': getAlertLevelHex('CRITICAL'),
|
||||
'중위험': getAlertLevelHex('MEDIUM'),
|
||||
'안전': '#22c55e',
|
||||
};
|
||||
|
||||
@ -50,22 +54,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
||||
};
|
||||
}
|
||||
|
||||
const cols: DataColumn<Gear>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ 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 <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
];
|
||||
|
||||
export function GearDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Gear>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
|
||||
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
|
||||
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ 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 <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||
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)),
|
||||
|
||||
@ -781,7 +781,7 @@ export function GearIdentification() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={runIdentification}
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
어구 국적 판별 실행
|
||||
|
||||
@ -3,6 +3,10 @@ import { Loader2, RefreshCw, MapPin } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* iran 백엔드의 실시간 어구/선단 그룹을 표시.
|
||||
@ -10,19 +14,9 @@ import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
* - 자체 DB의 ParentResolution이 합성되어 있음
|
||||
*/
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
FLEET: 'bg-blue-500/20 text-blue-400',
|
||||
GEAR_IN_ZONE: 'bg-orange-500/20 text-orange-400',
|
||||
GEAR_OUT_ZONE: 'bg-purple-500/20 text-purple-400',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
|
||||
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
|
||||
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
export function RealGearGroups() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [items, setItems] = useState<GearGroupItem[]>([]);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -61,7 +55,7 @@ export function RealGearGroups() {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹 (iran 백엔드)
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨
|
||||
@ -115,7 +109,7 @@ export function RealGearGroups() {
|
||||
{filtered.slice(0, 100).map((g) => (
|
||||
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge className={`${TYPE_COLORS[g.groupType]} border-0 text-[9px]`}>{g.groupType}</Badge>
|
||||
<Badge intent={getGearGroupTypeIntent(g.groupType)} size="sm">{getGearGroupTypeLabel(g.groupType, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
|
||||
@ -126,8 +120,8 @@ export function RealGearGroups() {
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{g.resolution ? (
|
||||
<Badge className={`${STATUS_COLORS[g.resolution.status] || ''} border-0 text-[9px]`}>
|
||||
{g.resolution.status}
|
||||
<Badge intent={getParentResolutionIntent(g.resolution.status)} size="sm">
|
||||
{getParentResolutionLabel(g.resolution.status, tc, lang)}
|
||||
</Badge>
|
||||
) : <span className="text-hint text-[10px]">-</span>}
|
||||
</td>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
@ -20,12 +21,7 @@ interface Props {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-500/20 text-red-400',
|
||||
HIGH: 'bg-orange-500/20 text-orange-400',
|
||||
MEDIUM: 'bg-yellow-500/20 text-yellow-400',
|
||||
LOW: 'bg-blue-500/20 text-blue-400',
|
||||
};
|
||||
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
TERRITORIAL_SEA: '영해',
|
||||
@ -82,7 +78,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
{icon} {title}
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
||||
@ -147,7 +143,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge className={`${RISK_COLORS[v.algorithms.riskScore.level] || ''} border-0 text-[9px]`}>
|
||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||
{v.algorithms.riskScore.level}
|
||||
</Badge>
|
||||
</td>
|
||||
@ -159,7 +155,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.darkVessel.isDark ? (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
<Badge intent="purple" size="sm">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
@ -169,7 +165,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.transship.isSuspect ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{v.algorithms.transship.durationMin}분</Badge>
|
||||
<Badge intent="critical" size="sm">{v.algorithms.transship.durationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
|
||||
import { getEnforcementActionLabel } from '@shared/constants/enforcementActions';
|
||||
import { getEnforcementResultLabel, getEnforcementResultClasses } from '@shared/constants/enforcementResults';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */
|
||||
|
||||
@ -19,86 +24,96 @@ interface Record {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const cols: DataColumn<Record>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
width: '130px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
width: '100px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'action', label: '조치', width: '90px' },
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '결과',
|
||||
width: '80px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const r = v as string;
|
||||
const c =
|
||||
r.includes('처벌') || r.includes('수사')
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: r.includes('오탐')
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-yellow-500/20 text-yellow-400';
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function EnforcementHistory() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { records, loading, error, load } = useEnforcementStore();
|
||||
|
||||
const cols: DataColumn<Record>[] = useMemo(() => [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{formatDateTime(v as string)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
minWidth: '90px',
|
||||
maxWidth: '160px',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '조치',
|
||||
minWidth: '70px',
|
||||
maxWidth: '110px',
|
||||
render: (v) => (
|
||||
<span className="text-label">{getEnforcementActionLabel(v as string, tc, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' || m === 'MATCH' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '결과',
|
||||
minWidth: '80px',
|
||||
maxWidth: '120px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${getEnforcementResultClasses(code)}`}>
|
||||
{getEnforcementResultLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [tc, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@ -115,23 +130,23 @@ export function EnforcementHistory() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
|
||||
{
|
||||
l: '처벌',
|
||||
v: DATA.filter((d) => d.result.includes('처벌')).length,
|
||||
l: '처벌·수사',
|
||||
v: DATA.filter((d) => d.result === 'PUNISHED' || d.result === 'REFERRED').length,
|
||||
c: 'text-red-400',
|
||||
},
|
||||
{
|
||||
l: 'AI 일치',
|
||||
v: DATA.filter((d) => d.aiMatch === '일치').length,
|
||||
v: DATA.filter((d) => d.aiMatch === '일치' || d.aiMatch === 'MATCH').length,
|
||||
c: 'text-green-400',
|
||||
},
|
||||
{
|
||||
l: '오탐',
|
||||
v: DATA.filter((d) => d.result.includes('오탐')).length,
|
||||
v: DATA.filter((d) => d.result === 'FALSE_POSITIVE').length,
|
||||
c: 'text-yellow-400',
|
||||
},
|
||||
].map((k) => (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
@ -8,6 +8,11 @@ import {
|
||||
Filter, Upload, X, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getEventStatusClasses, getEventStatusLabel } from '@shared/constants/eventStatuses';
|
||||
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/*
|
||||
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
||||
@ -15,7 +20,7 @@ import { useEventStore } from '@stores/eventStore';
|
||||
* 실제 백엔드 API 연동
|
||||
*/
|
||||
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
type AlertLevel = AlertLevelType;
|
||||
|
||||
interface EventRow {
|
||||
id: string;
|
||||
@ -33,64 +38,10 @@ interface EventRow {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<AlertLevel, { bg: string; text: string }> = {
|
||||
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400' },
|
||||
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400' },
|
||||
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400' },
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
NEW: 'bg-red-500/20 text-red-400',
|
||||
ACK: 'bg-orange-500/20 text-orange-400',
|
||||
IN_PROGRESS: 'bg-blue-500/20 text-blue-400',
|
||||
RESOLVED: 'bg-green-500/20 text-green-400',
|
||||
FALSE_POSITIVE: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
function statusColor(s: string): string {
|
||||
if (STATUS_COLORS[s]) return STATUS_COLORS[s];
|
||||
if (s === '완료' || s === '확인 완료' || s === '경고 완료') return 'bg-green-500/20 text-green-400';
|
||||
if (s.includes('추적') || s.includes('나포')) return 'bg-red-500/20 text-red-400';
|
||||
if (s.includes('감시') || s.includes('확인')) return 'bg-yellow-500/20 text-yellow-400';
|
||||
return 'bg-blue-500/20 text-blue-400';
|
||||
}
|
||||
|
||||
const columns: DataColumn<EventRow>[] = [
|
||||
{
|
||||
key: 'level', label: '등급', width: '70px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
const s = LEVEL_STYLES[lv];
|
||||
return <Badge className={`border-0 text-[9px] ${s?.bg ?? ''} ${s?.text ?? ''}`}>{lv}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', width: '90px', sortable: true,
|
||||
render: (val) => <span className="text-heading font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'area', label: '해역', width: '90px', sortable: true },
|
||||
{ key: 'speed', label: '속력', width: '60px', align: 'right' },
|
||||
{
|
||||
key: 'status', label: '처리상태', width: '80px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
return <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', width: '70px' },
|
||||
];
|
||||
|
||||
export function EventList() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const {
|
||||
events: storeEvents,
|
||||
stats,
|
||||
@ -100,6 +51,53 @@ export function EventList() {
|
||||
loadStats,
|
||||
} = useEventStore();
|
||||
|
||||
const columns: DataColumn<EventRow>[] = useMemo(() => [
|
||||
{
|
||||
key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
return (
|
||||
<Badge intent={getAlertLevelIntent(lv)} size="sm">
|
||||
{getAlertLevelLabel(lv, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(val as string)}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true,
|
||||
render: (val) => {
|
||||
const code = val as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
|
||||
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
|
||||
{
|
||||
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${getEventStatusClasses(s)}`}>
|
||||
{getEventStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
|
||||
], [tc, lang]);
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
width: '80px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
|
||||
<Badge intent="info" size="sm">{v as string}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@ -6,6 +6,7 @@ import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigat
|
||||
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { formatTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
|
||||
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
||||
|
||||
@ -98,7 +99,7 @@ export function MobileService() {
|
||||
<div className="space-y-1">
|
||||
{ALERTS.slice(0, 2).map((a, i) => (
|
||||
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[a.level as AlertLevel]?.classes.dot ?? 'bg-slate-500'}`} />
|
||||
<span className="text-[8px] text-label truncate">{a.title}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react';
|
||||
import { getDeviceStatusIntent, getDeviceStatusLabel } from '@shared/constants/deviceStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-16: 함정용 단말에서 이용가능한 Agent 개발 */
|
||||
|
||||
@ -15,19 +18,31 @@ const DATA: Agent[] = [
|
||||
{ id: 'AGT-105', ship: '서특단 1정', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:19:55', tasks: 2 },
|
||||
{ id: 'AGT-106', ship: '1503함', version: '-', status: '미배포', sync: '-', lastSync: '-', tasks: 0 },
|
||||
];
|
||||
const cols: DataColumn<Agent>[] = [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '70px' },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s === '온라인' ? 'bg-green-500/20 text-green-400' : s === '오프라인' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
];
|
||||
|
||||
export function ShipAgent() {
|
||||
const { t } = useTranslation('fieldOps');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Agent>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '70px' },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => {
|
||||
const s = v as string;
|
||||
return (
|
||||
<Badge intent={getDeviceStatusIntent(s)} size="sm">
|
||||
{getDeviceStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
|
||||
@ -8,28 +8,21 @@ import { AreaChart, PieChart } from '@lib/charts';
|
||||
import { useKpiStore } from '@stores/kpiStore';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
|
||||
import { getKpiUi } from '@shared/constants/kpiUiMap';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
||||
import { type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { SystemStatusPanel } from './SystemStatusPanel';
|
||||
|
||||
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
||||
|
||||
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
|
||||
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Target, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
// 위반 유형 → 차트 색상 매핑
|
||||
const PIE_COLOR_MAP: Record<string, string> = {
|
||||
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
||||
'불법환적': '#a855f7', '어구 불법': '#6b7280',
|
||||
};
|
||||
const LV: Record<string, string> = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' };
|
||||
|
||||
// KPI_UI_MAP은 shared/constants/kpiUiMap 공통 모듈 사용
|
||||
export function MonitoringDashboard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const kpiStore = useKpiStore();
|
||||
const eventStore = useEventStore();
|
||||
|
||||
@ -67,20 +60,20 @@ export function MonitoringDashboard() {
|
||||
const KPI = kpiStore.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
||||
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
||||
icon: getKpiUi(m.label).icon,
|
||||
color: getKpiUi(m.label).color,
|
||||
}));
|
||||
|
||||
// PIE: store violationTypes → 차트 데이터 변환
|
||||
// PIE: store violationTypes → 공통 카탈로그 기반 라벨/색상
|
||||
const PIE = kpiStore.violationTypes.map((v) => ({
|
||||
name: v.type,
|
||||
name: getViolationLabel(v.type, tc, lang),
|
||||
value: v.pct,
|
||||
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
|
||||
color: getViolationColor(v.type),
|
||||
}));
|
||||
|
||||
// 이벤트: store events → 첫 6개, time 포맷 변환
|
||||
// 이벤트: store events → 첫 6개, time은 KST로 포맷
|
||||
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
|
||||
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||
time: formatDateTime(e.time),
|
||||
level: e.level,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
@ -122,8 +115,10 @@ export function MonitoringDashboard() {
|
||||
<div className="space-y-2">
|
||||
{EVENTS.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-10">{e.time}</span>
|
||||
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</Badge>
|
||||
<span className="text-[10px] text-hint font-mono whitespace-nowrap shrink-0">{e.time}</span>
|
||||
<Badge intent={getAlertLevelIntent(e.level)} size="sm" className="min-w-[52px]">
|
||||
{getAlertLevelLabel(e.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
|
||||
<span className="text-[10px] text-hint">{e.detail}</span>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,9 @@ import {
|
||||
type LabelSession as LabelSessionType,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getLabelSessionIntent, getLabelSessionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 모선 추론 학습 세션 페이지.
|
||||
@ -18,13 +21,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
|
||||
*/
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-500/20 text-green-400',
|
||||
CANCELLED: 'bg-gray-500/20 text-gray-400',
|
||||
COMPLETED: 'bg-blue-500/20 text-blue-400',
|
||||
};
|
||||
|
||||
export function LabelSession() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
|
||||
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
|
||||
@ -162,7 +161,7 @@ export function LabelSession() {
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
|
||||
|
||||
@ -9,6 +9,9 @@ import {
|
||||
type ParentResolution,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 모선 확정/거부/리셋 페이지.
|
||||
@ -17,19 +20,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
* - 모든 액션은 백엔드에서 audit_log + review_log에 기록
|
||||
*/
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
|
||||
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
|
||||
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNRESOLVED: '미해결',
|
||||
MANUAL_CONFIRMED: '확정됨',
|
||||
REVIEW_REQUIRED: '검토필요',
|
||||
};
|
||||
|
||||
export function ParentReview() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { hasPermission } = useAuth();
|
||||
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
|
||||
const [items, setItems] = useState<ParentResolution[]>([]);
|
||||
@ -228,8 +221,8 @@ export function ParentReview() {
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
|
||||
{STATUS_LABELS[it.status] || it.status}
|
||||
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
|
||||
{getParentResolutionLabel(it.status, tc, lang)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
|
||||
@ -109,9 +109,9 @@ export function FleetOptimization() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />시뮬레이션</button>
|
||||
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />시뮬레이션</button>
|
||||
<button onClick={() => setApproved(true)} disabled={!simRunning}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-heading text-[10px] font-bold rounded-lg"><CheckCircle className="w-3 h-3" />최종 승인</button>
|
||||
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"><CheckCircle className="w-3 h-3" />최종 승인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ export function PatrolRoute() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />경로 생성</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />경로 생성</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Share2 className="w-3 h-3" />공유</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +39,7 @@ const cols: DataColumn<Plan>[] = [
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
];
|
||||
|
||||
export function EnforcementPlan() {
|
||||
@ -124,7 +124,7 @@ export function EnforcementPlan() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Shield className="w-5 h-5 text-orange-400" />{t('enforcementPlan.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('enforcementPlan.desc')}</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
</div>
|
||||
|
||||
{/* 로딩/에러 상태 */}
|
||||
@ -153,7 +153,7 @@ export function EnforcementPlan() {
|
||||
<div className="flex gap-4 text-[10px]">
|
||||
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{k}</Badge>
|
||||
<Badge intent="critical" size="sm">{k}</Badge>
|
||||
<span className="text-muted-foreground">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -18,7 +18,7 @@ const cols: DataColumn<Service>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '서비스명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'target', label: '제공 대상', width: '80px', sortable: true },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ key: 'format', label: '포맷', width: '60px', align: 'center' },
|
||||
{ key: 'cycle', label: '갱신주기', width: '70px' },
|
||||
{ key: 'privacy', label: '정보등급', width: '70px', align: 'center',
|
||||
|
||||
@ -37,7 +37,7 @@ export function ReportManagement() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
@ -66,7 +66,7 @@ export function ReportManagement() {
|
||||
>
|
||||
<Upload className="w-3 h-3" />증거 업로드
|
||||
</button>
|
||||
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-heading px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-on-vivid px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<Plus className="w-4 h-4" />새 보고서
|
||||
</button>
|
||||
</div>
|
||||
@ -116,7 +116,7 @@ export function ReportManagement() {
|
||||
</div>
|
||||
<div className="text-[11px] text-hint mt-1">증거 {r.evidence}건</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="bg-blue-600 text-heading text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors">한글</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,7 +131,7 @@ export function ReportManagement() {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-label">보고서 미리보기</div>
|
||||
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-heading px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -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<KpiRow>[] = [
|
||||
width: '60px',
|
||||
align: 'center',
|
||||
render: (v) => (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
|
||||
<Badge intent="success" size="sm">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
@ -69,6 +71,8 @@ const kpiCols: DataColumn<KpiRow>[] = [
|
||||
|
||||
export function Statistics() {
|
||||
const { t } = useTranslation('statistics');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||
@ -205,20 +209,25 @@ export function Statistics() {
|
||||
위반 유형별 분포
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{BY_TYPE.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
|
||||
>
|
||||
<div className="text-lg font-bold text-heading">
|
||||
{item.count}
|
||||
{BY_TYPE.map((item) => {
|
||||
const color = getViolationColor(item.type);
|
||||
const label = getViolationLabel(item.type, tc, lang);
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg border-l-4"
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="text-lg font-bold tabular-nums" style={{ color }}>
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.type}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -14,13 +14,7 @@ import {
|
||||
type PredictionEvent,
|
||||
} from '@/services/event';
|
||||
|
||||
// ─── 위험도 레벨 → 마커 색상 ─────────────────
|
||||
const RISK_MARKER_COLOR: Record<string, string> = {
|
||||
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() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-heading font-medium">AI 판단 근거</span>
|
||||
<Badge className="bg-red-500/20 text-red-400 text-[10px]">신뢰도: High</Badge>
|
||||
<Badge intent="critical" size="md">신뢰도: High</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-2 border-red-500 pl-3">
|
||||
|
||||
@ -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<NtmRecord>[] = [
|
||||
render: v => <Badge className={`border-0 text-[9px] ${v === '발령중' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
|
||||
];
|
||||
|
||||
// ─── 범례 색상 ──────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<string, { bg: string; text: string; label: string; mapColor: string }> = {
|
||||
'해군': { 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<TrainingZone>[] = [
|
||||
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
|
||||
render: v => { const t = TYPE_COLORS[v as string]; return <Badge className={`border-0 text-[9px] ${t?.bg} ${t?.text}`}>{v as string}</Badge>; } },
|
||||
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ key: 'sea', label: '해역', width: '60px', sortable: true },
|
||||
{ key: 'lat', label: '위도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'lng', label: '경도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
@ -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() {
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint font-bold">범례:</span>
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: c.mapColor, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: meta.hex, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 탭 + 해역 필터 */}
|
||||
@ -315,7 +312,7 @@ export function MapControl() {
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
{['', '서해', '남해', '동해', '제주'].map(s => (
|
||||
<button key={s} onClick={() => setSeaFilter(s)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
{s || '전체'}
|
||||
</button>
|
||||
))}
|
||||
@ -346,7 +343,7 @@ export function MapControl() {
|
||||
<span className="text-[10px] text-hint">구분:</span>
|
||||
{NTM_CATEGORIES.map(c => (
|
||||
<button key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
|
||||
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}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -358,7 +355,7 @@ export function MapControl() {
|
||||
<div className="space-y-2">
|
||||
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
|
||||
<div key={n.no} className="flex items-start gap-3 px-3 py-2.5 bg-red-500/5 border border-red-500/10 rounded-lg">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px] shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<Badge intent="critical" size="sm" className="shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-heading font-medium">{n.title}</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{n.detail}</div>
|
||||
@ -397,12 +394,16 @@ export function MapControl() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">훈련구역 범례</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: c.mapColor, borderColor: c.mapColor, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: meta.hex, borderColor: meta.hex, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/50" /><span className="text-[8px] text-hint">EEZ</span></div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
|
||||
|
||||
export function TransferDetection() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading">환적·접촉 탐지</h2>
|
||||
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||
|
||||
@ -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<VesselPermitData | null>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 위험도 레벨 → 색상 매핑 ──────────────
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
||||
@ -280,12 +279,12 @@ export function VesselDetail() {
|
||||
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-hint">위험도</span>
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
{riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
{getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className={`text-xl font-bold ${riskConfig.color}`}>
|
||||
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
|
||||
{Math.round(riskScore * 100)}
|
||||
</span>
|
||||
<span className="text-[10px] text-hint">/100</span>
|
||||
@ -341,15 +340,14 @@ export function VesselDetail() {
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((evt) => {
|
||||
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
|
||||
return (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
|
||||
{evt.level}
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
|
||||
<Badge intent="muted" size="xs" className="px-1.5 py-0">
|
||||
{evt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -378,8 +376,8 @@ export function VesselDetail() {
|
||||
<Ship className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
||||
{vessel && (
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
위험도: {riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
위험도: {getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user