feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
1 parent
a5711f3e1e
commit
21b01528e9
51 files changed
+3739
-504
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
|
||||
import {
|
||||
ActivityEntry, ActivityUnit, DayOverview,
|
||||
addActivity, createWorkoutTemplateFromActivities, updateActivity,
|
||||
} from "../../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
||||
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
|
||||
|
||||
@@ -21,6 +24,7 @@ function emptyForm(date: string) {
|
||||
name: '',
|
||||
unit: ActivityUnit.KROKY as ActivityUnit,
|
||||
quantity: '',
|
||||
sets: '',
|
||||
caloriesPer100Units: '',
|
||||
calories: '',
|
||||
note: '',
|
||||
@@ -37,15 +41,18 @@ function emptyForm(date: string) {
|
||||
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||
const [form, setForm] = useState(() => emptyForm(date));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setAlsoTemplate(false);
|
||||
setForm(entry
|
||||
? {
|
||||
date: entry.date,
|
||||
name: entry.name,
|
||||
unit: entry.unit,
|
||||
quantity: String(entry.quantity),
|
||||
sets: entry.sets != null ? String(entry.sets) : '',
|
||||
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
||||
calories: entry.calories != null ? String(entry.calories) : '',
|
||||
note: entry.note ?? '',
|
||||
@@ -70,8 +77,9 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
};
|
||||
|
||||
const quantity = parseIntInput(form.quantity);
|
||||
const sets = parseIntInput(form.sets);
|
||||
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
|
||||
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
|
||||
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
|
||||
const manualCalories = parseIntInput(form.calories);
|
||||
const effectiveCalories = derivedCalories ?? manualCalories;
|
||||
|
||||
@@ -85,6 +93,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
name: form.name.trim(),
|
||||
unit: form.unit,
|
||||
quantity,
|
||||
sets,
|
||||
caloriesPer100Units,
|
||||
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
||||
calories: derivedCalories != null ? undefined : manualCalories,
|
||||
@@ -93,6 +102,17 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
const response = entry
|
||||
? await updateActivity({ body: { id: entry.id, ...body } })
|
||||
: await addActivity({ body });
|
||||
if (response.data && alsoTemplate) {
|
||||
const saved = entry
|
||||
? response.data.activities.entries.find(item => item.id === entry.id)
|
||||
: [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||
if (saved) {
|
||||
// Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony
|
||||
await createWorkoutTemplateFromActivities({
|
||||
body: { date: saved.date, name: saved.name, ids: [saved.id] },
|
||||
});
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
@@ -135,14 +155,26 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Množství a spotřeba</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-2">
|
||||
<Form.Label>Série</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.sets}
|
||||
placeholder="1"
|
||||
onChange={event => update({ sets: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Množství</Form.Label>
|
||||
<Form.Label>{(sets ?? 1) > 1 ? 'Na sérii' : 'Množství'}</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.quantity}
|
||||
placeholder="10000"
|
||||
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
{(sets ?? 1) > 1 && quantity != null && (
|
||||
<Form.Text>celkem {(quantity * sets!).toLocaleString('cs-CZ')}</Form.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Jednotka</Form.Label>
|
||||
@@ -155,8 +187,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kcal na 100 jedn.</Form.Label>
|
||||
<div className="col-sm-2">
|
||||
<Form.Label>Kcal/100</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100Units}
|
||||
@@ -164,7 +196,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<div className="col-sm-2">
|
||||
<Form.Label>Spáleno</Form.Label>
|
||||
{derivedCalories != null ? (
|
||||
<div className="derived-value">{formatCalories(derivedCalories)}</div>
|
||||
@@ -195,6 +227,13 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Form.Check
|
||||
className="modal-template-check"
|
||||
id="activity-also-template"
|
||||
checked={alsoTemplate}
|
||||
label="Uložit i jako šablonu"
|
||||
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||
/>
|
||||
<div className="meal-modal-summary">
|
||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import { deriveAmounts, formatCalories, formatPrice, formatWeight } from "../../Utils";
|
||||
import { SABLONY_URL } from "../../routes";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se jídlo založí. */
|
||||
date: string,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po založení jídla s aktualizovaným přehledem dne. */
|
||||
onApplied: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Popíše šablonu jednou řádkou — gramáž, kalorie a cena, co je vyplněné. */
|
||||
const describe = (template: MealTemplate): string => {
|
||||
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||
const calories = derived.calories ?? template.calories;
|
||||
return [
|
||||
template.source,
|
||||
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
||||
calories != null ? formatCalories(calories) : undefined,
|
||||
template.price != null ? formatPrice(template.price) : undefined,
|
||||
].filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Výběr šablony jídla k založení do dne.
|
||||
*
|
||||
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
|
||||
* vybrat hotové jídlo. Chod lze při zakládání přepsat, protože totéž jídlo může
|
||||
* být jednou oběd a podruhé večeře.
|
||||
*/
|
||||
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||
const [mealType, setMealType] = useState<MealType | ''>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
setMealType('');
|
||||
getMealTemplates().then(response => {
|
||||
setTemplates(response.data ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const apply = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await applyMealTemplate({
|
||||
body: { id, date, ...(mealType ? { mealType } : {}) },
|
||||
});
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Použít šablonu jídla</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{loading && (
|
||||
<div className="text-center py-3">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !!templates.length && (
|
||||
<>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Založit jako</Form.Label>
|
||||
<Form.Select
|
||||
value={mealType}
|
||||
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||
>
|
||||
<option value="">— chod ze šablony —</option>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
|
||||
{templates.map(template => (
|
||||
<div className="workout-row" key={template.id}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">{template.name}</div>
|
||||
<div className="workout-items">{describe(template)}</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||
>
|
||||
Spravovat šablony
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplyMealModal;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Modal, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||
import { SABLONY_URL } from "../../routes";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se šablona použije. */
|
||||
date: string,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
|
||||
onApplied: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Popíše položky šablony jednou řádkou. */
|
||||
const describeItems = (template: WorkoutTemplate): string =>
|
||||
template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? (item.templateRefName ?? 'šablona')
|
||||
: `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}`
|
||||
).join(' · ');
|
||||
|
||||
/**
|
||||
* Výběr šablony tréninku k založení do dne.
|
||||
*
|
||||
* Zakládání a úpravy šablon se řeší na vlastní stránce, sem chodí uživatel jen
|
||||
* vybrat hotovou šablonu a rozbalit ji do dne.
|
||||
*/
|
||||
const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
getWorkoutTemplates().then(response => {
|
||||
setTemplates(response.data ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const apply = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await applyWorkoutTemplate({ body: { id, date } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Použít šablonu tréninku</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{loading && (
|
||||
<div className="text-center py-3">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
|
||||
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.map(template => (
|
||||
<div className="workout-row" key={template.id}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">
|
||||
{template.name}
|
||||
{template.hasNested && (
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="workout-nested-icon" title="skládaná šablona" />
|
||||
)}
|
||||
</div>
|
||||
<div className="workout-items">{describeItems(template)}</div>
|
||||
</div>
|
||||
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||
>
|
||||
Spravovat šablony
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplyWorkoutModal;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
|
||||
import {
|
||||
DayRecord, MealEntry, MealType, UserSettings,
|
||||
addMeal, createMealTemplateFromMeal, getSettings, updateMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import {
|
||||
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
|
||||
@@ -50,10 +53,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
const [form, setForm] = useState(() => emptyForm(date));
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
||||
|
||||
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setAlsoTemplate(false);
|
||||
setForm(entry
|
||||
? {
|
||||
date: entry.date,
|
||||
@@ -126,6 +131,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
const response = entry
|
||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||
: await addMeal({ body });
|
||||
if (response.data && alsoTemplate) {
|
||||
// U nového jídla je ten správný záznam vždy ten poslední založený
|
||||
const saved = entry
|
||||
? response.data.entries.find(item => item.id === entry.id)
|
||||
: [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||
if (saved) {
|
||||
await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } });
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
@@ -295,6 +309,13 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Form.Check
|
||||
className="modal-template-check"
|
||||
id="meal-also-template"
|
||||
checked={alsoTemplate}
|
||||
label="Uložit i jako šablonu"
|
||||
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||
/>
|
||||
<div className="meal-modal-summary">
|
||||
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||
import { ActivityEntry, createWorkoutTemplateFromActivities } from "../../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, ze kterého se aktivity berou. */
|
||||
date: string,
|
||||
/** Aktivity daného dne, ze kterých lze vybírat. */
|
||||
activities: ActivityEntry[],
|
||||
/** Aktivity zaškrtnuté při otevření. Bez nich se zaškrtnou všechny. */
|
||||
preselected?: string[],
|
||||
onClose: () => void,
|
||||
/** Zavolá se po úspěšném vytvoření šablony. */
|
||||
onSaved: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Uložení aktivit dne jako šablony tréninku.
|
||||
*
|
||||
* Opačný směr než použití šablony: odcvičený trénink jde takhle povýšit na
|
||||
* šablonu a příště ho založit jedním kliknutím. Aktivity ve dni zůstanou.
|
||||
*/
|
||||
const SaveWorkoutTemplateModal = ({
|
||||
isOpen, date, activities, preselected, onClose, onSaved,
|
||||
}: Readonly<Props>) => {
|
||||
const [name, setName] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const initial = preselected?.length ? preselected : activities.map(entry => entry.id);
|
||||
setSelected(initial);
|
||||
setError(undefined);
|
||||
// U jediného cviku se jako název nabídne jeho jméno, jinak nechá uživatel na sobě
|
||||
setName(initial.length === 1
|
||||
? (activities.find(entry => entry.id === initial[0])?.name ?? '')
|
||||
: '');
|
||||
}, [isOpen, activities, preselected]);
|
||||
|
||||
const toggle = (id: string) => setSelected(current =>
|
||||
current.includes(id) ? current.filter(item => item !== id) : [...current, id]);
|
||||
|
||||
const canSave = name.trim().length > 0 && selected.length > 0 && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
const response = await createWorkoutTemplateFromActivities({
|
||||
body: { date, name: name.trim(), ids: selected },
|
||||
});
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
onSaved();
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Uložit jako šablonu tréninku</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název šablony</Form.Label>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
value={name}
|
||||
placeholder="Např. Workout day 3"
|
||||
onChange={event => setName(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Label>Zahrnout cviky</Form.Label>
|
||||
{activities.map(entry => (
|
||||
<div className="template-pick-row" key={entry.id}>
|
||||
<Form.Check
|
||||
id={`pick-${entry.id}`}
|
||||
checked={selected.includes(entry.id)}
|
||||
label={entry.name}
|
||||
onChange={() => toggle(entry.id)}
|
||||
/>
|
||||
<span className="template-pick-quantity">
|
||||
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
||||
</span>
|
||||
<span className="template-pick-calories">{formatCalories(entry.calories)}</span>
|
||||
</div>
|
||||
))}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button>
|
||||
<Button onClick={() => save()} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Vytvořit šablonu'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveWorkoutTemplateModal;
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
|
||||
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
|
||||
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
|
||||
import { formatPriceInput, parsePriceInput } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -20,17 +20,13 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
const [newSource, setNewSource] = useState('');
|
||||
const [newRate, setNewRate] = useState('');
|
||||
const [basal, setBasal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setNewSource('');
|
||||
setNewRate('');
|
||||
getSettings().then(response => {
|
||||
setSettings(response.data);
|
||||
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
|
||||
});
|
||||
getSettings().then(response => setSettings(response.data));
|
||||
}, [isOpen]);
|
||||
|
||||
const save = async (source: string, pricePer100g: number | null) => {
|
||||
@@ -42,16 +38,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
|
||||
const saveBasal = async () => {
|
||||
setSaving(true);
|
||||
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
setSettings(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const addRate = async () => {
|
||||
const rate = parsePriceInput(newRate);
|
||||
if (!newSource.trim().length || !rate) return;
|
||||
@@ -66,24 +52,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
<Modal.Title>Nastavení</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<h3 className="settings-section-title">Klidový výdej</h3>
|
||||
<p className="text-body-secondary small">
|
||||
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
|
||||
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
|
||||
</p>
|
||||
<div className="rate-form mb-4">
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={basal}
|
||||
placeholder="např. 1600"
|
||||
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">kcal / den</span>
|
||||
</div>
|
||||
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
|
||||
</div>
|
||||
|
||||
<h3 className="settings-section-title">Cena za 100 g</h3>
|
||||
<p className="text-body-secondary small">
|
||||
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
|
||||
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se šablona použije. */
|
||||
date: string,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
|
||||
onApplied: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
|
||||
type DraftItem = {
|
||||
name: string,
|
||||
unit: ActivityUnit,
|
||||
quantity: string,
|
||||
caloriesPer100Units: string,
|
||||
};
|
||||
|
||||
function emptyItem(): DraftItem {
|
||||
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Správa šablon tréninků a jejich použití na den.
|
||||
*
|
||||
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
|
||||
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
|
||||
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
|
||||
*/
|
||||
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
|
||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
const [editedId, setEditedId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditedId(undefined);
|
||||
setName('');
|
||||
setItems([emptyItem()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
resetForm();
|
||||
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
|
||||
}, [isOpen]);
|
||||
|
||||
const updateItem = (index: number, changes: Partial<DraftItem>) =>
|
||||
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
|
||||
|
||||
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
|
||||
const applyPreset = (index: number, value: string) => {
|
||||
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
|
||||
updateItem(index, preset
|
||||
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
|
||||
: { name: value });
|
||||
};
|
||||
|
||||
/** Převede rozpracované položky na ty, které jdou uložit. */
|
||||
const toItems = (): WorkoutTemplateItem[] => items
|
||||
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
|
||||
.map(item => ({
|
||||
name: item.name.trim(),
|
||||
unit: item.unit,
|
||||
quantity: parseIntInput(item.quantity)!,
|
||||
...(parseIntInput(item.caloriesPer100Units) != null
|
||||
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await deleteWorkoutTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (editedId === id) resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await applyWorkoutTemplate({ body: { id, date } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
/** Načte šablonu do formuláře k úpravě. */
|
||||
const edit = (template: WorkoutTemplate) => {
|
||||
setEditedId(template.id);
|
||||
setName(template.name);
|
||||
setItems(template.items.map(item => ({
|
||||
name: item.name,
|
||||
unit: item.unit,
|
||||
quantity: String(item.quantity),
|
||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||
})));
|
||||
};
|
||||
|
||||
/** Průběžný odhad kalorií rozpracované šablony. */
|
||||
const draftCalories = items.reduce((sum, item) =>
|
||||
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Šablony tréninků</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{templates.length === 0 && (
|
||||
<p className="text-body-secondary small fst-italic">
|
||||
Zatím nemáte žádnou šablonu. Sestavte si ji níže — příště pak stačí jedno kliknutí.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{templates.map(template => (
|
||||
<div className="workout-row" key={template.id}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">{template.name}</div>
|
||||
<div className="workout-items">
|
||||
{template.items.map(item =>
|
||||
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
|
||||
).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
</Button>
|
||||
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => remove(template.id)}
|
||||
disabled={busy}
|
||||
title="Smazat"
|
||||
aria-label="Smazat šablonu"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<fieldset className="meal-fieldset mt-3">
|
||||
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={name}
|
||||
placeholder="Např. Workout day 1"
|
||||
onChange={event => setName(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{items.map((item, index) => (
|
||||
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
|
||||
<div className="workout-item-row" key={index}>
|
||||
<Form.Control
|
||||
list="activity-presets-workout"
|
||||
value={item.name}
|
||||
placeholder="Cvik"
|
||||
onChange={event => applyPreset(index, event.target.value)}
|
||||
/>
|
||||
<Form.Control
|
||||
className="workout-item-quantity"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
placeholder="60"
|
||||
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<Form.Select
|
||||
className="workout-item-unit"
|
||||
value={item.unit}
|
||||
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
|
||||
>
|
||||
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
|
||||
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
className="workout-item-rate"
|
||||
inputMode="numeric"
|
||||
value={item.caloriesPer100Units}
|
||||
placeholder="kcal/100"
|
||||
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
|
||||
disabled={items.length === 1}
|
||||
title="Odebrat položku"
|
||||
aria-label="Odebrat položku"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<datalist id="activity-presets-workout">
|
||||
{ACTIVITY_PRESETS.map(preset => (
|
||||
<option key={preset.name} value={preset.name} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="workout-form-footer">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
onClick={() => setItems(current => [...current, emptyItem()])}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} /> Další cvik
|
||||
</Button>
|
||||
{draftCalories > 0 && (
|
||||
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
|
||||
)}
|
||||
{editedId && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={resetForm}>Zrušit úpravu</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={save} disabled={!canSave}>
|
||||
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user