205 lines
9.3 KiB
TypeScript
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;
|