// 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 (
{editableLabel && editingLabel ? ( setLbl(e.target.value)} onBlur={saveLabel} onKeyDown={e => e.key === 'Enter' && saveLabel()} /> ) : ( editableLabel && setEditingLabel(true)} > {label ?? '—'} )} {onRemove && }
{editingVal ? ( setVal(e.target.value)} onBlur={saveVal} onKeyDown={e => e.key === 'Enter' && saveVal()} /> ) : ( setEditingVal(true)} title="Klicken zum Bearbeiten" > {isEmpty ? 'nicht erwähnt' : val} )}
) } // 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' }, }