-
Selecteer een gebied
+
Baken uw onderzoeksvraag af
{bboxSelectionMode
? 'Sleep nu een rechthoek op de kaart.'
@@ -2640,10 +2640,9 @@ export function MapWorkspace({
aria-live="polite"
>
-
3
Inzichten
-
Alleen gemeten gegevens uit beschikbare bronnen.
+
Gemeten resultaten en beschikbaarheid voor de volledige selectie.
diff --git a/frontend/src/components/map/MunicipalitySearch.test.tsx b/frontend/src/components/map/MunicipalitySearch.test.tsx
index a37e6601..00fdc17c 100644
--- a/frontend/src/components/map/MunicipalitySearch.test.tsx
+++ b/frontend/src/components/map/MunicipalitySearch.test.tsx
@@ -1,32 +1,36 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { areasApi } from '../../services/api/areas'
import { MunicipalitySearch } from './MunicipalitySearch'
import type { AreaRead } from '../../types'
-const areas = [
- { id: 'mol', project_id: 'project', name: 'Gemeente Mol', geometry: { type: 'Polygon', coordinates: [] } },
- { id: 'gent', project_id: 'project', name: 'Gemeente Gent', geometry: { type: 'Polygon', coordinates: [] } },
- { id: 'flanders', project_id: 'project', name: 'Vlaanderen', geometry: { type: 'Polygon', coordinates: [] } },
-] satisfies AreaRead[]
+vi.mock('../../services/api/areas', () => ({
+ areasApi: { searchMunicipalities: vi.fn() },
+}))
describe('MunicipalitySearch', () => {
+ beforeEach(() => {
+ vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
+ items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
+ total: 1,
+ })
+ })
afterEach(() => cleanup())
- it('selects an exact municipality while excluding regional areas', () => {
- const onSelect = vi.fn()
- render(
)
+ it('searches the official municipality catalog and activates a result', async () => {
+ const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
+ const onActivate = vi.fn().mockResolvedValue(activated)
+ render(
)
- const input = screen.getByTestId('municipality-search-input')
- fireEvent.change(input, { target: { value: 'Mol' } })
- fireEvent.click(screen.getByRole('button', { name: 'Gemeente laden' }))
-
- expect(onSelect).toHaveBeenCalledWith('mol')
- expect(screen.getByText('2 gemeenten beschikbaar')).toBeTruthy()
+ fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
+ await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
+ fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
+ await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
})
- it('shows the active municipality without the technical prefix', () => {
- render(
)
- expect(screen.getByDisplayValue('Gent')).toBeTruthy()
- expect(screen.getByText('Gent actief')).toBeTruthy()
+ it('presents municipality search as optional and keeps free selection visible', () => {
+ render(
)
+ expect(screen.getByText('optioneel')).toBeTruthy()
+ expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
})
})
diff --git a/frontend/src/components/map/MunicipalitySearch.tsx b/frontend/src/components/map/MunicipalitySearch.tsx
index e91e5224..9d40a2c7 100644
--- a/frontend/src/components/map/MunicipalitySearch.tsx
+++ b/frontend/src/components/map/MunicipalitySearch.tsx
@@ -1,53 +1,109 @@
-import { useEffect, useMemo, useState, type FormEvent } from 'react'
-import { MapPin, Search } from 'lucide-react'
-import type { AreaRead } from '../../types'
+import { useEffect, useRef, useState } from 'react'
+import { MapPin, Search, X } from 'lucide-react'
+import { areasApi } from '../../services/api/areas'
+import type { AreaRead, MunicipalitySearchItem } from '../../types'
interface MunicipalitySearchProps {
- areas: AreaRead[]
- selectedAreaId: string
+ projectId: string | null
+ activeArea: AreaRead | null
disabled?: boolean
- onSelect: (areaId: string) => void
+ onActivate: (niscode: string) => Promise
}
-function municipalityLabel(area: AreaRead): string {
- return area.name.replace(/^Gemeente\s+/i, '')
+function areaDisplayName(area: AreaRead | null): string | null {
+ return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
}
-export function MunicipalitySearch({ areas, selectedAreaId, disabled = false, onSelect }: MunicipalitySearchProps): JSX.Element {
- const municipalities = useMemo(
- () => areas.filter((area) => /^Gemeente\s/i.test(area.name)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')),
- [areas],
- )
- const selectedArea = municipalities.find((area) => area.id === selectedAreaId) ?? null
- const [query, setQuery] = useState(selectedArea ? municipalityLabel(selectedArea) : '')
- const exactMatch = municipalities.find(
- (area) => municipalityLabel(area).toLocaleLowerCase('nl-BE') === query.trim().toLocaleLowerCase('nl-BE'),
- )
+export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [activatingCode, setActivatingCode] = useState(null)
+ const [error, setError] = useState(null)
+ const searchSequence = useRef(0)
+ const activeMunicipality = areaDisplayName(activeArea)
useEffect(() => {
- if (selectedArea) setQuery(municipalityLabel(selectedArea))
- }, [selectedArea])
+ const normalized = query.trim()
+ const requestId = ++searchSequence.current
+ if (!projectId || normalized.length < 2) {
+ setResults([])
+ setSearching(false)
+ setError(null)
+ return
+ }
+ setSearching(true)
+ setError(null)
+ const timeout = window.setTimeout(() => {
+ void areasApi.searchMunicipalities(projectId, normalized)
+ .then((response) => {
+ if (requestId === searchSequence.current) setResults(response.items)
+ })
+ .catch((caught: unknown) => {
+ if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
+ })
+ .finally(() => {
+ if (requestId === searchSequence.current) setSearching(false)
+ })
+ }, 220)
+ return () => window.clearTimeout(timeout)
+ }, [projectId, query])
- const submit = (event: FormEvent) => {
- event.preventDefault()
- if (exactMatch) onSelect(exactMatch.id)
+ const activate = async (municipality: MunicipalitySearchItem) => {
+ setActivatingCode(municipality.niscode)
+ setError(null)
+ const area = await onActivate(municipality.niscode)
+ if (area) {
+ setQuery('')
+ setResults([])
+ } else {
+ setError('De officiële gemeentegrens kon niet worden geactiveerd.')
+ }
+ setActivatingCode(null)
}
return (
-
+
+
+ {query.trim().length >= 2 ? (
+
+ {searching ?
Gemeenten zoeken…
: null}
+ {!searching && results.length === 0 && !error ?
Geen officiële gemeente gevonden.
: null}
+ {results.map((municipality) => (
+
+ ))}
+ {error ?
{error}
: null}
+
+ ) : null}
+
+
+ Actief werkgebied
+ {activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}
+
+
)
}
diff --git a/frontend/src/hooks/useProjectWorkspace.ts b/frontend/src/hooks/useProjectWorkspace.ts
index 5f432eec..19e8a09d 100644
--- a/frontend/src/hooks/useProjectWorkspace.ts
+++ b/frontend/src/hooks/useProjectWorkspace.ts
@@ -217,6 +217,25 @@ export function useProjectWorkspace() {
}
}
+ const activateMunicipality = async (niscode: string): Promise => {
+ if (!selectedProjectId) {
+ setErrorMessage('Kies eerst een werkruimte')
+ return null
+ }
+ setLoadingAreas(true)
+ setErrorMessage(null)
+ try {
+ const area = await areasApi.activateMunicipality(selectedProjectId, niscode)
+ await loadProjectData(selectedProjectId)
+ return area
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : 'De gemeente kon niet als werkgebied worden geladen')
+ return null
+ } finally {
+ setLoadingAreas(false)
+ }
+ }
+
const archiveProject = async (projectId: string) => {
setArchivingProjectId(projectId)
setErrorMessage(null)
@@ -257,6 +276,7 @@ export function useProjectWorkspace() {
loadProjectData,
createProject,
createArea,
+ activateMunicipality,
archiveProject,
resetProjectData,
setSelectedProjectId,
diff --git a/frontend/src/services/api/areas.ts b/frontend/src/services/api/areas.ts
index 853621a9..9e49397b 100644
--- a/frontend/src/services/api/areas.ts
+++ b/frontend/src/services/api/areas.ts
@@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPatch } from './client'
-import type { AreaCreate, AreaListResponse, AreaRead } from '../../types'
+import type { AreaCreate, AreaListResponse, AreaRead, MunicipalitySearchResponse } from '../../types'
const AREA_PAGE_SIZE = 200
@@ -36,4 +36,8 @@ export const areasApi = {
apiGet(`/api/v1/projects/${projectId}/areas/${areaId}`),
update: (projectId: string, areaId: string, payload: Partial): Promise =>
apiPatch(`/api/v1/projects/${projectId}/areas/${areaId}`, payload),
+ searchMunicipalities: (projectId: string, query: string): Promise =>
+ apiGet(`/api/v1/projects/${projectId}/areas/municipalities?query=${encodeURIComponent(query)}&limit=12`),
+ activateMunicipality: (projectId: string, niscode: string): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/areas/municipalities/${encodeURIComponent(niscode)}/activate`, {}),
}
diff --git a/frontend/src/styles/atlas-premium-v2.css b/frontend/src/styles/atlas-premium-v2.css
index e960c94f..100f54fc 100644
--- a/frontend/src/styles/atlas-premium-v2.css
+++ b/frontend/src/styles/atlas-premium-v2.css
@@ -998,7 +998,7 @@ body {
/* Senior UX audit: compact navigation, safe text containment and task-first map flow. */
@media (min-width: 1361px) {
- .workbench-layout { grid-template-columns: 13.25rem minmax(0, 1fr); }
+ .workbench-layout { grid-template-columns: 11.5rem minmax(0, 1fr); }
}
.workbench-shell :where(.entity-card, .dataset-card, .quality-check-card, .system-provider-card, .data-selection-summary) { min-width: 0; }
@@ -1013,11 +1013,13 @@ body {
.geo-analysis-mode { align-items: stretch; grid-template-columns: repeat(2, minmax(6.5rem, 1fr)); }
.geo-analysis-mode button { display: grid; place-items: center; white-space: nowrap; }
.geo-explorer { grid-template-rows: auto auto minmax(0, 1fr) auto; }
+.geo-explorer-layout { grid-template-columns: 14rem minmax(28rem, 1fr) 19rem; }
.municipality-search {
+ position: relative;
display: grid;
min-width: 0;
- grid-template-columns: minmax(17rem, 0.75fr) minmax(17rem, 1fr) auto auto;
+ grid-template-columns: minmax(16rem, 0.85fr) minmax(18rem, 1.15fr) minmax(10rem, auto);
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid var(--atlas-line);
@@ -1027,13 +1029,33 @@ body {
.municipality-search-copy { display: flex; min-width: 0; gap: 0.7rem; align-items: center; }
.municipality-search-copy > div { display: grid; min-width: 0; gap: 0.12rem; }
.municipality-search-copy strong { font-size: 0.78rem; }
-.municipality-search-copy small { color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
-.municipality-step { display: inline-flex; flex: 0 0 auto; gap: 0.3rem; align-items: center; border-radius: 999px; padding: 0.35rem 0.52rem; background: var(--atlas-900); color: #fff; font-size: 0.57rem; font-weight: 750; text-transform: uppercase; }
-.municipality-step svg { width: 0.8rem; height: 0.8rem; }
-.municipality-search label { position: relative; min-width: 0; }
-.municipality-search label > svg { position: absolute; top: 50%; left: 0.72rem; width: 0.95rem; height: 0.95rem; color: var(--atlas-muted); transform: translateY(-50%); pointer-events: none; }
+.municipality-search-copy strong small { margin-left: 0.35rem; color: var(--atlas-600); font-size: 0.56rem; font-weight: 750; text-transform: uppercase; }
+.municipality-search-copy p { margin: 0; color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
+.municipality-shortcut { display: inline-flex; flex: 0 0 auto; gap: 0.3rem; align-items: center; border-radius: 999px; padding: 0.35rem 0.52rem; background: #e0f3ee; color: var(--atlas-800); font-size: 0.57rem; font-weight: 750; text-transform: uppercase; }
+.municipality-shortcut svg { width: 0.8rem; height: 0.8rem; }
+.municipality-search-control { position: relative; min-width: 0; }
+.municipality-search-control label { position: relative; display: block; min-width: 0; }
+.municipality-search-control label > svg { position: absolute; top: 50%; left: 0.72rem; width: 0.95rem; height: 0.95rem; color: var(--atlas-muted); transform: translateY(-50%); pointer-events: none; }
.municipality-search input { width: 100%; min-width: 0; padding-left: 2.15rem; }
-.municipality-search-status { color: var(--atlas-muted); font-size: 0.62rem; white-space: nowrap; }
+.municipality-search-clear { position: absolute; top: 50%; right: 0.35rem; display: grid; width: 1.8rem; height: 1.8rem; min-height: 0; place-items: center; border: 0; border-radius: 50%; padding: 0; background: transparent; color: var(--atlas-muted); transform: translateY(-50%); }
+.municipality-search-clear svg { width: 0.9rem; height: 0.9rem; }
+.municipality-search-results { position: absolute; z-index: 70; top: calc(100% + 0.35rem); right: 0; left: 0; max-height: 18rem; overflow: auto; border: 1px solid var(--atlas-line-strong); border-radius: 0.75rem; padding: 0.35rem; background: #fff; box-shadow: var(--atlas-shadow-floating); }
+.municipality-search-results > p { margin: 0; padding: 0.75rem; color: var(--atlas-muted); font-size: 0.7rem; }
+.municipality-search-results > button { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; border: 0; border-radius: 0.55rem; padding: 0.65rem 0.7rem; background: transparent; text-align: left; }
+.municipality-search-results > button:hover { background: var(--atlas-50); }
+.municipality-search-results > button span { display: grid; min-width: 0; gap: 0.12rem; }
+.municipality-search-results > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.municipality-search-results > button small { color: var(--atlas-muted); font-size: 0.58rem; }
+.municipality-search-results > button em { flex: 0 0 auto; color: var(--atlas-700); font-size: 0.6rem; font-style: normal; font-weight: 750; }
+.municipality-search-status { display: grid; min-width: 0; gap: 0.15rem; border-left: 1px solid var(--atlas-line); padding-left: 0.85rem; }
+.municipality-search-status span { color: var(--atlas-muted); font-size: 0.56rem; text-transform: uppercase; }
+.municipality-search-status strong { overflow: hidden; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
+
+.geo-panel-heading h3 small { margin-left: 0.35rem; color: var(--atlas-600); font-size: 0.54rem; font-weight: 750; text-transform: uppercase; }
+.geo-theme-option { height: auto; min-height: 4.35rem; align-items: start; }
+.geo-theme-option > span:nth-child(2) { min-width: 0; align-self: start; }
+.geo-theme-option small { display: -webkit-box; overflow: hidden; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.25; }
+.geo-theme-option i { align-self: center; white-space: nowrap; }
.quality-user-empty-state .button-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.9rem; }
.system-command-surface { display: grid; grid-template-columns: repeat(2, minmax(10rem, 0.5fr)) minmax(22rem, 1.4fr); gap: 0.75rem; align-items: center; margin-bottom: 0.9rem; border: 1px solid var(--atlas-line); border-radius: var(--atlas-radius-md); padding: 0.9rem; background: var(--atlas-50); }
@@ -1043,7 +1065,7 @@ body {
.system-command-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; }
@media (max-width: 1180px) and (min-width: 921px) {
- .municipality-search { grid-template-columns: minmax(13rem, 0.8fr) minmax(15rem, 1fr) auto; }
+ .municipality-search { grid-template-columns: minmax(13rem, 0.8fr) minmax(15rem, 1fr); }
.municipality-search-status { display: none; }
}
@media (max-width: 920px) {
@@ -1060,6 +1082,9 @@ body {
.municipality-search { grid-template-columns: 1fr; }
.municipality-search-copy { grid-column: auto; }
.municipality-search button { width: 100%; }
+ .municipality-search-clear { width: 1.8rem; }
+ .geo-theme-option { grid-template-columns: 0.3rem minmax(0, 1fr); min-height: 4rem; }
+ .geo-theme-option i { grid-column: 2; justify-self: start; margin-top: 0.2rem; }
.system-command-surface { grid-template-columns: 1fr; }
.system-command-actions { grid-column: auto; }
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 062622d3..7f56ed98 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -76,6 +76,19 @@ export interface AreaListResponse {
offset: number
}
+export interface MunicipalitySearchItem {
+ niscode: string
+ name: string
+ name_nl?: string | null
+ name_fr?: string | null
+ name_de?: string | null
+}
+
+export interface MunicipalitySearchResponse {
+ items: MunicipalitySearchItem[]
+ total: number
+}
+
export interface DatasetCreateResponse {
id: string
name: string