Files
food-tracer/client/src/components/modals/MealModal.tsx
T
Ondřej Anděl 8dac67bdd1 feat: import jen některých dnů
feat: násobení jídel/cviků
feat: refaktor napojování šablon
2026-09-30 19:10:54 +02:00

392 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import {
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 {
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
import TemplateSuggestions from "../TemplateSuggestions";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nové jídlo. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
};
/** Prázdný formulář pro nové jídlo v daném dni. */
function emptyForm(date: string) {
return {
date,
mealType: MealType.OBED as MealType,
name: '',
source: '',
price: '',
pricePer100g: '',
weight: '',
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(/[̀-ͯ]/g, '').toLowerCase().trim();
}
/**
* Dialog pro přidání nebo úpravu jídla.
*
* Kromě ceny umí i gramáž — u podniků účtujících podle váhy (TechTower, 44 Kč/100 g)
* 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,
mealType: entry.mealType,
name: entry.name,
source: entry.source ?? '',
price: formatPriceInput(entry.price),
pricePer100g: formatPriceInput(entry.pricePer100g),
weight: entry.weight != null ? String(entry.weight) : '',
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;
getSettings().then(response => setSettings(response.data));
}, [isOpen, settings]);
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);
if (!key.length) return undefined;
return settings?.sourceRates.find(rate => normalizeSource(rate.source) === key)?.pricePer100g;
}, [settings, form.source]);
/**
* Doplní sazbu podle zdroje, jakmile ji uživatel vybere a pole je prázdné.
* Ručně zadanou sazbu nepřepisujeme — u salátu bývá jiná než u hlavního jídla.
*/
useEffect(() => {
if (rateForSource && !form.pricePer100g.trim().length) {
setForm(current => ({ ...current, pricePer100g: formatPriceInput(rateForSource) }));
}
}, [rateForSource, form.pricePer100g]);
const price = parsePriceInput(form.price);
const pricePer100g = parsePriceInput(form.pricePer100g);
const manualWeight = parseIntInput(form.weight);
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
const derived = deriveAmounts(price, pricePer100g, manualWeight, caloriesPer100g);
/** 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 && !multiplierInvalid && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
mealType: form.mealType,
name: form.name.trim(),
source: form.source.trim() || undefined,
price,
pricePer100g,
weight: manualWeight,
caloriesPer100g,
// 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 } })
: 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);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava jídla' : 'Nové jídlo'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<Form.Group className="mb-3">
<Form.Label>Jídlo</Form.Label>
<Form.Control
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
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">
<div className="col-sm-4">
<Form.Label>Chod</Form.Label>
<Form.Select
value={form.mealType}
onChange={event => update({ mealType: event.target.value as MealType })}
>
{MEAL_TYPES_IN_ORDER.map(mealType => (
<option key={mealType} value={mealType}>
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
</option>
))}
</Form.Select>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
list="known-sources"
value={form.source}
placeholder="TechTower, doma…"
onChange={event => update({ source: event.target.value })}
/>
<datalist id="known-sources">
{settings?.sourceRates.map(rate => (
<option key={rate.source} value={rate.source} />
))}
</datalist>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Cena a gramáž</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Cena</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.price}
placeholder="0,00"
onChange={event => update({ price: event.target.value })}
/>
<span className="input-group-text">Kč</span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Cena za 100 g</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.pricePer100g}
placeholder="např. 44,00"
onChange={event => update({ pricePer100g: event.target.value })}
/>
<span className="input-group-text">Kč</span>
</div>
{rateForSource != null && (
<Form.Text>sazba zdroje {form.source.trim()}</Form.Text>
)}
</div>
<div className="col-sm-4">
<Form.Label>Gramáž</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.weight}
placeholder={derived.weight != null ? String(derived.weight) : 'volitelné'}
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
{manualWeight == null && derived.weight != null && (
<Form.Text>dopočteno z ceny</Form.Text>
)}
{manualWeight != null && pricePer100g != null && (
<Form.Text>zadáno ručně, cena se nemění</Form.Text>
)}
</div>
</div>
</fieldset>
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<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="např. 172"
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>Kalorie porce</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
<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>
)}
{derived.calories != null && (
<Form.Text>
{formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g
</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}
onPick={value => update({ caloriesPer100g: String(value) })}
/>
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</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>}
{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}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}