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:
htlee 2026-04-08 10:53:58 +09:00
부모 5812d9dea3
커밋 a07c745cbc
38개의 변경된 파일572개의 추가작업 그리고 641개의 파일을 삭제

파일 보기

@ -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>