ClinDoc_CDSS / frontend /src /components /ExtractionCard.jsx
iyadh-bencheikh's picture
Update
764531e
// 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 (
<div style={s.card}>
<div style={s.header} onClick={() => setCollapsed(c => !c)}>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={s.title}>Strukturierte Patientendaten</span>
{confirmed && <span style={s.confirmedBadge}>bestätigt</span>}
</div>
<span style={s.chevron}>{collapsed ? '▶' : '▼'}</span>
</div>
{!collapsed && (
<>
<div style={s.body}>
{/* Patient */}
<SectionHeader label="Patient" addLabel="+ Feld" onAdd={() => addScalarField('patient')} collapsed={sectionsCollapsed['patient']} onToggle={() => toggleSection('patient')} />
{!sectionsCollapsed['patient'] && (
<div style={s.singleSub}>
<div style={s.twoCol}>
<Field label="Name" value={edited.patient?.name} onSave={v => update('patient.name', v)} />
<Field label="Alter" value={edited.patient?.age} onSave={v => update('patient.age', v)} />
<Field label="Geschlecht" value={edited.patient?.sex} onSave={v => update('patient.sex', v)} />
{Object.entries(edited.patient ?? {})
.filter(([k]) => !PATIENT_KEYS.includes(k))
.map(([k, v]) => (
<Field
key={k}
label={k}
value={v}
editableLabel
onSave={val => update(`patient.${k}`, val)}
onLabelSave={newKey => renameScalarField('patient', k, newKey)}
onRemove={() => removeScalarField('patient', k)}
/>
))
}
</div>
</div>
)}
{/* Anamnese */}
<SectionHeader
label="Anamnese"
collapsed={sectionsCollapsed['history']}
onToggle={() => toggleSection('history')}
customActions={
<>
<span style={s.addBtn} onClick={() => setEdited(prev => {
const next = structuredJSON(prev)
next.history.medical = [...(next.history.medical ?? []), { _id: crypto.randomUUID(), value: '' }]
return next
})}>+ Medizinisch</span>
<span style={s.addBtn} onClick={() => setEdited(prev => {
const next = structuredJSON(prev)
next.history.surgical = [...(next.history.surgical ?? []), { _id: crypto.randomUUID(), value: '' }]
return next
})}>+ Chirurgisch</span>
</>
}
/>
{!sectionsCollapsed['history'] && (
!edited.history?.medical?.length && !edited.history?.surgical?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.history?.medical?.map((m) => (
<SubCard key={m._id} onRemove={() => setEdited(prev => {
const next = structuredJSON(prev)
next.history.medical = next.history.medical.filter(x => x._id !== m._id)
return next
})}>
<Field
label="Medizinisch"
value={typeof m === 'object' ? (m.value ?? JSON.stringify(m)) : m}
onSave={v => 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
})}
/>
</SubCard>
))}
{edited.history?.surgical?.map((s2) => (
<SubCard key={s2._id} onRemove={() => setEdited(prev => {
const next = structuredJSON(prev)
next.history.surgical = next.history.surgical.filter(x => x._id !== s2._id)
return next
})}>
<Field
label="Chirurgisch"
value={typeof s2 === 'object' ? (s2.value ?? JSON.stringify(s2)) : s2}
onSave={v => 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
})}
/>
</SubCard>
))}
</div>
)}
{/* Diagnosen */}
<SectionHeader label="Diagnosen" addLabel="+ Diagnose" onAdd={() => addArrayItem('diagnoses', { name: '', certainty: 'confirmed' })} collapsed={sectionsCollapsed['diagnoses']} onToggle={() => toggleSection('diagnoses')} />
{!sectionsCollapsed['diagnoses'] && (
!edited.diagnoses?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.diagnoses.map((d, i) => (
<SubCard key={d._id} onRemove={() => removeArrayItem('diagnoses', d._id)}>
<Field label="Name" value={d.name?.value ?? d.name} onSave={v => update(`diagnoses.${i}.name`, v)} />
<div style={s.field}>
<span style={s.fieldLabel}>Sicherheit</span>
<select
style={s.fieldSelect}
value={d.certainty ?? 'confirmed'}
onChange={e => update(`diagnoses.${i}.certainty`, e.target.value)}
>
<option value="confirmed">bestätigt</option>
<option value="suspected">vermutet</option>
</select>
</div>
</SubCard>
))}
</div>
)}
{/* Allergien */}
<SectionHeader label="Allergien & Unverträglichkeiten" addLabel="+ Allergie" onAdd={() => addArrayItem('allergies', { substance: '', reaction: '' })} collapsed={sectionsCollapsed['allergies']} onToggle={() => toggleSection('allergies')} />
{!sectionsCollapsed['allergies'] && (
!edited.allergies?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.allergies.map((a, i) => (
<SubCard key={a._id} onRemove={() => removeArrayItem('allergies', a._id)}>
<Field label="Substanz" value={a.substance} onSave={v => update(`allergies.${i}.substance`, v)} />
<Field label="Reaktion" value={a.reaction} onSave={v => update(`allergies.${i}.reaction`, v)} />
</SubCard>
))}
</div>
)}
{/* Medikation */}
<SectionHeader label="Medikation" addLabel="+ Medikament" onAdd={() => addArrayItem('medications', { name: '', dose: '', regimen: '' })} collapsed={sectionsCollapsed['medications']} onToggle={() => toggleSection('medications')} />
{!sectionsCollapsed['medications'] && (
!edited.medications?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.medications.map((m, i) => (
<SubCard key={m._id} onRemove={() => removeArrayItem('medications', m._id)}>
<Field label="Name" value={m.name} onSave={v => update(`medications.${i}.name`, v)} />
<Field label="Dosis" value={m.dose} onSave={v => update(`medications.${i}.dose`, v)} />
<Field label="Einnahme" value={m.regimen} onSave={v => update(`medications.${i}.regimen`, v)} />
</SubCard>
))}
</div>
)}
{/* Vitalwerte */}
<SectionHeader label="Vitalwerte" addLabel="+ Feld" onAdd={() => addScalarField('vital_signs')} collapsed={sectionsCollapsed['vital_signs']} onToggle={() => toggleSection('vital_signs')} />
{!sectionsCollapsed['vital_signs'] && (
<div style={s.singleSub}>
<div style={s.twoCol}>
{Object.entries(edited.vital_signs ?? {}).map(([k, v]) => {
const isKnown = k in VITALSIGNS_LABELS
return (
<Field
key={k}
label={isKnown ? VITALSIGNS_LABELS[k] : k}
value={v}
editableLabel={!isKnown}
onSave={val => update(`vital_signs.${k}`, val)}
onLabelSave={newKey => renameScalarField('vital_signs', k, newKey)}
onRemove={() => removeScalarField('vital_signs', k)}
/>
)
})}
</div>
</div>
)}
{/* Laborwerte */}
<SectionHeader label="Laborwerte" addLabel="+ Labor" onAdd={() => addArrayItem('laboratory', { name: '', value: '', unit: '', abnormal: false })} collapsed={sectionsCollapsed['laboratory']} onToggle={() => toggleSection('laboratory')} />
{!sectionsCollapsed['laboratory'] && (
!edited.laboratory?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.laboratory.map((l, i) => (
<SubCard key={l._id} onRemove={() => removeArrayItem('laboratory', l._id)} conflict={l.abnormal === true}>
<Field label="Name" value={l.name} onSave={v => update(`laboratory.${i}.name`, v)} />
<Field label="Wert" value={l.value !== null ? `${l.value ?? ''} ${l.unit ?? ''}`.trim() : null} conflict={l.abnormal === true} onSave={v => update(`laboratory.${i}.value`, v)} />
<div style={{ ...s.field, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={s.fieldLabel}>Abnormal</span>
<input type="checkbox" checked={!!l.abnormal} onChange={e => update(`laboratory.${i}.abnormal`, e.target.checked)} style={{ cursor: 'pointer' }} />
</div>
</SubCard>
))}
</div>
)}
{/* Verordnete Behandlung */}
<SectionHeader label="Verordnete Behandlung" addLabel="+ Behandlung" onAdd={() => addArrayItem('treatment_ordered', { name: '', dose: '', route: '' })} collapsed={sectionsCollapsed['treatment_ordered']} onToggle={() => toggleSection('treatment_ordered')} />
{!sectionsCollapsed['treatment_ordered'] && (
!edited.treatment_ordered?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.treatment_ordered.map((t, i) => (
<SubCard key={t._id} onRemove={() => removeArrayItem('treatment_ordered', t._id)}>
<Field label="Name" value={t.name} onSave={v => update(`treatment_ordered.${i}.name`, v)} />
<Field label="Dosis" value={t.dose} onSave={v => update(`treatment_ordered.${i}.dose`, v)} />
<Field label="Route" value={t.route} onSave={v => update(`treatment_ordered.${i}.route`, v)} />
</SubCard>
))}
</div>
)}
{/* Ausstehende Ergebnisse */}
<SectionHeader label="Ausstehende Ergebnisse" addLabel="+ Ergebnis" onAdd={() => addArrayItem('pending_results', { value: '' })} collapsed={sectionsCollapsed['pending_results']} onToggle={() => toggleSection('pending_results')} />
{!sectionsCollapsed['pending_results'] && (
!edited.pending_results?.length
? <Empty />
: <div style={s.subCardGrid}>
{edited.pending_results.map((r, i) => (
<SubCard key={r._id ?? i} onRemove={() => removeArrayItem('pending_results', r._id)}>
<Field
label={`Ergebnis ${i + 1}`}
value={typeof r === 'object' ? (r.value ?? JSON.stringify(r)) : r}
onSave={v => update(`pending_results.${i}.value`, v)}
/>
</SubCard>
))}
</div>
)}
{safetyFlags && safetyFlags.length > 0 && (
<div style={{ marginTop: 14 }}>
<SafetyCard flags={safetyFlags} />
</div>
)}
</div>
<div style={s.confirmStrip}>
<span style={s.confirmHint}>Extraktion korrekt? Werte anklicken zum Bearbeiten.</span>
<button
style={{ ...s.btn, opacity: loading ? 0.6 : 1 }}
disabled={!!loading}
onClick={handleConfirm}
>
Weiter zur Dokumentenzusammenfassung →
</button>
</div>
</>
)}
</div>
)
}
// Sub-components
function SubCard({ children, onRemove, conflict }) {
return (
<div style={{
...s.subCard,
...(conflict ? { borderLeft: '2px solid #EF4444' } : {}),
position: 'relative',
paddingTop: 14,
}}>
<span style={{ ...s.subCardRemove, position: 'absolute', top: 4, right: 6 }} onClick={onRemove}></span>
{children}
</div>
)
}
function SectionHeader({ label, addLabel, onAdd, customActions, collapsed, onToggle }) {
return (
<div style={s.sectionHeader} onClick={onToggle} title={collapsed ? 'Aufklappen' : 'Einklappen'}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<span style={{ fontSize: 8, color: '#A09D94' }}>{collapsed ? '▶' : '▼'}</span>
<span style={s.sectionLabel}>{label}</span>
</div>
<div style={{ display: 'flex', gap: 8 }} onClick={e => e.stopPropagation()}>
{customActions}
{onAdd && <span style={s.addBtn} onClick={onAdd}>{addLabel ?? '+ Feld'}</span>}
</div>
</div>
)
}
function Empty() {
return <div style={s.empty}>nicht erwähnt</div>
}
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 (
<div style={s.field}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{editableLabel && editingLabel ? (
<input
style={s.labelInput}
value={lbl}
autoFocus
onChange={e => setLbl(e.target.value)}
onBlur={saveLabel}
onKeyDown={e => e.key === 'Enter' && saveLabel()}
/>
) : (
<span
style={{ ...s.fieldLabel, ...(editableLabel ? { cursor: 'pointer', borderBottom: '0.5px dashed #C8C6BC' } : {}) }}
onClick={() => editableLabel && setEditingLabel(true)}
>
{label ?? '—'}
</span>
)}
{onRemove && <span style={s.fieldRemove} onClick={onRemove}></span>}
</div>
{editingVal ? (
<input
style={s.fieldInput}
value={val}
autoFocus
onChange={e => setVal(e.target.value)}
onBlur={saveVal}
onKeyDown={e => e.key === 'Enter' && saveVal()}
/>
) : (
<span
style={{ ...s.fieldValue, ...(conflict ? s.conflict : {}), ...(isEmpty ? s.missing : {}) }}
onClick={() => setEditingVal(true)}
title="Klicken zum Bearbeiten"
>
{isEmpty ? 'nicht erwähnt' : val}
</span>
)}
</div>
)
}
// 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' },
}