mitai-jinkendo/frontend/src/pages/NewMeasurement.jsx
Lars Stommer 89b6c0b072
Some checks are pending
Deploy to Raspberry Pi / deploy (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Build Test / lint-backend (push) Waiting to run
feat: initial commit – Mitai Jinkendo v9a
2026-03-16 13:35:11 +01:00

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