diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac7a89f..55015508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ without coverage download or insecure fallback. - Exposed truthful MDK readiness and bathymetry partition-finalization API contracts and packaged all operator scripts in the all-in-one image. +- Provisioned the live Flanders workbench with all 285 current municipalities + and 128,913 VHA profile points across 269 data-bearing partitions; 16 + municipalities are explicitly recorded as having no profiles. +- Made frontend Area loading exhaustive and bounded the Area/Dataset catalogs + with search and pagination so complete regional workspaces remain usable. ## Sprint 235 Governed bathymetry profiles and Belgian scale architecture (2026-07-17) diff --git a/backend/tests/test_sprint236_bathymetry_expansion.py b/backend/tests/test_sprint236_bathymetry_expansion.py index b65b1b2b..8683d94f 100644 --- a/backend/tests/test_sprint236_bathymetry_expansion.py +++ b/backend/tests/test_sprint236_bathymetry_expansion.py @@ -293,3 +293,32 @@ def test_expansion_scripts_are_packaged_and_readiness_checked() -> None: assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only" assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"] + + +def test_frontend_exhaustively_pages_regional_area_inventory() -> None: + area_api = (ROOT / "frontend" / "src" / "services" / "api" / "areas.ts").read_text( + encoding="utf-8" + ) + + assert "const AREA_PAGE_SIZE = 200" in area_api + assert "while (offset < (total ?? 0))" in area_api + assert "items.length !== total" in area_api + assert "list: listProjectAreas" in area_api + + +def test_frontend_bounds_large_area_and_dataset_catalogs() -> None: + area_panel = ( + ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx" + ).read_text(encoding="utf-8") + dataset_panel = ( + ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx" + ).read_text(encoding="utf-8") + + assert "const AREA_CATALOG_PAGE_SIZE = 12" in area_panel + assert "{catalogOpen ? (" in area_panel + assert "visibleAreas.map" in area_panel + assert "Zoek gemeente of regio" in area_panel + assert "const DATASET_CATALOG_PAGE_SIZE = 10" in dataset_panel + assert "visiblePrimaryDatasets.map" in dataset_panel + assert "Zoek in beschikbare bronnen" in dataset_panel + assert "{historyOpen ? dataset.id === selectedDatasetId) const latestDatasetBySeries = new Map() datasets.forEach((dataset) => { @@ -140,6 +145,27 @@ export function DatasetPanel({ const historicalDatasets = datasets.filter( (dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id, ) + const filteredPrimaryDatasets = useMemo(() => { + const query = searchQuery.trim().toLocaleLowerCase('nl-BE') + if (!query) return primaryDatasets + return primaryDatasets.filter((dataset) => { + const searchable = [ + getDatasetDisplayName(dataset), + getDatasetSourceDisplayName(dataset), + dataset.source, + dataset.source_name, + dataset.reference_layer_name, + String(dataset.source_metadata?.['municipality'] ?? ''), + datasetRoleLabel(normalizeDatasetRole(dataset)), + ] + return searchable.some((value) => String(value ?? '').toLocaleLowerCase('nl-BE').includes(query)) + }) + }, [primaryDatasets, searchQuery]) + const pageCount = Math.max(1, Math.ceil(filteredPrimaryDatasets.length / DATASET_CATALOG_PAGE_SIZE)) + const visiblePrimaryDatasets = filteredPrimaryDatasets.slice( + (page - 1) * DATASET_CATALOG_PAGE_SIZE, + page * DATASET_CATALOG_PAGE_SIZE, + ) const readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length const roleSummaries = [ { @@ -172,6 +198,16 @@ export function DatasetPanel({ }, ] + useEffect(() => { + setPage((current) => Math.min(current, pageCount)) + }, [pageCount]) + + useEffect(() => { + setSearchQuery('') + setPage(1) + setHistoryOpen(false) + }, [selectedProjectId]) + return (
@@ -257,8 +293,42 @@ export function DatasetPanel({ ) : null}

Beschikbare bronnen

+
+ +
+ {filteredPrimaryDatasets.length} van {primaryDatasets.length} + + {page} / {pageCount} + +
+
    - {primaryDatasets.map((dataset) => { + {visiblePrimaryDatasets.map((dataset) => { const datasetRole = normalizeDatasetRole(dataset) const roleLabel = datasetRoleLabel(datasetRole) return ( @@ -337,13 +407,19 @@ export function DatasetPanel({ ) })}
+ {visiblePrimaryDatasets.length === 0 && datasets.length > 0 ? ( +

Geen bron gevonden voor deze zoekterm.

+ ) : null} {historicalDatasets.length > 0 ? ( -
+
setHistoryOpen(event.currentTarget.open)} + > Historische meetmomenten {historicalDatasets.length} oudere lagen -
    + {historyOpen ?
      {historicalDatasets.map((dataset) => (
    • @@ -356,7 +432,7 @@ export function DatasetPanel({
    • ))} -
    +
: null}
) : null}
diff --git a/frontend/src/components/project/AreaPanel.tsx b/frontend/src/components/project/AreaPanel.tsx index 04aec770..9af0d6a6 100644 --- a/frontend/src/components/project/AreaPanel.tsx +++ b/frontend/src/components/project/AreaPanel.tsx @@ -1,6 +1,8 @@ -import type { FormEvent } from 'react' +import { useEffect, useMemo, useState, type FormEvent } from 'react' import type { AreaRead, ProjectRead } from '../../types' +const AREA_CATALOG_PAGE_SIZE = 12 + function projectDisplayName(project: ProjectRead | null): string { if (project?.name === 'Kempen Regional Workbench') { return 'Kempen ยท volledige regio' @@ -40,7 +42,30 @@ export function AreaPanel({ onUpdateAreaForm, onSelectMapArea, }: AreaPanelProps): JSX.Element { + const [catalogOpen, setCatalogOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [page, setPage] = useState(1) const selectedArea = areas.find((area) => area.id === selectedMapAreaId) + const filteredAreas = useMemo(() => { + const query = searchQuery.trim().toLocaleLowerCase('nl-BE') + if (!query) return areas + return areas.filter((area) => area.name.toLocaleLowerCase('nl-BE').includes(query)) + }, [areas, searchQuery]) + const pageCount = Math.max(1, Math.ceil(filteredAreas.length / AREA_CATALOG_PAGE_SIZE)) + const visibleAreas = filteredAreas.slice( + (page - 1) * AREA_CATALOG_PAGE_SIZE, + page * AREA_CATALOG_PAGE_SIZE, + ) + + useEffect(() => { + setPage((current) => Math.min(current, pageCount)) + }, [pageCount]) + + useEffect(() => { + setSearchQuery('') + setPage(1) + setCatalogOpen(false) + }, [selectedProjectId]) return (
@@ -94,28 +119,73 @@ export function AreaPanel({ {loadingAreas ?

Gebieden laden...

: null} {areas.length === 0 ?

Nog geen gebieden beschikbaar.

: null} -
+
setCatalogOpen(event.currentTarget.open)} + > {areas.length} beschikbare gemeenten en regiogrenzen -
    - {areas.map((area) => ( -
  • - {area.name} -
    - {area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'} - {area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'} + {catalogOpen ? ( + <> +
    + +
    + {filteredAreas.length} gevonden + + {page} / {pageCount} +
    - -
  • - ))} -
+
+ {visibleAreas.length > 0 ? ( +
    + {visibleAreas.map((area) => ( +
  • + {area.name} +
    + {area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'} + {area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'} +
    + +
  • + ))} +
+ ) : ( +

Geen gebied gevonden voor deze zoekterm.

+ )} + + ) : null}
) diff --git a/frontend/src/services/api/areas.ts b/frontend/src/services/api/areas.ts index cafdec79..853621a9 100644 --- a/frontend/src/services/api/areas.ts +++ b/frontend/src/services/api/areas.ts @@ -1,8 +1,35 @@ import { apiGet, apiPost, apiPatch } from './client' import type { AreaCreate, AreaListResponse, AreaRead } from '../../types' +const AREA_PAGE_SIZE = 200 + +async function listProjectAreas(projectId: string): Promise { + const items: AreaRead[] = [] + let offset = 0 + let total: number | null = null + do { + const page = await apiGet( + `/api/v1/projects/${projectId}/areas?limit=${AREA_PAGE_SIZE}&offset=${offset}`, + ) + if (total === null) { + total = page.total + } else if (page.total !== total) { + throw new Error('De gebiedslijst wijzigde tijdens het laden. Vernieuw de werkruimte.') + } + items.push(...page.items) + if (page.items.length === 0) { + break + } + offset += page.items.length + } while (offset < (total ?? 0)) + if (total !== null && items.length !== total) { + throw new Error(`Niet alle gebieden konden worden geladen (${items.length}/${total}).`) + } + return { items, total: total ?? 0, limit: items.length, offset: 0 } +} + export const areasApi = { - list: (projectId: string): Promise => apiGet(`/api/v1/projects/${projectId}/areas`), + list: listProjectAreas, create: (projectId: string, payload: AreaCreate): Promise => apiPost(`/api/v1/projects/${projectId}/areas`, payload), get: (projectId: string, areaId: string): Promise => diff --git a/frontend/src/styles/premium.css b/frontend/src/styles/premium.css index 51ea62ba..a19c1e49 100644 --- a/frontend/src/styles/premium.css +++ b/frontend/src/styles/premium.css @@ -571,6 +571,54 @@ details.data-panel-form-block > form { background: transparent; } +.catalog-browser-toolbar { + display: grid; + gap: 0.55rem; + padding: 0.65rem; + border-bottom: 1px solid var(--line); + background: #f8fafb; +} + +.catalog-browser-toolbar-datasets { + margin-bottom: 0.65rem; + border: 1px solid var(--line); + border-radius: 6px; +} + +.catalog-search-field { + display: grid; + gap: 0.3rem; + color: #53616d; + font-size: 0.74rem; + font-weight: 750; +} + +.catalog-search-field input { + margin: 0; + background: #ffffff; +} + +.catalog-pagination { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: center; + color: var(--muted); + font-size: 0.76rem; +} + +.catalog-pagination strong { + min-width: 3.5rem; + color: var(--ink); + text-align: center; +} + +.catalog-pagination .secondary-action { + width: auto; + min-height: 2rem; + padding: 0.35rem 0.6rem; +} + /* AI and review workspaces */ .ai-lab-shell {