// ExtractionCard.jsx
import SafetyCard from './SafetyCard'
import { useState, useEffect } from 'react'
let _fieldCounter = 0
const DEFAULT_SCHEMA = {
patient: { name: null, age: null, sex: null },
diagnoses: [],
history: { medical: [], surgical: [] },
medications: [],
allergies: [],
vital_signs: { blood_pressure: null, heart_rate: null, temperature: null, oxygen_saturation: null },
laboratory: [],
treatment_ordered: [],
pending_results: [],
}
function withId(item) {
return { ...item, _id: crypto.randomUUID() }
}
function mergeWithSchema(llmOutput) {
const result = structuredJSON(DEFAULT_SCHEMA)
if (llmOutput.patient) result.patient = { ...result.patient, ...llmOutput.patient }
if (llmOutput.vital_signs) result.vital_signs = { ...llmOutput.vital_signs }
if (llmOutput.history) result.history = { ...result.history, ...llmOutput.history }
if (llmOutput.diagnoses?.length > 0) result.diagnoses = llmOutput.diagnoses.filter(Boolean).map(withId)
if (llmOutput.allergies?.length > 0) result.allergies = llmOutput.allergies.filter(Boolean).map(withId)
if (llmOutput.medications?.length > 0) result.medications = llmOutput.medications.filter(Boolean).map(withId)
if (llmOutput.laboratory?.length > 0) result.laboratory = llmOutput.laboratory.filter(Boolean).map(withId)
if (llmOutput.treatment_ordered?.length > 0) result.treatment_ordered = llmOutput.treatment_ordered.filter(Boolean).map(withId)
if (llmOutput.pending_results?.length > 0) result.pending_results = llmOutput.pending_results.filter(Boolean).map((r) =>
typeof r === 'object' ? withId(r) : { _id: crypto.randomUUID(), value: r }
)
if (llmOutput.history?.medical?.length > 0) result.history.medical = llmOutput.history.medical.map((m, i) => ({ _id: crypto.randomUUID(), value: m }))
if (llmOutput.history?.surgical?.length > 0) result.history.surgical = llmOutput.history.surgical.map((s) => ({ _id: crypto.randomUUID(), value: s }))
return result
}
function parseStructuredInfo(structuredInfo) {
if (typeof structuredInfo === 'object' && structuredInfo !== null) {
return mergeWithSchema(structuredInfo)
}
try {
const cleaned = structuredInfo
.replace(/```json|```/g, '')
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']')
.trim()
return mergeWithSchema(JSON.parse(cleaned))
} catch (e) {
console.log('parse error:', e.message)
return DEFAULT_SCHEMA
}
}
const PATIENT_KEYS = ['name', 'age', 'sex']
const VITALSIGNS_LABELS = {
blood_pressure: 'Blutdruck',
heart_rate: 'Herzfrequenz',
temperature: 'Temperatur',
oxygen_saturation: 'SpO2',
}
export default function ExtractionCard({ structuredInfo, safetyFlags, onConfirm, loading }) {
const [edited, setEdited] = useState(() => parseStructuredInfo(structuredInfo))
const [collapsed, setCollapsed] = useState(false)
const [confirmed, setConfirmed] = useState(false)
const [sectionsCollapsed, setSectionsCollapsed] = useState({})
function toggleSection(name) {
setSectionsCollapsed(prev => ({ ...prev, [name]: !prev[name] }))
}
useEffect(() => {
setEdited(parseStructuredInfo(structuredInfo))
}, [structuredInfo])
function update(path, value) {
setEdited(prev => {
const next = structuredJSON(prev)
setNestedValue(next, path, value)
return next
})
}
function addArrayItem(section, template) {
setEdited(prev => ({
...structuredJSON(prev),
[section]: [...(prev[section] ?? []), { ...structuredJSON(template), _id: crypto.randomUUID() }]
}))
}
function removeArrayItem(section, id) {
setEdited(prev => ({
...structuredJSON(prev),
[section]: prev[section].filter(item => item._id !== id)
}))
}
function addScalarField(section) {
const n = ++_fieldCounter
setEdited(prev => {
const next = structuredJSON(prev)
next[section][`Feldname ${n}`] = null
return next
})
}
function removeScalarField(section, key) {
setEdited(prev => {
const next = structuredJSON(prev)
delete next[section][key]
return next
})
}
function renameScalarField(section, oldKey, newKey) {
if (!newKey || newKey === oldKey) return
setEdited(prev => {
const next = structuredJSON(prev)
next[section] = Object.fromEntries(
Object.entries(next[section]).map(([k, v]) => [k === oldKey ? newKey : k, v])
)
return next
})
}
function handleConfirm() {
setConfirmed(true)
const clean = structuredJSON(edited)
const stripIds = arr => arr?.map(({ _id, ...rest }) => rest) ?? []
clean.diagnoses = stripIds(clean.diagnoses)
clean.allergies = stripIds(clean.allergies)
clean.medications = stripIds(clean.medications)
clean.laboratory = stripIds(clean.laboratory)
clean.treatment_ordered = stripIds(clean.treatment_ordered)
clean.pending_results = clean.pending_results?.map(r => r.value ?? r) ?? []
clean.history.medical = clean.history.medical?.map(r => r.value ?? r) ?? []
clean.history.surgical = clean.history.surgical?.map(r => r.value ?? r) ?? []
onConfirm(clean)
}
return (
setCollapsed(c => !c)}>
Strukturierte Patientendaten
{confirmed && bestätigt}
{collapsed ? '▶' : '▼'}
{!collapsed && (
<>
{/* Patient */}
addScalarField('patient')} collapsed={sectionsCollapsed['patient']} onToggle={() => toggleSection('patient')} />
{!sectionsCollapsed['patient'] && (
update('patient.name', v)} />
update('patient.age', v)} />
update('patient.sex', v)} />
{Object.entries(edited.patient ?? {})
.filter(([k]) => !PATIENT_KEYS.includes(k))
.map(([k, v]) => (
update(`patient.${k}`, val)}
onLabelSave={newKey => renameScalarField('patient', k, newKey)}
onRemove={() => removeScalarField('patient', k)}
/>
))
}
)}
{/* Anamnese */}
toggleSection('history')}
customActions={
<>
setEdited(prev => {
const next = structuredJSON(prev)
next.history.medical = [...(next.history.medical ?? []), { _id: crypto.randomUUID(), value: '' }]
return next
})}>+ Medizinisch
setEdited(prev => {
const next = structuredJSON(prev)
next.history.surgical = [...(next.history.surgical ?? []), { _id: crypto.randomUUID(), value: '' }]
return next
})}>+ Chirurgisch
>
}
/>
{!sectionsCollapsed['history'] && (
!edited.history?.medical?.length && !edited.history?.surgical?.length
?
:
{edited.history?.medical?.map((m) => (
setEdited(prev => {
const next = structuredJSON(prev)
next.history.medical = next.history.medical.filter(x => x._id !== m._id)
return next
})}>
setEdited(prev => {
const next = structuredJSON(prev)
const idx = next.history.medical.findIndex(x => x._id === m._id)
if (idx !== -1) next.history.medical[idx].value = v
return next
})}
/>
))}
{edited.history?.surgical?.map((s2) => (
setEdited(prev => {
const next = structuredJSON(prev)
next.history.surgical = next.history.surgical.filter(x => x._id !== s2._id)
return next
})}>
setEdited(prev => {
const next = structuredJSON(prev)
const idx = next.history.surgical.findIndex(x => x._id === s2._id)
if (idx !== -1) next.history.surgical[idx].value = v
return next
})}
/>
))}
)}
{/* Diagnosen */}
addArrayItem('diagnoses', { name: '', certainty: 'confirmed' })} collapsed={sectionsCollapsed['diagnoses']} onToggle={() => toggleSection('diagnoses')} />
{!sectionsCollapsed['diagnoses'] && (
!edited.diagnoses?.length
?
:
{edited.diagnoses.map((d, i) => (
removeArrayItem('diagnoses', d._id)}>
update(`diagnoses.${i}.name`, v)} />
Sicherheit
))}
)}
{/* Allergien */}
addArrayItem('allergies', { substance: '', reaction: '' })} collapsed={sectionsCollapsed['allergies']} onToggle={() => toggleSection('allergies')} />
{!sectionsCollapsed['allergies'] && (
!edited.allergies?.length
?
:
{edited.allergies.map((a, i) => (
removeArrayItem('allergies', a._id)}>
update(`allergies.${i}.substance`, v)} />
update(`allergies.${i}.reaction`, v)} />
))}
)}
{/* Medikation */}
addArrayItem('medications', { name: '', dose: '', regimen: '' })} collapsed={sectionsCollapsed['medications']} onToggle={() => toggleSection('medications')} />
{!sectionsCollapsed['medications'] && (
!edited.medications?.length
?
:
{edited.medications.map((m, i) => (
removeArrayItem('medications', m._id)}>
update(`medications.${i}.name`, v)} />
update(`medications.${i}.dose`, v)} />
update(`medications.${i}.regimen`, v)} />
))}
)}
{/* Vitalwerte */}
addScalarField('vital_signs')} collapsed={sectionsCollapsed['vital_signs']} onToggle={() => toggleSection('vital_signs')} />
{!sectionsCollapsed['vital_signs'] && (
{Object.entries(edited.vital_signs ?? {}).map(([k, v]) => {
const isKnown = k in VITALSIGNS_LABELS
return (
update(`vital_signs.${k}`, val)}
onLabelSave={newKey => renameScalarField('vital_signs', k, newKey)}
onRemove={() => removeScalarField('vital_signs', k)}
/>
)
})}
)}
{/* Laborwerte */}
addArrayItem('laboratory', { name: '', value: '', unit: '', abnormal: false })} collapsed={sectionsCollapsed['laboratory']} onToggle={() => toggleSection('laboratory')} />
{!sectionsCollapsed['laboratory'] && (
!edited.laboratory?.length
?
:
{edited.laboratory.map((l, i) => (
removeArrayItem('laboratory', l._id)} conflict={l.abnormal === true}>
update(`laboratory.${i}.name`, v)} />
update(`laboratory.${i}.value`, v)} />
Abnormal
update(`laboratory.${i}.abnormal`, e.target.checked)} style={{ cursor: 'pointer' }} />
))}
)}
{/* Verordnete Behandlung */}
addArrayItem('treatment_ordered', { name: '', dose: '', route: '' })} collapsed={sectionsCollapsed['treatment_ordered']} onToggle={() => toggleSection('treatment_ordered')} />
{!sectionsCollapsed['treatment_ordered'] && (
!edited.treatment_ordered?.length
?
:
{edited.treatment_ordered.map((t, i) => (
removeArrayItem('treatment_ordered', t._id)}>
update(`treatment_ordered.${i}.name`, v)} />
update(`treatment_ordered.${i}.dose`, v)} />
update(`treatment_ordered.${i}.route`, v)} />
))}
)}
{/* Ausstehende Ergebnisse */}
addArrayItem('pending_results', { value: '' })} collapsed={sectionsCollapsed['pending_results']} onToggle={() => toggleSection('pending_results')} />
{!sectionsCollapsed['pending_results'] && (
!edited.pending_results?.length
?
:
{edited.pending_results.map((r, i) => (
removeArrayItem('pending_results', r._id)}>
update(`pending_results.${i}.value`, v)}
/>
))}
)}
{safetyFlags && safetyFlags.length > 0 && (
)}
Extraktion korrekt? Werte anklicken zum Bearbeiten.
>
)}
)
}
// Sub-components
function SubCard({ children, onRemove, conflict }) {
return (
✕
{children}
)
}
function SectionHeader({ label, addLabel, onAdd, customActions, collapsed, onToggle }) {
return (
{collapsed ? '▶' : '▼'}
{label}
e.stopPropagation()}>
{customActions}
{onAdd && {addLabel ?? '+ Feld'}}
)
}
function Empty() {
return nicht erwähnt
}
function Field({ label, value, onSave, onRemove, conflict, editableLabel, onLabelSave }) {
const [editingVal, setEditingVal] = useState(false)
const [editingLabel, setEditingLabel] = useState(false)
const [val, setVal] = useState(value ?? '')
const [lbl, setLbl] = useState(label ?? '')
const isEmpty = val === '' || val === null || val === undefined || val === 'null'
function saveVal() {
setEditingVal(false)
onSave(val)
}
function saveLabel() {
setEditingLabel(false)
if (lbl !== label) onLabelSave?.(lbl)
}
return (
)
}
// Helpers
function structuredJSON(obj) {
return JSON.parse(JSON.stringify(obj))
}
function setNestedValue(obj, path, value) {
const keys = path.split('.')
let current = obj
for (let i = 0; i < keys.length - 1; i++) {
const key = isNaN(keys[i]) ? keys[i] : parseInt(keys[i])
current = current[key]
}
const lastKey = isNaN(keys[keys.length - 1]) ? keys[keys.length - 1] : parseInt(keys[keys.length - 1])
current[lastKey] = value
}
// Styles
const s = {
card: { borderRadius: 10, border: '0.5px solid #E2E0D8', background: '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column', maxHeight: '80vh' },
header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', cursor: 'pointer', userSelect: 'none' },
title: { fontSize: 13, fontWeight: 500 },
chevron: { fontSize: 10, color: '#A09D94' },
body: { padding: '12px 14px', borderTop: '0.5px solid #E2E0D8', overflowY: 'auto', flex: 1 },
sectionHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 0 6px' },
sectionLabel: { fontSize: 10, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: '#A09D94' },
addBtn: { fontSize: 10, color: '#378ADD', cursor: 'pointer', userSelect: 'none' },
subCardList: { display: 'flex', flexDirection: 'column', gap: 6 },
subCardGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 },
subCard: { background: '#F7F6F3', border: '0.5px solid #E2E0D8', borderRadius: 8, padding: '6px 10px' },
subCardRemove: { fontSize: 10, color: '#A09D94', cursor: 'pointer', lineHeight: 1 },
singleSub: { background: '#F7F6F3', border: '0.5px solid #E2E0D8', borderRadius: 8, padding: '6px 10px' },
twoCol: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 12px' },
empty: { fontSize: 11, color: '#A09D94', fontStyle: 'italic', padding: '4px 0' },
field: { display: 'flex', flexDirection: 'column', gap: 1, padding: '3px 0', borderBottom: '0.5px solid #E2E0D8' },
fieldLabel: { fontSize: 9, fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase', color: '#A09D94' },
fieldValue: { fontSize: 12, color: '#1A1916', cursor: 'pointer', padding: '1px 2px', borderRadius: 3 },
fieldSelect: { fontSize: 11, border: 'none', background: 'transparent', color: '#1A1916', cursor: 'pointer', fontFamily: 'inherit', outline: 'none', padding: '1px 2px' },
fieldInput: { fontSize: 12, border: '0.5px solid #378ADD', borderRadius: 4, padding: '1px 6px', outline: 'none', width: '100%' },
labelInput: { fontSize: 9, border: 'none', borderBottom: '0.5px dashed #C8C6BC', outline: 'none', background: 'transparent', color: '#A09D94', fontFamily: 'inherit', width: '80px' },
fieldRemove: { fontSize: 9, color: '#A09D94', cursor: 'pointer', padding: '0 2px', opacity: 0.6 },
conflict: { color: '#B91C1C' },
missing: { color: '#A09D94', fontStyle: 'italic' },
confirmStrip: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderTop: '0.5px solid #E2E0D8', background: '#F7F6F3', flexShrink: 0 },
confirmHint: { fontSize: 11, color: '#6B6960' },
btn: { fontFamily: 'inherit', fontSize: 11, padding: '4px 12px', borderRadius: 6, border: '0.5px solid #1A1916', background: '#1A1916', color: '#fff', cursor: 'pointer' },
confirmedBadge:{ fontSize:10, fontWeight:500, padding:'1px 7px', borderRadius:99, background:'#F0FDF4', color:'#166534', border:'0.5px solid #BBF7D0' },
}