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>
1090 lines
45 KiB
JavaScript
1090 lines
45 KiB
JavaScript
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>
|
||
)
|
||
}
|