feat: clarify municipality-first workbench flow
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user