216 lines
9.3 KiB
React
216 lines
9.3 KiB
React
|
|
import { useState, useEffect, useRef } from 'react'
|
|||
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|||
|
|
import { Camera, CheckCircle, ChevronDown, ChevronUp, BookOpen } from 'lucide-react'
|
|||
|
|
import { api } from '../utils/api'
|
|||
|
|
import { calcBodyFat, METHOD_POINTS } from '../utils/calc'
|
|||
|
|
import { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
|
|||
|
|
import dayjs from 'dayjs'
|
|||
|
|
|
|||
|
|
function Section({ title, open, onToggle, children, hint }) {
|
|||
|
|
return (
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer'}} onClick={onToggle}>
|
|||
|
|
<div>
|
|||
|
|
<div className="card-title" style={{margin:0}}>{title}</div>
|
|||
|
|
{hint && !open && <div style={{fontSize:11,color:'var(--text3)',marginTop:2}}>{hint}</div>}
|
|||
|
|
</div>
|
|||
|
|
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
|
|||
|
|
</div>
|
|||
|
|
{open && <div style={{marginTop:12}}>{children}</div>}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function NumInput({ label, sub, value, onChange, unit, min, max, step=0.1, guideText }) {
|
|||
|
|
return (
|
|||
|
|
<div className="form-row">
|
|||
|
|
<label className="form-label">
|
|||
|
|
{label}
|
|||
|
|
{sub && <span className="form-sub">{sub}</span>}
|
|||
|
|
{guideText && <span className="form-sub" style={{color:'var(--accent)',fontStyle:'normal'}}>📍 {guideText}</span>}
|
|||
|
|
</label>
|
|||
|
|
<input className="form-input" type="number" min={min} max={max} step={step}
|
|||
|
|
value={value??''} placeholder="–"
|
|||
|
|
onChange={e => onChange(e.target.value==='' ? null : parseFloat(e.target.value))}/>
|
|||
|
|
<span className="form-unit">{unit}</span>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function NewMeasurement() {
|
|||
|
|
const { id } = useParams()
|
|||
|
|
const nav = useNavigate()
|
|||
|
|
const fileRef = useRef()
|
|||
|
|
const [profile, setProfile] = useState(null)
|
|||
|
|
const [saving, setSaving] = useState(false)
|
|||
|
|
const [saved, setSaved] = useState(false)
|
|||
|
|
const [photoPreview, setPhotoPreview] = useState(null)
|
|||
|
|
const [photoFile, setPhotoFile] = useState(null)
|
|||
|
|
const [openSections, setOpenSections] = useState({ basic:true, circum:true, caliper:false, notes:false })
|
|||
|
|
const isEdit = !!id
|
|||
|
|
|
|||
|
|
const [form, setForm] = useState({
|
|||
|
|
date: dayjs().format('YYYY-MM-DD'),
|
|||
|
|
weight: null,
|
|||
|
|
c_neck:null, c_chest:null, c_waist:null, c_belly:null,
|
|||
|
|
c_hip:null, c_thigh:null, c_calf:null, c_arm:null,
|
|||
|
|
sf_method:'jackson3',
|
|||
|
|
sf_chest:null, sf_axilla:null, sf_triceps:null, sf_subscap:null,
|
|||
|
|
sf_suprailiac:null, sf_abdomen:null, sf_thigh:null,
|
|||
|
|
sf_calf_med:null, sf_lowerback:null, sf_biceps:null,
|
|||
|
|
notes:''
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
api.getProfile().then(setProfile)
|
|||
|
|
if (id) {
|
|||
|
|
api.getMeasurement(id).then(m => {
|
|||
|
|
setForm(f => ({...f, ...m}))
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}, [id])
|
|||
|
|
|
|||
|
|
const toggle = s => setOpenSections(o => ({...o,[s]:!o[s]}))
|
|||
|
|
const set = (k,v) => setForm(f => ({...f,[k]:v}))
|
|||
|
|
|
|||
|
|
const sex = profile?.sex || 'm'
|
|||
|
|
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
|||
|
|
const height = profile?.height || 178
|
|||
|
|
const weight = form.weight || 80
|
|||
|
|
|
|||
|
|
const sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || []
|
|||
|
|
const sfVals = {}
|
|||
|
|
sfPoints.forEach(k => { if (form[`sf_${k}`]) sfVals[k] = form[`sf_${k}`] })
|
|||
|
|
const bfPct = Object.keys(sfVals).length===sfPoints.length
|
|||
|
|
? Math.round(calcBodyFat(form.sf_method, sfVals, sex, age)*10)/10 : null
|
|||
|
|
|
|||
|
|
const handlePhoto = e => {
|
|||
|
|
const file = e.target.files[0]; if(!file) return
|
|||
|
|
setPhotoFile(file); setPhotoPreview(URL.createObjectURL(file))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
const payload = {...form}
|
|||
|
|
if (bfPct) {
|
|||
|
|
payload.body_fat_pct = bfPct
|
|||
|
|
payload.lean_mass = Math.round(weight*(1-bfPct/100)*10)/10
|
|||
|
|
payload.fat_mass = Math.round(weight*(bfPct/100)*10)/10
|
|||
|
|
}
|
|||
|
|
let mid = id
|
|||
|
|
if (id) { await api.updateMeasurement(id, payload) }
|
|||
|
|
else { const r = await api.createMeasurement(payload); mid = r.id }
|
|||
|
|
if (photoFile && mid) {
|
|||
|
|
const pr = await api.uploadPhoto(photoFile, mid)
|
|||
|
|
await api.updateMeasurement(mid, {photo_id: pr.id})
|
|||
|
|
}
|
|||
|
|
setSaved(true); setTimeout(()=>nav('/'), 1200)
|
|||
|
|
} catch(e) { alert('Fehler beim Speichern: '+e.message) }
|
|||
|
|
finally { setSaving(false) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
|
|||
|
|
<h1 className="page-title" style={{margin:0}}>{isEdit ? 'Messung bearbeiten' : 'Neue Messung'}</h1>
|
|||
|
|
<button className="btn btn-secondary" style={{padding:'6px 10px',fontSize:12}} onClick={()=>nav('/guide')}>
|
|||
|
|
<BookOpen size={14}/> Anleitung
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Grunddaten */}
|
|||
|
|
<Section title="Grunddaten" open={openSections.basic} onToggle={()=>toggle('basic')}>
|
|||
|
|
<div className="form-row">
|
|||
|
|
<label className="form-label">Datum</label>
|
|||
|
|
<input type="date" className="form-input" style={{width:140}}
|
|||
|
|
value={form.date} onChange={e=>set('date',e.target.value)}/>
|
|||
|
|
<span className="form-unit"/>
|
|||
|
|
</div>
|
|||
|
|
<NumInput label="Gewicht" value={form.weight} onChange={v=>set('weight',v)} unit="kg" min={30} max={250}/>
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
{/* Umfänge */}
|
|||
|
|
<Section title="Umfänge" open={openSections.circum} onToggle={()=>toggle('circum')}
|
|||
|
|
hint="Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm">
|
|||
|
|
{CIRCUMFERENCE_POINTS.map(p => (
|
|||
|
|
<NumInput key={p.id} label={p.label}
|
|||
|
|
guideText={p.where.length > 50 ? p.where.substring(0,48)+'…' : p.where}
|
|||
|
|
value={form[p.id]} onChange={v=>set(p.id,v)} unit="cm" min={10} max={200}/>
|
|||
|
|
))}
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
{/* Caliper */}
|
|||
|
|
<Section title="Caliper Körperfett" open={openSections.caliper} onToggle={()=>toggle('caliper')}
|
|||
|
|
hint="Optional – für präzise Körperfett-Messung">
|
|||
|
|
<div className="form-row">
|
|||
|
|
<label className="form-label">Methode</label>
|
|||
|
|
<select className="form-select" style={{width:'auto'}}
|
|||
|
|
value={form.sf_method} onChange={e=>set('sf_method',e.target.value)}>
|
|||
|
|
{Object.entries(CALIPER_METHODS).map(([k,m]) => (
|
|||
|
|
<option key={k} value={k}>{m.label}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{padding:'8px 12px',background:'var(--warn-bg)',borderRadius:8,marginBottom:10,fontSize:12,color:'var(--warn-text)'}}>
|
|||
|
|
Immer rechte Körperseite · Falte 1 cm abheben · 3× messen, Mittelwert nehmen
|
|||
|
|
<span style={{marginLeft:8,cursor:'pointer',textDecoration:'underline'}} onClick={()=>nav('/guide')}>→ Anleitung</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{sfPoints.map(k => {
|
|||
|
|
const p = CALIPER_POINTS[k]
|
|||
|
|
return p ? (
|
|||
|
|
<NumInput key={k} label={p.label}
|
|||
|
|
guideText={p.where.length > 50 ? p.where.substring(0,48)+'…' : p.where}
|
|||
|
|
value={form[`sf_${k}`]} onChange={v=>set(`sf_${k}`,v)} unit="mm" min={2} max={80}/>
|
|||
|
|
) : null
|
|||
|
|
})}
|
|||
|
|
|
|||
|
|
{bfPct !== null && (
|
|||
|
|
<div style={{marginTop:12,padding:'12px',background:'var(--accent-light)',borderRadius:8}}>
|
|||
|
|
<div style={{fontSize:24,fontWeight:700,color:'var(--accent)'}}>{bfPct} %</div>
|
|||
|
|
<div style={{fontSize:12,color:'var(--accent-dark)'}}>Körperfett ({CALIPER_METHODS[form.sf_method]?.label})</div>
|
|||
|
|
{form.weight && <div style={{fontSize:12,color:'var(--accent-dark)',marginTop:2}}>
|
|||
|
|
Magermasse: {Math.round(form.weight*(1-bfPct/100)*10)/10} kg ·{' '}
|
|||
|
|
Fettmasse: {Math.round(form.weight*(bfPct/100)*10)/10} kg
|
|||
|
|
</div>}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
{/* Foto */}
|
|||
|
|
<div className="card section-gap">
|
|||
|
|
<div className="card-title">Fortschrittsfoto</div>
|
|||
|
|
<input ref={fileRef} type="file" accept="image/*" capture="environment"
|
|||
|
|
style={{display:'none'}} onChange={handlePhoto}/>
|
|||
|
|
{photoPreview && <img src={photoPreview} style={{width:'100%',borderRadius:8,marginBottom:10}} alt="preview"/>}
|
|||
|
|
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
|
|||
|
|
<Camera size={16}/> {photoPreview ? 'Foto ändern' : 'Foto aufnehmen / auswählen'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Notizen */}
|
|||
|
|
<Section title="Notizen" open={openSections.notes} onToggle={()=>toggle('notes')}>
|
|||
|
|
<textarea style={{width:'100%',minHeight:80,padding:10,fontFamily:'var(--font)',fontSize:14,
|
|||
|
|
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
|||
|
|
color:'var(--text1)',resize:'vertical'}}
|
|||
|
|
placeholder="Besonderheiten, Befinden, Trainingszustand…"
|
|||
|
|
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||saved} style={{marginTop:4}}>
|
|||
|
|
{saved ? <><CheckCircle size={16}/> Gespeichert!</>
|
|||
|
|
: saving ? <><div className="spinner" style={{width:16,height:16}}/> Speichern…</>
|
|||
|
|
: isEdit ? 'Änderungen speichern' : 'Messung speichern'}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{isEdit && (
|
|||
|
|
<button className="btn btn-secondary btn-full" onClick={()=>nav('/')} style={{marginTop:8}}>
|
|||
|
|
Abbrechen
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|