/** * BatchUpload — Feature 3.5 * * CSV upload interface for batch predictions. Displays per-row results * in a color-coded table with a summary bar chart and CSV download. * * Requires: auth token (AuthContext) + clinical role on backend. */ import { useState, useRef, useCallback } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell, ResponsiveContainer, } from 'recharts' import { Upload, FileText, Download, AlertCircle, CheckCircle2, XCircle, Loader2, RefreshCw, Table2, } from 'lucide-react' import { useAuth } from '../context/AuthContext' import { useDisease } from '../context/DiseaseContext' const BASE = '/api/v4' function downloadCsv(rows, disease) { const headers = ['row', 'status', 'prediction', 'confidence', 'diagnosis', 'error'] const lines = [headers.join(',')] for (const r of rows) { lines.push([ r.row, r.status, r.prediction ?? '', r.confidence != null ? (r.confidence * 100).toFixed(1) + '%' : '', r.diagnosis ?? '', r.error ? `"${r.error.replace(/"/g, '""')}"` : '', ].join(',')) } const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `omnidiag_batch_${disease}_${new Date().toISOString().slice(0, 10)}.csv` a.click() URL.revokeObjectURL(url) } // ── Drop zone ───────────────────────────────────────────────────────────────── function DropZone({ onFile, disabled }) { const inputRef = useRef(null) const [dragOver, setDragOver] = useState(false) function handleDrop(e) { e.preventDefault() setDragOver(false) const f = e.dataTransfer.files[0] if (f) onFile(f) } return (
{ e.preventDefault(); setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} onClick={() => !disabled && inputRef.current?.click()} className={` border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors ${dragOver ? 'border-primary-400 bg-primary-50' : 'border-clinical-border hover:border-primary-300 hover:bg-slate-50'} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} `} >

Drop a CSV file here, or click to browse

Max 500 rows · UTF-8 encoding · Header row required

e.target.files[0] && onFile(e.target.files[0])} />
) } // ── Results table ───────────────────────────────────────────────────────────── function ResultsTable({ results }) { const [filter, setFilter] = useState('all') const visible = results.filter(r => filter === 'all' ? true : filter === 'ok' ? r.status === 'ok' : r.status === 'error' ) return (
{/* Filter tabs */}
{[ { id: 'all', label: 'All' }, { id: 'ok', label: 'Succeeded' }, { id: 'error', label: 'Failed' }, ].map(f => ( ))}
{['Row', 'Status', 'Prediction', 'Confidence', 'Diagnosis', 'Error'].map(h => ( ))} {visible.slice(0, 200).map(r => ( ))} {visible.length > 200 && ( )}
{h}
{r.row} {r.status === 'ok' ? ok : error} {r.prediction != null ? ( {r.prediction === 1 ? 'Positive' : 'Negative'} ) : '—'} {r.confidence != null ? `${Math.round(r.confidence * 100)}%` : '—'} {r.diagnosis ?? '—'} {r.error ?? '—'}
Showing first 200 of {visible.length} rows. Download CSV for full results.
) } // ── Summary bar chart ───────────────────────────────────────────────────────── function SummaryChart({ data }) { const chartData = [ { name: 'Positive', count: data.results.filter(r => r.prediction === 1).length, fill: '#dc2626' }, { name: 'Negative', count: data.results.filter(r => r.prediction === 0).length, fill: '#16a34a' }, { name: 'Error', count: data.failed, fill: '#94a3b8' }, ].filter(d => d.count > 0) return (

Result Distribution

[v, 'Patients']} /> {chartData.map((d, i) => )}
) } function Stat({ label, value, color }) { return (

{value}

{label}

) } // ── Main BatchUpload ────────────────────────────────────────────────────────── export default function BatchUpload() { const { token } = useAuth() const { selectedDisease } = useDisease() const [file, setFile] = useState(null) const [loading, setLoading] = useState(false) const [data, setData] = useState(null) const [error, setError] = useState(null) const handleFile = useCallback((f) => { setFile(f) setData(null) setError(null) }, []) async function runBatch() { if (!file || !selectedDisease) return if (!token) { setError('Please sign in to use batch predictions.'); return } setLoading(true) setError(null) setData(null) try { const form = new FormData() form.append('file', file) const res = await fetch(`${BASE}/${selectedDisease}/batch`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form, }) const json = await res.json() if (!res.ok) throw new Error(json?.error || json?.detail || `HTTP ${res.status}`) setData(json) } catch (e) { setError(e.message) } finally { setLoading(false) } } function reset() { setFile(null) setData(null) setError(null) } const diseaseName = (selectedDisease || 'disease').replace(/_/g, ' ') return (
{/* Header */}

Batch Prediction

Upload a CSV to predict {diseaseName} risk for multiple patients

{data && (
)}
{!token && (
Sign in with a clinical account to run batch predictions.
)} {/* Template hint */} {!data && (

CSV Format

The first row must be a header matching the {diseaseName} schema field names. Each subsequent row is one patient. Numeric and categorical values are accepted as-is.

)} {/* Upload area */} {!data && ( <> {file && (

{file.name}

{(file.size / 1024).toFixed(1)} KB

)} )} {error && (
{error}
)} {/* Results */} {data && ( <> )}
) }