feat: předělání historie a profilu

This commit is contained in:
Ondřej Anděl committed 2026-10-07 14:48:21 +02:00
1 parent 9e72f431a1
commit 8fa7f601f2
97 files changed
+7502 -1034

No files matched your search

+65 -206
View File
@@ -1,47 +1,33 @@
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 { Alert, Button, Form } from "react-bootstrap";
import { MealTemplate, MealType, saveMealTemplate } from "../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
import SourceInput from "./SourceInput";
import RecipeIngredientsEditor, {
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
} from "./RecipeIngredientsEditor";
type Props = {
/** Upravovaná šablona. Pokud chybí, zakládá se nová. */
/** Upravovaný recept. 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. */
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
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.
* 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, onSaved, onCancel }: Readonly<Props>) => {
const [form, setForm] = useState(() => createForm(template));
@@ -53,96 +39,57 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
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 canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
setError(undefined);
const common = {
// Hodnoty porce si server dopočte ze surovin sám
const body = {
...(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() } : {}),
ingredients: filledIngredients,
servings: parseIntInput(form.servings),
cookedWeight: parseIntInput(form.cookedWeight),
};
// 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');
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 šablony jídla' : 'Nová šablona jídla'}</h2>
<h2 className="chart-title">{template ? 'Úprava receptu' : 'Nový recept'}</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'}
placeholder="Např. Kuřecí rizoto"
onChange={event => update({ name: event.target.value })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
<SourceInput
value={form.source}
placeholder={isRecipe ? 'doma' : 'Chefie, Albert…'}
onChange={event => update({ source: event.target.value })}
placeholder="doma"
onChange={source => update({ source })}
/>
</div>
<div className="col-sm-3">
@@ -161,159 +108,71 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
</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>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>
<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="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 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>
</fieldset>
)}
</div>
</fieldset>
<Form.Group className="mb-3">
<Form.Label>{isRecipe ? 'Postup' : 'Poznámka'}</Form.Label>
<Form.Label>Postup</Form.Label>
<Form.Control
as="textarea"
rows={isRecipe ? 6 : 2}
rows={6}
value={form.note}
placeholder={isRecipe ? 'Jak se jídlo připravuje…' : undefined}
placeholder="Jak se jídlo připravuje…"
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 && (
{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'}
{template ? 'Uložit změny' : 'Vytvořit recept'}
</Button>
</div>
</div>