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
This commit is contained in:
Ondřej Anděl committed 2026-10-16 08:30:18 +02:00
1 parent b637e243d5
commit 35cbc5a9ca
11 files changed
+206 -21

No files matched your search

+8 -3
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { MealGroup } from "../../../types";
import { MealGroupAction } from "../enums";
import { MealGroup, RecipeFlavor } from "../../../types";
import { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums";
import { formatDateString } from "../Utils";
import MealGroupForm from "./MealGroupForm";
import MealGroupRenameForm from "./MealGroupRenameForm";
@@ -57,7 +57,12 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh
? <span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
: <span className="tag">zatím nejedeno</span>}
{group.recipe && (
<span className="tag tag-recipe"><FontAwesomeIcon icon={faBookOpen} /> recept</span>
<span className="tag tag-recipe">
<FontAwesomeIcon icon={faBookOpen} /> {group.recipe.category ?? 'recept'}
</span>
)}
{group.recipe?.flavor === RecipeFlavor.SLADKE && (
<span className="tag tag-recipe">{RECIPE_FLAVOR_NAMES[RecipeFlavor.SLADKE].toLowerCase()}</span>
)}
{group.missingCaloriesCount > 0 && (
<span className="tag tag-missing">
@@ -1,6 +1,7 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { MealTemplate } from "../../../types";
import { RECIPE_FLAVOR_NAMES } from "../enums";
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
type Props = {
@@ -22,6 +23,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
<div className="template-head">
<div className="template-title">
Recept
{template.category && <span className="tag">{template.category}</span>}
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
{(template.servings ?? 1) > 1 && (
<span className="tag">{template.servings} porcí</span>
+37 -3
View File
@@ -1,8 +1,10 @@
import { useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { MealTemplate, saveMealTemplate } from "../../../types";
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";
@@ -23,6 +25,8 @@ type Props = {
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) : '',
@@ -38,6 +42,7 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
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 }));
@@ -57,6 +62,8 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
...(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),
@@ -98,6 +105,33 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
</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} />
@@ -0,0 +1,55 @@
import { useEffect, useId, useState } from "react";
import { Form } from "react-bootstrap";
import { MealTemplate, getMealTemplates } from "../../../types";
import { normalizeSearch } from "../Utils";
type Props = {
value: string,
onChange: (value: string) => void,
};
/**
* Typy z ostatních receptů, každý jednou, od nejpoužívanějšího. Zápis je
* z prvního receptu, ve kterém se typ našel.
*/
const listCategories = (templateList: MealTemplate[]): string[] => {
const countMap = new Map<string, { label: string, count: number }>();
for (const template of templateList) {
if (!template.category) continue;
const key = normalizeSearch(template.category);
const existing = countMap.get(key);
countMap.set(key, { label: existing?.label ?? template.category, count: (existing?.count ?? 0) + 1 });
}
return [...countMap.values()]
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'cs'))
.map(item => item.label);
};
/**
* Pole pro typ receptu (polévka, omáčka, pečení…). Typ není pevný číselník —
* nabízejí se typy už zapsané u ostatních receptů a psát jde i nový.
*/
const RecipeCategoryInput = ({ value, onChange }: Readonly<Props>) => {
const listId = useId();
const [categoryList, setCategoryList] = useState<string[]>([]);
useEffect(() => {
getMealTemplates().then(response => setCategoryList(listCategories(response.data ?? [])));
}, []);
return (
<>
<Form.Control
list={listId}
value={value}
placeholder="Např. polévka, omáčka"
onChange={event => onChange(event.target.value)}
/>
<datalist id={listId}>
{categoryList.map(category => <option key={category} value={category} />)}
</datalist>
</>
);
};
export default RecipeCategoryInput;
+7 -1
View File
@@ -1,4 +1,4 @@
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi } from "../../types";
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi, RecipeFlavor } from "../../types";
/** Lidsky čitelné názvy typů jídel. */
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
@@ -187,6 +187,12 @@ export const RECIPE_FILTER_NAMES: Record<RecipeFilter, string> = {
BEZ_RECEPTU: 'Bez receptu',
};
/** Popisky chuti receptu. */
export const RECIPE_FLAVOR_NAMES: Record<RecipeFlavor, string> = {
SLANE: 'Slané',
SLADKE: 'Sladké',
};
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
export enum SourceFilter {
VSE = 'VSE',
+8
View File
@@ -19,6 +19,14 @@
margin: 0;
white-space: nowrap;
}
.meal-groups-count {
margin-left: auto;
font-size: 0.85rem;
color: var(--ft-text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
}
.meal-group {
+15 -4
View File
@@ -40,6 +40,11 @@ const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean =>
return true;
};
/** Hledá se v názvu jídla a v typu jeho receptu (polévka, omáčka…). */
const matchesSearch = (group: MealGroup, search: string): boolean =>
normalizeSearch(group.name).includes(search)
|| (!!group.recipe?.category && normalizeSearch(group.recipe.category).includes(search));
/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */
type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null;
@@ -85,10 +90,9 @@ const MealGroupsPage = () => {
(!onlyIncomplete || isIncomplete(group))
&& matchesSource(group, sourceFilter)
&& matchesRecipe(group, recipeFilter)
&& (!search.length || normalizeSearch(group.name).includes(search)));
&& (!search.length || matchesSearch(group, search)));
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
return (
@@ -115,7 +119,7 @@ const MealGroupsPage = () => {
<div className="card-panel meal-groups-filter">
<Form.Control
value={query}
placeholder="Hledat jídlo…"
placeholder="Hledat jídlo nebo typ receptu…"
onChange={event => setQuery(event.target.value)}
/>
<Form.Select
@@ -139,10 +143,17 @@ const MealGroupsPage = () => {
<Form.Check
type="switch"
id="meal-groups-incomplete"
label={`Jen s chybějícími údaji (${incompleteCount})`}
label="K doplnění"
checked={onlyIncomplete}
onChange={event => setOnlyIncomplete(event.target.checked)}
/>
{!loading && (
<span className="meal-groups-count">
Zobrazeno {visibleList.length === groupList.length
? groupList.length
: `${visibleList.length} z ${groupList.length}`}
</span>
)}
</div>
{loading && (