feat: předělání historie a profilu
This commit is contained in:
1 parent
9e72f431a1
commit
8fa7f601f2
97 files changed
+7502
-1034
No files matched your search
@@ -2,12 +2,12 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
||||
import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||
} from "../../enums";
|
||||
import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
||||
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums";
|
||||
import { describeMealSuggestion, describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
||||
import { SABLONY_URL } from "../../routes";
|
||||
|
||||
type Props = {
|
||||
@@ -15,20 +15,24 @@ type Props = {
|
||||
/** Den, do kterého se jídlo založí. */
|
||||
date: string,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po založení jídla s aktualizovaným přehledem dne. */
|
||||
onApplied: (day: DayOverview) => void,
|
||||
/** Zavolá se po založení jídla; přehled dne je potřeba načíst znovu. */
|
||||
onApplied: () => void,
|
||||
};
|
||||
|
||||
/** Kolik jídel z historie se vypíše — dlouhý seznam se zúží hledáním. */
|
||||
const HISTORY_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Výběr šablony jídla k založení do dne.
|
||||
* Výběr jídla z historie nebo receptu k založení do dne jedním kliknutím.
|
||||
*
|
||||
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
|
||||
* vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat,
|
||||
* protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se
|
||||
* zapíší třeba dvě stejné sušenky naráz.
|
||||
* Jídlo z historie se založí s hodnotami z posledního záznamu toho jména, recept
|
||||
* jako jedna porce. Chod lze zvolit, protože totéž jídlo může být jednou oběd
|
||||
* a podruhé večeře, a násobkem se zapíší třeba dvě stejné sušenky naráz.
|
||||
* Kdo chce hodnoty před založením upravit, použije našeptávání ve formuláři jídla.
|
||||
*/
|
||||
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||
const [mealType, setMealType] = useState<MealType | ''>('');
|
||||
const [multiplierInput, setMultiplierInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -42,8 +46,9 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
setMealType('');
|
||||
setMultiplierInput('');
|
||||
setSearch('');
|
||||
getMealTemplates().then(response => {
|
||||
setTemplates(response.data ?? []);
|
||||
Promise.all([getMealTemplates(), getMealHistory()]).then(([recipeResponse, historyResponse]) => {
|
||||
setRecipes(recipeResponse.data ?? []);
|
||||
setHistory(historyResponse.data ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
@@ -51,30 +56,50 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
const multiplier = parseMultiplierInput(multiplierInput);
|
||||
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
|
||||
|
||||
const apply = async (id: string) => {
|
||||
const applyRecipe = async (recipe: MealTemplate) => {
|
||||
setBusy(true);
|
||||
const response = await applyMealTemplate({
|
||||
body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) },
|
||||
body: {
|
||||
id: recipe.id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}),
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
}
|
||||
if (response.data) onApplied();
|
||||
};
|
||||
|
||||
const applyHistory = async (suggestion: MealSuggestion) => {
|
||||
setBusy(true);
|
||||
const response = await addMeal({
|
||||
body: {
|
||||
date,
|
||||
mealType: mealType || MealType.OBED,
|
||||
name: suggestion.name,
|
||||
source: suggestion.source,
|
||||
price: suggestion.price,
|
||||
pricePer100g: suggestion.pricePer100g,
|
||||
weight: suggestion.weight,
|
||||
caloriesPer100g: suggestion.caloriesPer100g,
|
||||
calories: suggestion.calories,
|
||||
nutrientsPer100g: suggestion.nutrientsPer100g,
|
||||
multiplier,
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (response.data) onApplied();
|
||||
};
|
||||
|
||||
const key = normalizeSearch(search);
|
||||
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
|
||||
.map(kind => ({
|
||||
kind,
|
||||
templates: templates.filter(template => template.kind === kind
|
||||
&& (!key.length || normalizeSearch(template.name).includes(key))),
|
||||
}))
|
||||
.filter(group => group.templates.length > 0);
|
||||
const matches = (name: string) => !key.length || normalizeSearch(name).includes(key);
|
||||
const recipeList = recipes.filter(recipe => matches(recipe.name));
|
||||
// Jídlo stejného jména jako recept vzniklo nejspíš z něj, nabízí se jen recept
|
||||
const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name)));
|
||||
const historyMatchList = history.filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name)));
|
||||
const historyList = historyMatchList.slice(0, HISTORY_LIMIT);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Použít šablonu jídla</Modal.Title>
|
||||
<Modal.Title>Vybrat z historie</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{loading && (
|
||||
@@ -83,18 +108,18 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
{!loading && !recipes.length && !history.length && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faClockRotateLeft} /></div>
|
||||
<div className="empty-title">Zatím nemáte co vybírat</div>
|
||||
<p className="empty-description">
|
||||
Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem
|
||||
„Uložit jako šablonu".
|
||||
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
|
||||
recept na stránce Šablony.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !!templates.length && (
|
||||
{!loading && (!!recipes.length || !!history.length) && (
|
||||
<>
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-5">
|
||||
@@ -110,9 +135,10 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
<Form.Label>Založit jako</Form.Label>
|
||||
<Form.Select
|
||||
value={mealType}
|
||||
title="Bez volby se jídlo z historie založí jako oběd a recept pod svým chodem"
|
||||
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||
>
|
||||
<option value="">— chod ze šablony —</option>
|
||||
<option value="">— výchozí —</option>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
@@ -133,25 +159,22 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!groupList.length && (
|
||||
<p className="text-body-secondary small">Žádná šablona neodpovídá hledání.</p>
|
||||
{!recipeList.length && !historyList.length && (
|
||||
<p className="text-body-secondary small">Hledání nic neodpovídá.</p>
|
||||
)}
|
||||
|
||||
{groupList.map(group => (
|
||||
<div className="apply-template-group" key={group.kind}>
|
||||
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
||||
{group.templates.map(template => (
|
||||
<div className="workout-row" key={template.id}>
|
||||
{!!recipeList.length && (
|
||||
<div className="apply-template-group">
|
||||
<h3 className="apply-template-group-title">Recepty</h3>
|
||||
{recipeList.map(recipe => (
|
||||
<div className="workout-row" key={recipe.id}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">{template.name}</div>
|
||||
<div className="workout-items">
|
||||
{describeMealTemplate(template)}
|
||||
{!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`}
|
||||
</div>
|
||||
<div className="workout-name">{recipe.name}</div>
|
||||
<div className="workout-items">{describeMealTemplate(recipe)}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => apply(template.id)}
|
||||
onClick={() => applyRecipe(recipe)}
|
||||
disabled={busy || multiplierInvalid}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
@@ -159,16 +182,45 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{!!historyList.length && (
|
||||
<div className="apply-template-group">
|
||||
<h3 className="apply-template-group-title">Dříve snědené</h3>
|
||||
{historyList.map(item => (
|
||||
<div className="workout-row" key={item.name}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">{item.name}</div>
|
||||
<div className="workout-items">
|
||||
{describeMealSuggestion(item)}
|
||||
{item.count > 1 && ` · ${item.count}×`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyHistory(item)}
|
||||
disabled={busy || multiplierInvalid}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{historyMatchList.length > historyList.length && (
|
||||
<p className="text-body-secondary small mt-2">
|
||||
A dalších {historyMatchList.length - historyList.length}, upřesněte hledání.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL, { state: { tab: TemplateTab.RECEPTY } }); }}
|
||||
>
|
||||
Spravovat šablony
|
||||
Spravovat recepty
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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,
|
||||
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings,
|
||||
addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
|
||||
import {
|
||||
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
||||
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
|
||||
formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
||||
parsePriceInput,
|
||||
} from "../../Utils";
|
||||
import CalorieLookup from "../CalorieLookup";
|
||||
import TemplateSuggestions from "../TemplateSuggestions";
|
||||
import MealSuggestions from "../MealSuggestions";
|
||||
import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset";
|
||||
import SourceInput from "../SourceInput";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -19,7 +22,8 @@ type Props = {
|
||||
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
|
||||
entry?: MealEntry,
|
||||
onClose: () => void,
|
||||
onSaved: (day: DayRecord) => void,
|
||||
/** Zavolá se po uložení s přehledem dne a uloženým jídlem. */
|
||||
onSaved: (day: DayRecord, saved?: MealEntry) => void,
|
||||
};
|
||||
|
||||
/** Prázdný formulář pro nové jídlo v daném dni. */
|
||||
@@ -36,9 +40,35 @@ function emptyForm(date: string) {
|
||||
calories: '',
|
||||
note: '',
|
||||
multiplier: '',
|
||||
nutrients: emptyNutrientInputs(),
|
||||
};
|
||||
}
|
||||
|
||||
type MealForm = ReturnType<typeof emptyForm>;
|
||||
|
||||
/**
|
||||
* Spojí formulář s hodnotami z návrhu. U nového jídla návrh přepíše vše —
|
||||
* uživatel může mezi návrhy přeskakovat. U upravovaného jídla jen doplní prázdná
|
||||
* pole: cena a zdroj z importu jsou skutečné a návrh je nesmí přepsat.
|
||||
*/
|
||||
const mergeSuggestion = (current: MealForm, values: Partial<MealForm>, onlyEmpty: boolean): MealForm => {
|
||||
if (!onlyEmpty) return { ...current, ...values };
|
||||
const result = { ...current };
|
||||
for (const [key, value] of Object.entries(values) as [keyof MealForm, MealForm[keyof MealForm]][]) {
|
||||
if (key === 'nutrients') {
|
||||
const empty = Object.values(current.nutrients).every(item => !item.trim().length);
|
||||
if (empty) result.nutrients = value as NutrientInputs;
|
||||
} else if (key === 'name' || !String(current[key]).trim().length) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Převede uložené živiny na text pro editační pole. */
|
||||
const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
|
||||
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
|
||||
|
||||
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
||||
function normalizeSource(source: string): string {
|
||||
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||
@@ -52,23 +82,24 @@ function normalizeSource(source: string): string {
|
||||
* 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.
|
||||
* K rozepsanému názvu se nabízejí recepty a jídla z historie, takže jídlo, které
|
||||
* už uživatel někdy měl, nemusí dohledávat znovu. U upravovaného jídla se nabídnou
|
||||
* až po změně názvu a doplní jen prázdná pole — hodí se k pojmenování
|
||||
* naimportovaného jídla, jehož název Luncher složil z poznámky.
|
||||
*/
|
||||
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>();
|
||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||
/** Název návrhu, ze kterého se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
|
||||
const [pickedSuggestion, setPickedSuggestion] = 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);
|
||||
setPickedSuggestion(entry?.name);
|
||||
setForm(entry
|
||||
? {
|
||||
date: entry.date,
|
||||
@@ -82,15 +113,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
calories: entry.calories != null ? String(entry.calories) : '',
|
||||
note: entry.note ?? '',
|
||||
multiplier: formatMultiplierInput(entry.multiplier),
|
||||
nutrients: toNutrientInputs(entry.nutrientsPer100g),
|
||||
}
|
||||
: 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
|
||||
// Návrhy se načtou při každém otevření, mohly přibýt
|
||||
useEffect(() => {
|
||||
if (!isOpen || entry) return;
|
||||
getMealTemplates().then(response => setTemplates(response.data ?? []));
|
||||
}, [isOpen, entry]);
|
||||
if (!isOpen) return;
|
||||
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||
getMealHistory().then(response => setHistory(response.data ?? []));
|
||||
}, [isOpen]);
|
||||
|
||||
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
|
||||
useEffect(() => {
|
||||
@@ -100,22 +133,41 @@ 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 ?? '',
|
||||
}));
|
||||
/**
|
||||
* Vyplní formulář jednou porcí receptu. Datum a násobek zůstanou, jak jsou,
|
||||
* a postup do záznamu dne nepatří, zůstává u receptu.
|
||||
*/
|
||||
const applyRecipe = (recipe: MealTemplate) => {
|
||||
setPickedSuggestion(recipe.name);
|
||||
setForm(current => mergeSuggestion(current, {
|
||||
name: recipe.name,
|
||||
...(recipe.mealType ? { mealType: recipe.mealType } : {}),
|
||||
source: recipe.source ?? '',
|
||||
price: formatPriceInput(recipe.price),
|
||||
pricePer100g: '',
|
||||
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||
calories: recipe.calories != null ? String(recipe.calories) : '',
|
||||
nutrients: emptyNutrientInputs(),
|
||||
}, !!entry));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vyplní formulář hodnotami jídla z historie. Chod, datum, násobek a poznámka
|
||||
* patří ke konkrétnímu dni, takže zůstanou, jak je uživatel má.
|
||||
*/
|
||||
const applyHistory = (suggestion: MealSuggestion) => {
|
||||
setPickedSuggestion(suggestion.name);
|
||||
setForm(current => mergeSuggestion(current, {
|
||||
name: suggestion.name,
|
||||
source: suggestion.source ?? '',
|
||||
price: formatPriceInput(suggestion.price),
|
||||
pricePer100g: formatPriceInput(suggestion.pricePer100g),
|
||||
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
||||
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
||||
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
||||
nutrients: toNutrientInputs(suggestion.nutrientsPer100g),
|
||||
}, !!entry));
|
||||
};
|
||||
|
||||
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
|
||||
@@ -147,8 +199,10 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
const multiplier = parseMultiplierInput(form.multiplier);
|
||||
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
|
||||
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
|
||||
const nutrientsInvalid = NUTRIENTS_IN_ORDER
|
||||
.some(key => form.nutrients[key].trim().length > 0 && parseNutrientInput(form.nutrients[key]) == null);
|
||||
|
||||
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving;
|
||||
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !nutrientsInvalid && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
@@ -166,22 +220,18 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
calories: derived.calories != null ? undefined : manualCalories,
|
||||
note: form.note.trim() || undefined,
|
||||
multiplier,
|
||||
nutrientsPer100g: toNutrients(form.nutrients),
|
||||
};
|
||||
const response = entry
|
||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||
: await addMeal({ body });
|
||||
if (response.data && alsoTemplate) {
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
// 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);
|
||||
onSaved(response.data, saved);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,13 +248,24 @@ 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 }); setPickedTemplate(undefined); }}
|
||||
onChange={event => { update({ name: event.target.value }); setPickedSuggestion(undefined); }}
|
||||
/>
|
||||
{!entry && pickedTemplate !== form.name && (
|
||||
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
|
||||
{pickedSuggestion !== form.name && (
|
||||
<MealSuggestions
|
||||
recipes={recipes}
|
||||
// Upravované jídlo samo sebe nenabízí
|
||||
history={entry
|
||||
? history.filter(item => normalizeSearch(item.name) !== normalizeSearch(entry.name))
|
||||
: history}
|
||||
query={form.name}
|
||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||
onPickHistory={suggestion => applyHistory(suggestion)}
|
||||
/>
|
||||
)}
|
||||
{!entry && pickedTemplate === form.name && (
|
||||
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
|
||||
{pickedSuggestion === form.name && pickedSuggestion !== entry?.name && (
|
||||
<Form.Text>
|
||||
{entry ? 'doplněna prázdná pole z dřívějška' : 'vyplněno z dřívějška, hodnoty jde upravit'}
|
||||
</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
@@ -232,17 +293,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<Form.Control
|
||||
list="known-sources"
|
||||
<SourceInput
|
||||
value={form.source}
|
||||
placeholder="TechTower, doma…"
|
||||
onChange={event => update({ source: event.target.value })}
|
||||
onChange={source => update({ source })}
|
||||
/>
|
||||
<datalist id="known-sources">
|
||||
{settings?.sourceRates.map(rate => (
|
||||
<option key={rate.source} value={rate.source} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,6 +408,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<NutrientsFieldset
|
||||
values={form.nutrients}
|
||||
weight={derived.weight}
|
||||
onChange={nutrients => update({ nutrients })}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>Poznámka</Form.Label>
|
||||
<Form.Control
|
||||
@@ -365,13 +426,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
</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 && (
|
||||
|
||||
Reference in new issue
Block a user