feat: import jen některých dnů

feat: násobení jídel/cviků
feat: refaktor napojování šablon
This commit is contained in:
Ondřej Anděl committed 2026-09-30 19:10:54 +02:00
1 parent c3990d80f8
commit 8dac67bdd1
37 files changed
+2314 -481

No files matched your search

+72 -11
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import {
DayRecord, MealEntry, MealType, UserSettings,
addMeal, createMealTemplateFromMeal, getSettings, updateMeal,
DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings,
addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
import TemplateSuggestions from "../TemplateSuggestions";
type Props = {
isOpen: boolean,
@@ -33,12 +35,13 @@ function emptyForm(date: string) {
caloriesPer100g: '',
calories: '',
note: '',
multiplier: '',
};
}
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
}
/**
@@ -48,17 +51,24 @@ function normalizeSource(source: string): string {
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
* s jiným cenováním můžou být ve stejném dni vedle sebe.
*
* U nového jídla se k rozepsanému názvu nabízejí šablony, takže jídlo, které už
* uživatel někdy měl, nemusí dohledávat znovu.
*/
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [settings, setSettings] = useState<UserSettings | undefined>();
const [saving, setSaving] = useState(false);
const [alsoTemplate, setAlsoTemplate] = useState(false);
const [templates, setTemplates] = useState<MealTemplate[]>([]);
/** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
const [pickedTemplate, setPickedTemplate] = useState<string | undefined>();
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setAlsoTemplate(false);
setPickedTemplate(undefined);
setForm(entry
? {
date: entry.date,
@@ -71,10 +81,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
multiplier: formatMultiplierInput(entry.multiplier),
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Šablony se nabízejí jen při zakládání; načtou se při každém otevření, mohly přibýt
useEffect(() => {
if (!isOpen || entry) return;
getMealTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen, entry]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
if (!isOpen || settings) return;
@@ -83,6 +100,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */
const applyTemplate = (template: MealTemplate) => {
setPickedTemplate(template.name);
setForm(current => ({
...current,
name: template.name,
mealType: template.mealType ?? current.mealType,
source: template.source ?? '',
price: formatPriceInput(template.price),
pricePer100g: formatPriceInput(template.pricePer100g),
weight: template.weight != null ? String(template.weight) : '',
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
calories: template.calories != null ? String(template.calories) : '',
// Postup receptu do záznamu dne nepatří, zůstává u šablony
note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '',
}));
};
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
const rateForSource = useMemo(() => {
const key = normalizeSource(form.source);
@@ -109,8 +144,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derived.calories ?? manualCalories;
const multiplier = parseMultiplierInput(form.multiplier);
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
const canSave = form.name.trim().length > 0 && !saving;
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving;
const save = async () => {
if (!canSave) return;
@@ -127,6 +165,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
calories: derived.calories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
multiplier,
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
@@ -159,8 +198,14 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => update({ name: event.target.value })}
onChange={event => { update({ name: event.target.value }); setPickedTemplate(undefined); }}
/>
{!entry && pickedTemplate !== form.name && (
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
)}
{!entry && pickedTemplate === form.name && (
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
)}
</Form.Group>
<div className="row g-3 mb-3">
@@ -255,7 +300,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<div className="col-sm-4">
<div className="col-sm-3">
<Form.Label>Energie na 100 g</Form.Label>
<div className="input-group">
<Form.Control
@@ -267,8 +312,8 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
<span className="input-group-text">kcal</span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Kalorie celkem</Form.Label>
<div className="col-sm-3">
<Form.Label>Kalorie porce</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
@@ -288,6 +333,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</Form.Text>
)}
</div>
<div className="col-sm-2">
<Form.Label>Násobek</Form.Label>
<Form.Control
inputMode="decimal"
value={form.multiplier}
placeholder="1"
isInvalid={multiplierInvalid}
title="Kolik porcí jste snědli, třeba 2 nebo 0,5. Cena se nenásobí."
onChange={event => update({ multiplier: event.target.value })}
/>
</div>
<div className="col-sm-4 d-flex align-items-end">
<CalorieLookup
name={form.name}
@@ -318,10 +374,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
/>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
{totalCalories != null && (
<span>
{formatCalories(totalCalories)}
{multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`}
</span>
)}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
<Button onClick={() => save()} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>