import { useEffect, useState } from 'react' import { qaApi } from '../../services/api' import type { DetectionEvidenceRole, DetectionReviewDecision, DetectionReviewList, DetectionReviewRead, } from '../../types' import { formatError } from '../../lib/formatError' interface DetectionReviewPanelProps { projectId: string qualityCheckId: string reviewLocked?: boolean onOpenEvidenceMap?: (qualityCheckId: string) => void } const ROLE_LABELS: Record = { false_positive: 'Onterecht gevonden', false_negative: 'Gemist gebouw', } const DECISION_LABELS: Record = { confirmed_model_false_positive: 'Bevestigde foutdetectie', confirmed_model_false_negative: 'Bevestigd gemist gebouw', reference_gap_or_change: 'Referentie ontbreekt of is verouderd', qa_alignment_mismatch: 'Vormvergelijking is te streng', imagery_obscured_or_uncertain: 'Luchtbeeld is onduidelijk', uncertain: 'Verder onderzoek nodig', unreviewed: 'Nog niet beoordeeld', } const ROLE_DECISIONS: Record = { false_positive: [ 'unreviewed', 'confirmed_model_false_positive', 'reference_gap_or_change', 'qa_alignment_mismatch', 'uncertain', ], false_negative: [ 'unreviewed', 'confirmed_model_false_negative', 'reference_gap_or_change', 'qa_alignment_mismatch', 'imagery_obscured_or_uncertain', 'uncertain', ], } function shortId(value: string): string { return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value } function formatScore(value: number | null | undefined): string { return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.' } export function DetectionReviewPanel({ projectId, qualityCheckId, reviewLocked = false, onOpenEvidenceMap, }: DetectionReviewPanelProps): JSX.Element { const [queue, setQueue] = useState(null) const [loading, setLoading] = useState(false) const [savingKey, setSavingKey] = useState(null) const [error, setError] = useState(null) const [roleFilter, setRoleFilter] = useState<'all' | DetectionEvidenceRole>('all') const [statusFilter, setStatusFilter] = useState<'all' | 'reviewed' | 'unreviewed'>('unreviewed') const [offset, setOffset] = useState(0) const [draftDecisions, setDraftDecisions] = useState>({}) const [draftNotes, setDraftNotes] = useState>({}) const load = async () => { setLoading(true) setError(null) try { const result = await qaApi.listDetectionReviews(projectId, qualityCheckId, { evidenceRole: roleFilter === 'all' ? undefined : roleFilter, reviewed: statusFilter === 'all' ? undefined : statusFilter === 'reviewed', limit: 50, offset, }) setQueue(result) setDraftDecisions(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.decision]))) setDraftNotes(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.notes ?? '']))) } catch (caught) { setError(formatError(caught, 'De controlelijst kon niet worden geladen')) } finally { setLoading(false) } } useEffect(() => { void load() }, [projectId, qualityCheckId, roleFilter, statusFilter, offset]) const save = async (item: DetectionReviewRead) => { const key = reviewKey(item) setSavingKey(key) setError(null) try { await qaApi.upsertDetectionReview(projectId, qualityCheckId, { evidence_role: item.evidence_role, evidence_feature_id: item.evidence_feature_id, decision: draftDecisions[key] ?? item.decision, notes: draftNotes[key]?.trim() || null, reviewed_by: 'operator', }) await load() } catch (caught) { setError(formatError(caught, 'De beoordeling kon niet worden bewaard')) } finally { setSavingKey(null) } } return (

{reviewLocked ? 'Beoordelingen bekijken' : 'Fouten controleren'}

{reviewLocked ? 'De demo toont het bestaande kwaliteitsbewijs zonder beoordelingen te wijzigen.' : 'Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.'}

{queue ? (
Te beoordelen{queue.summary.total}
Afgerond{queue.summary.reviewed}
Resterend{queue.summary.remaining}
Fout gevonden{queue.summary.false_positive_total}
Gemist{queue.summary.false_negative_total}
) : null} {queue?.summary.reviewed_metrics ? (
Precisie zoals gemeten {formatScore(queue.summary.reviewed_metrics.raw.precision)}
Precisie na uw beoordeling {formatScore(queue.summary.reviewed_metrics.adjudicated.precision)}
Herkenningsgraad na beoordeling {formatScore(queue.summary.reviewed_metrics.adjudicated.recall)}

{queue.summary.reviewed_metrics.review_complete ? `Alle bevindingen zijn beoordeeld. ${queue.summary.reviewed_metrics.false_positive_breakdown.exonerated} onterecht gevonden objecten en ${queue.summary.reviewed_metrics.false_negative_breakdown.exonerated} gemiste objecten bleken een hiaat in de referentielaag, niet een modelfout.` : `Nog ${queue.summary.reviewed_metrics.false_positive_breakdown.unreviewed + queue.summary.reviewed_metrics.false_negative_breakdown.unreviewed} bevindingen onbeoordeeld. De precisie ligt tussen ${formatScore(queue.summary.reviewed_metrics.pessimistic.precision)} en ${formatScore(queue.summary.reviewed_metrics.optimistic.precision)}; de getoonde waarde rekent elke onbeoordeelde bevinding nog als modelfout.`}

) : null}
{error ?

{error}

: null} {!loading && queue && queue.items.length === 0 ? (
Geen objecten in deze selectie

Pas de filters aan of open de bewijslaag op de kaart.

) : null}
    {queue?.items?.map((item) => { const key = reviewKey(item) const decision = draftDecisions[key] ?? item.decision return (
  1. {ROLE_LABELS[item.evidence_role]} {item.class_name ?? 'gebouw'} / {shortId(item.evidence_feature_id)}
    {typeof item.confidence === 'number' ? {Math.round(item.confidence * 100)}% vertrouwen : null}
  2. ) })}
{queue && queue.total > queue.limit ? (
{offset + 1}-{Math.min(offset + queue.limit, queue.total)} van {queue.total}
) : null}
) } function reviewKey(item: Pick): string { return `${item.evidence_role}:${item.evidence_feature_id}` }