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:
부모
1940caf73b
커밋
6887a2b4fc
@ -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>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user