mitai-jinkendo/frontend/src/pages/VitalsPage.jsx
Lars 6b64cf31c4
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
fix: return error details in import response for debugging
Problem: Errors during import were logged but not visible to user.

Changes:
- Backend: Collect error messages and return in response (first 10 errors)
- Frontend: Display error details in import result box
- UI: Red background when errors > 0, shows detailed error messages

Now users can see exactly which rows failed and why.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:47:36 +01:00

1090 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<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>
)
}