Files
food-tracer/client/src/components/MealTemplateForm.tsx
T
Ondřej Anděl 35cbc5a9ca fix: Jen s chybějícímí údaji -> k doplnění
feat: zobrazení aktuálního počtu jídel
feat: chuť a typ receptu
2026-10-16 08:30:18 +02:00

205 lines
9.3 KiB
TypeScript

import { useId, useState } from "react";
import { Alert, Button, Form, ToggleButton, ToggleButtonGroup } from "react-bootstrap";
import { MealTemplate, RecipeFlavor, saveMealTemplate } from "../../../types";
import { RECIPE_FLAVOR_NAMES } from "../enums";
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
import SourceInput from "./SourceInput";
import RecipeCategoryInput from "./RecipeCategoryInput";
import RecipeIngredientsEditor, {
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
} from "./RecipeIngredientsEditor";
type Props = {
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
template?: MealTemplate,
/**
* Jídlo ze seznamu jídel, ke kterému recept patří. Název a zdroj pak nejdou
* měnit — mění se přejmenováním jídla, aby recept zůstal u svých záznamů.
*/
meal?: { name: string, source?: string },
onSaved: (templates: MealTemplate[]) => void,
onCancel: () => void,
};
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
name: meal?.name ?? template?.name ?? '',
source: meal?.source ?? template?.source ?? '',
category: template?.category ?? '',
flavor: template?.flavor ?? RecipeFlavor.SLANE,
note: template?.note ?? '',
servings: template?.servings != null ? String(template.servings) : '',
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
});
/**
* Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty
* porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
*/
const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>) => {
const [form, setForm] = useState(() => createForm(template, meal));
const [ingredients, setIngredients] = useState<DraftIngredient[]>(() =>
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const flavorId = useId();
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
setForm(current => ({ ...current, ...changes }));
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
setError(undefined);
// Hodnoty porce si server dopočte ze surovin sám
const body = {
...(template ? { id: template.id } : {}),
name: form.name.trim(),
...(form.source.trim() ? { source: form.source.trim() } : {}),
...(form.category.trim() ? { category: form.category.trim() } : {}),
flavor: form.flavor,
...(form.note.trim() ? { note: form.note.trim() } : {}),
ingredients: filledIngredients,
servings: parseIntInput(form.servings),
cookedWeight: parseIntInput(form.cookedWeight),
};
const response = await saveMealTemplate({ body });
setBusy(false);
if (response.data) {
onSaved(response.data);
} else {
setError((response.error as { error?: string })?.error ?? 'Recept se nepodařilo uložit');
}
};
return (
<div className="card-panel template-form">
<h2 className="chart-title">{template ? 'Úprava receptu' : 'Nový recept'}</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{!meal && (
<div className="row g-3 mb-3">
<div className="col-sm-7">
<Form.Label>Název</Form.Label>
<Form.Control
value={form.name}
placeholder="Např. Kuřecí rizoto"
onChange={event => update({ name: event.target.value })}
/>
</div>
<div className="col-sm-5">
<Form.Label>Zdroj</Form.Label>
<SourceInput
value={form.source}
placeholder="doma"
onChange={source => update({ source })}
/>
</div>
</div>
)}
<div className="row g-3 mb-3">
<div className="col-sm-7">
<Form.Label>Typ</Form.Label>
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
</div>
<div className="col-sm-5">
<Form.Label className="d-block">Chuť</Form.Label>
<ToggleButtonGroup
type="radio"
name={flavorId}
value={form.flavor}
onChange={(flavor: RecipeFlavor) => update({ flavor })}
>
{Object.values(RecipeFlavor).map(flavor => (
<ToggleButton
key={flavor}
id={`${flavorId}-${flavor}`}
value={flavor}
variant="outline-secondary"
>
{RECIPE_FLAVOR_NAMES[flavor]}
</ToggleButton>
))}
</ToggleButtonGroup>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Suroviny</legend>
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
</fieldset>
<fieldset className="meal-fieldset mb-3">
<legend>Výsledek</legend>
<div className="row g-3">
<div className="col-sm-3">
<Form.Label>Počet porcí</Form.Label>
<Form.Control
inputMode="numeric"
value={form.servings}
placeholder="1"
onChange={event => update({ servings: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Hmotnost hotového jídla</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.cookedWeight}
placeholder={ingredientWeight ? String(ingredientWeight) : 'volitelné'}
onChange={event => update({ cookedWeight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
<Form.Text>vařením se mění, bez ní se vezme součet surovin</Form.Text>
</div>
<div className="col-sm-5">
<Form.Label>Celkem / jedna porce</Form.Label>
<div className="recipe-summary">
{formatCalories(recipe.totalCalories)}
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
<div className="recipe-summary-portion">
porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)}
{recipe.price != null && <> · {formatPrice(recipe.price)}</>}
</div>
</div>
</div>
</div>
</fieldset>
<Form.Group className="mb-3">
<Form.Label>Postup</Form.Label>
<Form.Control
as="textarea"
rows={6}
value={form.note}
placeholder="Jak se jídlo připravuje…"
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
<div className="workout-form-footer">
{recipe.calories != null && (
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
)}
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit</Button>
<Button size="sm" onClick={() => save()} disabled={!canSave}>
{template ? 'Uložit změny' : 'Vytvořit recept'}
</Button>
</div>
</div>
);
};
export default MealTemplateForm;