Merge pull request 'feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)' (#85) from feature/phase0-2-illegal-fishing-pattern into develop

This commit is contained in:
htlee 2026-04-20 05:47:17 +09:00
커밋 f2d145c9a2
10개의 변경된 파일637개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -0,0 +1,40 @@
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
--
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
--
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
'/illegal-fishing', 'nav.illegalFishing',
'features/detection/IllegalFishingPattern', 920,
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -4,6 +4,9 @@
## [Unreleased]
### 추가
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)**`/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
### 수정
- **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소

파일 보기

@ -42,6 +42,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
'features/detection/IllegalFishingPattern': lazy(() =>
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),

파일 보기

@ -0,0 +1,391 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Card, CardContent } from '@shared/components/ui/card';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import {
ILLEGAL_FISHING_CATEGORIES,
listIllegalFishingEvents,
type IllegalFishingCategory,
type IllegalFishingPatternPage,
} from '@/services/illegalFishingPatternApi';
import type { PredictionEvent } from '@/services/event';
import { useSettingsStore } from '@stores/settingsStore';
/**
* event_generator "불법 조업" 3
* .
*
* GEAR_ILLEGAL : G-01 - / G-05 drift / G-06
* EEZ_INTRUSION : 영해 /
* ZONE_DEPARTURE : 특정수역 (risk 40)
*
* (// ) /event-list .
* **READ ** .
*/
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
const DEFAULT_SIZE = 200;
export function IllegalFishingPattern() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [page, setPage] = useState<IllegalFishingPatternPage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [categoryFilter, setCategoryFilter] = useState<IllegalFishingCategory | ''>('');
const [levelFilter, setLevelFilter] = useState<string>('');
const [mmsiFilter, setMmsiFilter] = useState('');
const [selected, setSelected] = useState<PredictionEvent | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await listIllegalFishingEvents({
category: categoryFilter || undefined,
level: levelFilter || undefined,
vesselMmsi: mmsiFilter || undefined,
size: DEFAULT_SIZE,
});
setPage(result);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
} finally {
setLoading(false);
}
}, [categoryFilter, levelFilter, mmsiFilter, t]);
useEffect(() => {
loadData();
}, [loadData]);
const rows = page?.content ?? [];
const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
const cols: DataColumn<PredictionEvent & Record<string, unknown>>[] = useMemo(
() => [
{
key: 'occurredAt',
label: t('illegalPattern.columns.occurredAt'),
width: '140px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
{
key: 'level',
label: t('illegalPattern.columns.level'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'category',
label: t('illegalPattern.columns.category'),
width: '130px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent="info" size="sm">
{t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
</Badge>
),
},
{
key: 'title',
label: t('illegalPattern.columns.title'),
minWidth: '260px',
render: (v) => <span className="text-label">{v as string}</span>,
},
{
key: 'vesselMmsi',
label: t('illegalPattern.columns.mmsi'),
width: '110px',
render: (v, row) => (
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
{(v as string) || '-'}
{row.vesselName ? <span className="text-hint ml-1">({row.vesselName})</span> : null}
</span>
),
},
{
key: 'zoneCode',
label: t('illegalPattern.columns.zone'),
width: '130px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
{
key: 'status',
label: t('illegalPattern.columns.status'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={(v as string) === 'NEW' ? 'warning' : 'muted'} size="sm">
{t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
</Badge>
),
},
],
[t, tc, lang],
);
return (
<PageContainer>
<PageHeader
icon={Ban}
iconColor="text-red-600 dark:text-red-400"
title={t('illegalPattern.title')}
description={t('illegalPattern.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('illegalPattern.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('illegalPattern.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('illegalPattern.stats.total')} value={page?.content.length ?? 0} />
<StatCard
label={getAlertLevelLabel('CRITICAL', tc, lang)}
value={levelCount('CRITICAL')}
intent="critical"
/>
<StatCard
label={getAlertLevelLabel('HIGH', tc, lang)}
value={levelCount('HIGH')}
intent="warning"
/>
<StatCard
label={getAlertLevelLabel('MEDIUM', tc, lang)}
value={levelCount('MEDIUM')}
intent="info"
/>
<StatCard
label={getAlertLevelLabel('LOW', tc, lang)}
value={levelCount('LOW')}
intent="muted"
/>
</div>
</Section>
<Section title={t('illegalPattern.byCategory.title')}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{ILLEGAL_FISHING_CATEGORIES.map((code) => (
<Card key={code} variant="default">
<CardContent className="py-2 px-3 flex items-center justify-between">
<div>
<div className="text-xs text-heading">
{t(`illegalPattern.category.${code}`)}
</div>
<div className="text-[10px] text-hint">
{t(`illegalPattern.categoryDesc.${code}`)}
</div>
</div>
<div className="text-lg font-bold text-heading">{categoryCount(code)}</div>
</CardContent>
</Card>
))}
</div>
</Section>
<Section title={t('illegalPattern.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('illegalPattern.filters.category')}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as IllegalFishingCategory | '')}
>
<option value="">{t('illegalPattern.filters.allCategory')}</option>
{ILLEGAL_FISHING_CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`illegalPattern.category.${c}`)}
</option>
))}
</Select>
<Select
aria-label={t('illegalPattern.filters.level')}
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
>
<option value="">{t('illegalPattern.filters.allLevel')}</option>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{getAlertLevelLabel(l, tc, lang)}
</option>
))}
</Select>
<Input
aria-label={t('illegalPattern.filters.mmsi')}
placeholder={t('illegalPattern.filters.mmsi')}
value={mmsiFilter}
onChange={(e) => setMmsiFilter(e.target.value)}
/>
<div className="flex items-center justify-end gap-1.5">
<Badge intent="info" size="sm">
{t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
</Badge>
</div>
</div>
{rows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">{t('illegalPattern.list.empty')}</p>
) : (
<DataTable
data={rows as (PredictionEvent & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as PredictionEvent)}
/>
)}
</Section>
{selected && (
<Section title={t('illegalPattern.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
<DetailRow
label={t('illegalPattern.columns.occurredAt')}
value={formatDateTime(selected.occurredAt)}
/>
<DetailRow label={t('illegalPattern.columns.category')} value={selected.category} />
<DetailRow label={t('illegalPattern.columns.level')} value={selected.level} />
<DetailRow label={t('illegalPattern.columns.title')} value={selected.title} />
<DetailRow
label={t('illegalPattern.columns.mmsi')}
value={selected.vesselMmsi ?? '-'}
mono
/>
<DetailRow
label={t('illegalPattern.detail.vesselName')}
value={selected.vesselName ?? '-'}
/>
<DetailRow
label={t('illegalPattern.columns.zone')}
value={selected.zoneCode ?? '-'}
/>
<DetailRow
label={t('illegalPattern.detail.location')}
value={
selected.lat != null && selected.lon != null
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
: '-'
}
/>
<DetailRow label={t('illegalPattern.columns.status')} value={selected.status} />
</div>
<div className="space-y-2">
{selected.detail && (
<p className="text-xs text-label border-l-2 border-border pl-2">
{selected.detail}
</p>
)}
{selected.features && Object.keys(selected.features).length > 0 && (
<div>
<div className="text-[10px] text-hint mb-1">
{t('illegalPattern.detail.features')}
</div>
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
{JSON.stringify(selected.features, null, 2)}
</pre>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
{t('illegalPattern.detail.close')}
</Button>
<Button
variant="primary"
size="sm"
icon={<ExternalLink className="w-3.5 h-3.5" />}
onClick={() => {
window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
}}
>
{t('illegalPattern.detail.openEventList')}
</Button>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-start gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default IllegalFishingPattern;

파일 보기

@ -3,3 +3,4 @@ export { GearDetection } from './GearDetection';
export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';
export { IllegalFishingPattern } from './IllegalFishingPattern';

파일 보기

@ -9,6 +9,7 @@
"gearDetection": "Gear Detection",
"chinaFishing": "Chinese Vessel",
"gearCollision": "Gear Collision",
"illegalFishing": "Illegal Fishing",
"patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History",

파일 보기

@ -15,6 +15,66 @@
"title": "Gear Identification",
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
},
"illegalPattern": {
"title": "Illegal Fishing Events",
"desc": "Integrated view of illegal fishingrelated events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)",
"refresh": "Refresh",
"stats": {
"title": "Severity distribution",
"total": "Total"
},
"byCategory": {
"title": "By category"
},
"category": {
"GEAR_ILLEGAL": "Gear Violation",
"EEZ_INTRUSION": "Territorial/Contiguous",
"ZONE_DEPARTURE": "Protected Zone Entry"
},
"categoryDesc": {
"GEAR_ILLEGAL": "G-01/G-05/G-06 zone-gear mismatch, fixed-gear drift, pair trawl",
"EEZ_INTRUSION": "Territorial sea (CRITICAL) / Contiguous zone high-risk",
"ZONE_DEPARTURE": "Protected zone entry with risk ≥ 40"
},
"list": {
"title": "Events",
"empty": "No illegal fishing events match the filters."
},
"columns": {
"occurredAt": "Occurred",
"level": "Level",
"category": "Category",
"title": "Title",
"mmsi": "MMSI",
"zone": "Zone",
"status": "Status"
},
"filters": {
"category": "Category",
"level": "Level",
"mmsi": "MMSI",
"limit": "Limit",
"allCategory": "All categories",
"allLevel": "All levels"
},
"status": {
"NEW": "New",
"ACKED": "Acked",
"RESOLVED": "Resolved",
"FALSE_ALARM": "False alarm"
},
"detail": {
"title": "Event detail",
"vesselName": "Vessel name",
"location": "Location",
"features": "Extra info",
"close": "Close",
"openEventList": "Open in Event List"
},
"error": {
"loadFailed": "Failed to load events."
}
},
"gearCollision": {
"title": "Gear Identity Collision",
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",

파일 보기

@ -9,6 +9,7 @@
"gearDetection": "어구 탐지",
"chinaFishing": "중국어선 분석",
"gearCollision": "어구 정체성 충돌",
"illegalFishing": "불법 조업 이벤트",
"patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력",

파일 보기

@ -15,6 +15,66 @@
"title": "어구 식별 분석",
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
},
"illegalPattern": {
"title": "불법 조업 이벤트",
"desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",
"refresh": "새로고침",
"stats": {
"title": "심각도 분포",
"total": "전체"
},
"byCategory": {
"title": "카테고리별 건수"
},
"category": {
"GEAR_ILLEGAL": "어구 위반",
"EEZ_INTRUSION": "영해/접속수역 침범",
"ZONE_DEPARTURE": "특정수역 진입"
},
"categoryDesc": {
"GEAR_ILLEGAL": "G-01/G-05/G-06 수역·어구 불일치, 고정어구 drift, 쌍끌이 공조",
"EEZ_INTRUSION": "영해(CRITICAL) / 접속수역 + 고위험 위반",
"ZONE_DEPARTURE": "관심 수역(ZONE_I~IV) 진입 + 위험도 40+"
},
"list": {
"title": "이벤트 목록",
"empty": "조건에 맞는 불법 조업 이벤트가 없습니다."
},
"columns": {
"occurredAt": "발생 시각",
"level": "심각도",
"category": "카테고리",
"title": "제목",
"mmsi": "MMSI",
"zone": "수역",
"status": "상태"
},
"filters": {
"category": "카테고리",
"level": "심각도",
"mmsi": "MMSI 검색",
"limit": "최대",
"allCategory": "전체 카테고리",
"allLevel": "전체 심각도"
},
"status": {
"NEW": "신규",
"ACKED": "확인",
"RESOLVED": "처리완료",
"FALSE_ALARM": "오탐"
},
"detail": {
"title": "이벤트 상세",
"vesselName": "선박명",
"location": "좌표",
"features": "추가 정보",
"close": "닫기",
"openEventList": "이벤트 목록에서 열기"
},
"error": {
"loadFailed": "이벤트를 불러오지 못했습니다."
}
},
"gearCollision": {
"title": "어구 정체성 충돌 탐지",
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",

파일 보기

@ -0,0 +1,77 @@
/**
* /api/events category .
*
* category event_generator rule , UI "불법 조업"
* :
* - GEAR_ILLEGAL : G-01 - / G-05 drift / G-06
* - EEZ_INTRUSION : 영해 / +
* - ZONE_DEPARTURE : 특정수역 ( )
*
* backend .
*/
import { getEvents, type EventPageResponse, type PredictionEvent } from './event';
export const ILLEGAL_FISHING_CATEGORIES = [
'GEAR_ILLEGAL',
'EEZ_INTRUSION',
'ZONE_DEPARTURE',
] as const;
export type IllegalFishingCategory = (typeof ILLEGAL_FISHING_CATEGORIES)[number];
export interface ListParams {
/** 단일 카테고리를 지정하면 해당 카테고리만, '' 이면 3개 모두 병합 조회 */
category?: IllegalFishingCategory | '';
level?: string;
status?: string;
vesselMmsi?: string;
size?: number;
}
export interface IllegalFishingPatternPage {
content: PredictionEvent[];
totalElements: number;
byCategory: Record<string, number>;
byLevel: Record<string, number>;
}
/**
* category 3 occurredAt desc .
* size , 200 * 3 = 600 .
*/
export async function listIllegalFishingEvents(params?: ListParams): Promise<IllegalFishingPatternPage> {
const size = params?.size ?? 200;
const targetCategories: IllegalFishingCategory[] = params?.category
? [params.category]
: [...ILLEGAL_FISHING_CATEGORIES];
const pages: EventPageResponse[] = await Promise.all(
targetCategories.map((category) =>
getEvents({
category,
level: params?.level,
status: params?.status,
vesselMmsi: params?.vesselMmsi,
page: 0,
size,
}),
),
);
const allEvents: PredictionEvent[] = pages.flatMap((p) => p.content);
allEvents.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const byCategory: Record<string, number> = {};
const byLevel: Record<string, number> = {};
for (const e of allEvents) {
byCategory[e.category] = (byCategory[e.category] ?? 0) + 1;
byLevel[e.level] = (byLevel[e.level] ?? 0) + 1;
}
return {
content: allEvents,
totalElements: pages.reduce((acc, p) => acc + p.totalElements, 0),
byCategory,
byLevel,
};
}