feat: make spatial entry optional and authoritative
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import Area
|
from app.models import Area
|
||||||
from app.schemas import Envelope
|
from app.schemas import Envelope
|
||||||
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
|
||||||
from app.services.area_service import AreaService
|
from app.services.area_service import AreaService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
@@ -33,6 +33,23 @@ def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get
|
|||||||
return envelope(AreaService.serialize_area(area))
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
|
||||||
|
def search_municipalities(
|
||||||
|
project_id: UUID,
|
||||||
|
query: str = Query(default="", max_length=120),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items, total = AreaService.search_municipalities(db, project_id, query, limit)
|
||||||
|
return envelope({"items": items, "total": total})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
|
||||||
|
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
|
||||||
|
area = AreaService.activate_municipality(db, project_id, niscode)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
def get_area(
|
def get_area(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
|
|||||||
@@ -39,3 +39,16 @@ class AreaList(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalitySearchItem(BaseModel):
|
||||||
|
niscode: str
|
||||||
|
name: str
|
||||||
|
name_nl: str | None = None
|
||||||
|
name_fr: str | None = None
|
||||||
|
name_de: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalitySearchList(BaseModel):
|
||||||
|
items: list[MunicipalitySearchItem]
|
||||||
|
total: int
|
||||||
|
|||||||
@@ -7,12 +7,90 @@ from geoalchemy2.shape import from_shape, to_shape
|
|||||||
from shapely.geometry import mapping
|
from shapely.geometry import mapping
|
||||||
|
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models import Area, Project
|
from app.models import Area, Dataset, Project, VectorFeature
|
||||||
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
|
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
|
||||||
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
|
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
|
||||||
|
|
||||||
|
|
||||||
class AreaService:
|
class AreaService:
|
||||||
|
@staticmethod
|
||||||
|
def _municipality_dataset(db: Session, project_id: uuid.UUID) -> Dataset | None:
|
||||||
|
return (
|
||||||
|
db.query(Dataset)
|
||||||
|
.filter(
|
||||||
|
Dataset.project_id == project_id,
|
||||||
|
Dataset.reference_layer_name == "belgium_municipalities",
|
||||||
|
Dataset.status == "ready",
|
||||||
|
)
|
||||||
|
.order_by(Dataset.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_municipality_properties(properties_items: list[dict], query: str, limit: int) -> tuple[list[dict], int]:
|
||||||
|
normalized = query.strip().casefold()
|
||||||
|
matches: list[dict] = []
|
||||||
|
for properties in properties_items:
|
||||||
|
names = [str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger")]
|
||||||
|
niscode = str(properties.get("niscode") or "").strip()
|
||||||
|
if normalized and normalized not in " ".join([niscode, *names]).casefold():
|
||||||
|
continue
|
||||||
|
display_name = next((name for name in names if name), niscode)
|
||||||
|
matches.append({
|
||||||
|
"niscode": niscode,
|
||||||
|
"name": display_name,
|
||||||
|
"name_nl": names[0] or None,
|
||||||
|
"name_fr": names[1] or None,
|
||||||
|
"name_de": names[2] or None,
|
||||||
|
})
|
||||||
|
matches.sort(key=lambda item: (item["name"].casefold(), item["niscode"]))
|
||||||
|
return matches[:limit], len(matches)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_municipalities(db: Session, project_id: uuid.UUID, query: str, limit: int = 20) -> tuple[list[dict], int]:
|
||||||
|
dataset = AreaService._municipality_dataset(db, project_id)
|
||||||
|
if dataset is None:
|
||||||
|
return [], 0
|
||||||
|
property_rows = (
|
||||||
|
db.query(VectorFeature.properties_json)
|
||||||
|
.filter(VectorFeature.dataset_id == dataset.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
properties_items = [row[0] for row in property_rows if isinstance(row[0], dict)]
|
||||||
|
return AreaService._filter_municipality_properties(properties_items, query, limit)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def activate_municipality(db: Session, project_id: uuid.UUID, niscode: str) -> Area:
|
||||||
|
normalized_code = niscode.strip()
|
||||||
|
dataset = AreaService._municipality_dataset(db, project_id)
|
||||||
|
if dataset is None:
|
||||||
|
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
|
||||||
|
feature = (
|
||||||
|
db.query(VectorFeature)
|
||||||
|
.filter(
|
||||||
|
VectorFeature.dataset_id == dataset.id,
|
||||||
|
VectorFeature.properties_json["niscode"].as_string() == normalized_code,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if feature is not None:
|
||||||
|
properties = feature.properties_json if isinstance(feature.properties_json, dict) else {}
|
||||||
|
display_name = next(
|
||||||
|
(str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger") if str(properties.get(key) or "").strip()),
|
||||||
|
normalized_code,
|
||||||
|
)
|
||||||
|
area_name = f"Gemeente {display_name} - NIS {normalized_code}"
|
||||||
|
existing = db.query(Area).filter(Area.project_id == project_id, Area.name == area_name).first()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
geometry = to_shape(feature.geometry)
|
||||||
|
return AreaService.create_area(
|
||||||
|
db,
|
||||||
|
project_id,
|
||||||
|
AreaCreate(name=area_name, geometry=mapping(geometry), crs="EPSG:4326"),
|
||||||
|
)
|
||||||
|
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_area(area: Area) -> dict:
|
def serialize_area(area: Area) -> dict:
|
||||||
geometry = to_shape(area.geometry) if area.geometry else None
|
geometry = to_shape(area.geometry) if area.geometry else None
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import create_app
|
||||||
|
from app.services.area_service import AreaService
|
||||||
|
|
||||||
|
|
||||||
|
def test_municipality_service_matches_all_official_names_and_nis(monkeypatch):
|
||||||
|
records = [
|
||||||
|
{"niscode": "63004", "namedut": "Baelen", "namefre": "Baelen", "nameger": "Balen"},
|
||||||
|
{"niscode": "13025", "namedut": "Mol", "namefre": "Mol", "nameger": "Mol"},
|
||||||
|
]
|
||||||
|
|
||||||
|
by_german_name, name_total = AreaService._filter_municipality_properties(records, "Balen", 20)
|
||||||
|
by_nis, nis_total = AreaService._filter_municipality_properties(records, "13025", 20)
|
||||||
|
|
||||||
|
assert name_total == 1
|
||||||
|
assert by_german_name[0]["niscode"] == "63004"
|
||||||
|
assert nis_total == 1
|
||||||
|
assert by_nis[0]["name"] == "Mol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_municipality_search_uses_authoritative_catalog(monkeypatch):
|
||||||
|
project_id = uuid4()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
AreaService,
|
||||||
|
"search_municipalities",
|
||||||
|
staticmethod(lambda _db, requested_project_id, query, limit: (
|
||||||
|
[{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
|
||||||
|
1,
|
||||||
|
) if requested_project_id == project_id and query == "mol" and limit == 12 else ([], 0)),
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_db] = lambda: object()
|
||||||
|
try:
|
||||||
|
response = TestClient(app).get(f"/api/v1/projects/{project_id}/areas/municipalities?query=mol&limit=12")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["data"] == {
|
||||||
|
"items": [{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_municipality_activation_returns_persisted_area(monkeypatch):
|
||||||
|
project_id = uuid4()
|
||||||
|
area_id = uuid4()
|
||||||
|
area = SimpleNamespace(id=area_id, project_id=project_id)
|
||||||
|
monkeypatch.setattr(AreaService, "activate_municipality", staticmethod(lambda _db, requested_project_id, niscode: area))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
AreaService,
|
||||||
|
"serialize_area",
|
||||||
|
staticmethod(lambda _area: {
|
||||||
|
"id": area_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"name": "Gemeente Mol - NIS 13025",
|
||||||
|
"original_crs": "EPSG:4326",
|
||||||
|
"area_m2": 114000000.0,
|
||||||
|
"created_at": datetime(2026, 7, 26, tzinfo=timezone.utc),
|
||||||
|
"geometry_type": "MultiPolygon",
|
||||||
|
"geometry": {"type": "MultiPolygon", "coordinates": []},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_db] = lambda: object()
|
||||||
|
try:
|
||||||
|
response = TestClient(app).post(f"/api/v1/projects/{project_id}/areas/municipalities/13025/activate", json={})
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["data"]["id"] == str(area_id)
|
||||||
|
assert response.json()["data"]["name"] == "Gemeente Mol - NIS 13025"
|
||||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 140 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||||
<title id="title">GeoIntel Atlas</title>
|
<title id="title">GeoIntel</title>
|
||||||
<desc id="desc">Een geografische lens met contourlijnen, kompasrichting en locatiepunt.</desc>
|
<desc id="desc">Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt.</desc>
|
||||||
<defs><linearGradient id="bg" x1="30" y1="18" x2="222" y2="238" gradientUnits="userSpaceOnUse"><stop stop-color="#10564E"/><stop offset="1" stop-color="#062824"/></linearGradient><linearGradient id="land" x1="69" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse"><stop stop-color="#D9FFF7"/><stop offset="1" stop-color="#62D5C0"/></linearGradient><filter id="shadow" x="-25%" y="-25%" width="150%" height="150%"><feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#001C19" flood-opacity=".35"/></filter></defs>
|
<defs><linearGradient id="surface" x1="28" y1="20" x2="226" y2="236" gradientUnits="userSpaceOnUse"><stop stop-color="#123F3B"/><stop offset="1" stop-color="#061F20"/></linearGradient><linearGradient id="geo" x1="68" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse"><stop stop-color="#B9FFF0"/><stop offset="1" stop-color="#48C9B1"/></linearGradient><filter id="lift" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#001615" flood-opacity=".45"/></filter></defs>
|
||||||
<rect width="256" height="256" rx="56" fill="url(#bg)"/><path d="M26 76C63 57 92 68 120 56s59-33 110-14M20 116c41-17 70-9 101-22s68-29 116-9M20 158c38-16 73-6 108-21s67-22 109-7M33 202c40-16 65-5 99-18s57-17 90-8" fill="none" stroke="#A9EBDD" stroke-opacity=".16" stroke-width="8" stroke-linecap="round"/><g filter="url(#shadow)"><path d="M128 35c51 0 93 41 93 93 0 51-42 93-93 93s-93-42-93-93c0-52 42-93 93-93Z" fill="#082F2B" stroke="#86E4D2" stroke-width="9"/><path d="M128 58c38 0 70 31 70 70 0 38-32 70-70 70s-70-32-70-70c0-39 32-70 70-70Z" fill="none" stroke="#D8FFF7" stroke-opacity=".22" stroke-width="5" stroke-dasharray="2 12" stroke-linecap="round"/><path d="M86 156c18-8 29-4 44-11 16-8 24-22 43-23M79 135c19-8 33-4 48-12 16-8 24-21 42-24M83 113c13-6 27-4 39-10 15-8 25-19 38-22" fill="none" stroke="url(#land)" stroke-width="8" stroke-linecap="round"/><path d="M128 72v14M128 170v14M72 128h14M170 128h14" stroke="#E9FFF9" stroke-width="7" stroke-linecap="round"/><path d="m128 92 12 25 25 11-25 12-12 25-12-25-25-12 25-11 12-25Z" fill="#EFCB72" stroke="#FFF5CE" stroke-width="4" stroke-linejoin="round"/><circle cx="128" cy="128" r="9" fill="#082F2B" stroke="#FFF" stroke-width="4"/></g>
|
<rect width="256" height="256" rx="54" fill="url(#surface)"/><path d="M28 74c36-17 62-9 91-20 32-12 61-27 108-13M20 117c39-17 69-8 100-21 33-13 65-24 116-7M20 163c42-17 72-7 105-19 31-12 59-20 111-5M31 207c37-14 66-5 96-16 31-12 56-17 96-7" fill="none" stroke="#A6E8DC" stroke-opacity=".10" stroke-width="7" stroke-linecap="round"/><g filter="url(#lift)"><path d="M179 78a72 72 0 1 0 9 83" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round"/><path d="M137 128h62v50" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round" stroke-linejoin="round"/><circle cx="128" cy="128" r="23" fill="#082927" stroke="#DFFFF8" stroke-width="7"/><circle cx="128" cy="128" r="10" fill="#F2C864"/></g><path d="M203 52v18M194 61h18" stroke="#DFFFF8" stroke-opacity=".55" stroke-width="4" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -262,6 +262,19 @@ area list endpoint and includes persisted GeoJSON geometry for map display.
|
|||||||
Updates the area name and/or geometry. Geometry updates follow the same
|
Updates the area name and/or geometry. Geometry updates follow the same
|
||||||
validation, repair and metric-calculation rules as area creation.
|
validation, repair and metric-calculation rules as area creation.
|
||||||
|
|
||||||
|
### GET `/api/v1/projects/{project_id}/areas/municipalities`
|
||||||
|
|
||||||
|
Searches the persisted authoritative NGI AdminVector municipality layer by
|
||||||
|
Dutch, French or German name and NIS code. `query` is optional and `limit` is
|
||||||
|
bounded to 50. Results contain names and NIS identity but no fabricated or
|
||||||
|
browser-fetched geometry.
|
||||||
|
|
||||||
|
### POST `/api/v1/projects/{project_id}/areas/municipalities/{niscode}/activate`
|
||||||
|
|
||||||
|
Idempotently creates or returns a project Area from the exact persisted NGI
|
||||||
|
municipality geometry. The resulting Area can be used by all existing bounded
|
||||||
|
selection, acquisition, analysis and export contracts.
|
||||||
|
|
||||||
## Datasets
|
## Datasets
|
||||||
|
|
||||||
### POST `/api/v1/projects/{project_id}/datasets/upload`
|
### POST `/api/v1/projects/{project_id}/datasets/upload`
|
||||||
|
|||||||
@@ -11326,3 +11326,23 @@ Validation:
|
|||||||
Known limitation:
|
Known limitation:
|
||||||
|
|
||||||
- the authenticated live workbench still requires a user session for visual browser verification after deployment; public landing and runtime health remain independently testable.
|
- the authenticated live workbench still requires a user session for visual browser verification after deployment; public landing and runtime health remain independently testable.
|
||||||
|
|
||||||
|
## 2026-07-26 - Optional spatial entry and authoritative municipality activation
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- corrected the false assumption that municipalities were already project Areas: the national workspace contains 565 official NGI AdminVector municipality features instead;
|
||||||
|
- added bounded search across the persisted Dutch, French and German NGI names plus NIS code, and an idempotent activation route that creates a normal project Area from the exact persisted geometry;
|
||||||
|
- replaced the non-functional Area-only datalist with an asynchronous official municipality search, explicit result selection, loading/error states and active-area feedback;
|
||||||
|
- repositioned municipality search as an optional map shortcut alongside free drawing rather than a mandatory first step;
|
||||||
|
- reframed themes as optional map focus: they select the visible layer and primary metric while the insights workflow continues to report broader source availability;
|
||||||
|
- removed misleading step numbers, clarified the spatial research flow and reduced both the application rail and map focus panel;
|
||||||
|
- constrained narrow theme cards so titles, descriptions and availability labels remain inside their bounds;
|
||||||
|
- replaced the compass illustration with a new vector GeoIntel mark: a geometric G/geo-lens, coordinate point and subtle contour field, applied to every existing favicon and application-icon derivative.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- verified the production database contains 565 authoritative municipality features and inspected the actual multilingual NGI property contract read-only;
|
||||||
|
- added backend API/service tests for multilingual/NIS search and persisted-area activation;
|
||||||
|
- added frontend tests for live search, activation and explicit optional/free-selection semantics;
|
||||||
|
- visually inspected the 512 px application mark and 32 px favicon derivative.
|
||||||
|
|||||||
@@ -908,3 +908,13 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Replace passive management naming with an actionable system/source monitor.
|
- [x] Replace passive management naming with an actionable system/source monitor.
|
||||||
- [x] Preserve existing persisted Area, source, analysis and QA contracts.
|
- [x] Preserve existing persisted Area, source, analysis and QA contracts.
|
||||||
- [ ] Add more direct quality-review actions only when a persisted candidate/reference pair exists; never synthesize a check.
|
- [ ] Add more direct quality-review actions only when a persisted candidate/reference pair exists; never synthesize a check.
|
||||||
|
|
||||||
|
# Sprint 228 - Optional spatial entry and municipality activation
|
||||||
|
|
||||||
|
- [x] Search the real persisted NGI municipality catalog instead of existing project Areas.
|
||||||
|
- [x] Match Dutch, French and German names plus NIS codes.
|
||||||
|
- [x] Activate the exact official geometry idempotently as a reusable project Area.
|
||||||
|
- [x] Present municipality search as an optional shortcut beside free map drawing.
|
||||||
|
- [x] Explain theme choice as map and primary-metric focus rather than a mandatory workflow gate.
|
||||||
|
- [x] Keep narrow theme-card copy and status labels inside their cards.
|
||||||
|
- [x] Replace the former compass icon with a scale-safe GeoIntel geo-lens mark.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 140 KiB |
@@ -1,27 +1,26 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||||
<title id="title">GeoIntel Atlas</title>
|
<title id="title">GeoIntel</title>
|
||||||
<desc id="desc">Een geografische lens met contourlijnen, kompasrichting en locatiepunt.</desc>
|
<desc id="desc">Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt.</desc>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="30" y1="18" x2="222" y2="238" gradientUnits="userSpaceOnUse">
|
<linearGradient id="surface" x1="28" y1="20" x2="226" y2="236" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#10564E"/>
|
<stop stop-color="#123F3B"/>
|
||||||
<stop offset="1" stop-color="#062824"/>
|
<stop offset="1" stop-color="#061F20"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="land" x1="69" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse">
|
<linearGradient id="geo" x1="68" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#D9FFF7"/>
|
<stop stop-color="#B9FFF0"/>
|
||||||
<stop offset="1" stop-color="#62D5C0"/>
|
<stop offset="1" stop-color="#48C9B1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadow" x="-25%" y="-25%" width="150%" height="150%">
|
<filter id="lift" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#001C19" flood-opacity=".35"/>
|
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#001615" flood-opacity=".45"/>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
|
<rect width="256" height="256" rx="54" fill="url(#surface)"/>
|
||||||
<path d="M26 76C63 57 92 68 120 56s59-33 110-14M20 116c41-17 70-9 101-22s68-29 116-9M20 158c38-16 73-6 108-21s67-22 109-7M33 202c40-16 65-5 99-18s57-17 90-8" fill="none" stroke="#A9EBDD" stroke-opacity=".16" stroke-width="8" stroke-linecap="round"/>
|
<path d="M28 74c36-17 62-9 91-20 32-12 61-27 108-13M20 117c39-17 69-8 100-21 33-13 65-24 116-7M20 163c42-17 72-7 105-19 31-12 59-20 111-5M31 207c37-14 66-5 96-16 31-12 56-17 96-7" fill="none" stroke="#A6E8DC" stroke-opacity=".10" stroke-width="7" stroke-linecap="round"/>
|
||||||
<g filter="url(#shadow)">
|
<g filter="url(#lift)">
|
||||||
<path d="M128 35c51 0 93 41 93 93 0 51-42 93-93 93s-93-42-93-93c0-52 42-93 93-93Z" fill="#082F2B" stroke="#86E4D2" stroke-width="9"/>
|
<path d="M179 78a72 72 0 1 0 9 83" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round"/>
|
||||||
<path d="M128 58c38 0 70 31 70 70 0 38-32 70-70 70s-70-32-70-70c0-39 32-70 70-70Z" fill="none" stroke="#D8FFF7" stroke-opacity=".22" stroke-width="5" stroke-dasharray="2 12" stroke-linecap="round"/>
|
<path d="M137 128h62v50" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M86 156c18-8 29-4 44-11 16-8 24-22 43-23M79 135c19-8 33-4 48-12 16-8 24-21 42-24M83 113c13-6 27-4 39-10 15-8 25-19 38-22" fill="none" stroke="url(#land)" stroke-width="8" stroke-linecap="round"/>
|
<circle cx="128" cy="128" r="23" fill="#082927" stroke="#DFFFF8" stroke-width="7"/>
|
||||||
<path d="M128 72v14M128 170v14M72 128h14M170 128h14" stroke="#E9FFF9" stroke-width="7" stroke-linecap="round"/>
|
<circle cx="128" cy="128" r="10" fill="#F2C864"/>
|
||||||
<path d="m128 92 12 25 25 11-25 12-12 25-12-25-25-12 25-11 12-25Z" fill="#EFCB72" stroke="#FFF5CE" stroke-width="4" stroke-linejoin="round"/>
|
|
||||||
<circle cx="128" cy="128" r="9" fill="#082F2B" stroke="#FFF" stroke-width="4"/>
|
|
||||||
</g>
|
</g>
|
||||||
|
<path d="M203 52v18M194 61h18" stroke="#DFFFF8" stroke-opacity=".55" stroke-width="4" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -125,6 +125,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
loadProjectData,
|
loadProjectData,
|
||||||
createProject,
|
createProject,
|
||||||
createArea,
|
createArea,
|
||||||
|
activateMunicipality,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
resetProjectData,
|
resetProjectData,
|
||||||
setSelectedProjectId,
|
setSelectedProjectId,
|
||||||
@@ -1014,6 +1015,11 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
selectedMapDatasetId={selectedDataset && availableMapDatasets.some((dataset) => dataset.id === selectedDataset.id) ? selectedDataset.id : ''}
|
selectedMapDatasetId={selectedDataset && availableMapDatasets.some((dataset) => dataset.id === selectedDataset.id) ? selectedDataset.id : ''}
|
||||||
selectedFeature={selectedMapFeature}
|
selectedFeature={selectedMapFeature}
|
||||||
onSelectMapArea={setSelectedMapAreaId}
|
onSelectMapArea={setSelectedMapAreaId}
|
||||||
|
onActivateMunicipality={async (niscode) => {
|
||||||
|
const area = await activateMunicipality(niscode)
|
||||||
|
if (area) setSelectedMapAreaId(area.id)
|
||||||
|
return area
|
||||||
|
}}
|
||||||
onSetContextSourceLabel={setMapContextSourceLabel}
|
onSetContextSourceLabel={setMapContextSourceLabel}
|
||||||
onSetContextLayerLabel={setMapContextLayerLabel}
|
onSetContextLayerLabel={setMapContextLayerLabel}
|
||||||
onOpenDatasetInMap={openDatasetInMap}
|
onOpenDatasetInMap={openDatasetInMap}
|
||||||
|
|||||||
@@ -762,6 +762,7 @@ interface MapWorkspaceProps {
|
|||||||
availableMapDatasets: DatasetCreateResponse[]
|
availableMapDatasets: DatasetCreateResponse[]
|
||||||
selectedMapDatasetId: string
|
selectedMapDatasetId: string
|
||||||
onSelectMapArea: (areaId: string) => void
|
onSelectMapArea: (areaId: string) => void
|
||||||
|
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
|
||||||
onSetContextSourceLabel: (label: string | null) => void
|
onSetContextSourceLabel: (label: string | null) => void
|
||||||
onSetContextLayerLabel: (label: string | null) => void
|
onSetContextLayerLabel: (label: string | null) => void
|
||||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||||
@@ -855,6 +856,7 @@ export function MapWorkspace({
|
|||||||
availableMapDatasets,
|
availableMapDatasets,
|
||||||
selectedMapDatasetId,
|
selectedMapDatasetId,
|
||||||
onSelectMapArea,
|
onSelectMapArea,
|
||||||
|
onActivateMunicipality,
|
||||||
onSetContextSourceLabel,
|
onSetContextSourceLabel,
|
||||||
onSetContextLayerLabel,
|
onSetContextLayerLabel,
|
||||||
onOpenDatasetInMap,
|
onOpenDatasetInMap,
|
||||||
@@ -2178,7 +2180,7 @@ export function MapWorkspace({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
||||||
<h2>Gebied analyseren</h2>
|
<h2>Gebied analyseren</h2>
|
||||||
<p>Kies een gemeente, selecteer een thema en analyseer het volledige gebied of een eigen rechthoek.</p>
|
<p>Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="geo-explorer-header-tools">
|
<div className="geo-explorer-header-tools">
|
||||||
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
||||||
@@ -2225,10 +2227,10 @@ export function MapWorkspace({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MunicipalitySearch
|
<MunicipalitySearch
|
||||||
areas={areas}
|
projectId={selectedProjectId}
|
||||||
selectedAreaId={selectedMapAreaId}
|
activeArea={selectedMapArea ?? null}
|
||||||
disabled={workspaceLoading}
|
disabled={workspaceLoading}
|
||||||
onSelect={handleSelectMapArea}
|
onActivate={onActivateMunicipality}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{workspaceLoading ? (
|
{workspaceLoading ? (
|
||||||
@@ -2251,10 +2253,9 @@ export function MapWorkspace({
|
|||||||
<div className="geo-explorer-layout">
|
<div className="geo-explorer-layout">
|
||||||
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
||||||
<div className="geo-panel-heading">
|
<div className="geo-panel-heading">
|
||||||
<span>2</span>
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Thema</h3>
|
<h3>Focus op de kaart <small>optioneel</small></h3>
|
||||||
<p>Kies welke gegevens u wilt meten.</p>
|
<p>Dit bepaalt de zichtbare laag en hoofdmeting; Inzichten controleert ook de overige beschikbare thema’s.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
||||||
@@ -2264,8 +2265,8 @@ export function MapWorkspace({
|
|||||||
{workspaceLoading
|
{workspaceLoading
|
||||||
? 'Gebieden en bronnen worden geladen'
|
? 'Gebieden en bronnen worden geladen'
|
||||||
: municipalityAreaCount > 0
|
: municipalityAreaCount > 0
|
||||||
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
|
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
||||||
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
|
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="geo-theme-list">
|
<div className="geo-theme-list">
|
||||||
@@ -2517,9 +2518,8 @@ export function MapWorkspace({
|
|||||||
<div className="geo-map-stage">
|
<div className="geo-map-stage">
|
||||||
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
||||||
<div className="geo-panel-heading geo-map-step">
|
<div className="geo-panel-heading geo-map-step">
|
||||||
<span>3</span>
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Selecteer een gebied</h3>
|
<h3>Baken uw onderzoeksvraag af</h3>
|
||||||
<p>
|
<p>
|
||||||
{bboxSelectionMode
|
{bboxSelectionMode
|
||||||
? 'Sleep nu een rechthoek op de kaart.'
|
? 'Sleep nu een rechthoek op de kaart.'
|
||||||
@@ -2640,10 +2640,9 @@ export function MapWorkspace({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div className="geo-panel-heading">
|
<div className="geo-panel-heading">
|
||||||
<span>3</span>
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Inzichten</h3>
|
<h3>Inzichten</h3>
|
||||||
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
|
<p>Gemeten resultaten en beschikbaarheid voor de volledige selectie.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { areasApi } from '../../services/api/areas'
|
||||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||||
import type { AreaRead } from '../../types'
|
import type { AreaRead } from '../../types'
|
||||||
|
|
||||||
const areas = [
|
vi.mock('../../services/api/areas', () => ({
|
||||||
{ id: 'mol', project_id: 'project', name: 'Gemeente Mol', geometry: { type: 'Polygon', coordinates: [] } },
|
areasApi: { searchMunicipalities: vi.fn() },
|
||||||
{ 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[]
|
|
||||||
|
|
||||||
describe('MunicipalitySearch', () => {
|
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())
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
it('selects an exact municipality while excluding regional areas', () => {
|
it('searches the official municipality catalog and activates a result', async () => {
|
||||||
const onSelect = vi.fn()
|
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
|
||||||
render(<MunicipalitySearch areas={areas} selectedAreaId="" onSelect={onSelect} />)
|
const onActivate = vi.fn().mockResolvedValue(activated)
|
||||||
|
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
|
||||||
|
|
||||||
const input = screen.getByTestId('municipality-search-input')
|
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
|
||||||
fireEvent.change(input, { target: { value: 'Mol' } })
|
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Gemeente laden' }))
|
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
|
||||||
|
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
|
||||||
expect(onSelect).toHaveBeenCalledWith('mol')
|
|
||||||
expect(screen.getByText('2 gemeenten beschikbaar')).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the active municipality without the technical prefix', () => {
|
it('presents municipality search as optional and keeps free selection visible', () => {
|
||||||
render(<MunicipalitySearch areas={areas} selectedAreaId="gent" onSelect={vi.fn()} />)
|
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
|
||||||
expect(screen.getByDisplayValue('Gent')).toBeTruthy()
|
expect(screen.getByText('optioneel')).toBeTruthy()
|
||||||
expect(screen.getByText('Gent actief')).toBeTruthy()
|
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,53 +1,109 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { MapPin, Search } from 'lucide-react'
|
import { MapPin, Search, X } from 'lucide-react'
|
||||||
import type { AreaRead } from '../../types'
|
import { areasApi } from '../../services/api/areas'
|
||||||
|
import type { AreaRead, MunicipalitySearchItem } from '../../types'
|
||||||
|
|
||||||
interface MunicipalitySearchProps {
|
interface MunicipalitySearchProps {
|
||||||
areas: AreaRead[]
|
projectId: string | null
|
||||||
selectedAreaId: string
|
activeArea: AreaRead | null
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSelect: (areaId: string) => void
|
onActivate: (niscode: string) => Promise<AreaRead | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
function municipalityLabel(area: AreaRead): string {
|
function areaDisplayName(area: AreaRead | null): string | null {
|
||||||
return area.name.replace(/^Gemeente\s+/i, '')
|
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 {
|
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
|
||||||
const municipalities = useMemo(
|
const [query, setQuery] = useState('')
|
||||||
() => areas.filter((area) => /^Gemeente\s/i.test(area.name)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')),
|
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
|
||||||
[areas],
|
const [searching, setSearching] = useState(false)
|
||||||
)
|
const [activatingCode, setActivatingCode] = useState<string | null>(null)
|
||||||
const selectedArea = municipalities.find((area) => area.id === selectedAreaId) ?? null
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState(selectedArea ? municipalityLabel(selectedArea) : '')
|
const searchSequence = useRef(0)
|
||||||
const exactMatch = municipalities.find(
|
const activeMunicipality = areaDisplayName(activeArea)
|
||||||
(area) => municipalityLabel(area).toLocaleLowerCase('nl-BE') === query.trim().toLocaleLowerCase('nl-BE'),
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedArea) setQuery(municipalityLabel(selectedArea))
|
const normalized = query.trim()
|
||||||
}, [selectedArea])
|
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<HTMLFormElement>) => {
|
const activate = async (municipality: MunicipalitySearchItem) => {
|
||||||
event.preventDefault()
|
setActivatingCode(municipality.niscode)
|
||||||
if (exactMatch) onSelect(exactMatch.id)
|
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 (
|
return (
|
||||||
<form className="municipality-search" onSubmit={submit} aria-label="Gemeente zoeken">
|
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
|
||||||
<div className="municipality-search-copy">
|
<div className="municipality-search-copy">
|
||||||
<span className="municipality-step"><MapPin aria-hidden="true" /> Start hier</span>
|
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
|
||||||
<div><strong>Kies een gemeente</strong><small>De grens en beschikbare gegevens worden meteen als werkgebied geladen.</small></div>
|
<div>
|
||||||
|
<strong>Ga naar een gemeente <small>optioneel</small></strong>
|
||||||
|
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<div className="municipality-search-control">
|
||||||
<span className="sr-only">Zoek gemeente</span><Search aria-hidden="true" />
|
<label>
|
||||||
<input type="search" list="municipality-options" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Typ bijvoorbeeld Mol, Gent of Namen" disabled={disabled || municipalities.length === 0} autoComplete="off" data-testid="municipality-search-input" />
|
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
|
||||||
</label>
|
<Search aria-hidden="true" />
|
||||||
<datalist id="municipality-options">
|
<input
|
||||||
{municipalities.map((area) => <option key={area.id} value={municipalityLabel(area)} />)}
|
type="search"
|
||||||
</datalist>
|
value={query}
|
||||||
<button className="primary-action" type="submit" disabled={disabled || !exactMatch}>Gemeente laden</button>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
<span className="municipality-search-status" aria-live="polite">{selectedArea ? `${municipalityLabel(selectedArea)} actief` : `${municipalities.length} gemeenten beschikbaar`}</span>
|
placeholder="Gemeentenaam of NIS-code"
|
||||||
</form>
|
disabled={disabled || !projectId}
|
||||||
|
autoComplete="off"
|
||||||
|
data-testid="municipality-search-input"
|
||||||
|
/>
|
||||||
|
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
|
||||||
|
</label>
|
||||||
|
{query.trim().length >= 2 ? (
|
||||||
|
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
|
||||||
|
{searching ? <p role="status">Gemeenten zoeken…</p> : null}
|
||||||
|
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
|
||||||
|
{results.map((municipality) => (
|
||||||
|
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
|
||||||
|
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
|
||||||
|
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{error ? <p className="error" role="alert">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="municipality-search-status">
|
||||||
|
<span>Actief werkgebied</span>
|
||||||
|
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,25 @@ export function useProjectWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activateMunicipality = async (niscode: string): Promise<AreaRead | null> => {
|
||||||
|
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) => {
|
const archiveProject = async (projectId: string) => {
|
||||||
setArchivingProjectId(projectId)
|
setArchivingProjectId(projectId)
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
@@ -257,6 +276,7 @@ export function useProjectWorkspace() {
|
|||||||
loadProjectData,
|
loadProjectData,
|
||||||
createProject,
|
createProject,
|
||||||
createArea,
|
createArea,
|
||||||
|
activateMunicipality,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
resetProjectData,
|
resetProjectData,
|
||||||
setSelectedProjectId,
|
setSelectedProjectId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from './client'
|
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
|
const AREA_PAGE_SIZE = 200
|
||||||
|
|
||||||
@@ -36,4 +36,8 @@ export const areasApi = {
|
|||||||
apiGet<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`),
|
apiGet<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`),
|
||||||
update: (projectId: string, areaId: string, payload: Partial<AreaCreate>): Promise<AreaRead> =>
|
update: (projectId: string, areaId: string, payload: Partial<AreaCreate>): Promise<AreaRead> =>
|
||||||
apiPatch<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`, payload),
|
apiPatch<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`, payload),
|
||||||
|
searchMunicipalities: (projectId: string, query: string): Promise<MunicipalitySearchResponse> =>
|
||||||
|
apiGet<MunicipalitySearchResponse>(`/api/v1/projects/${projectId}/areas/municipalities?query=${encodeURIComponent(query)}&limit=12`),
|
||||||
|
activateMunicipality: (projectId: string, niscode: string): Promise<AreaRead> =>
|
||||||
|
apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas/municipalities/${encodeURIComponent(niscode)}/activate`, {}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -998,7 +998,7 @@ body {
|
|||||||
|
|
||||||
/* Senior UX audit: compact navigation, safe text containment and task-first map flow. */
|
/* Senior UX audit: compact navigation, safe text containment and task-first map flow. */
|
||||||
@media (min-width: 1361px) {
|
@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; }
|
.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 { 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-analysis-mode button { display: grid; place-items: center; white-space: nowrap; }
|
||||||
.geo-explorer { grid-template-rows: auto auto minmax(0, 1fr) auto; }
|
.geo-explorer { grid-template-rows: auto auto minmax(0, 1fr) auto; }
|
||||||
|
.geo-explorer-layout { grid-template-columns: 14rem minmax(28rem, 1fr) 19rem; }
|
||||||
|
|
||||||
.municipality-search {
|
.municipality-search {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
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;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--atlas-line);
|
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 { 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 > div { display: grid; min-width: 0; gap: 0.12rem; }
|
||||||
.municipality-search-copy strong { font-size: 0.78rem; }
|
.municipality-search-copy strong { font-size: 0.78rem; }
|
||||||
.municipality-search-copy small { color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
|
.municipality-search-copy strong small { margin-left: 0.35rem; color: var(--atlas-600); font-size: 0.56rem; font-weight: 750; text-transform: uppercase; }
|
||||||
.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-search-copy p { margin: 0; color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
|
||||||
.municipality-step svg { width: 0.8rem; height: 0.8rem; }
|
.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-search label { position: relative; min-width: 0; }
|
.municipality-shortcut svg { width: 0.8rem; height: 0.8rem; }
|
||||||
.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-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 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; }
|
.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); }
|
.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; }
|
.system-command-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; }
|
||||||
|
|
||||||
@media (max-width: 1180px) and (min-width: 921px) {
|
@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; }
|
.municipality-search-status { display: none; }
|
||||||
}
|
}
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
@@ -1060,6 +1082,9 @@ body {
|
|||||||
.municipality-search { grid-template-columns: 1fr; }
|
.municipality-search { grid-template-columns: 1fr; }
|
||||||
.municipality-search-copy { grid-column: auto; }
|
.municipality-search-copy { grid-column: auto; }
|
||||||
.municipality-search button { width: 100%; }
|
.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-surface { grid-template-columns: 1fr; }
|
||||||
.system-command-actions { grid-column: auto; }
|
.system-command-actions { grid-column: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ export interface AreaListResponse {
|
|||||||
offset: number
|
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 {
|
export interface DatasetCreateResponse {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||