mitai-jinkendo/frontend/src/pages/MeasureWizard.jsx
Lars a639d08037
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: Update capture page layout for improved responsiveness and organization across multiple screens
2026-04-05 10:14:07 +02:00

419 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, X, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import dayjs from 'dayjs'
// ── Circumference Wizard ──────────────────────────────────────────────────────
function CircumWizard({ onDone, onCancel }) {
const [step, setStep] = useState(0)
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({})
const [saving, setSaving] = useState(false)
const points = CIRCUMFERENCE_POINTS
const current = points[step]
const totalSteps = points.length
const handleNext = () => {
if (step < totalSteps - 1) setStep(s => s + 1)
else handleSave()
}
const handleSave = async () => {
setSaving(true)
try {
const payload = { date }
Object.entries(values).forEach(([k,v]) => { if(v) payload[k] = parseFloat(v) })
await api.upsertCirc(payload)
onDone()
} finally { setSaving(false) }
}
const progress = ((step + 1) / totalSteps) * 100
return (
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
{/* Header */}
<div style={{marginBottom:20}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<span style={{fontSize:12,color:'var(--text3)'}}>Schritt {step+1} von {totalSteps}</span>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
<div style={{height:'100%',background:'var(--accent)',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
</div>
</div>
{/* Datum (nur erster Schritt) */}
{step === 0 && (
<div className="card" style={{marginBottom:16}}>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={date} onChange={e=>setDate(e.target.value)}/>
<span className="form-unit"/>
</div>
</div>
)}
{/* Messpunkt */}
<div className="card" style={{flex:1,marginBottom:16}}>
{/* Punkt-Indikator */}
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
</div>
<div>
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>Umfang messen</div>
</div>
</div>
{/* Anleitung */}
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{[
['📍 Wo', current.where],
['🧍 Haltung', current.posture],
['📏 Maßband', current.how],
['💡 Tipp', current.tip],
].map(([label, text]) => (
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
</div>
))}
</div>
{/* Eingabe */}
<div style={{display:'flex',gap:10,alignItems:'center'}}>
<input
type="number" min={10} max={200} step={0.1}
className="form-input"
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
placeholder=""
value={values[current.id] || ''}
onChange={e => setValues(v => ({...v, [current.id]: e.target.value}))}
onKeyDown={e => e.key==='Enter' && handleNext()}
autoFocus
/>
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>cm</span>
</div>
{values[current.id] && (
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
{values[current.id]} cm erfasst
</div>
)}
</div>
{/* Navigation */}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-secondary" style={{flex:1}} onClick={()=>setStep(s=>s-1)} disabled={step===0}>
<ChevronLeft size={16}/> Zurück
</button>
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
step === totalSteps-1
? <><Check size={16}/> Speichern</>
: <>Weiter <ChevronRight size={16}/></>}
</button>
</div>
{/* Übersicht bereits erfasster Werte */}
{Object.keys(values).length > 0 && (
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{points.slice(0, step+1).map(p => values[p.id] ? (
<span key={p.id} onClick={()=>setStep(points.indexOf(p))}
style={{fontSize:12,background:'var(--accent-light)',color:'var(--accent-dark)',
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
{p.label}: {values[p.id]}
</span>
) : null)}
</div>
</div>
)}
</div>
)
}
// ── Caliper Wizard ────────────────────────────────────────────────────────────
function CaliperWizard({ onDone, onCancel, profile }) {
const [method, setMethod] = useState('jackson3')
const [step, setStep] = useState(-1) // -1 = method select
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({})
const [saving, setSaving] = useState(false)
const sex = profile?.sex || 'm'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const sfPoints = METHOD_POINTS[method]?.[sex] || []
const current = step >= 0 ? CALIPER_POINTS[sfPoints[step]] : null
const totalSteps = sfPoints.length
// Live BF calculation
const sfVals = {}
sfPoints.forEach(k => { const v=values[`sf_${k}`]; if(v) sfVals[k]=parseFloat(v) })
const bfPct = Object.keys(sfVals).length === sfPoints.length && sfPoints.length > 0
? Math.round(calcBodyFat(method, sfVals, sex, age)*10)/10 : null
const bfCat = bfPct ? getBfCategory(bfPct, sex) : null
const handleNext = () => {
if (step < totalSteps - 1) setStep(s => s+1)
else handleSave()
}
const handleSave = async () => {
setSaving(true)
try {
const payload = { date, sf_method: method }
Object.entries(values).forEach(([k,v]) => { if(v) payload[k]=parseFloat(v) })
if (bfPct) {
payload.body_fat_pct = bfPct
if (profile?.weight || values.weight) {
const w = parseFloat(profile?.weight || values.weight)
payload.lean_mass = Math.round(w*(1-bfPct/100)*10)/10
payload.fat_mass = Math.round(w*(bfPct/100)*10)/10
}
}
await api.upsertCaliper(payload)
onDone()
} finally { setSaving(false) }
}
const progress = step >= 0 ? ((step+1)/totalSteps)*100 : 0
// Method selection screen
if (step === -1) {
return (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:20}}>
<h2 style={{fontSize:18,fontWeight:700,margin:0}}>Caliper-Methode</h2>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div className="form-row" style={{marginBottom:16}}>
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={date} onChange={e=>setDate(e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{Object.entries(CALIPER_METHODS).map(([k,m]) => (
<button key={k} onClick={()=>setMethod(k)}
style={{padding:'14px 16px',borderRadius:12,border:`2px solid ${method===k?'var(--accent)':'var(--border2)'}`,
background:method===k?'var(--accent-light)':'var(--surface)',cursor:'pointer',
fontFamily:'var(--font)',textAlign:'left',transition:'all 0.15s'}}>
<div style={{fontSize:15,fontWeight:600,color:method===k?'var(--accent-dark)':'var(--text1)'}}>{m.label}</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{m.points_m.length} Messpunkte</div>
</button>
))}
</div>
<div style={{padding:'10px 12px',background:'var(--warn-bg)',borderRadius:8,marginBottom:16,fontSize:12,color:'var(--warn-text)'}}>
Immer rechte Körperseite · Falte 1 cm abheben · Caliper 2 Sek. · 3× messen, Mittelwert
</div>
<button className="btn btn-primary btn-full" onClick={()=>setStep(0)}>
Weiter mit {CALIPER_METHODS[method]?.label} <ChevronRight size={16}/>
</button>
</div>
)
}
return (
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
{/* Header */}
<div style={{marginBottom:20}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<span style={{fontSize:12,color:'var(--text3)'}}>Punkt {step+1} von {totalSteps} · {CALIPER_METHODS[method]?.label}</span>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
<div style={{height:'100%',background:'#D85A30',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
</div>
</div>
{/* Live BF Preview */}
{bfPct && (
<div style={{padding:'10px 14px',background:'var(--accent-light)',borderRadius:10,marginBottom:14,
display:'flex',alignItems:'center',gap:10}}>
<div style={{fontSize:22,fontWeight:700,color:bfCat?.color||'var(--accent)'}}>{bfPct}%</div>
{bfCat && <div style={{fontSize:12,color:'var(--accent-dark)'}}>{bfCat.label}</div>}
<div style={{flex:1,fontSize:11,color:'var(--accent-dark)',textAlign:'right'}}>Körperfett (live)</div>
</div>
)}
{/* Messpunkt */}
{current && (
<div className="card" style={{flex:1,marginBottom:16}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
</div>
<div>
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>Hautfalte messen</div>
</div>
</div>
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{[
['📍 Wo', current.where],
['🧍 Haltung', current.posture],
['🔧 Technik', current.how],
['💡 Tipp', current.tip],
].map(([label, text]) => (
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
</div>
))}
</div>
<div style={{display:'flex',gap:10,alignItems:'center'}}>
<input
type="number" min={2} max={80} step={0.5}
className="form-input"
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
placeholder=""
value={values[`sf_${sfPoints[step]}`] || ''}
onChange={e => setValues(v => ({...v, [`sf_${sfPoints[step]}`]: e.target.value}))}
onKeyDown={e => e.key==='Enter' && handleNext()}
autoFocus
/>
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>mm</span>
</div>
{values[`sf_${sfPoints[step]}`] && (
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
{values[`sf_${sfPoints[step]}`]} mm erfasst
</div>
)}
</div>
)}
{/* Navigation */}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-secondary" style={{flex:1}}
onClick={()=>setStep(s=>s<=0?-1:s-1)}>
<ChevronLeft size={16}/> Zurück
</button>
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
step === totalSteps-1
? <><Check size={16}/> Speichern</>
: <>Weiter <ChevronRight size={16}/></>}
</button>
</div>
{/* Übersicht */}
{Object.keys(values).length > 0 && (
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{sfPoints.slice(0,step+1).map((k,idx) => values[`sf_${k}`] ? (
<span key={k} onClick={()=>setStep(idx)}
style={{fontSize:12,background:'#D85A3022',color:'#D85A30',
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
{CALIPER_POINTS[k]?.label}: {values[`sf_${k}`]}mm
</span>
) : null)}
</div>
</div>
)}
</div>
)
}
// ── Main Wizard Page ──────────────────────────────────────────────────────────
export default function MeasureWizard() {
const [mode, setMode] = useState(null) // null | 'circum' | 'caliper'
const [done, setDone] = useState(false)
const [profile, setProfile] = useState(null)
const nav = useNavigate()
useState(() => { api.getProfile().then(setProfile) })
if (done) {
return (
<div className="capture-page" style={{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',
minHeight:'60vh',gap:16,textAlign:'center'}}>
<div style={{fontSize:48}}></div>
<h2 style={{fontSize:20,fontWeight:700}}>Gespeichert!</h2>
<p style={{color:'var(--text2)',fontSize:14}}>Deine Messung wurde erfolgreich gespeichert.</p>
<div style={{display:'flex',gap:8}}>
<button className="btn btn-primary" onClick={()=>{ setDone(false); setMode(null) }}>
Weitere Messung
</button>
<button className="btn btn-secondary" onClick={()=>nav('/')}>
Zum Dashboard
</button>
</div>
</div>
)
}
if (mode === 'circum') return (
<div className="capture-page">
<CircumWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)}/>
</div>
)
if (mode === 'caliper') return (
<div className="capture-page">
<CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/>
</div>
)
return (
<div className="capture-page">
<h1 className="page-title">Assistent</h1>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}>
Der Assistent führt dich Schritt für Schritt durch die Messung mit Anleitung für jeden Messpunkt.
</p>
<div style={{display:'flex',flexDirection:'column',gap:12}}>
<button onClick={()=>setMode('circum')}
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
display:'flex',alignItems:'center',gap:14}}>
<div style={{fontSize:32}}>📏</div>
<div>
<div style={{fontSize:16,fontWeight:600}}>Umfänge messen</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
8 Messpunkte · Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm
</div>
</div>
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
</button>
<button onClick={()=>setMode('caliper')}
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
display:'flex',alignItems:'center',gap:14}}>
<div style={{fontSize:32}}>📐</div>
<div>
<div style={{fontSize:16,fontWeight:600}}>Caliper Körperfett</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
39 Messpunkte · Jackson/Pollock, Durnin oder Parrillo
</div>
</div>
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
</button>
<div style={{marginTop:8,padding:'12px 14px',background:'var(--surface2)',borderRadius:10,
fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
💡 Für schnelle Einzeleingaben oder Bearbeitung bestehender Werte nutze die direkten Screens
unter <strong>Gewicht</strong>, <strong>Umfänge</strong> und <strong>Caliper</strong>.
</div>
</div>
</div>
)
}