mitai-jinkendo/frontend/src/pages/NewMeasurement.jsx

216 lines
9.3 KiB
React
Raw Normal View History

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