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) { const [form, setForm] = useState(() => emptyForm(date)); const [settings, setSettings] = useState(); const [saving, setSaving] = useState(false); const [alsoTemplate, setAlsoTemplate] = useState(false); const [templates, setTemplates] = useState([]); /** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */ const [pickedTemplate, setPickedTemplate] = useState(); // 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) => 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 ( {entry ? 'Úprava jídla' : 'Nové jídlo'}
{ event.preventDefault(); save(); }}> Jídlo { update({ name: event.target.value }); setPickedTemplate(undefined); }} /> {!entry && pickedTemplate !== form.name && ( )} {!entry && pickedTemplate === form.name && ( vyplněno ze šablony, hodnoty jde upravit )}
Chod update({ mealType: event.target.value as MealType })} > {MEAL_TYPES_IN_ORDER.map(mealType => ( ))}
Datum event.target.value && update({ date: event.target.value })} />
Zdroj update({ source: event.target.value })} /> {settings?.sourceRates.map(rate => (
Cena a gramáž
Cena
update({ price: event.target.value })} /> Kč
Cena za 100 g
update({ pricePer100g: event.target.value })} /> Kč
{rateForSource != null && ( sazba zdroje {form.source.trim()} )}
Gramáž
update({ weight: event.target.value.replace(/\D/g, '') })} /> g
{manualWeight == null && derived.weight != null && ( dopočteno z ceny )} {manualWeight != null && pricePer100g != null && ( zadáno ručně, cena se nemění )}
Kalorie
Energie na 100 g
update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })} /> kcal
Kalorie porce {derived.calories != null ? (
{formatCalories(derived.calories)}
) : (
update({ calories: event.target.value.replace(/\D/g, '') })} /> kcal
)} {derived.calories != null && ( {formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g )}
Násobek update({ multiplier: event.target.value })} />
update({ caloriesPer100g: String(value) })} />
Poznámka update({ note: event.target.value })} />
setAlsoTemplate(event.target.checked)} />
{derived.weight != null && {formatWeight(derived.weight)}} {totalCalories != null && ( {formatCalories(totalCalories)} {multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`} )}
); }