v9d Phase 2d: Vitals Module Refactoring (Baseline + Blood Pressure) #22
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -6,13 +8,629 @@ dayjs.locale('de')
|
|||
/**
|
||||
* VitalsPage - Refactored v9d Phase 2d
|
||||
*
|
||||
* Separated into:
|
||||
* - Tab 1: Morgenmessung (Baseline) - once daily
|
||||
* - Tab 2: Blutdruck (BP) - multiple daily with context
|
||||
* - Tab 3: Import
|
||||
* Separated vitals tracking:
|
||||
* - Baseline Vitals: Once daily (morning, fasted) - RHR, HRV, VO2 Max, SpO2
|
||||
* - Blood Pressure: Multiple daily with context - Systolic/Diastolic + context tagging
|
||||
* - Imports: Omron CSV (BP) + Apple Health CSV (Baseline)
|
||||
*/
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// BASELINE VITALS TAB (Once daily, morning)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
function BaselineTab() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [form, setForm] = useState({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
resting_hr: '',
|
||||
hrv: '',
|
||||
vo2_max: '',
|
||||
spo2: '',
|
||||
respiratory_rate: '',
|
||||
note: ''
|
||||
})
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [e, s] = await Promise.all([
|
||||
api.listBaseline(90),
|
||||
api.getBaselineStats(30)
|
||||
])
|
||||
setEntries(e)
|
||||
setStats(s)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = { date: form.date }
|
||||
if (form.resting_hr) payload.resting_hr = parseInt(form.resting_hr)
|
||||
if (form.hrv) payload.hrv = parseInt(form.hrv)
|
||||
if (form.vo2_max) payload.vo2_max = parseFloat(form.vo2_max)
|
||||
if (form.spo2) payload.spo2 = parseInt(form.spo2)
|
||||
if (form.respiratory_rate) payload.respiratory_rate = parseFloat(form.respiratory_rate)
|
||||
if (form.note) payload.note = form.note
|
||||
|
||||
if (!payload.resting_hr && !payload.hrv && !payload.vo2_max && !payload.spo2 && !payload.respiratory_rate) {
|
||||
setError('Mindestens ein Wert muss angegeben werden')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
await api.createBaseline(payload)
|
||||
setSuccess(true)
|
||||
await load()
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
setForm({ date: dayjs().format('YYYY-MM-DD'), resting_hr: '', hrv: '', vo2_max: '', spo2: '', respiratory_rate: '', note: '' })
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Eintrag löschen?')) return
|
||||
try {
|
||||
await api.deleteBaseline(id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendIcon = (trend) => {
|
||||
if (trend === 'increasing') return <TrendingUp size={14} style={{ color: '#D85A30' }} />
|
||||
if (trend === 'decreasing') return <TrendingDown size={14} style={{ color: '#1D9E75' }} />
|
||||
return <Minus size={14} style={{ color: 'var(--text3)' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
{stats && stats.total_entries > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 8 }}>
|
||||
{stats.avg_rhr_7d && (
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>{Math.round(stats.avg_rhr_7d)}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø RHR 7d</div>
|
||||
</div>
|
||||
)}
|
||||
{stats.avg_hrv_7d && (
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>{Math.round(stats.avg_hrv_7d)}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
|
||||
</div>
|
||||
)}
|
||||
{stats.latest_vo2_max && (
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>{stats.latest_vo2_max.toFixed(1)}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>VO2 Max</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">Morgenmessung erfassen</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
|
||||
Einmal täglich, morgens vor dem Aufstehen (nüchtern)
|
||||
</p>
|
||||
|
||||
{error && <div style={{ padding: 10, background: '#FCEBEB', border: '1px solid #D85A30', borderRadius: 8, fontSize: 13, color: '#D85A30', marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Datum</label>
|
||||
<input type="date" className="form-input" style={{ width: 140 }} value={form.date} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ruhepuls</label>
|
||||
<input type="number" className="form-input" min={30} max={120} placeholder="z.B. 58" value={form.resting_hr} onChange={e => setForm(f => ({ ...f, resting_hr: e.target.value }))} />
|
||||
<span className="form-unit">bpm</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">HRV</label>
|
||||
<input type="number" className="form-input" min={1} max={300} placeholder="optional" value={form.hrv} onChange={e => setForm(f => ({ ...f, hrv: e.target.value }))} />
|
||||
<span className="form-unit">ms</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">VO2 Max</label>
|
||||
<input type="number" className="form-input" min={10} max={90} step={0.1} placeholder="optional" value={form.vo2_max} onChange={e => setForm(f => ({ ...f, vo2_max: e.target.value }))} />
|
||||
<span className="form-unit">ml/kg/min</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">SpO2</label>
|
||||
<input type="number" className="form-input" min={70} max={100} placeholder="optional" value={form.spo2} onChange={e => setForm(f => ({ ...f, spo2: e.target.value }))} />
|
||||
<span className="form-unit">%</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Atemfrequenz</label>
|
||||
<input type="number" className="form-input" min={1} max={60} step={0.1} placeholder="optional" value={form.respiratory_rate} onChange={e => setForm(f => ({ ...f, respiratory_rate: e.target.value }))} />
|
||||
<span className="form-unit">/min</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Notiz</label>
|
||||
<input type="text" className="form-input" placeholder="optional" value={form.note} onChange={e => setForm(f => ({ ...f, note: e.target.value }))} />
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving}>
|
||||
{success ? '✓ Gespeichert!' : saving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{entries.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Letzte Messungen ({entries.length})</div>
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="card" style={{ marginBottom: 8, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
|
||||
{dayjs(e.date).format('dd, DD. MMM YYYY')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12, color: 'var(--text2)' }}>
|
||||
{e.resting_hr && <span>❤️ {e.resting_hr} bpm</span>}
|
||||
{e.hrv && <span>📊 HRV {e.hrv} ms</span>}
|
||||
{e.vo2_max && <span style={{ color: '#1D9E75', fontWeight: 600 }}>🏃 VO2 {e.vo2_max}</span>}
|
||||
{e.spo2 && <span>🫁 SpO2 {e.spo2}%</span>}
|
||||
{e.respiratory_rate && <span>💨 {e.respiratory_rate}/min</span>}
|
||||
</div>
|
||||
{e.note && <p style={{ fontSize: 12, color: 'var(--text3)', fontStyle: 'italic', marginTop: 4 }}>"{e.note}"</p>}
|
||||
</div>
|
||||
<button className="btn btn-danger" style={{ padding: '5px 8px' }} onClick={() => handleDelete(e.id)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// BLOOD PRESSURE TAB (Multiple daily with context)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: 'morning_fasted', label: 'Nüchtern (morgens)' },
|
||||
{ value: 'after_meal', label: 'Nach dem Essen' },
|
||||
{ value: 'before_training', label: 'Vor dem Training' },
|
||||
{ value: 'after_training', label: 'Nach dem Training' },
|
||||
{ value: 'evening', label: 'Abends' },
|
||||
{ value: 'stress', label: 'Bei Stress' },
|
||||
{ value: 'resting', label: 'Ruhemessung' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function BloodPressureTab() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [form, setForm] = useState({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
time: dayjs().format('HH:mm'),
|
||||
systolic: '',
|
||||
diastolic: '',
|
||||
pulse: '',
|
||||
context: 'morning_fasted',
|
||||
irregular_heartbeat: false,
|
||||
possible_afib: false,
|
||||
note: ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [e, s] = await Promise.all([
|
||||
api.listBloodPressure(90),
|
||||
api.getBPStats(30)
|
||||
])
|
||||
setEntries(e)
|
||||
setStats(s)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.systolic || !form.diastolic) {
|
||||
setError('Systolisch und Diastolisch sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const measured_at = `${form.date} ${form.time}:00`
|
||||
const payload = {
|
||||
measured_at,
|
||||
systolic: parseInt(form.systolic),
|
||||
diastolic: parseInt(form.diastolic),
|
||||
pulse: form.pulse ? parseInt(form.pulse) : null,
|
||||
context: form.context,
|
||||
irregular_heartbeat: form.irregular_heartbeat,
|
||||
possible_afib: form.possible_afib,
|
||||
note: form.note || null
|
||||
}
|
||||
|
||||
await api.createBloodPressure(payload)
|
||||
setSuccess(true)
|
||||
await load()
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
setForm({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
time: dayjs().format('HH:mm'),
|
||||
systolic: '',
|
||||
diastolic: '',
|
||||
pulse: '',
|
||||
context: 'morning_fasted',
|
||||
irregular_heartbeat: false,
|
||||
possible_afib: false,
|
||||
note: ''
|
||||
})
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Messung löschen?')) return
|
||||
try {
|
||||
await api.deleteBloodPressure(id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getBPCategory = (sys, dia) => {
|
||||
if (sys < 120 && dia < 80) return { label: 'Optimal', color: '#1D9E75' }
|
||||
if (sys < 130 && dia < 85) return { label: 'Normal', color: '#1D9E75' }
|
||||
if (sys < 140 && dia < 90) return { label: 'Hochnormal', color: '#EF9F27' }
|
||||
if (sys < 160 && dia < 100) return { label: 'Hypertonie Grad 1', color: '#D85A30' }
|
||||
if (sys < 180 && dia < 110) return { label: 'Hypertonie Grad 2', color: '#D85A30' }
|
||||
return { label: 'Hypertonie Grad 3', color: '#C41E3A' }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
{stats && stats.total_measurements > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 8 }}>
|
||||
{stats.avg_systolic && (
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#E74C3C' }}>
|
||||
{Math.round(stats.avg_systolic)}/{Math.round(stats.avg_diastolic)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Blutdruck 30d</div>
|
||||
</div>
|
||||
)}
|
||||
{stats.bp_category && (
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: getBPCategory(stats.avg_systolic, stats.avg_diastolic).color }}>
|
||||
{getBPCategory(stats.avg_systolic, stats.avg_diastolic).label}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Kategorie</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ background: 'var(--surface2)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text2)' }}>{stats.total_measurements}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Messungen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">Blutdruck messen</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
|
||||
Mehrfach täglich mit Kontext-Tagging
|
||||
</p>
|
||||
|
||||
{error && <div style={{ padding: 10, background: '#FCEBEB', border: '1px solid #D85A30', borderRadius: 8, fontSize: 13, color: '#D85A30', marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label">Datum</label>
|
||||
<input type="date" className="form-input" value={form.date} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label">Uhrzeit</label>
|
||||
<input type="time" className="form-input" value={form.time} onChange={e => setForm(f => ({ ...f, time: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Systolisch</label>
|
||||
<input type="number" className="form-input" min={50} max={250} placeholder="z.B. 125" value={form.systolic} onChange={e => setForm(f => ({ ...f, systolic: e.target.value }))} />
|
||||
<span className="form-unit">mmHg</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Diastolisch</label>
|
||||
<input type="number" className="form-input" min={30} max={150} placeholder="z.B. 83" value={form.diastolic} onChange={e => setForm(f => ({ ...f, diastolic: e.target.value }))} />
|
||||
<span className="form-unit">mmHg</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Puls</label>
|
||||
<input type="number" className="form-input" min={30} max={200} placeholder="optional" value={form.pulse} onChange={e => setForm(f => ({ ...f, pulse: e.target.value }))} />
|
||||
<span className="form-unit">bpm</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kontext</label>
|
||||
<select className="form-input" value={form.context} onChange={e => setForm(f => ({ ...f, context: e.target.value }))}>
|
||||
{CONTEXT_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, marginBottom: 4 }}>
|
||||
<input type="checkbox" checked={form.irregular_heartbeat} onChange={e => setForm(f => ({ ...f, irregular_heartbeat: e.target.checked }))} />
|
||||
Unregelmäßiger Herzschlag
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input type="checkbox" checked={form.possible_afib} onChange={e => setForm(f => ({ ...f, possible_afib: e.target.checked }))} />
|
||||
Mögliches Vorhofflimmern (AFib)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Notiz</label>
|
||||
<input type="text" className="form-input" placeholder="optional" value={form.note} onChange={e => setForm(f => ({ ...f, note: e.target.value }))} />
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving}>
|
||||
{success ? '✓ Gespeichert!' : saving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{entries.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Letzte Messungen ({entries.length})</div>
|
||||
{entries.map(e => {
|
||||
const cat = getBPCategory(e.systolic, e.diastolic)
|
||||
const ctx = CONTEXT_OPTIONS.find(o => o.value === e.context)
|
||||
return (
|
||||
<div key={e.id} className="card" style={{ marginBottom: 8, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 2 }}>
|
||||
{dayjs(e.measured_at).format('dd, DD. MMM • HH:mm')} Uhr
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: cat.color, marginBottom: 4 }}>
|
||||
{e.systolic}/{e.diastolic} mmHg
|
||||
{e.pulse && <span style={{ fontSize: 12, fontWeight: 400, marginLeft: 8 }}>💓 {e.pulse} bpm</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
{ctx?.label} · {cat.label}
|
||||
{(e.irregular_heartbeat || e.possible_afib) && <span style={{ color: '#D85A30', marginLeft: 8 }}>⚠️ {e.irregular_heartbeat ? 'Unregelmäßig' : ''} {e.possible_afib ? 'AFib?' : ''}</span>}
|
||||
</div>
|
||||
{e.note && <p style={{ fontSize: 12, color: 'var(--text3)', fontStyle: 'italic', marginTop: 4 }}>"{e.note}"</p>}
|
||||
</div>
|
||||
<button className="btn btn-danger" style={{ padding: '5px 8px' }} onClick={() => handleDelete(e.id)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// IMPORT TAB (CSV imports)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
function ImportTab({ onImportComplete }) {
|
||||
const [importingOmron, setImportingOmron] = useState(false)
|
||||
const [importingApple, setImportingApple] = useState(false)
|
||||
const [draggingOmron, setDraggingOmron] = useState(false)
|
||||
const [draggingApple, setDraggingApple] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const omronRef = useRef(null)
|
||||
const appleRef = useRef(null)
|
||||
|
||||
const handleOmronImport = async (file) => {
|
||||
if (!file || !file.name.endsWith('.csv')) {
|
||||
setError('Bitte eine CSV-Datei auswählen')
|
||||
return
|
||||
}
|
||||
|
||||
setImportingOmron(true)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await api.importBPOmron(file)
|
||||
setResult({ type: 'omron', ...res })
|
||||
if (onImportComplete) onImportComplete()
|
||||
} catch (err) {
|
||||
setError('Omron Import fehlgeschlagen: ' + err.message)
|
||||
} finally {
|
||||
setImportingOmron(false)
|
||||
if (omronRef.current) omronRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppleImport = async (file) => {
|
||||
if (!file || !file.name.endsWith('.csv')) {
|
||||
setError('Bitte eine CSV-Datei auswählen')
|
||||
return
|
||||
}
|
||||
|
||||
setImportingApple(true)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await api.importBaselineAppleHealth(file)
|
||||
setResult({ type: 'apple_health', ...res })
|
||||
if (onImportComplete) onImportComplete()
|
||||
} catch (err) {
|
||||
setError('Apple Health Import fehlgeschlagen: ' + err.message)
|
||||
} finally {
|
||||
setImportingApple(false)
|
||||
if (appleRef.current) appleRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div style={{ padding: 10, background: '#FCEBEB', border: '1px solid #D85A30', borderRadius: 8, fontSize: 13, color: '#D85A30', marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div style={{ padding: 12, background: '#E8F7F0', border: '1px solid #1D9E75', borderRadius: 8, fontSize: 13, color: '#085041', marginBottom: 12 }}>
|
||||
<strong>Import erfolgreich ({result.type === 'omron' ? 'Omron' : 'Apple Health'}):</strong><br />
|
||||
{result.inserted} neu · {result.updated} aktualisiert · {result.skipped} übersprungen
|
||||
{result.errors > 0 && <> · {result.errors} Fehler</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Omron */}
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">Omron Blutdruckmessgerät</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
|
||||
Exportiere CSV aus der Omron Connect App (Blutdruck + Puls + Warnungen)
|
||||
</p>
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDraggingOmron(true) }}
|
||||
onDragLeave={() => setDraggingOmron(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
setDraggingOmron(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleOmronImport(file)
|
||||
}}
|
||||
onClick={() => omronRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${draggingOmron ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 10,
|
||||
padding: '20px 16px',
|
||||
textAlign: 'center',
|
||||
background: draggingOmron ? 'var(--accent)14' : 'var(--surface2)',
|
||||
cursor: importingOmron ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
opacity: importingOmron ? 0.6 : 1
|
||||
}}>
|
||||
{importingOmron ? (
|
||||
<>
|
||||
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>Importiere Omron-Daten...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={28} style={{ color: draggingOmron ? 'var(--accent)' : 'var(--text3)', marginBottom: 8 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: draggingOmron ? 'var(--accent-dark)' : 'var(--text2)' }}>
|
||||
{draggingOmron ? 'CSV loslassen...' : 'Omron CSV hierher ziehen oder tippen'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input ref={omronRef} type="file" accept=".csv" onChange={e => { const file = e.target.files[0]; if (file) handleOmronImport(file) }} style={{ display: 'none' }} />
|
||||
</div>
|
||||
|
||||
{/* Apple Health */}
|
||||
<div className="card">
|
||||
<div className="card-title">Apple Health</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
|
||||
Exportiere Health-Daten (Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz)
|
||||
</p>
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDraggingApple(true) }}
|
||||
onDragLeave={() => setDraggingApple(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
setDraggingApple(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleAppleImport(file)
|
||||
}}
|
||||
onClick={() => appleRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${draggingApple ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 10,
|
||||
padding: '20px 16px',
|
||||
textAlign: 'center',
|
||||
background: draggingApple ? 'var(--accent)14' : 'var(--surface2)',
|
||||
cursor: importingApple ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
opacity: importingApple ? 0.6 : 1
|
||||
}}>
|
||||
{importingApple ? (
|
||||
<>
|
||||
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>Importiere Apple Health-Daten...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={28} style={{ color: draggingApple ? 'var(--accent)' : 'var(--text3)', marginBottom: 8 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: draggingApple ? 'var(--accent-dark)' : 'var(--text2)' }}>
|
||||
{draggingApple ? 'CSV loslassen...' : 'Apple Health CSV hierher ziehen oder tippen'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input ref={appleRef} type="file" accept=".csv" onChange={e => { const file = e.target.files[0]; if (file) handleAppleImport(file) }} style={{ display: 'none' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// MAIN COMPONENT
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
export default function VitalsPage() {
|
||||
const [tab, setTab] = useState('baseline')
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const handleImportComplete = () => {
|
||||
setRefreshKey(k => k + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -30,46 +648,11 @@ export default function VitalsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'baseline' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Morgenmessung (Baseline-Vitals)</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||
Einmal täglich, morgens vor dem Aufstehen:
|
||||
Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz
|
||||
</p>
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
🚧 Frontend-Refactoring läuft noch...<br />
|
||||
Nutze vorerst die alten Endpoints oder warte auf Update.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'bp' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Blutdruck (mehrfach täglich)</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||
Mehrfach täglich mit Kontext-Tagging:
|
||||
Nüchtern, Nach dem Essen, Vor/Nach Training, Abends, Stress
|
||||
</p>
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
🚧 Frontend-Refactoring läuft noch...<br />
|
||||
Nutze vorerst die alten Endpoints oder warte auf Update.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">CSV Import</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||
Omron (Blutdruck) + Apple Health (Baseline-Vitals)
|
||||
</p>
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
🚧 Frontend-Refactoring läuft noch...<br />
|
||||
Import-Funktionen folgen im nächsten Update.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
{tab === 'baseline' && <BaselineTab key={`baseline-${refreshKey}`} />}
|
||||
{tab === 'bp' && <BloodPressureTab key={`bp-${refreshKey}`} />}
|
||||
{tab === 'import' && <ImportTab onImportComplete={handleImportComplete} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user