feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
@@ -681,6 +681,12 @@ body {
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
// Počet sérií je skoro vždy jednociferný, stačí užší pole než na množství
|
||||
.workout-item-sets {
|
||||
width: 74px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workout-item-quantity {
|
||||
width: 90px;
|
||||
flex: 0 0 auto;
|
||||
@@ -699,6 +705,7 @@ body {
|
||||
@media (max-width: 640px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.workout-item-sets,
|
||||
.workout-item-quantity,
|
||||
.workout-item-unit,
|
||||
.workout-item-rate {
|
||||
@@ -721,3 +728,46 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Ikona u skládané šablony ve výběru
|
||||
.workout-nested-icon {
|
||||
margin-left: 8px;
|
||||
color: var(--ft-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
// Výběr cviků při ukládání dne jako šablony
|
||||
.template-pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
font-size: 0.88rem;
|
||||
|
||||
.form-check {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-pick-quantity {
|
||||
color: var(--ft-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-pick-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Zaškrtávátko v patičce dialogu stojí vlevo, před souhrnem a tlačítky
|
||||
.modal-template-check {
|
||||
margin: 0 16px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import Header from "./components/Header";
|
||||
import Login from "./Login";
|
||||
import DayPage from "./pages/DayPage";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import { DEN_URL, STATISTIKY_URL } from "./routes";
|
||||
import TemplatesPage from "./pages/TemplatesPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
||||
|
||||
export default function AppRoutes() {
|
||||
const auth = useAuth();
|
||||
@@ -21,6 +23,8 @@ export default function AppRoutes() {
|
||||
<Routes>
|
||||
<Route path={DEN_URL} element={<DayPage />} />
|
||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
||||
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
+64
-4
@@ -182,14 +182,74 @@ export function formatQuantity(quantity: number, unitShort: string): string {
|
||||
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
|
||||
}
|
||||
|
||||
/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */
|
||||
export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined {
|
||||
/**
|
||||
* Dopočte spálené kalorie z množství, počtu sérií a sazby na 100 jednotek.
|
||||
* Musí dávat stejné výsledky jako `deriveActivityCalories` na serveru.
|
||||
*/
|
||||
export const deriveActivityCalories = (
|
||||
quantity?: number,
|
||||
caloriesPer100Units?: number,
|
||||
sets?: number,
|
||||
): number | undefined => {
|
||||
if (quantity == null || !caloriesPer100Units) return undefined;
|
||||
return Math.round(quantity / 100 * caloriesPer100Units);
|
||||
}
|
||||
return Math.round(quantity * Math.max(sets ?? 1, 1) / 100 * caloriesPer100Units);
|
||||
};
|
||||
|
||||
/** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */
|
||||
export function formatBalance(kcal: number): string {
|
||||
const sign = kcal < 0 ? '−' : '+';
|
||||
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
|
||||
}
|
||||
|
||||
/** Naformátuje délku spánku z minut, např. 450 → "7 h 30 min". */
|
||||
export const formatSleep = (minutes?: number): string => {
|
||||
if (!minutes) return '—';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
};
|
||||
|
||||
/** Převede minuty na hodiny s jedním desetinným místem pro editační pole. */
|
||||
export const formatSleepInput = (minutes?: number): string =>
|
||||
minutes ? (minutes / 60).toFixed(1).replace('.', ',') : '';
|
||||
|
||||
/** Převede hodiny zadané uživatelem (i s desetinnou čárkou) na minuty. */
|
||||
export const parseSleepInput = (value: string): number | undefined => {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 60) : undefined;
|
||||
};
|
||||
|
||||
/** Naformátuje hmotnost z gramů na kilogramy, např. 90000 → "90,0 kg". */
|
||||
export const formatWeightKg = (grams?: number): string =>
|
||||
grams == null ? '—' : `${(grams / 1000).toFixed(1).replace('.', ',')} kg`;
|
||||
|
||||
/** Převede kilogramy zadané uživatelem na gramy. */
|
||||
export const parseWeightKgInput = (value: string): number | undefined => {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 1000) : undefined;
|
||||
};
|
||||
|
||||
/** Převede gramy na kilogramy pro editační pole. */
|
||||
export const formatWeightKgInput = (grams?: number): string =>
|
||||
grams == null ? '' : (grams / 1000).toFixed(1).replace('.', ',');
|
||||
|
||||
/**
|
||||
* Naformátuje množství aktivity i se sériemi.
|
||||
*
|
||||
* Bez sérií vrací "40 opak.", se sériemi "3 × 40 opak. (120)" — celkové množství
|
||||
* v závorce, protože z něj vychází spálené kalorie, ale rozpad na série je to,
|
||||
* co odlišuje tři série s pauzami od jedné dlouhé.
|
||||
*/
|
||||
export const formatQuantityWithSets = (quantity: number, unitShort: string, sets?: number): string => {
|
||||
const one = formatQuantity(quantity, unitShort);
|
||||
if (!sets || sets <= 1) return one;
|
||||
return `${sets} × ${one} (${(quantity * sets).toLocaleString('cs-CZ')})`;
|
||||
};
|
||||
|
||||
/** Vrátí celkové množství aktivity — počet sérií krát množství v jedné. */
|
||||
export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined =>
|
||||
quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ActivityEntry } from "../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../enums";
|
||||
import { formatCalories, formatQuantityWithSets } from "../Utils";
|
||||
import RowActions from "./RowActions";
|
||||
|
||||
type Props = {
|
||||
entry: ActivityEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
onSaveTemplate: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu pohybových aktivit v přehledu dne. */
|
||||
const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
<span className="tag">
|
||||
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
||||
</span>
|
||||
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="aktivitu" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ActivityRow;
|
||||
@@ -2,15 +2,16 @@ import { useState } from "react";
|
||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSun, faMoon, faGear, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useAuth } from "../context/auth";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import { DEN_URL, STATISTIKY_URL } from "../routes";
|
||||
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
|
||||
/** Položky hlavní navigace. */
|
||||
const NAV_ITEMS = [
|
||||
{ url: DEN_URL, label: 'Můj den' },
|
||||
{ url: SABLONY_URL, label: 'Šablony' },
|
||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||
];
|
||||
|
||||
@@ -53,7 +54,11 @@ export default function Header() {
|
||||
</button>
|
||||
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(SABLONY_URL)}>Šablony tréninků</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(PROFIL_URL)}>
|
||||
<FontAwesomeIcon icon={faUser} /> Osobní údaje
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
||||
<FontAwesomeIcon icon={faGear} /> Nastavení
|
||||
</NavDropdown.Item>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MealEntry } from "../../../types";
|
||||
import { formatCalories, formatPrice, formatWeight } from "../Utils";
|
||||
import RowActions from "./RowActions";
|
||||
|
||||
type Props = {
|
||||
entry: MealEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
onSaveTemplate: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
{entry.source && <span className="tag">{entry.source}</span>}
|
||||
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
||||
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
||||
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MealRow;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare, faTrash, faBookmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
type Props = {
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
/** Když je vyplněné, přibude tlačítko pro uložení záznamu jako šablony. */
|
||||
onSaveTemplate?: () => void,
|
||||
/** Doplní se do popisku pro čtečky, např. "Upravit jídlo". */
|
||||
label: string,
|
||||
};
|
||||
|
||||
/** Tlačítka pro úpravu a smazání řádku seznamu. */
|
||||
const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>) => (
|
||||
<div className="meal-actions">
|
||||
{onSaveTemplate && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onSaveTemplate()}
|
||||
title="Uložit jako šablonu"
|
||||
aria-label={`Uložit ${label} jako šablonu`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label={`Upravit ${label}`}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button className="btn-icon danger" onClick={() => onDelete()} title="Smazat" aria-label={`Smazat ${label}`}>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RowActions;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
-3
@@ -1,4 +1,4 @@
|
||||
import { ActivityUnit, MealType } from "../../types";
|
||||
import { ActivityUnit, MealType, Pohlavi } from "../../types";
|
||||
|
||||
/** Lidsky čitelné názvy typů jídel. */
|
||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||
@@ -38,12 +38,12 @@ export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
|
||||
OPAKOVANI: 'opakování',
|
||||
};
|
||||
|
||||
/** Zkratka jednotky za číslo, např. "10 000 kroků". */
|
||||
/** Zkratka jednotky za číslo, např. "10 000 kroků". U opakování nesmí být '×', kolidovalo by se zápisem sérií. */
|
||||
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
|
||||
KROKY: 'kroků',
|
||||
MINUTY: 'min',
|
||||
KM: 'km',
|
||||
OPAKOVANI: '×',
|
||||
OPAKOVANI: 'opak.',
|
||||
};
|
||||
|
||||
/** Jednotky v pořadí, v jakém se nabízejí. */
|
||||
@@ -71,3 +71,46 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
|
||||
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
|
||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
||||
];
|
||||
|
||||
/** Záložky přehledu dne. */
|
||||
export enum DayTab {
|
||||
PRIJEM = 'PRIJEM',
|
||||
VYDEJ = 'VYDEJ',
|
||||
}
|
||||
|
||||
export const DAY_TAB_NAMES: Record<DayTab, string> = {
|
||||
PRIJEM: 'Příjem',
|
||||
VYDEJ: 'Výdej',
|
||||
};
|
||||
|
||||
/** Rozsahy, za které lze zobrazit statistiky. */
|
||||
export enum StatsRange {
|
||||
MESIC = 'MESIC',
|
||||
ROK = 'ROK',
|
||||
VSE = 'VSE',
|
||||
}
|
||||
|
||||
export const STATS_RANGE_NAMES: Record<StatsRange, string> = {
|
||||
MESIC: 'Měsíc',
|
||||
ROK: 'Rok',
|
||||
VSE: 'Vše',
|
||||
};
|
||||
|
||||
/** Lidsky čitelné názvy pohlaví. */
|
||||
export const POHLAVI_NAMES: Record<Pohlavi, string> = {
|
||||
MUZ: 'Muž',
|
||||
ZENA: 'Žena',
|
||||
};
|
||||
|
||||
export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA];
|
||||
|
||||
/** Záložky stránky se šablonami. */
|
||||
export enum TemplateTab {
|
||||
TRENINKY = 'TRENINKY',
|
||||
JIDLA = 'JIDLA',
|
||||
}
|
||||
|
||||
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
||||
TRENINKY: 'Tréninky',
|
||||
JIDLA: 'Jídla',
|
||||
};
|
||||
|
||||
@@ -198,3 +198,36 @@
|
||||
.meal-price.burn {
|
||||
color: var(--ft-warning);
|
||||
}
|
||||
|
||||
// Spánek se zadává přímo v dlaždici, ať to není další dialog
|
||||
.sleep-input-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
|
||||
.sleep-input {
|
||||
width: 84px;
|
||||
padding: 0 6px;
|
||||
height: auto;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px dashed var(--ft-border);
|
||||
border-radius: 0;
|
||||
color: var(--ft-text);
|
||||
|
||||
&:focus {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-bottom-color: var(--ft-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sleep-unit {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
+117
-90
@@ -1,26 +1,28 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { Alert, Button, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
|
||||
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
|
||||
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
|
||||
faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
|
||||
import {
|
||||
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||
} from "../enums";
|
||||
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
||||
deleteActivity, deleteMeal, getDayOverview, saveSleep,
|
||||
} from "../../../types";
|
||||
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import {
|
||||
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
|
||||
getDayOfWeek, getHumanDate, shiftDate,
|
||||
formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
||||
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
||||
} from "../Utils";
|
||||
import MealModal from "../components/modals/MealModal";
|
||||
import ActivityModal from "../components/modals/ActivityModal";
|
||||
import WorkoutModal from "../components/modals/WorkoutModal";
|
||||
import ApplyWorkoutModal from "../components/modals/ApplyWorkoutModal";
|
||||
import ApplyMealModal from "../components/modals/ApplyMealModal";
|
||||
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
|
||||
import MealRow from "../components/MealRow";
|
||||
import ActivityRow from "../components/ActivityRow";
|
||||
import "./DayPage.scss";
|
||||
|
||||
/** Záložky dne — co do těla přišlo a co se vydalo. */
|
||||
type Tab = 'prijem' | 'vydej';
|
||||
|
||||
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
|
||||
function emptyOverview(date: string): DayOverview {
|
||||
return {
|
||||
@@ -34,13 +36,18 @@ function emptyOverview(date: string): DayOverview {
|
||||
export default function DayPage() {
|
||||
const [date, setDate] = useState<string>(() => formatDate(new Date()));
|
||||
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
|
||||
const [tab, setTab] = useState<Tab>('prijem');
|
||||
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
|
||||
const [mealModalOpen, setMealModalOpen] = useState(false);
|
||||
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
||||
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
||||
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
||||
const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false);
|
||||
const [sleepInput, setSleepInput] = useState('');
|
||||
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
||||
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
||||
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||
|
||||
const loadDay = useCallback(async (forDate: string) => {
|
||||
setLoading(true);
|
||||
@@ -51,6 +58,17 @@ export default function DayPage() {
|
||||
|
||||
useEffect(() => { loadDay(date); }, [date, loadDay]);
|
||||
|
||||
// Pole spánku se plní z načteného dne; uživatelovu rozepsanou hodnotu přepíše jen při změně dne
|
||||
useEffect(() => { setSleepInput(formatSleepInput(day.sleepMinutes)); }, [day.date, day.sleepMinutes]);
|
||||
|
||||
/** Uloží spánek při opuštění pole — průběžné ukládání po každé číslici nedává smysl. */
|
||||
const commitSleep = async () => {
|
||||
const minutes = parseSleepInput(sleepInput) ?? null;
|
||||
if (minutes === (day.sleepMinutes ?? null)) return;
|
||||
const response = await saveSleep({ body: { date, sleepMinutes: minutes } });
|
||||
if (response.data) setDay(response.data);
|
||||
};
|
||||
|
||||
const isToday = date === formatDate(new Date());
|
||||
|
||||
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
||||
@@ -69,6 +87,25 @@ export default function DayPage() {
|
||||
loadDay(date);
|
||||
};
|
||||
|
||||
/** Krátká hláška o úspěchu; sama zmizí, ať nepřekáží. */
|
||||
const showNotice = (text: string) => {
|
||||
setSavedNotice(text);
|
||||
setTimeout(() => setSavedNotice(undefined), 3000);
|
||||
};
|
||||
|
||||
const saveMealAsTemplate = async (entry: MealEntry) => {
|
||||
const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } });
|
||||
if (response.data) {
|
||||
showNotice(`„${entry.name}" uloženo jako šablonu jídla.`);
|
||||
}
|
||||
};
|
||||
|
||||
/** Otevře dialog pro uložení tréninku, se zaškrtnutou jednou aktivitou nebo všemi. */
|
||||
const openSaveWorkout = (entry?: ActivityEntry) => {
|
||||
setPreselectedActivities(entry ? [entry.id] : []);
|
||||
setSaveWorkoutOpen(true);
|
||||
};
|
||||
|
||||
const removeActivity = async (entry: ActivityEntry) => {
|
||||
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
|
||||
if (response.data) setDay(response.data);
|
||||
@@ -78,12 +115,22 @@ export default function DayPage() {
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Můj den</h1>
|
||||
{tab === 'prijem' ? (
|
||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||
</Button>
|
||||
{tab === DayTab.PRIJEM ? (
|
||||
<div className="header-actions">
|
||||
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faBowlFood} /> Šablony
|
||||
</Button>
|
||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="header-actions">
|
||||
{!!day.activities.entries.length && (
|
||||
<Button variant="outline-secondary" onClick={() => openSaveWorkout()}>
|
||||
<FontAwesomeIcon icon={faBookmark} /> Uložit jako šablonu
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faDumbbell} /> Šablony
|
||||
</Button>
|
||||
@@ -156,21 +203,41 @@ export default function DayPage() {
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
|
||||
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faBed} /> Spánek</div>
|
||||
<div className="sleep-input-row">
|
||||
<input
|
||||
className="form-control sleep-input"
|
||||
inputMode="decimal"
|
||||
value={sleepInput}
|
||||
placeholder="7,5"
|
||||
aria-label="Délka spánku v hodinách"
|
||||
onChange={event => setSleepInput(event.target.value)}
|
||||
onBlur={() => commitSleep()}
|
||||
/>
|
||||
<span className="sleep-unit">h</span>
|
||||
</div>
|
||||
<div className="summary-hint">
|
||||
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
||||
|
||||
<div className="day-tabs">
|
||||
<button
|
||||
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
|
||||
onClick={() => setTab('prijem')}
|
||||
className={`day-tab ${tab === DayTab.PRIJEM ? 'active' : ''}`}
|
||||
onClick={() => setTab(DayTab.PRIJEM)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUtensils} /> Příjem
|
||||
<FontAwesomeIcon icon={faUtensils} /> {DAY_TAB_NAMES[DayTab.PRIJEM]}
|
||||
<span className="day-tab-count">{day.meals.entries.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
|
||||
onClick={() => setTab('vydej')}
|
||||
className={`day-tab ${tab === DayTab.VYDEJ ? 'active' : ''}`}
|
||||
onClick={() => setTab(DayTab.VYDEJ)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPersonRunning} /> Výdej
|
||||
<FontAwesomeIcon icon={faPersonRunning} /> {DAY_TAB_NAMES[DayTab.VYDEJ]}
|
||||
<span className="day-tab-count">{day.activities.entries.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -181,7 +248,7 @@ export default function DayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tab === 'prijem' && (
|
||||
{!loading && tab === DayTab.PRIJEM && (
|
||||
<>
|
||||
{!day.meals.entries.length && (
|
||||
<div className="card-panel empty-state">
|
||||
@@ -208,6 +275,7 @@ export default function DayPage() {
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||
onDelete={() => removeMeal(entry)}
|
||||
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -216,7 +284,7 @@ export default function DayPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && tab === 'vydej' && (
|
||||
{!loading && tab === DayTab.VYDEJ && (
|
||||
<>
|
||||
{!day.activities.entries.length && (
|
||||
<div className="card-panel empty-state">
|
||||
@@ -235,6 +303,7 @@ export default function DayPage() {
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
||||
onDelete={() => removeActivity(entry)}
|
||||
onSaveTemplate={() => openSaveWorkout(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -273,7 +342,29 @@ export default function DayPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkoutModal
|
||||
<SaveWorkoutTemplateModal
|
||||
isOpen={saveWorkoutOpen}
|
||||
date={date}
|
||||
activities={day.activities.entries}
|
||||
preselected={preselectedActivities}
|
||||
onClose={() => setSaveWorkoutOpen(false)}
|
||||
onSaved={() => {
|
||||
setSaveWorkoutOpen(false);
|
||||
showNotice('Šablona tréninku vytvořena.');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ApplyMealModal
|
||||
isOpen={mealTemplateModalOpen}
|
||||
date={date}
|
||||
onClose={() => setMealTemplateModalOpen(false)}
|
||||
onApplied={saved => {
|
||||
setDay(saved);
|
||||
setMealTemplateModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ApplyWorkoutModal
|
||||
isOpen={workoutModalOpen}
|
||||
date={date}
|
||||
onClose={() => setWorkoutModalOpen(false)}
|
||||
@@ -285,67 +376,3 @@ export default function DayPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MealRowProps = {
|
||||
entry: MealEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu jídel. */
|
||||
function MealRow({ entry, onEdit, onDelete }: Readonly<MealRowProps>) {
|
||||
return (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
{entry.source && <span className="tag">{entry.source}</span>}
|
||||
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
||||
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
||||
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityRowProps = {
|
||||
entry: ActivityEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu aktivit. */
|
||||
function ActivityRow({ entry, onEdit, onDelete }: Readonly<ActivityRowProps>) {
|
||||
return (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
<span className="tag">{formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])}</span>
|
||||
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="aktivitu" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tlačítka pro úpravu a smazání řádku. */
|
||||
function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) {
|
||||
return (
|
||||
<div className="meal-actions">
|
||||
<button className="btn-icon" onClick={onEdit} title="Upravit" aria-label={`Upravit ${label}`}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button className="btn-icon danger" onClick={onDelete} title="Smazat" aria-label={`Smazat ${label}`}>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPenToSquare, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate, MealType, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import {
|
||||
deriveAmounts, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
||||
parseIntInput, parsePriceInput,
|
||||
} from "../Utils";
|
||||
|
||||
/** Prázdný formulář nové šablony jídla. */
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
source: '',
|
||||
mealType: '' as MealType | '',
|
||||
weight: '',
|
||||
caloriesPer100g: '',
|
||||
calories: '',
|
||||
price: '',
|
||||
pricePer100g: '',
|
||||
note: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Panel se správou šablon jídel.
|
||||
*
|
||||
* Na rozdíl od šablon tréninků se neskládají z jiných — jsou to ploché záznamy.
|
||||
* Poznámka slouží i na postup přípravy u domácích jídel; strukturované suroviny
|
||||
* mohou přibýt později, aniž by se model musel měnit.
|
||||
*/
|
||||
const MealTemplatesPanel = () => {
|
||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editedId, setEditedId] = useState<string | undefined>();
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getMealTemplates();
|
||||
setTemplates(response.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const update = (changes: Partial<ReturnType<typeof emptyForm>>) =>
|
||||
setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditedId(undefined);
|
||||
setForm(emptyForm());
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const canSave = form.name.trim().length > 0 && !busy;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
const response = await saveMealTemplate({
|
||||
body: {
|
||||
...(editedId ? { id: editedId } : {}),
|
||||
name: form.name.trim(),
|
||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||
weight: parseIntInput(form.weight),
|
||||
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||
calories: parseIntInput(form.calories),
|
||||
price: parsePriceInput(form.price),
|
||||
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
resetForm();
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await deleteMealTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (editedId === id) resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (template: MealTemplate) => {
|
||||
setEditedId(template.id);
|
||||
setError(undefined);
|
||||
setForm({
|
||||
name: template.name,
|
||||
source: template.source ?? '',
|
||||
mealType: template.mealType ?? '',
|
||||
weight: template.weight != null ? String(template.weight) : '',
|
||||
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||
calories: template.calories != null ? String(template.calories) : '',
|
||||
price: formatPriceInput(template.price),
|
||||
pricePer100g: formatPriceInput(template.pricePer100g),
|
||||
note: template.note ?? '',
|
||||
});
|
||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
/** Živý náhled toho, co šablona při použití založí. */
|
||||
const preview = deriveAmounts(
|
||||
parsePriceInput(form.price),
|
||||
parsePriceInput(form.pricePer100g),
|
||||
parseIntInput(form.weight),
|
||||
parseIntInput(form.caloriesPer100g));
|
||||
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-body-secondary small">
|
||||
Šablona jídla je opakovaně jedené jídlo, které v přehledu dne založíte jedním
|
||||
kliknutím. Stačí název; gramáž, kalorie a cena se vyplňují podle toho, co o jídle
|
||||
víte. U domácích jídel jde do poznámky zapsat i postup přípravy.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="card-panel 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">Sestavte si ji ve formuláři níže.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.map(template => {
|
||||
const derived = deriveAmounts(
|
||||
template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||
const calories = derived.calories ?? template.calories;
|
||||
return (
|
||||
<div className="card-panel template-card" key={template.id}>
|
||||
<div className="template-head">
|
||||
<div className="template-title">
|
||||
{template.name}
|
||||
{template.mealType && (
|
||||
<span className="tag">
|
||||
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
||||
</span>
|
||||
)}
|
||||
{template.source && <span className="tag">{template.source}</span>}
|
||||
{derived.weight != null && <span className="tag">{formatWeight(derived.weight)}</span>}
|
||||
{template.note && <span className="tag">s postupem</span>}
|
||||
</div>
|
||||
{calories != null && (
|
||||
<span className="template-calories">{formatCalories(calories)}</span>
|
||||
)}
|
||||
{template.price != null && (
|
||||
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
||||
)}
|
||||
<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>
|
||||
{template.note && <p className="meal-template-note">{template.note}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="card-panel template-form">
|
||||
<h2 className="chart-title">{editedId ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={form.name}
|
||||
placeholder="Např. Těstovinový salát"
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<Form.Control
|
||||
value={form.source}
|
||||
placeholder="Chefie, doma…"
|
||||
onChange={event => update({ source: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Chod</Form.Label>
|
||||
<Form.Select
|
||||
value={form.mealType}
|
||||
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
||||
>
|
||||
<option value="">— vybrat při použití —</option>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Kalorie, gramáž a cena</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Gramáž</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.weight}
|
||||
placeholder="350"
|
||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Energie na 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100g}
|
||||
placeholder="160"
|
||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.price}
|
||||
placeholder="129,00"
|
||||
onChange={event => update({ price: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena za 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.pricePer100g}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ pricePer100g: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kalorie celé porce dávají smysl jen tam, kde se nedají dopočítat z gramáže */}
|
||||
{preview.calories == null && (
|
||||
<div className="row g-3 mt-0">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kalorie porce</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.calories}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Poznámka / postup</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
value={form.note}
|
||||
placeholder="U domácích jídel sem lze zapsat recept…"
|
||||
onChange={event => update({ note: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className="workout-form-footer">
|
||||
{previewCalories != null && (
|
||||
<span className="workout-draft-calories">
|
||||
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
||||
{formatCalories(previewCalories)}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealTemplatesPanel;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFire, faCalculator } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Pohlavi, UserSettings, getSettings, saveBasalCalories, saveProfile } from "../../../types";
|
||||
import { POHLAVI_IN_ORDER, POHLAVI_NAMES } from "../enums";
|
||||
import { formatCalories, formatWeightKgInput, parseIntInput, parseWeightKgInput } from "../Utils";
|
||||
|
||||
/**
|
||||
* Osobní údaje a klidový výdej.
|
||||
*
|
||||
* Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá bazální
|
||||
* metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní
|
||||
* hodnotu, může ji zadat ručně a ta má přednost.
|
||||
*/
|
||||
const ProfilePage = () => {
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sex, setSex] = useState('');
|
||||
const [birthYear, setBirthYear] = useState('');
|
||||
const [heightCm, setHeightCm] = useState('');
|
||||
const [weightKg, setWeightKg] = useState('');
|
||||
const [manualBasal, setManualBasal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
/** Naplní formulář z nastavení vráceného serverem. */
|
||||
const fillForm = (data?: UserSettings) => {
|
||||
setSettings(data);
|
||||
setSex(data?.profile?.sex ?? '');
|
||||
setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : '');
|
||||
setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : '');
|
||||
setWeightKg(formatWeightKgInput(data?.profile?.weightGrams));
|
||||
setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : '');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getSettings();
|
||||
fillForm(response.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const saveAll = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
setSaved(false);
|
||||
|
||||
const profileResponse = await saveProfile({
|
||||
body: {
|
||||
...(sex ? { sex: sex as Pohlavi } : {}),
|
||||
...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}),
|
||||
...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}),
|
||||
...(parseWeightKgInput(weightKg) != null ? { weightGrams: parseWeightKgInput(weightKg) } : {}),
|
||||
},
|
||||
});
|
||||
if (!profileResponse.data) {
|
||||
setSaving(false);
|
||||
setError((profileResponse.error as { error?: string })?.error ?? 'Údaje se nepodařilo uložit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ruční klidový výdej se ukládá zvlášť; prázdné pole ho odstraní
|
||||
const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } });
|
||||
setSaving(false);
|
||||
if (!basalResponse.data) {
|
||||
setError((basalResponse.error as { error?: string })?.error ?? 'Klidový výdej se nepodařilo uložit');
|
||||
return;
|
||||
}
|
||||
fillForm(basalResponse.data);
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const computed = settings?.computedBasalCalories;
|
||||
const effective = settings?.effectiveBasalCalories;
|
||||
const manualWins = settings?.basalCalories != null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Osobní údaje</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-body-secondary small">
|
||||
Z těchto údajů se spočítá klidový výdej (bazální metabolismus) rovnicí
|
||||
Mifflin–St Jeor. Ten pak v přehledu dne tvoří základ výdeje, ke kterému
|
||||
se přičítá zaznamenaný pohyb.
|
||||
</p>
|
||||
|
||||
<div className="card-panel mb-3">
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Pohlaví</Form.Label>
|
||||
<Form.Select value={sex} onChange={event => setSex(event.target.value)}>
|
||||
<option value="">— nevyplněno —</option>
|
||||
{POHLAVI_IN_ORDER.map(value => (
|
||||
<option key={value} value={value}>{POHLAVI_NAMES[value]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Rok narození</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={birthYear}
|
||||
placeholder="1990"
|
||||
onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Výška</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={heightCm}
|
||||
placeholder="185"
|
||||
onChange={event => setHeightCm(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">cm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Hmotnost</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={weightKg}
|
||||
placeholder="90,0"
|
||||
onChange={event => setWeightKg(event.target.value)}
|
||||
/>
|
||||
<span className="input-group-text">kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalculator} /> Spočítaný klidový výdej</div>
|
||||
<div className="summary-value">{formatCalories(computed)}</div>
|
||||
<div className="summary-hint">
|
||||
{computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Použije se v bilanci</div>
|
||||
<div className="summary-value">{formatCalories(effective)}</div>
|
||||
<div className="summary-hint">
|
||||
{manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-panel mb-3">
|
||||
<h2 className="chart-title">Ruční klidový výdej</h2>
|
||||
<p className="text-body-secondary small">
|
||||
Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou.
|
||||
Prázdné pole znamená, že se použije výpočet z údajů výše.
|
||||
</p>
|
||||
<div className="rate-form">
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={manualBasal}
|
||||
placeholder={computed != null ? String(computed) : 'např. 1800'}
|
||||
onChange={event => setManualBasal(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">kcal / den</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
{saved && !error && <Alert variant="success" className="py-2 small">Uloženo.</Alert>}
|
||||
|
||||
<Button onClick={() => saveAll()} disabled={saving}>
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
|
||||
<p className="text-body-secondary small mt-4">
|
||||
Poznámka k výpočtu: klidový výdej je hodnota za celých 24 hodin včetně spánku.
|
||||
Záměrně se nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky TDEE —
|
||||
pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení
|
||||
by ho započítalo dvakrát.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||
} from "recharts";
|
||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||
import { MEAL_TYPE_NAMES } from "../enums";
|
||||
import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
|
||||
import {
|
||||
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
|
||||
getHumanMonth, getMonthEnd, getMonthStart,
|
||||
@@ -14,15 +14,6 @@ import {
|
||||
import ImportModal from "../components/modals/ImportModal";
|
||||
import "./StatsPage.scss";
|
||||
|
||||
/** Rozsahy, za které lze statistiky zobrazit. */
|
||||
type RangeMode = 'month' | 'year' | 'all';
|
||||
|
||||
const RANGE_LABELS: Record<RangeMode, string> = {
|
||||
month: 'Měsíc',
|
||||
year: 'Rok',
|
||||
all: 'Vše',
|
||||
};
|
||||
|
||||
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
|
||||
const EARLIEST_YEAR = 2015;
|
||||
|
||||
@@ -43,7 +34,7 @@ function formatChartPrice(value: unknown): string {
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const [mode, setMode] = useState<RangeMode>('month');
|
||||
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
|
||||
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
||||
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||
@@ -52,10 +43,10 @@ export default function StatsPage() {
|
||||
|
||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||
const range = useMemo(() => {
|
||||
if (mode === 'month') {
|
||||
if (mode === StatsRange.MESIC) {
|
||||
return { from: getMonthStart(month), to: getMonthEnd(month) };
|
||||
}
|
||||
if (mode === 'year') {
|
||||
if (mode === StatsRange.ROK) {
|
||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||
}
|
||||
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
|
||||
@@ -73,14 +64,14 @@ export default function StatsPage() {
|
||||
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||
const series = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
if (mode === 'month') {
|
||||
if (mode === StatsRange.MESIC) {
|
||||
return stats.byDay.map(total => ({
|
||||
label: formatDateString(total.period).slice(0, 5),
|
||||
price: total.price / 100,
|
||||
calories: total.calories,
|
||||
}));
|
||||
}
|
||||
if (mode === 'year') {
|
||||
if (mode === StatsRange.ROK) {
|
||||
return stats.byMonth.map(total => ({
|
||||
label: getHumanMonth(total.period).split(' ')[0],
|
||||
price: total.price / 100,
|
||||
@@ -117,17 +108,17 @@ export default function StatsPage() {
|
||||
|
||||
<div className="range-picker card-panel">
|
||||
<div className="range-modes">
|
||||
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
|
||||
{Object.values(StatsRange).map(value => (
|
||||
<button
|
||||
key={value}
|
||||
className={`range-mode ${mode === value ? 'active' : ''}`}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{RANGE_LABELS[value]}
|
||||
{STATS_RANGE_NAMES[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'month' && (
|
||||
{mode === StatsRange.MESIC && (
|
||||
<input
|
||||
type="month"
|
||||
className="form-control range-input"
|
||||
@@ -135,7 +126,7 @@ export default function StatsPage() {
|
||||
onChange={event => event.target.value && setMonth(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{mode === 'year' && (
|
||||
{mode === StatsRange.ROK && (
|
||||
<input
|
||||
type="number"
|
||||
className="form-control range-input"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
.template-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.template-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-weight: 700;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.template-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-items {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.87rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
}
|
||||
}
|
||||
|
||||
.template-item-icon {
|
||||
color: var(--ft-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.template-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.template-item-quantity {
|
||||
color: var(--ft-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-item-calories {
|
||||
color: var(--ft-text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// Odkaz na vnořenou šablonu se v editaci odlišuje ikonou před výběrem
|
||||
.workout-item-nested-icon {
|
||||
align-self: center;
|
||||
color: var(--ft-primary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.meal-template-price {
|
||||
color: var(--ft-text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Postup přípravy si drží zalomení řádků tak, jak ho uživatel zapsal
|
||||
.meal-template-note {
|
||||
margin: 10px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
font-size: 0.83rem;
|
||||
color: var(--ft-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDumbbell, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||
import MealTemplatesPanel from "./MealTemplatesPanel";
|
||||
import "./TemplatesPage.scss";
|
||||
|
||||
/**
|
||||
* Šablony — tréninky a jídla vedle sebe.
|
||||
*
|
||||
* Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají
|
||||
* jinou strukturu (tréninky se skládají z jiných šablon, jídla ne), proto mají
|
||||
* vlastní panely a společné jen záložky.
|
||||
*/
|
||||
const TemplatesPage = () => {
|
||||
const [tab, setTab] = useState<TemplateTab>(TemplateTab.TRENINKY);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Šablony</h1>
|
||||
</div>
|
||||
|
||||
<div className="day-tabs">
|
||||
<button
|
||||
className={`day-tab ${tab === TemplateTab.TRENINKY ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.TRENINKY)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
||||
</button>
|
||||
<button
|
||||
className={`day-tab ${tab === TemplateTab.JIDLA ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.JIDLA)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBowlFood} /> {TEMPLATE_TAB_NAMES[TemplateTab.JIDLA]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === TemplateTab.TRENINKY ? <WorkoutTemplatesPanel /> : <MealTemplatesPanel />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatesPage;
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
|
||||
import { deriveActivityCalories, formatCalories, formatQuantityWithSets, parseIntInput } from "../Utils";
|
||||
|
||||
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
|
||||
type DraftItem = {
|
||||
kind: WorkoutItemKind,
|
||||
name: string,
|
||||
unit: ActivityUnit,
|
||||
quantity: string,
|
||||
sets: string,
|
||||
caloriesPer100Units: string,
|
||||
templateRefId: string,
|
||||
};
|
||||
|
||||
const emptyExercise = (): DraftItem => ({
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name: '',
|
||||
unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: '',
|
||||
sets: '',
|
||||
caloriesPer100Units: '',
|
||||
templateRefId: '',
|
||||
});
|
||||
|
||||
const emptyReference = (templateRefId: string): DraftItem => ({
|
||||
...emptyExercise(),
|
||||
kind: WorkoutItemKind.SABLONA,
|
||||
templateRefId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Panel se správou šablon tréninků.
|
||||
*
|
||||
* Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu,
|
||||
* jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu
|
||||
* úroveň, takže šablona, která už něco skládá, se sama vnořit nedá.
|
||||
*/
|
||||
const WorkoutTemplatesPanel = () => {
|
||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editedId, setEditedId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [items, setItems] = useState<DraftItem[]>([emptyExercise()]);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getWorkoutTemplates();
|
||||
setTemplates(response.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditedId(undefined);
|
||||
setName('');
|
||||
setItems([emptyExercise()]);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Šablony, které lze vnořit do právě upravované.
|
||||
*
|
||||
* Vypadne ta upravovaná (odkaz sám na sebe) a všechny, které samy něco
|
||||
* skládají — ty už by druhou úroveň zanoření přinesly.
|
||||
*/
|
||||
const nestable = templates.filter(template => template.id !== editedId && !template.hasNested);
|
||||
|
||||
/** True, pokud upravovanou šablonu už někdo skládá — pak do ní nelze vnořit další. */
|
||||
const isUsedByOther = !!editedId && (templates.find(t => t.id === editedId)?.usedByCount ?? 0) > 0;
|
||||
|
||||
const toItems = (): WorkoutTemplateItem[] => items
|
||||
.filter(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? item.templateRefId.length > 0
|
||||
: item.name.trim().length > 0 && parseIntInput(item.quantity) != null)
|
||||
.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId }
|
||||
: {
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name: item.name.trim(),
|
||||
unit: item.unit,
|
||||
quantity: parseIntInput(item.quantity)!,
|
||||
...(parseIntInput(item.sets) != null && parseIntInput(item.sets)! > 1
|
||||
? { sets: parseIntInput(item.sets) }
|
||||
: {}),
|
||||
...(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);
|
||||
setError(undefined);
|
||||
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
resetForm();
|
||||
} else {
|
||||
// Server odmítá typicky kvůli pravidlu o jedné úrovni zanoření
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
const response = await deleteWorkoutTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (editedId === id) resetForm();
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo smazat');
|
||||
}
|
||||
};
|
||||
|
||||
/** Načte šablonu do formuláře k úpravě. */
|
||||
const edit = (template: WorkoutTemplate) => {
|
||||
setEditedId(template.id);
|
||||
setName(template.name);
|
||||
setError(undefined);
|
||||
setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? emptyReference(item.templateRefId ?? '')
|
||||
: {
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name: item.name ?? '',
|
||||
unit: item.unit ?? ActivityUnit.OPAKOVANI,
|
||||
quantity: item.quantity != null ? String(item.quantity) : '',
|
||||
sets: item.sets != null ? String(item.sets) : '',
|
||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||
templateRefId: '',
|
||||
}));
|
||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
/** Průběžný odhad kalorií rozpracované šablony, včetně vnořených. */
|
||||
const draftCalories = items.reduce((sum, item) => {
|
||||
if (item.kind === WorkoutItemKind.SABLONA) {
|
||||
return sum + (templates.find(t => t.id === item.templateRefId)?.estimatedCalories ?? 0);
|
||||
}
|
||||
return sum + (deriveActivityCalories(
|
||||
parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-body-secondary small">
|
||||
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím.
|
||||
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku.
|
||||
Zanoření je kvůli jednoduchosti jen na jednu úroveň.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="card-panel 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">Sestavte si ji ve formuláři níže.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.map(template => (
|
||||
<div className="card-panel template-card" key={template.id}>
|
||||
<div className="template-head">
|
||||
<div className="template-title">
|
||||
{template.name}
|
||||
{template.hasNested && (
|
||||
<span className="tag tag-import">
|
||||
<FontAwesomeIcon icon={faLayerGroup} /> skládaná
|
||||
</span>
|
||||
)}
|
||||
{template.usedByCount > 0 && (
|
||||
<span className="tag">používá ji {template.usedByCount}×</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="template-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||
<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={template.usedByCount > 0 ? 'Nelze smazat, skládá ji jiná šablona' : 'Smazat'}
|
||||
aria-label="Smazat šablonu"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="template-items">
|
||||
{template.items.map((item, index) => (
|
||||
// Položky nemají vlastní id, v rámci jedné šablony je index stabilní
|
||||
<li key={index}>
|
||||
{item.kind === WorkoutItemKind.SABLONA ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="template-item-icon" />
|
||||
<span className="template-item-name">{item.templateRefName ?? 'neznámá šablona'}</span>
|
||||
<span className="template-item-calories">
|
||||
{formatCalories(item.templateRefCalories)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="template-item-name">{item.name}</span>
|
||||
<span className="template-item-quantity">
|
||||
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
|
||||
</span>
|
||||
<span className="template-item-calories">
|
||||
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="card-panel template-form">
|
||||
<h2 className="chart-title">{editedId ? 'Úprava šablony' : 'Nová šablona'}</h2>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<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}>
|
||||
{item.kind === WorkoutItemKind.SABLONA ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="workout-item-nested-icon" />
|
||||
<Form.Select
|
||||
value={item.templateRefId}
|
||||
onChange={event => updateItem(index, { templateRefId: event.target.value })}
|
||||
>
|
||||
<option value="">— vyberte šablonu —</option>
|
||||
{nestable.map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name} ({template.estimatedCalories} kcal)
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Control
|
||||
list="activity-presets-templates"
|
||||
value={item.name}
|
||||
placeholder="Cvik"
|
||||
onChange={event => applyPreset(index, event.target.value)}
|
||||
/>
|
||||
<Form.Control
|
||||
className="workout-item-sets"
|
||||
inputMode="numeric"
|
||||
value={item.sets}
|
||||
placeholder="série"
|
||||
title="Počet sérií"
|
||||
onChange={event => updateItem(index, { sets: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<Form.Control
|
||||
className="workout-item-quantity"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
placeholder="60"
|
||||
title="Množství v jedné sérii"
|
||||
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-templates">
|
||||
{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, emptyExercise()])}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} /> Cvik
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
onClick={() => setItems(current => [...current, emptyReference('')])}
|
||||
disabled={!nestable.length || isUsedByOther}
|
||||
title={isUsedByOther
|
||||
? 'Tuto šablonu už skládá jiná, další zanoření by bylo druhou úrovní'
|
||||
: (!nestable.length ? 'Není k dispozici žádná šablona, kterou by šlo vnořit' : undefined)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLayerGroup} /> Vnořit šablonu
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutTemplatesPanel;
|
||||
@@ -6,3 +6,5 @@
|
||||
*/
|
||||
export const DEN_URL = '/';
|
||||
export const STATISTIKY_URL = '/statistiky';
|
||||
export const SABLONY_URL = '/sablony';
|
||||
export const PROFIL_URL = '/profil';
|
||||
|
||||
Reference in New Issue
Block a user