Deze worden voorgelezen aan gebruikers die met een schermlezer werken. In een verder Nederlandse werkbank hoorden zij "Export artifact actions" en "Map selection QA shortcut". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
497 lines
20 KiB
TypeScript
497 lines
20 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../../types'
|
|
|
|
interface ExportCenterProps {
|
|
selectedProjectId: string | null
|
|
selectedDataset: DatasetCreateResponse | null
|
|
selectedDetectionRunId: string
|
|
selectedSegmentationRunId: string
|
|
exports: ExportRead[]
|
|
latestExport: ExportCreateResponse | null
|
|
exportError: string | null
|
|
loadingExports: boolean
|
|
exporting: boolean
|
|
onRefresh: () => void
|
|
onExportDataset: () => void
|
|
onExportDetectionRun: () => void
|
|
onExportSegmentationRun: () => void
|
|
onExportProjectMetadata: () => void
|
|
onExportProjectReport: () => void
|
|
onPreviewContent: (exportId: string) => void
|
|
onDownload: (exportId: string) => void
|
|
}
|
|
|
|
function isVectorDatasetType(datasetType: string): boolean {
|
|
return datasetType === 'vector' || datasetType === 'geojson'
|
|
}
|
|
|
|
function canPreviewJson(item: ExportRead): boolean {
|
|
if (item.export_type === 'project_report_html') {
|
|
return false
|
|
}
|
|
if (item.metadata_json?.format === 'html') {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function compactPath(value: string): string {
|
|
const parts = value.split('/').filter(Boolean)
|
|
if (parts.length <= 2) {
|
|
return value
|
|
}
|
|
return parts.slice(-2).join('/')
|
|
}
|
|
|
|
function formatExportType(value: string): string {
|
|
const labels: Record<string, string> = {
|
|
vector_selection_geojson: 'Kaartselectie (GeoJSON)',
|
|
map_analysis_json: 'Gebiedsanalyse (JSON)',
|
|
map_evolution_json: 'Historische vergelijking (JSON)',
|
|
dataset_geojson: 'Kaartlaag (GeoJSON)',
|
|
detection_geojson: 'Gebouwdetecties - controlelaag (GeoJSON)',
|
|
segmentation_geojson: 'Segmentaties (GeoJSON)',
|
|
project_metadata_json: 'Werkruimtedata (JSON)',
|
|
project_report_html: 'Projectrapport (HTML)',
|
|
}
|
|
if (labels[value]) {
|
|
return labels[value]
|
|
}
|
|
return value
|
|
.split('_')
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
function formatExportCreated(value?: string | null): string {
|
|
if (!value) {
|
|
return 'Tijdstip onbekend'
|
|
}
|
|
const parsed = new Date(value)
|
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('nl-BE')
|
|
}
|
|
|
|
function getLatestExportByType(exports: ExportRead[], exportType: string): ExportRead | null {
|
|
return exports.find((item) => item.export_type === exportType) ?? null
|
|
}
|
|
|
|
function exportMatchesSearch(item: ExportRead, query: string): boolean {
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
if (!normalizedQuery) {
|
|
return true
|
|
}
|
|
return [item.export_type, item.status, item.id, item.storage_path]
|
|
.filter(Boolean)
|
|
.some((value) => String(value).toLowerCase().includes(normalizedQuery))
|
|
}
|
|
|
|
export function ExportCenter({
|
|
selectedProjectId,
|
|
selectedDataset,
|
|
selectedDetectionRunId,
|
|
selectedSegmentationRunId,
|
|
exports,
|
|
latestExport,
|
|
exportError,
|
|
loadingExports,
|
|
exporting,
|
|
onRefresh,
|
|
onExportDataset,
|
|
onExportDetectionRun,
|
|
onExportSegmentationRun,
|
|
onExportProjectMetadata,
|
|
onExportProjectReport,
|
|
onPreviewContent,
|
|
onDownload,
|
|
}: ExportCenterProps): JSX.Element {
|
|
const [showAllExports, setShowAllExports] = useState(false)
|
|
const [exportTypeFilter, setExportTypeFilter] = useState('all')
|
|
const [exportStatusFilter, setExportStatusFilter] = useState('all')
|
|
const [exportSearchQuery, setExportSearchQuery] = useState('')
|
|
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
|
|
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
|
|
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
|
|
const handoffStatus = selectedProjectId ? 'werkruimte gekozen' : 'kies een werkruimte'
|
|
const exportTypes = useMemo(() => Array.from(new Set(exports.map((item) => item.export_type))).sort(), [exports])
|
|
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
|
|
const latestHandoffArtifacts = useMemo(
|
|
() => [
|
|
{
|
|
key: 'map_analysis_json',
|
|
label: 'Gebiedsanalyse',
|
|
detail: 'Server-side herberekende metingen van de kaartselectie',
|
|
item: getLatestExportByType(exports, 'map_analysis_json'),
|
|
},
|
|
{
|
|
key: 'map_evolution_json',
|
|
label: 'Historische vergelijking',
|
|
detail: 'Bewaarde evolutie van het geselecteerde gebied',
|
|
item: getLatestExportByType(exports, 'map_evolution_json'),
|
|
},
|
|
{
|
|
key: 'vector_selection_geojson',
|
|
label: 'Kaartselectie (GeoJSON)',
|
|
detail: 'Persistente objecten binnen de geselecteerde rechthoek',
|
|
item: getLatestExportByType(exports, 'vector_selection_geojson'),
|
|
},
|
|
{
|
|
key: 'project_report_html',
|
|
label: 'Leesbaar rapport',
|
|
detail: 'HTML-rapport om te bekijken en te delen',
|
|
item: getLatestExportByType(exports, 'project_report_html'),
|
|
},
|
|
{
|
|
key: 'project_metadata_json',
|
|
label: 'Werkruimtedata',
|
|
detail: 'Technische inventaris in JSON-formaat',
|
|
item: getLatestExportByType(exports, 'project_metadata_json'),
|
|
},
|
|
{
|
|
key: 'dataset_geojson',
|
|
label: 'Kaartlaag (GeoJSON)',
|
|
detail: 'De geselecteerde vector- of referentielaag',
|
|
item: getLatestExportByType(exports, 'dataset_geojson'),
|
|
},
|
|
{
|
|
key: 'detection_geojson',
|
|
label: 'Herkende gebouwen - controlelaag (GeoJSON)',
|
|
detail: 'Bewaarde geometrieën uit beeldanalyse',
|
|
item: getLatestExportByType(exports, 'detection_geojson'),
|
|
},
|
|
{
|
|
key: 'segmentation_geojson',
|
|
label: 'Segmentaties (GeoJSON)',
|
|
detail: 'Bewaarde segmentatiepolygonen',
|
|
item: getLatestExportByType(exports, 'segmentation_geojson'),
|
|
},
|
|
],
|
|
[exports],
|
|
)
|
|
const availableLatestArtifacts = latestHandoffArtifacts.filter((artifact) => artifact.item)
|
|
const filteredExports = useMemo(
|
|
() =>
|
|
exports.filter((item) => {
|
|
const typeMatches = exportTypeFilter === 'all' || item.export_type === exportTypeFilter
|
|
const statusMatches = exportStatusFilter === 'all' || item.status === exportStatusFilter
|
|
return typeMatches && statusMatches && exportMatchesSearch(item, exportSearchQuery)
|
|
}),
|
|
[exports, exportTypeFilter, exportStatusFilter, exportSearchQuery],
|
|
)
|
|
const visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)
|
|
const hiddenExportCount = Math.max(filteredExports.length - visibleExports.length, 0)
|
|
const hasActiveExportFilters = exportTypeFilter !== 'all' || exportStatusFilter !== 'all' || exportSearchQuery.trim().length > 0
|
|
|
|
return (
|
|
<section className="export-center" data-testid="export-center">
|
|
<div className="export-center-shell">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h2>Downloads en rapporten</h2>
|
|
<p className="muted">Bewaar de geselecteerde kaartlaag, analyseresultaten of een leesbaar projectrapport.</p>
|
|
</div>
|
|
<span className="count-pill">{exports.length} bestanden</span>
|
|
</div>
|
|
|
|
<div className="export-summary-surface" aria-label="Samenvatting van de downloads">
|
|
<div className="quality-summary-grid">
|
|
<div>
|
|
<span>GeoJSON</span>
|
|
<strong>{geojsonExports}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Rapporten</span>
|
|
<strong>{reportExports}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Geselecteerde kaartlaag</span>
|
|
<strong>{selectedDataset?.name ?? 'geen'}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="export-handoff-surface" aria-label="Gereedheid voor overdracht">
|
|
<div className="handoff-summary-card">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Klaar om te delen</h3>
|
|
<p className="muted">Controleer het juiste bestand voordat je gegevens buiten GeoIntel gebruikt.</p>
|
|
</div>
|
|
<span className="status-badge">{handoffStatus}</span>
|
|
</div>
|
|
<div className="handoff-readiness-grid">
|
|
<div>
|
|
<span>Vectorkaartlaag</span>
|
|
<strong>{canExportDataset ? selectedDataset?.name : 'geen beschikbaar'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Gebouwanalyse</span>
|
|
<strong>{selectedDetectionRunId || 'geen'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Segmentatieanalyse</span>
|
|
<strong>{selectedSegmentationRunId || 'geen'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Laatste bestand</span>
|
|
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'geen'}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="latest-artifact-surface" aria-label="Latest handoff artifacts">
|
|
<div>
|
|
<h3>Laatste downloads</h3>
|
|
<p className="muted">Open de recentste bestanden zonder in de volledige geschiedenis te zoeken.</p>
|
|
</div>
|
|
<div className="latest-artifact-grid">
|
|
{availableLatestArtifacts.map((artifact) => {
|
|
const item = artifact.item
|
|
return (
|
|
<div className={item ? 'latest-artifact-card' : 'latest-artifact-card latest-artifact-card-empty'} key={artifact.key}>
|
|
<span>{artifact.label}</span>
|
|
<strong>{item ? (item.status === 'ready' ? 'Beschikbaar' : item.status) : 'Nog niet gemaakt'}</strong>
|
|
<p>{item ? compactPath(item.storage_path) : artifact.detail}</p>
|
|
{item ? (
|
|
<>
|
|
<small>{formatExportCreated(item.created_at)}</small>
|
|
<div className="latest-artifact-actions">
|
|
{canPreviewJson(item) ? (
|
|
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
|
|
Bekijken
|
|
</button>
|
|
) : (
|
|
<span className="status-badge">alleen downloaden</span>
|
|
)}
|
|
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
|
|
Downloaden
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
{availableLatestArtifacts.length === 0 ? (
|
|
<div className="result-state result-state-empty latest-download-empty">
|
|
<strong>Nog geen recente downloads</strong>
|
|
<p>De laatst bewaarde bestanden verschijnen hier.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="export-actions-surface" aria-label="Acties om resultaten te bewaren">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Wat wil je bewaren?</h3>
|
|
<p className="muted">Maak een leesbaar rapport of download gegevens voor verder gebruik.</p>
|
|
</div>
|
|
</div>
|
|
<div className="export-action-grid">
|
|
<div className="handoff-action-grid">
|
|
<div className="handoff-action-card handoff-action-card-technical">
|
|
<span>Vernieuwen</span>
|
|
<strong>Bewaarde bestanden</strong>
|
|
<p>Laad de persistente downloads van de geselecteerde werkruimte opnieuw.</p>
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={onRefresh}
|
|
disabled={!selectedProjectId || loadingExports}
|
|
data-testid="refresh-exports"
|
|
>
|
|
Downloads vernieuwen
|
|
</button>
|
|
</div>
|
|
<div className="handoff-action-card handoff-action-card-primary">
|
|
<span>Rapport</span>
|
|
<strong>Leesbaar projectoverzicht</strong>
|
|
<p>Maak een HTML-rapport met de beschikbare bronnen, controles en resultaten.</p>
|
|
<button type="button" className="primary-action" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
|
|
Projectrapport maken
|
|
</button>
|
|
</div>
|
|
<div className="handoff-action-card">
|
|
<span>Technische gegevens</span>
|
|
<strong>Projectgegevens (JSON)</strong>
|
|
<p>Download de volledige projectinventaris voor geautomatiseerde verwerking.</p>
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={onExportProjectMetadata}
|
|
disabled={!selectedProjectId || exporting}
|
|
data-testid="export-project-metadata"
|
|
>
|
|
Projectgegevens downloaden
|
|
</button>
|
|
</div>
|
|
<div className="handoff-action-card">
|
|
<span>Kaartlaag</span>
|
|
<strong>Geselecteerde bron (GeoJSON)</strong>
|
|
<p>Download de actieve vector- of referentielaag.</p>
|
|
<button type="button" className="secondary-action" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
|
|
Kaartlaag downloaden
|
|
</button>
|
|
</div>
|
|
{selectedDetectionRunId ? <div className="handoff-action-card">
|
|
<span>Gebouwanalyse</span>
|
|
<strong>Herkende gebouwen - controlelaag (GeoJSON)</strong>
|
|
<p>Bewaar de persistente detectiegeometrie van de geselecteerde analyserun.</p>
|
|
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
|
|
Gebouwanalyse bewaren
|
|
</button>
|
|
</div> : null}
|
|
{selectedSegmentationRunId ? <div className="handoff-action-card">
|
|
<span>Segmentatieanalyse</span>
|
|
<strong>Segmentaties (GeoJSON)</strong>
|
|
<p>Bewaar de persistente polygonen van de geselecteerde segmentatierun.</p>
|
|
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
|
|
Segmentatie bewaren
|
|
</button>
|
|
</div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="export-state-stack">
|
|
{loadingExports ? (
|
|
<div className="result-state result-state-loading">
|
|
<strong>Downloads laden.</strong>
|
|
<p>De bewaarde bestanden van deze werkruimte worden opgehaald.</p>
|
|
</div>
|
|
) : null}
|
|
{exportError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Download kon niet worden gemaakt.</strong>
|
|
<p>{exportError}</p>
|
|
</div>
|
|
) : null}
|
|
{latestExport ? (
|
|
<div className="latest-export-card">
|
|
<span>Laatste download</span>
|
|
<strong>{formatExportType(latestExport.export_type)}</strong>
|
|
<p>{latestExport.path}</p>
|
|
</div>
|
|
) : null}
|
|
{exports.length === 0 ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>Nog niets bewaard</strong>
|
|
<p>Maak een leesbaar rapport, een projectoverzicht, of bewaar de actieve kaartlaag.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<details className="export-history-surface export-history-disclosure" aria-label="Downloadgeschiedenis">
|
|
<summary>
|
|
<span>Downloadgeschiedenis</span>
|
|
<strong>{exports.length} bestanden</strong>
|
|
</summary>
|
|
<div className="export-history-body">
|
|
<div>
|
|
<h3>Bewaarde downloads</h3>
|
|
<p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p>
|
|
</div>
|
|
{exports.length > 0 ? (
|
|
<div className="export-history-controls" aria-label="Filters op de downloadgeschiedenis">
|
|
<label>
|
|
Downloads zoeken
|
|
<input
|
|
type="search"
|
|
placeholder="Type, id of pad"
|
|
value={exportSearchQuery}
|
|
onChange={(event) => setExportSearchQuery(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Type
|
|
<select value={exportTypeFilter} onChange={(event) => setExportTypeFilter(event.target.value)}>
|
|
<option value="all">Alle types</option>
|
|
{exportTypes.map((type) => (
|
|
<option key={type} value={type}>
|
|
{type}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Status
|
|
<select value={exportStatusFilter} onChange={(event) => setExportStatusFilter(event.target.value)}>
|
|
<option value="all">Alle statussen</option>
|
|
{exportStatuses.map((status) => (
|
|
<option key={status} value={status}>
|
|
{status}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="secondary-action"
|
|
onClick={() => {
|
|
setExportSearchQuery('')
|
|
setExportTypeFilter('all')
|
|
setExportStatusFilter('all')
|
|
setShowAllExports(false)
|
|
}}
|
|
disabled={!hasActiveExportFilters && !showAllExports}
|
|
>
|
|
Filters wissen
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{filteredExports.length > 10 ? (
|
|
<div className="list-limit-banner">
|
|
<span>
|
|
{visibleExports.length} van {filteredExports.length} passende downloads zichtbaar.
|
|
{hiddenExportCount > 0 ? ` ${hiddenExportCount} oudere bestanden verborgen.` : ' Alle passende bestanden zijn zichtbaar.'}
|
|
</span>
|
|
<button type="button" className="secondary-action" onClick={() => setShowAllExports((value) => !value)}>
|
|
{showAllExports ? 'Toon laatste 10' : 'Toon alle downloads'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{exports.length > 0 && filteredExports.length === 0 ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>Geen downloads passen bij deze filters.</strong>
|
|
<p>Wis de zoekopdracht of filters om de recentste bestanden opnieuw te tonen.</p>
|
|
</div>
|
|
) : null}
|
|
<ul className="export-list">
|
|
{visibleExports.map((item) => (
|
|
<li className="export-card" key={item.id}>
|
|
<div className="export-card-header">
|
|
<div>
|
|
<span className="export-type-badge">{formatExportType(item.export_type)}</span>
|
|
<div className="entity-meta">
|
|
<span>download-id: {item.id}</span>
|
|
{item.analysis_run_id ? <span>analyserun: {item.analysis_run_id}</span> : null}
|
|
{item.created_at ? <span>gemaakt: {formatExportCreated(item.created_at)}</span> : null}
|
|
</div>
|
|
</div>
|
|
<span className={item.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>{item.status}</span>
|
|
</div>
|
|
<p title={item.storage_path}>{compactPath(item.storage_path)}</p>
|
|
<div className="button-row">
|
|
{canPreviewJson(item) ? (
|
|
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
|
|
JSON bekijken
|
|
</button>
|
|
) : (
|
|
<span className="status-badge">HTML alleen downloaden</span>
|
|
)}
|
|
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
|
|
Downloaden
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|