mitai-jinkendo/frontend/src/pages/VitalsPage.jsx

1090 lines
45 KiB
React
Raw Normal View History

import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, X, Save, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react'
import { api } from '../utils/api'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
/**
* VitalsPage - Refactored v9d Phase 2d (Mobile-optimized, Inline Editing, Smart Upsert)
*
* 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({
id: null, // Für Update
date: dayjs().format('YYYY-MM-DD'),
resting_hr: '',
hrv: '',
vo2_max: '',
spo2: '',
respiratory_rate: '',
note: ''
})
const [editingId, setEditingId] = useState(null)
const [editForm, setEditForm] = useState({})
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() }, [])
// Smart Upsert: Beim Datum-Wechsel existierenden Eintrag laden
useEffect(() => {
const loadExisting = async () => {
if (!form.date) return
try {
const existing = await api.getBaselineByDate(form.date)
if (existing && existing.id) {
// Eintrag gefunden → Formular vorausfüllen
setForm({
id: existing.id,
date: existing.date,
resting_hr: existing.resting_hr || '',
hrv: existing.hrv || '',
vo2_max: existing.vo2_max || '',
spo2: existing.spo2 || '',
respiratory_rate: existing.respiratory_rate || '',
note: existing.note || ''
})
} else {
// Kein Eintrag → leeres Formular (nur Datum behalten)
setForm(f => ({
id: null,
date: f.date,
resting_hr: '',
hrv: '',
vo2_max: '',
spo2: '',
respiratory_rate: '',
note: ''
}))
}
} catch (err) {
// 404 ist ok (kein Eintrag vorhanden)
if (!err.message.includes('404')) {
console.error('Fehler beim Laden:', err)
}
}
}
loadExisting()
}, [form.date])
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
}
if (form.id) {
await api.updateBaseline(form.id, payload)
} else {
await api.createBaseline(payload)
}
setSuccess(true)
await load()
setTimeout(() => {
setSuccess(false)
setForm({ id: null, 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 startEdit = (entry) => {
setEditingId(entry.id)
setEditForm({
resting_hr: entry.resting_hr || '',
hrv: entry.hrv || '',
vo2_max: entry.vo2_max || '',
spo2: entry.spo2 || '',
respiratory_rate: entry.respiratory_rate || '',
note: entry.note || ''
})
}
const cancelEdit = () => {
setEditingId(null)
setEditForm({})
}
const saveEdit = async (id) => {
try {
const entry = entries.find(e => e.id === id)
const payload = { date: entry.date }
if (editForm.resting_hr) payload.resting_hr = parseInt(editForm.resting_hr)
if (editForm.hrv) payload.hrv = parseInt(editForm.hrv)
if (editForm.vo2_max) payload.vo2_max = parseFloat(editForm.vo2_max)
if (editForm.spo2) payload.spo2 = parseInt(editForm.spo2)
if (editForm.respiratory_rate) payload.respiratory_rate = parseFloat(editForm.respiratory_rate)
if (editForm.note) payload.note = editForm.note
await api.updateBaseline(id, payload)
setEditingId(null)
setEditForm({})
await load()
} catch (err) {
setError(err.message)
}
}
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: 16 }}>
Einmal täglich, morgens vor dem Aufstehen (nüchtern). {form.id && <strong style={{ color: 'var(--accent)' }}>Eintrag wird aktualisiert.</strong>}
</p>
{error && <div style={{ padding: 10, background: '#FCEBEB', border: '1px solid #D85A30', borderRadius: 8, fontSize: 13, color: '#D85A30', marginBottom: 12 }}>{error}</div>}
{/* Datum - volle Breite */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4, color: 'var(--text2)' }}>Datum</label>
<input
type="date"
className="form-input"
style={{ width: '100%' }}
value={form.date}
onChange={e => setForm(f => ({ ...f, date: e.target.value }))}
/>
</div>
{/* Sektion: Herzfunktion */}
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--text1)' }}>❤️ Herzfunktion</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Ruhepuls (bpm)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={30}
max={120}
placeholder="z.B. 58"
value={form.resting_hr}
onChange={e => setForm(f => ({ ...f, resting_hr: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>HRV (ms)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={1}
max={300}
placeholder="optional"
value={form.hrv}
onChange={e => setForm(f => ({ ...f, hrv: e.target.value }))}
/>
</div>
{/* Sektion: Fitness & Atmung */}
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>🏃 Fitness & Atmung</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>VO2 Max (ml/kg/min)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={10}
max={90}
step={0.1}
placeholder="optional"
value={form.vo2_max}
onChange={e => setForm(f => ({ ...f, vo2_max: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>SpO2 (%)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={70}
max={100}
placeholder="optional"
value={form.spo2}
onChange={e => setForm(f => ({ ...f, spo2: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Atemfrequenz (/min)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={1}
max={60}
step={0.1}
placeholder="optional"
value={form.respiratory_rate}
onChange={e => setForm(f => ({ ...f, respiratory_rate: e.target.value }))}
/>
</div>
{/* Notiz */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Notiz</label>
<input
type="text"
className="form-input"
style={{ width: '100%' }}
placeholder="optional"
value={form.note}
onChange={e => setForm(f => ({ ...f, note: e.target.value }))}
/>
</div>
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving}>
{success ? '✓ Gespeichert!' : saving ? 'Speichere...' : form.id ? 'Aktualisieren' : '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 }}>
{editingId === e.id ? (
// Edit Mode
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text2)' }}>
{dayjs(e.date).format('dd, DD. MMM YYYY')}
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Ruhepuls (bpm)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.resting_hr}
onChange={e => setEditForm(f => ({ ...f, resting_hr: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>HRV (ms)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.hrv}
onChange={e => setEditForm(f => ({ ...f, hrv: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>VO2 Max</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
step={0.1}
value={editForm.vo2_max}
onChange={e => setEditForm(f => ({ ...f, vo2_max: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>SpO2 (%)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.spo2}
onChange={e => setEditForm(f => ({ ...f, spo2: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Atemfrequenz (/min)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
step={0.1}
value={editForm.respiratory_rate}
onChange={e => setEditForm(f => ({ ...f, respiratory_rate: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Notiz</label>
<input
type="text"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.note}
onChange={e => setEditForm(f => ({ ...f, note: e.target.value }))}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary" style={{ flex: 1, fontSize: 13 }} onClick={() => saveEdit(e.id)}>
<Save size={13} style={{ marginRight: 4 }} /> Speichern
</button>
<button className="btn btn-secondary" style={{ fontSize: 13 }} onClick={cancelEdit}>
<X size={13} />
</button>
</div>
</div>
) : (
// View Mode
<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>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-secondary" style={{ padding: '5px 8px' }} onClick={() => startEdit(e)}>
<Pencil size={13} />
</button>
<button className="btn btn-danger" style={{ padding: '5px 8px' }} onClick={() => handleDelete(e.id)}>
<Trash2 size={13} />
</button>
</div>
</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 [editingId, setEditingId] = useState(null)
const [editForm, setEditForm] = useState({})
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 startEdit = (entry) => {
const dt = dayjs(entry.measured_at)
setEditingId(entry.id)
setEditForm({
date: dt.format('YYYY-MM-DD'),
time: dt.format('HH:mm'),
systolic: entry.systolic,
diastolic: entry.diastolic,
pulse: entry.pulse || '',
context: entry.context,
irregular_heartbeat: entry.irregular_heartbeat,
possible_afib: entry.possible_afib,
note: entry.note || ''
})
}
const cancelEdit = () => {
setEditingId(null)
setEditForm({})
}
const saveEdit = async (id) => {
try {
const measured_at = `${editForm.date} ${editForm.time}:00`
const payload = {
measured_at,
systolic: parseInt(editForm.systolic),
diastolic: parseInt(editForm.diastolic),
pulse: editForm.pulse ? parseInt(editForm.pulse) : null,
context: editForm.context,
irregular_heartbeat: editForm.irregular_heartbeat,
possible_afib: editForm.possible_afib,
note: editForm.note || null
}
await api.updateBloodPressure(id, payload)
setEditingId(null)
setEditForm({})
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: 16 }}>
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: 12 }}>{error}</div>}
{/* Datum + Uhrzeit - volle Breite */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4, color: 'var(--text2)' }}>Datum</label>
<input
type="date"
className="form-input"
style={{ width: '100%' }}
value={form.date}
onChange={e => setForm(f => ({ ...f, date: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4, color: 'var(--text2)' }}>Uhrzeit</label>
<input
type="time"
className="form-input"
style={{ width: '100%' }}
value={form.time}
onChange={e => setForm(f => ({ ...f, time: e.target.value }))}
/>
</div>
{/* Sektion: Blutdruck */}
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--text1)' }}>🩸 Blutdruck</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Systolisch (mmHg)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={50}
max={250}
placeholder="z.B. 125"
value={form.systolic}
onChange={e => setForm(f => ({ ...f, systolic: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Diastolisch (mmHg)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={30}
max={150}
placeholder="z.B. 83"
value={form.diastolic}
onChange={e => setForm(f => ({ ...f, diastolic: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Puls (bpm)</label>
<input
type="number"
className="form-input"
style={{ width: '100%' }}
min={30}
max={200}
placeholder="optional"
value={form.pulse}
onChange={e => setForm(f => ({ ...f, pulse: e.target.value }))}
/>
</div>
{/* Kontext */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Kontext</label>
<select
className="form-input"
style={{ width: '100%' }}
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>
</div>
{/* Checkboxen */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, marginBottom: 6 }}>
<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>
{/* Notiz */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4, color: 'var(--text2)' }}>Notiz</label>
<input
type="text"
className="form-input"
style={{ width: '100%' }}
placeholder="optional"
value={form.note}
onChange={e => setForm(f => ({ ...f, note: e.target.value }))}
/>
</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 }}>
{editingId === e.id ? (
// Edit Mode
<div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Datum</label>
<input
type="date"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.date}
onChange={ev => setEditForm(f => ({ ...f, date: ev.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Uhrzeit</label>
<input
type="time"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.time}
onChange={ev => setEditForm(f => ({ ...f, time: ev.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Systolisch (mmHg)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.systolic}
onChange={ev => setEditForm(f => ({ ...f, systolic: ev.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Diastolisch (mmHg)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.diastolic}
onChange={ev => setEditForm(f => ({ ...f, diastolic: ev.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Puls (bpm)</label>
<input
type="number"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.pulse}
onChange={ev => setEditForm(f => ({ ...f, pulse: ev.target.value }))}
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Kontext</label>
<select
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.context}
onChange={ev => setEditForm(f => ({ ...f, context: ev.target.value }))}
>
{CONTEXT_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 4 }}>
<input
type="checkbox"
checked={editForm.irregular_heartbeat}
onChange={ev => setEditForm(f => ({ ...f, irregular_heartbeat: ev.target.checked }))}
/>
Unregelmäßiger Herzschlag
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input
type="checkbox"
checked={editForm.possible_afib}
onChange={ev => setEditForm(f => ({ ...f, possible_afib: ev.target.checked }))}
/>
Mögliches Vorhofflimmern
</label>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>Notiz</label>
<input
type="text"
className="form-input"
style={{ width: '100%', fontSize: 13 }}
value={editForm.note}
onChange={ev => setEditForm(f => ({ ...f, note: ev.target.value }))}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary" style={{ flex: 1, fontSize: 13 }} onClick={() => saveEdit(e.id)}>
<Save size={13} style={{ marginRight: 4 }} /> Speichern
</button>
<button className="btn btn-secondary" style={{ fontSize: 13 }} onClick={cancelEdit}>
<X size={13} />
</button>
</div>
</div>
) : (
// View Mode
<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>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-secondary" style={{ padding: '5px 8px' }} onClick={() => startEdit(e)}>
<Pencil size={13} />
</button>
<button className="btn btn-danger" style={{ padding: '5px 8px' }} onClick={() => handleDelete(e.id)}>
<Trash2 size={13} />
</button>
</div>
</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: result.errors > 0 ? '#FCEBEB' : '#E8F7F0', border: `1px solid ${result.errors > 0 ? '#D85A30' : '#1D9E75'}`, borderRadius: 8, fontSize: 13, color: result.errors > 0 ? '#D85A30' : '#085041', marginBottom: 12 }}>
<strong>Import {result.errors > 0 ? 'mit Fehlern' : 'erfolgreich'} ({result.type === 'omron' ? 'Omron' : 'Apple Health'}):</strong><br />
{result.inserted} neu · {result.updated} aktualisiert · {result.skipped} übersprungen
{result.errors > 0 && <> · {result.errors} Fehler</>}
{result.error_details && result.error_details.length > 0 && (
<div style={{ marginTop: 8, padding: 8, background: 'rgba(0,0,0,0.05)', borderRadius: 4, fontSize: 12, fontFamily: 'monospace' }}>
<strong>Fehler-Details:</strong>
{result.error_details.map((err, i) => (
<div key={i} style={{ marginTop: 4 }}>• {err}</div>
))}
</div>
)}
</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 className="capture-page">
<h1 className="page-title">Vitalwerte</h1>
<div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
<button className={'tab' + (tab === 'baseline' ? ' active' : '')} onClick={() => setTab('baseline')}>
Morgenmessung
</button>
<button className={'tab' + (tab === 'bp' ? ' active' : '')} onClick={() => setTab('bp')}>
Blutdruck
</button>
<button className={'tab' + (tab === 'import' ? ' active' : '')} onClick={() => setTab('import')}>
Import
</button>
</div>
<div style={{ paddingBottom: 80 }}>
{tab === 'baseline' && <BaselineTab key={`baseline-${refreshKey}`} />}
{tab === 'bp' && <BloodPressureTab key={`bp-${refreshKey}`} />}
{tab === 'import' && <ImportTab onImportComplete={handleImportComplete} />}
</div>
</div>
)
}