216 lines
9.3 KiB
JavaScript
216 lines
9.3 KiB
JavaScript
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>
|
||
)
|
||
}
|