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
@@ -0,0 +1,323 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, ButtonGroup, Form } from "react-bootstrap";
|
||||
import { MealTemplate, MealTemplateKind, MealType, saveMealTemplate } from "../../../types";
|
||||
import {
|
||||
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||
} from "../enums";
|
||||
import {
|
||||
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
||||
parseIntInput, parsePriceInput,
|
||||
} from "../Utils";
|
||||
import CalorieLookup from "./CalorieLookup";
|
||||
import RecipeIngredientsEditor, {
|
||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||
} from "./RecipeIngredientsEditor";
|
||||
|
||||
type Props = {
|
||||
/** Upravovaná šablona. Pokud chybí, zakládá se nová. */
|
||||
template?: MealTemplate,
|
||||
onSaved: (templates: MealTemplate[]) => void,
|
||||
onCancel: () => void,
|
||||
};
|
||||
|
||||
/** Naplní formulář z šablony, nebo ho nechá prázdný pro novou. */
|
||||
const createForm = (template?: MealTemplate) => ({
|
||||
kind: template?.kind ?? MealTemplateKind.HOTOVE,
|
||||
name: template?.name ?? '',
|
||||
source: template?.source ?? '',
|
||||
mealType: (template?.mealType ?? '') as MealType | '',
|
||||
weight: template?.weight != null ? String(template.weight) : '',
|
||||
caloriesPer100g: template?.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||
calories: template?.calories != null ? String(template.calories) : '',
|
||||
price: formatPriceInput(template?.price),
|
||||
pricePer100g: formatPriceInput(template?.pricePer100g),
|
||||
note: template?.note ?? '',
|
||||
servings: template?.servings != null ? String(template.servings) : '',
|
||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Formulář šablony jídla.
|
||||
*
|
||||
* Hotové jídlo (objednané, koupené) si pamatuje jen výsledné hodnoty porce.
|
||||
* Recept je vařené jídlo: 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, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => createForm(template));
|
||||
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 update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||
setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
const isRecipe = form.kind === MealTemplateKind.RECEPT;
|
||||
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 preview = deriveAmounts(
|
||||
parsePriceInput(form.price),
|
||||
parsePriceInput(form.pricePer100g),
|
||||
parseIntInput(form.weight),
|
||||
parseIntInput(form.caloriesPer100g));
|
||||
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
||||
|
||||
const canSave = form.name.trim().length > 0 && (!isRecipe || filledIngredients.length > 0) && !busy;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
const common = {
|
||||
...(template ? { id: template.id } : {}),
|
||||
kind: form.kind,
|
||||
name: form.name.trim(),
|
||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||
};
|
||||
// Hodnoty porce receptu si server dopočte ze surovin sám
|
||||
const body = isRecipe
|
||||
? {
|
||||
...common,
|
||||
ingredients: filledIngredients,
|
||||
servings: parseIntInput(form.servings),
|
||||
cookedWeight: parseIntInput(form.cookedWeight),
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
weight: parseIntInput(form.weight),
|
||||
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||
calories: parseIntInput(form.calories),
|
||||
price: parsePriceInput(form.price),
|
||||
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||
};
|
||||
const response = await saveMealTemplate({ body });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-panel template-form">
|
||||
<h2 className="chart-title">{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<ButtonGroup className="mb-2">
|
||||
{MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => (
|
||||
<Button
|
||||
key={kind}
|
||||
variant={form.kind === kind ? 'primary' : 'outline-secondary'}
|
||||
size="sm"
|
||||
onClick={() => update({ kind })}
|
||||
>
|
||||
{MEAL_TEMPLATE_KIND_NAMES[kind]}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
<p className="text-body-secondary small">
|
||||
{isRecipe
|
||||
? 'Vařené jídlo — zapíšete suroviny a postup, kalorie a cena porce se dopočtou samy.'
|
||||
: 'Objednané nebo koupené jídlo — stačí jednou zapsat hodnoty a příště je nemusíte dohledávat.'}
|
||||
</p>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={form.name}
|
||||
placeholder={isRecipe ? 'Např. Kuřecí rizoto' : 'Např. Sušenka BeBe'}
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<Form.Control
|
||||
value={form.source}
|
||||
placeholder={isRecipe ? 'doma' : 'Chefie, Albert…'}
|
||||
onChange={event => update({ source: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Chod</Form.Label>
|
||||
<Form.Select
|
||||
value={form.mealType}
|
||||
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
||||
>
|
||||
<option value="">— vybrat při použití —</option>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRecipe ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Kalorie, gramáž a cena porce</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Gramáž</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.weight}
|
||||
placeholder="350"
|
||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
</div>
|
||||
<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="160"
|
||||
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>Cena</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.price}
|
||||
placeholder="129,00"
|
||||
onChange={event => update({ price: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena za 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.pricePer100g}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ pricePer100g: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mt-0">
|
||||
{preview.calories == null && (
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kalorie porce</Form.Label>
|
||||
<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>
|
||||
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-sm-6 d-flex align-items-end">
|
||||
<CalorieLookup
|
||||
name={form.name}
|
||||
onPick={value => update({ caloriesPer100g: String(value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{isRecipe ? 'Postup' : 'Poznámka'}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={isRecipe ? 6 : 2}
|
||||
value={form.note}
|
||||
placeholder={isRecipe ? 'Jak se jídlo připravuje…' : undefined}
|
||||
onChange={event => update({ note: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className="workout-form-footer">
|
||||
{!isRecipe && previewCalories != null && (
|
||||
<span className="workout-draft-calories">
|
||||
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
||||
{formatCalories(previewCalories)}
|
||||
</span>
|
||||
)}
|
||||
{isRecipe && recipe.calories != null && (
|
||||
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||
)}
|
||||
{template && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||
{template ? 'Uložit změny' : 'Vytvořit šablonu'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealTemplateForm;
|
||||
Reference in new issue
Block a user