Add V1 workbench status strip
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 00:55:12 +02:00
parent cee1a7f022
commit 8c3813d442
8 changed files with 293 additions and 0 deletions
+11
View File
@@ -10,6 +10,7 @@ import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter'
import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel'
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
import type {
ApiError,
ChangeDetectionSummary,
@@ -1374,6 +1375,16 @@ function App(): JSX.Element {
{errorMessage ? <p className="error">{errorMessage}</p> : null}
<WorkbenchStatusStrip
selectedProject={selectedProject}
areas={areas}
datasets={datasets}
qualityChecks={qualityChecks}
exports={exports}
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<main className="workspace-grid">
<ProjectPanel
projects={projects}
@@ -0,0 +1,132 @@
import type { AreaRead, DatasetCreateResponse, ExportRead, ProjectRead, QualityCheckRead } from '../types'
interface StatusItem {
key: string
label: string
value: string
detail: string
state: 'ready' | 'warning' | 'waiting'
}
interface WorkbenchStatusStripProps {
selectedProject: ProjectRead | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
activeLayerFeatureCount: number
selectedAreaHasGeometry: boolean
}
function countByDatasetType(datasets: DatasetCreateResponse[], datasetType: string): number {
return datasets.filter((dataset) => dataset.dataset_type === datasetType).length
}
function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
return datasets.filter((dataset) => dataset.dataset_role === 'reference').length
}
function nextAction(items: StatusItem[]): string {
if (!items.some((item) => item.key === 'project' && item.state === 'ready')) {
return 'Create or open a project.'
}
if (!items.some((item) => item.key === 'area' && item.state === 'ready')) {
return 'Create an AOI or select an area with geometry.'
}
if (!items.some((item) => item.key === 'datasets' && item.state === 'ready')) {
return 'Upload a vector or raster dataset, or load the offline demo workflow.'
}
if (!items.some((item) => item.key === 'qa' && item.state === 'ready')) {
return 'Run QA/QC against a reference dataset when candidate and reference data are available.'
}
if (!items.some((item) => item.key === 'exports' && item.state === 'ready')) {
return 'Create a metadata, report or GeoJSON export for handoff.'
}
return 'Workbench has the core V1 loop populated.'
}
export function WorkbenchStatusStrip({
selectedProject,
areas,
datasets,
qualityChecks,
exports,
activeLayerFeatureCount,
selectedAreaHasGeometry,
}: WorkbenchStatusStripProps): JSX.Element {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
const vectorDatasets = countByDatasetType(datasets, 'vector') + countByDatasetType(datasets, 'geojson')
const rasterDatasets = countByDatasetType(datasets, 'raster')
const referenceDatasets = countReferenceDatasets(datasets)
const latestQualityCheck = qualityChecks[0]
const latestExport = exports[0]
const items: StatusItem[] = [
{
key: 'project',
label: 'Project',
value: selectedProject ? selectedProject.name : 'No project',
detail: selectedProject ? selectedProject.region : 'Open or create a project',
state: selectedProject ? 'ready' : 'waiting',
},
{
key: 'area',
label: 'AOI',
value: `${areas.length} area${areas.length === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'selected area has map geometry' : 'no selected map geometry',
state: selectedAreaHasGeometry ? 'ready' : areas.length > 0 ? 'warning' : 'waiting',
},
{
key: 'datasets',
label: 'Datasets',
value: `${readyDatasets}/${datasets.length} ready`,
detail: `${vectorDatasets} vector, ${rasterDatasets} raster, ${referenceDatasets} reference`,
state: datasets.length === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
},
{
key: 'map',
label: 'Map',
value: `${activeLayerFeatureCount} feature${activeLayerFeatureCount === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'AOI overlay available' : 'AOI overlay missing',
state: selectedAreaHasGeometry || activeLayerFeatureCount > 0 ? 'ready' : 'waiting',
},
{
key: 'qa',
label: 'QA/QC',
value: `${qualityChecks.length} check${qualityChecks.length === 1 ? '' : 's'}`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'no persisted QA/QC result',
state: qualityChecks.length > 0 ? 'ready' : 'waiting',
},
{
key: 'exports',
label: 'Exports',
value: `${exports.length} export${exports.length === 1 ? '' : 's'}`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'no handoff artifact yet',
state: exports.length > 0 ? 'ready' : 'waiting',
},
]
return (
<section className="workbench-status-strip" aria-label="Workbench readiness">
<div className="status-strip-header">
<div>
<p className="eyebrow">V1 readiness</p>
<h2>Workbench status</h2>
</div>
<p className="status-next-action">{nextAction(items)}</p>
</div>
<div className="status-strip-grid">
{items.map((item) => (
<div className={`status-tile status-tile-${item.state}`} key={item.key}>
<div className="status-tile-topline">
<span>{item.label}</span>
<span className="status-pill">{item.state}</span>
</div>
<strong>{item.value}</strong>
<p>{item.detail}</p>
</div>
))}
</div>
</section>
)
}
+83
View File
@@ -134,6 +134,89 @@ ul {
padding: 0.6rem;
}
.workbench-status-strip {
margin: 1rem 0;
}
.status-strip-header {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.status-strip-header h2 {
margin-bottom: 0;
}
.status-next-action {
max-width: 32rem;
margin: 0;
color: var(--muted);
text-align: right;
}
.status-strip-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.65rem;
}
.status-tile {
min-height: 7rem;
border: 1px solid var(--line);
border-left: 4px solid #64748b;
border-radius: 8px;
padding: 0.65rem;
background: #ffffff;
}
.status-tile strong {
display: block;
margin-top: 0.55rem;
font-size: 1.05rem;
}
.status-tile p {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.status-tile-ready {
border-left-color: #15803d;
}
.status-tile-warning {
border-left-color: #b45309;
}
.status-tile-waiting {
border-left-color: #64748b;
}
.status-tile-topline {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.status-pill {
width: auto;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.1rem 0.45rem;
background: #f8fafc;
color: #334155;
text-transform: lowercase;
}
.metric {
display: block;
font-size: 1.4rem;