392 lines
19 KiB
TypeScript
392 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|