feat: import jen některých dnů
feat: násobení jídel/cviků feat: refaktor napojování šablon
This commit is contained in:
1 parent
c3990d80f8
commit
8dac67bdd1
37 files changed
+2314
-481
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user