feat: clarify municipality-first workbench flow
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-26 03:17:03 +02:00
parent d79f751773
commit 5efcf94d82
9 changed files with 245 additions and 28 deletions
+22
View File
@@ -11304,3 +11304,25 @@ Validation:
- visually inspected the canonical 512 px render and the 32 px favicon derivative;
- verified desktop and mobile landing-page renders with the new GeoIntel and ITWorx assets present, meaningful page content and no Vite error overlay; expected API 500 responses remained limited to the absent local backend;
- React review confirmed named, isolated brand components and accessible decorative versus meaningful image semantics.
## 2026-07-26 - Task-first usability and functional navigation audit
Implemented:
- reduced the wide desktop navigation rail and renamed the misleading read-only `Beheer` destination to the operationally accurate `Systeem` workspace;
- aligned the current/evolution period control into one stable map-header tool group and added containment rules for long dataset, quality, provider and context labels;
- promoted municipality selection from a hidden long select to a searchable first step above the map; selecting an exact persisted municipality immediately activates its existing Area geometry and project datasets;
- renumbered the simple map journey to municipality, theme, selection and results, and rewrote the map instruction to describe that complete path;
- turned the empty quality state into an actionable handoff to the map or image-analysis workflow while preserving persisted QA evidence, metrics and map drill-down when checks exist;
- replaced the passive provider-only management page with a system command surface that reports configured connectors and routes operators to the map, source/area management or readiness status.
Validation:
- frontend unit tests cover exact municipality search, regional-area exclusion and active municipality feedback;
- frontend TypeScript and production build passed;
- React review confirmed local state ownership, semantic form/button controls, stable list keys and accessible names;
- no API, persistence, CRS or official-source semantics changed.
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.
+10
View File
@@ -898,3 +898,13 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Generate matching 32 px, Apple touch and Unraid PNG derivatives.
- [x] Add the approved ITWorx.tech identity as a subtle Jens maker signature.
- [ ] Revisit alternative lockups only when an approved GeoIntel brand guide exists.
# Sprint 227 - Task-first usability audit
- [x] Reduce the desktop navigation rail and contain long card/context labels.
- [x] Align the latest-state and evolution controls as one responsive header group.
- [x] Make searchable municipality selection the first step of the map workflow.
- [x] Route an empty quality workspace to real map and image-analysis actions.
- [x] Replace passive management naming with an actionable system/source monitor.
- [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.
+6 -1
View File
@@ -75,7 +75,7 @@ const workspaceNavItems: WorkspaceNavigationItem[] = [
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Beheer', description: 'Databronnen en systeemstatus' },
{ key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' },
]
const workspaceNavGroups: WorkspaceNavigationGroup[] = [
@@ -1056,6 +1056,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
onOpenEvidenceMap={openQualityEvidenceOnMap}
evidenceLoading={qualityEvidenceLoading}
evidenceError={qualityEvidenceError}
onOpenMapWorkspace={() => setActiveWorkspace('map')}
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
/>
<details className="secondary-analysis-disclosure">
<summary>
@@ -1234,6 +1236,9 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
loadingCapabilities={loadingCapabilities}
capabilitiesError={capabilitiesError}
onRefresh={loadCapabilities}
onOpenSources={() => setActiveWorkspace('data')}
onOpenStatus={() => setActiveWorkspace('overview')}
onOpenMap={() => setActiveWorkspace('map')}
/>
) : null}
</main>
+26 -24
View File
@@ -7,6 +7,7 @@ import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
import { MunicipalitySearch } from './MunicipalitySearch'
import { terrainImageUrl } from '../../lib/terrainImage'
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
@@ -2177,9 +2178,10 @@ export function MapWorkspace({
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Gebied analyseren</h2>
<p>Kies een thema, teken een rechthoek en lees de beschikbare gegevens.</p>
<p>Kies een gemeente, selecteer een thema en analyseer het volledige gebied of een eigen rechthoek.</p>
</div>
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
<div className="geo-explorer-header-tools">
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
<button
id="geo-analysis-tab-current"
className={analysisMode === 'current' ? 'active' : ''}
@@ -2206,21 +2208,29 @@ export function MapWorkspace({
>
Evolutie
</button>
</div>
<button
className="secondary-action geo-explorer-advanced"
type="button"
onClick={() => setAdvancedMode(true)}
aria-expanded={advancedMode}
aria-controls="geo-advanced-workbench"
aria-label="Geavanceerde werkbank"
title="Geavanceerde werkbank"
>
<SlidersHorizontal aria-hidden="true" />
<span>Geavanceerde werkbank</span>
</button>
</div>
<button
className="secondary-action geo-explorer-advanced"
type="button"
onClick={() => setAdvancedMode(true)}
aria-expanded={advancedMode}
aria-controls="geo-advanced-workbench"
aria-label="Geavanceerde werkbank"
title="Geavanceerde werkbank"
>
<SlidersHorizontal aria-hidden="true" />
<span>Geavanceerde werkbank</span>
</button>
</header>
<MunicipalitySearch
areas={areas}
selectedAreaId={selectedMapAreaId}
disabled={workspaceLoading}
onSelect={handleSelectMapArea}
/>
{workspaceLoading ? (
<div className="geo-bootstrap-status" role="status" aria-live="polite">
<span className="geo-loading-indicator" aria-hidden="true" />
@@ -2241,7 +2251,7 @@ export function MapWorkspace({
<div className="geo-explorer-layout">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
<span>1</span>
<span>2</span>
<div>
<h3>Thema</h3>
<p>Kies welke gegevens u wilt meten.</p>
@@ -2502,20 +2512,12 @@ export function MapWorkspace({
</>
) : null}
<label className="geo-scope-select">
Snel naar een gemeente (optioneel)
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
</select>
</label>
</aside>
<div className="geo-map-stage">
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
<div className="geo-panel-heading geo-map-step">
<span>2</span>
<span>3</span>
<div>
<h3>Selecteer een gebied</h3>
<p>
@@ -0,0 +1,32 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
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[]
describe('MunicipalitySearch', () => {
afterEach(() => cleanup())
it('selects an exact municipality while excluding regional areas', () => {
const onSelect = vi.fn()
render(<MunicipalitySearch areas={areas} selectedAreaId="" onSelect={onSelect} />)
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()
})
it('shows the active municipality without the technical prefix', () => {
render(<MunicipalitySearch areas={areas} selectedAreaId="gent" onSelect={vi.fn()} />)
expect(screen.getByDisplayValue('Gent')).toBeTruthy()
expect(screen.getByText('Gent actief')).toBeTruthy()
})
})
@@ -0,0 +1,53 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { MapPin, Search } from 'lucide-react'
import type { AreaRead } from '../../types'
interface MunicipalitySearchProps {
areas: AreaRead[]
selectedAreaId: string
disabled?: boolean
onSelect: (areaId: string) => void
}
function municipalityLabel(area: AreaRead): string {
return area.name.replace(/^Gemeente\s+/i, '')
}
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'),
)
useEffect(() => {
if (selectedArea) setQuery(municipalityLabel(selectedArea))
}, [selectedArea])
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (exactMatch) onSelect(exactMatch.id)
}
return (
<form className="municipality-search" onSubmit={submit} aria-label="Gemeente zoeken">
<div className="municipality-search-copy">
<span className="municipality-step"><MapPin aria-hidden="true" /> Start hier</span>
<div><strong>Kies een gemeente</strong><small>De grens en beschikbare gegevens worden meteen als werkgebied geladen.</small></div>
</div>
<label>
<span className="sr-only">Zoek gemeente</span><Search aria-hidden="true" />
<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" />
</label>
<datalist id="municipality-options">
{municipalities.map((area) => <option key={area.id} value={municipalityLabel(area)} />)}
</datalist>
<button className="primary-action" type="submit" disabled={disabled || !exactMatch}>Gemeente laden</button>
<span className="municipality-search-status" aria-live="polite">{selectedArea ? `${municipalityLabel(selectedArea)} actief` : `${municipalities.length} gemeenten beschikbaar`}</span>
</form>
)
}
@@ -5,6 +5,9 @@ interface ProviderPanelProps {
loadingCapabilities: boolean
capabilitiesError: string | null
onRefresh: () => void
onOpenSources: () => void
onOpenStatus: () => void
onOpenMap: () => void
}
function providerStatusLabel(provider: ProviderCapability): string {
@@ -41,20 +44,34 @@ export function ProviderPanel({
loadingCapabilities,
capabilitiesError,
onRefresh,
onOpenSources,
onOpenStatus,
onOpenMap,
}: ProviderPanelProps): JSX.Element {
const configuredCount = providers.filter((provider) => provider.configured).length
return (
<section className="system-provider-panel">
<div className="system-provider-shell">
<div className="panel-title-row">
<div>
<h2>Databronnen</h2>
<p className="muted">Beschikbaarheid en herkomst van officiële, aanvullende en eigen bronnen.</p>
<h2>Systeem & databronnen</h2>
<p className="muted">Operationele beschikbaarheid, bronherkomst en directe routes naar beheeracties.</p>
</div>
<button type="button" className="secondary-action" onClick={onRefresh} disabled={loadingCapabilities}>
Status vernieuwen
</button>
</div>
<div className="system-command-surface" aria-label="Systeemacties">
<div><span>Bronkoppelingen</span><strong>{configuredCount} van {providers.length} actief</strong></div>
<div><span>Werkmodus</span><strong>Begrensde bronopvraging</strong></div>
<div className="system-command-actions">
<button type="button" className="primary-action" onClick={onOpenMap}>Open operationele kaart</button>
<button type="button" className="secondary-action" onClick={onOpenSources}>Beheer gebieden en bronnen</button>
<button type="button" className="secondary-action" onClick={onOpenStatus}>Bekijk gereedheid</button>
</div>
</div>
<div className="system-provider-state-stack">
{loadingCapabilities ? (
<div className="result-state result-state-loading">
@@ -21,6 +21,8 @@ interface QualityResultsPanelProps {
onOpenEvidenceMap?: (qualityCheckId: string) => void
evidenceLoading?: boolean
evidenceError?: string | null
onOpenMapWorkspace: () => void
onOpenAnalysisWorkspace: () => void
}
function qualityMetricLabel(metricKey: string): string {
@@ -133,6 +135,8 @@ export function QualityResultsPanel({
onOpenEvidenceMap,
evidenceLoading = false,
evidenceError = null,
onOpenMapWorkspace,
onOpenAnalysisWorkspace,
}: QualityResultsPanelProps): JSX.Element {
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
@@ -232,7 +236,11 @@ export function QualityResultsPanel({
</div>
<div className="result-state result-state-empty quality-user-empty-state">
<strong>Nog geen kwaliteitscontrole uitgevoerd</strong>
<p>Selecteer een gebied op de kaart en vergelijk een bewaard resultaat met een officiële referentielaag.</p>
<p>Start op de kaart met een gemeente en maak daar een begrensd resultaat. Vergelijk beeldanalyse vervolgens met een officiële referentielaag.</p>
<div className="button-row">
<button type="button" className="primary-action" onClick={onOpenMapWorkspace}>Open kaart en kies gemeente</button>
<button type="button" className="secondary-action" onClick={onOpenAnalysisWorkspace}>Open beeldanalyse</button>
</div>
</div>
</div>
<aside className="quality-empty-inspector">
+68
View File
@@ -996,6 +996,74 @@ 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-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) :where(strong, span, p, small),
.workspace-heading-actions > p,
.geo-source-summary :where(strong, small) { max-width: 100%; overflow-wrap: anywhere; }
.context-bar > div { flex: 1 1 0; }
.context-bar strong { max-width: 100%; }
.geo-explorer-header { grid-template-columns: minmax(0, 1fr) auto; }
.geo-explorer-header-tools { display: flex; gap: 0.55rem; align-items: center; }
.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; }
.municipality-search {
display: grid;
min-width: 0;
grid-template-columns: minmax(17rem, 0.75fr) minmax(17rem, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid var(--atlas-line);
padding: 0.65rem 1rem;
background: #f4fbf9;
}
.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 input { width: 100%; min-width: 0; padding-left: 2.15rem; }
.municipality-search-status { color: var(--atlas-muted); font-size: 0.62rem; 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); }
.system-command-surface > div:not(.system-command-actions) { display: grid; gap: 0.2rem; }
.system-command-surface span { color: var(--atlas-muted); font-size: 0.62rem; }
.system-command-surface strong { font-size: 0.78rem; overflow-wrap: anywhere; }
.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-status { display: none; }
}
@media (max-width: 920px) {
.municipality-search { grid-template-columns: minmax(0, 1fr) auto; }
.municipality-search-copy { grid-column: 1 / -1; }
.municipality-search-status { display: none; }
.system-command-surface { grid-template-columns: 1fr 1fr; }
.system-command-actions { grid-column: 1 / -1; justify-content: flex-start; }
}
@media (max-width: 620px) {
.geo-explorer-header { grid-template-columns: 1fr; }
.geo-explorer-header-tools { justify-content: space-between; }
.geo-analysis-mode { flex: 1 1 auto; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.municipality-search { grid-template-columns: 1fr; }
.municipality-search-copy { grid-column: auto; }
.municipality-search button { width: 100%; }
.system-command-surface { grid-template-columns: 1fr; }
.system-command-actions { grid-column: auto; }
}
/* Workspace identity illustrations */
.workspace-heading {