feat(frontend): 워크플로우 연결 Step 4 — Enforcement 연계 + admin 서브그룹

EnforcementHistory:
- eventId 역추적 컬럼 추가 (#{eventId} 클릭 → EventList 이동)
- Record 인터페이스에 eventId 필드 추가

EnforcementPlan:
- 미배정 CRITICAL 이벤트 패널 신설 (NEW 상태 CRITICAL 이벤트 표시)
- getEvents(level=CRITICAL, status=NEW) 연동

MainLayout:
- admin 메뉴 4개 서브그룹 분리 (AI 플랫폼/시스템 운영/사용자 관리/감사·보안)
- NavDivider 타입 도입으로 그룹 내 소제목 라벨 렌더링
- 기존 RBAC 필터링 + collapsed 모드 호환 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 11:32:03 +09:00
부모 1940caf73b
커밋 6887a2b4fc
3개의 변경된 파일91개의 추가작업 그리고 16개의 파일을 삭제

파일 보기

@ -35,10 +35,12 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
};
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
interface NavDivider { dividerLabel: string; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavDivider)[]; }
type NavEntry = NavItem | NavGroup;
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
const isDivider = (item: NavItem | NavDivider): item is NavDivider => 'dividerLabel' in item;
const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
@ -82,16 +84,20 @@ const NAV_ENTRIES: NavEntry[] = [
{
groupKey: 'group.admin', icon: Settings,
items: [
{ dividerLabel: 'AI 플랫폼' },
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
{ to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' },
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
{ dividerLabel: '시스템 운영' },
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
{ dividerLabel: '사용자 관리' },
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ dividerLabel: '감사·보안' },
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
@ -99,8 +105,10 @@ const NAV_ENTRIES: NavEntry[] = [
},
];
// getPageLabel용 flat 목록
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
// getPageLabel용 flat 목록 (divider 제외)
const NAV_ITEMS = NAV_ENTRIES.flatMap(e =>
isGroup(e) ? e.items.filter((i): i is NavItem => !isDivider(i)) : [e]
);
function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60);
@ -255,11 +263,12 @@ export function MainLayout() {
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{NAV_ENTRIES.map((entry) => {
if (isGroup(entry)) {
// 그룹 내 RBAC 필터링
const groupItems = entry.items.filter((item) => hasAccess(item.to));
if (groupItems.length === 0) return null;
// 그룹 내 RBAC 필터링 (divider는 유지)
const navItems = entry.items.filter((item): item is NavItem => !isDivider(item));
const accessibleItems = navItems.filter((item) => hasAccess(item.to));
if (accessibleItems.length === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
const isAnyActive = accessibleItems.some((item) => location.pathname.startsWith(item.to));
return (
<div key={entry.groupKey}>
{/* 그룹 헤더 */}
@ -282,7 +291,17 @@ export function MainLayout() {
{/* 그룹 하위 메뉴 */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{groupItems.map((item) => (
{entry.items.map((item, idx) => {
if (isDivider(item)) {
if (collapsed) return null;
return (
<div key={`div-${idx}`} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{item.dividerLabel}</span>
</div>
);
}
if (!hasAccess(item.to)) return null;
return (
<NavLink
key={item.to}
to={item.to}
@ -297,7 +316,8 @@ export function MainLayout() {
<item.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
</NavLink>
))}
);
})}
</div>
)}
</div>

파일 보기

@ -20,6 +20,7 @@ interface Record {
zone: string;
vessel: string;
mmsi: string;
eventId: number | null;
violation: string;
action: string;
aiMatch: string;
@ -73,6 +74,23 @@ export function EnforcementHistory() {
return <span className="text-cyan-400 font-medium">{vessel}</span>;
},
},
{
key: 'eventId',
label: '이벤트',
width: '70px',
render: (_v, row) => {
const eid = row.eventId;
if (!eid) return <span className="text-hint">-</span>;
return (
<button type="button"
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/events?id=${eid}`); }}
title={`이벤트 #${eid}`}>
#{eid}
</button>
);
},
},
{
key: 'violation',
label: '위반 내용',
@ -136,6 +154,7 @@ export function EnforcementHistory() {
const DATA: Record[] = records.map((r, idx) => ({
...r,
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
eventId: rawRecords[idx]?.eventId ?? null,
})) as Record[];
return (

파일 보기

@ -6,10 +6,14 @@ import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
@ -47,18 +51,25 @@ const cols: DataColumn<Plan>[] = [
export function EnforcementPlan() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [plans, setPlans] = useState<Plan[]>([]);
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
getEnforcementPlans({ size: 100 })
.then((res) => {
Promise.all([
getEnforcementPlans({ size: 100 }),
getEvents({ level: 'CRITICAL', status: 'NEW', size: 20 }).catch(() => null),
])
.then(([planRes, evtRes]) => {
if (!cancelled) {
setPlans(res.content.map(toPlan));
setPlans(planRes.content.map(toPlan));
setCriticalEvents(evtRes?.content ?? []);
setLoading(false);
}
})
@ -154,6 +165,31 @@ export function EnforcementPlan() {
</div>
))}
</div>
{/* 미배정 CRITICAL 이벤트 */}
{criticalEvents.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> CRITICAL </span>
<Badge intent="critical" size="xs">{criticalEvents.length}</Badge>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{criticalEvents.map((evt) => (
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
<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>
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>