feat: hromadné editace

This commit is contained in:
Ondřej Anděl committed 2026-10-08 15:03:23 +02:00
1 parent 8fa7f601f2
commit bc85a37fc0
18 files changed
+825 -18

No files matched your search

+3 -1
View File
@@ -7,7 +7,8 @@ import DayPage from "./pages/DayPage";
import StatsPage from "./pages/StatsPage";
import TemplatesPage from "./pages/TemplatesPage";
import ProfilePage from "./pages/ProfilePage";
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
import MealGroupsPage from "./pages/MealGroupsPage";
import { DEN_URL, DOPLNENI_JIDEL_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
export default function AppRoutes() {
const auth = useAuth();
@@ -25,6 +26,7 @@ export default function AppRoutes() {
<Route path={STATISTIKY_URL} element={<StatsPage />} />
<Route path={SABLONY_URL} element={<TemplatesPage />} />
<Route path={PROFIL_URL} element={<ProfilePage />} />
<Route path={DOPLNENI_JIDEL_URL} element={<MealGroupsPage />} />
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
</Routes>
</div>
+5 -1
View File
@@ -25,6 +25,10 @@ export default function Header() {
const effectiveDark = settings?.effectiveDark ?? false;
/** Podstránka (doplnění údajů ve statistikách) nechá zvýrazněnou svou nadřazenou položku. */
const isActive = (url: string) =>
location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`));
const toggleTheme = () => {
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(next);
@@ -39,7 +43,7 @@ export default function Header() {
{NAV_ITEMS.map(item => (
<button
key={item.url}
className={`nav-pill ${location.pathname === item.url ? 'active' : ''}`}
className={`nav-pill ${isActive(item.url) ? 'active' : ''}`}
onClick={() => navigate(item.url)}
>
{item.label}
+158
View File
@@ -0,0 +1,158 @@
import { useState } from "react";
import { Button, Form } from "react-bootstrap";
import { toast } from "react-toastify";
import { MealGroup, fillMealGroup } from "../../../types";
import { NUTRIENTS_IN_ORDER } from "../enums";
import { formatPriceInput, parseIntInput, parseNutrientInput, parsePriceInput } from "../Utils";
import CalorieLookup from "./CalorieLookup";
import NutrientsFieldset, { toNutrientInputs, toNutrients } from "./NutrientsFieldset";
type Props = {
group: MealGroup,
/** Zavolá se po doplnění, aby se počty chybějících údajů načetly znovu. */
onFilled: () => void,
onCancel: () => void,
};
const formatIntInput = (value?: number): string => value != null ? String(value) : '';
/** Popisek výsledku doplnění se skloňovaným počtem jídel. */
const describeFilled = (count: number): string => {
if (!count) return 'Všechna jídla už tyto údaje mají, nic se nezměnilo.';
if (count === 1) return 'Údaje doplněny do 1 jídla.';
return `Údaje doplněny do ${count} jídel.`;
};
/**
* Formulář hromadného doplnění údajů do všech jídel jedné skupiny (stejný
* název a zdroj). Předvyplní se hodnotami, které už některé jídlo skupiny má.
*
* Doplňuje se jen to, co jídlům chybí — cena ani nic jiného, co už mají, se
* nepřepíše. Proto tu není náhled kalorií porce: každé jídlo má jinou cenu,
* a tedy i jinou gramáž dopočtenou ze sazby.
*/
const MealGroupForm = ({ group, onFilled, onCancel }: Readonly<Props>) => {
const [form, setForm] = useState(() => ({
pricePer100g: formatPriceInput(group.values.pricePer100g),
weight: formatIntInput(group.values.weight),
caloriesPer100g: formatIntInput(group.values.caloriesPer100g),
calories: formatIntInput(group.values.calories),
nutrients: toNutrientInputs(group.values.nutrientsPer100g),
}));
const [saving, setSaving] = useState(false);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
const weight = parseIntInput(form.weight);
const nutrientsInvalid = NUTRIENTS_IN_ORDER
.some(key => form.nutrients[key].trim().length > 0 && parseNutrientInput(form.nutrients[key]) == null);
const canSave = !nutrientsInvalid && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const response = await fillMealGroup({
body: {
name: group.name,
source: group.source,
values: {
pricePer100g: parsePriceInput(form.pricePer100g),
weight,
caloriesPer100g: parseIntInput(form.caloriesPer100g),
calories: parseIntInput(form.calories),
nutrientsPer100g: toNutrients(form.nutrients),
},
},
});
setSaving(false);
if (response.data) {
toast.success(describeFilled(response.data.count));
onFilled();
}
};
return (
<Form className="meal-group-form" onSubmit={event => { event.preventDefault(); save(); }}>
<p className="text-body-secondary small">
Doplní se jen to, co jídlům chybí — vyplněné hodnoty zůstanou. Gramáž se nedoplní tam, kde jde
dopočítat z ceny a sazby za 100 g, a energie jen jídlům, která kalorie nemají.
</p>
<fieldset className="meal-fieldset mb-3">
<legend>Gramáž a kalorie</legend>
<div className="row g-3">
<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="např. 44,00"
onChange={event => update({ pricePer100g: event.target.value })}
/>
<span className="input-group-text">Kč</span>
</div>
</div>
<div className="col-sm-3">
<Form.Label>Gramáž porce</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.weight}
placeholder="volitelné"
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="např. 172"
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>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>jen u jídel, kde nejdou dopočítat</Form.Text>
</div>
</div>
<div className="mt-2">
<CalorieLookup
name={group.name}
onPick={value => update({ caloriesPer100g: String(value) })}
/>
</div>
</fieldset>
<NutrientsFieldset
values={form.nutrients}
weight={weight}
onChange={nutrients => update({ nutrients })}
/>
<div className="d-flex justify-content-end gap-2">
<Button variant="outline-secondary" onClick={() => onCancel()}>Zrušit</Button>
<Button type="submit" disabled={!canSave}>
{saving ? 'Doplňuji…' : 'Doplnit do jídel'}
</Button>
</div>
</Form>
);
};
export default MealGroupForm;
+56
View File
@@ -0,0 +1,56 @@
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { MealGroup } from "../../../types";
import { formatDateString } from "../Utils";
import MealGroupForm from "./MealGroupForm";
type Props = {
group: MealGroup,
expanded: boolean,
onToggle: () => void,
onFilled: () => void,
};
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
const describeMissing = (missing: number, count: number): string =>
missing === count ? 'u všech' : `${missing} z ${count}`;
/**
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
* Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny.
*/
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => (
<div className="card-panel meal-group">
<div className="meal-group-head">
<div className="meal-group-info">
<div className="meal-group-name">{group.name}</div>
<div className="meal-meta">
{group.source && <span className="tag">{group.source}</span>}
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
{group.missingCaloriesCount > 0 && (
<span className="tag tag-missing">
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
</span>
)}
{group.missingWeightCount > 0 && (
<span className="tag tag-missing">
bez gramáže {describeMissing(group.missingWeightCount, group.count)}
</span>
)}
{group.missingNutrientsCount > 0 && (
<span className="tag tag-missing">
bez živin {describeMissing(group.missingNutrientsCount, group.count)}
</span>
)}
</div>
</div>
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
</Button>
</div>
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
</div>
);
export default MealGroupRow;
+5 -1
View File
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { Nutrients } from "../../../types";
import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums";
import { formatNutrient, getPortionNutrients, parseNutrientInput } from "../Utils";
import { formatNutrient, formatNutrientInput, getPortionNutrients, parseNutrientInput } from "../Utils";
/** Rozepsané hodnoty živin tak, jak je uživatel píše do polí. */
export type NutrientInputs = Record<keyof Nutrients, string>;
@@ -25,6 +25,10 @@ export const emptyNutrientInputs = (): NutrientInputs => ({
protein: '', carbohydrates: '', sugars: '', fat: '', saturatedFat: '', fiber: '', salt: '',
});
/** Převede uložené živiny na text pro editační pole. */
export const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
/** Převede rozepsané hodnoty na živiny k odeslání; bez vyplněné hodnoty vrátí undefined. */
export const toNutrients = (values: NutrientInputs): Nutrients | undefined => {
const result: Nutrients = {};
+5 -7
View File
@@ -1,18 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import {
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings,
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, UserSettings,
addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
import {
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
import MealSuggestions from "../MealSuggestions";
import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset";
import NutrientsFieldset, {
NutrientInputs, emptyNutrientInputs, toNutrientInputs, toNutrients,
} from "../NutrientsFieldset";
import SourceInput from "../SourceInput";
type Props = {
@@ -65,10 +67,6 @@ const mergeSuggestion = (current: MealForm, values: Partial<MealForm>, onlyEmpty
return result;
};
/** Převede uložené živiny na text pro editační pole. */
const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
+56
View File
@@ -0,0 +1,56 @@
.meal-groups-filter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
margin-bottom: 16px;
padding: 14px 18px;
.form-control {
flex: 1 1 240px;
}
.form-check {
margin: 0;
white-space: nowrap;
}
}
.meal-group {
margin-bottom: 10px;
padding: 14px 18px;
.meal-group-head {
display: flex;
align-items: center;
gap: 12px;
}
.meal-group-info {
flex: 1;
min-width: 0;
}
.meal-group-name {
font-weight: 700;
color: var(--ft-text);
margin-bottom: 4px;
}
.meal-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-missing {
background: color-mix(in srgb, var(--ft-warning) 15%, transparent);
color: var(--ft-warning);
}
.meal-group-form {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--ft-border-light);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Form, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { MealGroup, getMealGroups } from "../../../types";
import { normalizeSearch } from "../Utils";
import { STATISTIKY_URL } from "../routes";
import MealGroupRow from "../components/MealGroupRow";
import "./MealGroupsPage.scss";
/** Skupina se pozná podle názvu a zdroje, porovnávaných stejně jako na serveru. */
const getGroupKey = (group: MealGroup): string =>
`${normalizeSearch(group.name)}|${normalizeSearch(group.source ?? '')}`;
const isIncomplete = (group: MealGroup): boolean =>
group.missingCaloriesCount + group.missingWeightCount + group.missingNutrientsCount > 0;
/**
* Hromadné doplnění údajů ke starším jídlům. Jídla se stejným názvem a zdrojem
* tvoří skupinu a chybějící kalorie, gramáž nebo živiny se jim doplní najednou,
* bez procházení jednotlivých dnů.
*/
const MealGroupsPage = () => {
const navigate = useNavigate();
const [groupList, setGroupList] = useState<MealGroup[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
const [expandedKey, setExpandedKey] = useState<string | undefined>();
const load = useCallback(async () => {
const response = await getMealGroups();
setGroupList(response.data ?? []);
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const visibleList = useMemo(() => {
const search = normalizeSearch(query);
return groupList.filter(group =>
(!onlyIncomplete || isIncomplete(group))
&& (!search.length || normalizeSearch(`${group.name} ${group.source ?? ''}`).includes(search)));
}, [groupList, query, onlyIncomplete]);
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
return (
<>
<div className="page-header">
<div>
<h1>Doplnění údajů jídel</h1>
<p className="page-subtitle">
Jídla se stejným názvem a zdrojem doplníte najednou — kalorie, gramáž i nutriční hodnoty
</p>
</div>
<Button variant="outline-secondary" onClick={() => navigate(STATISTIKY_URL)}>
<FontAwesomeIcon icon={faArrowLeft} /> Statistiky
</Button>
</div>
<div className="card-panel meal-groups-filter">
<Form.Control
value={query}
placeholder="Hledat jídlo nebo zdroj…"
onChange={event => setQuery(event.target.value)}
/>
<Form.Check
type="switch"
id="meal-groups-incomplete"
label={`Jen s chybějícími údaji (${incompleteCount})`}
checked={onlyIncomplete}
onChange={event => setOnlyIncomplete(event.target.checked)}
/>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !visibleList.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faCircleCheck} /></div>
<div className="empty-title">
{onlyIncomplete && !query.trim().length ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
</div>
</div>
)}
{!loading && visibleList.map(group => {
const key = getGroupKey(group);
return (
<MealGroupRow
key={key}
group={group}
expanded={expandedKey === key}
onToggle={() => setExpandedKey(current => current === key ? undefined : key)}
onFilled={() => { setExpandedKey(undefined); load(); }}
/>
);
})}
</>
);
};
export default MealGroupsPage;
+12 -3
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
faPenToSquare,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
@@ -12,6 +14,7 @@ import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES }
import {
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
} from "../Utils";
import { DOPLNENI_JIDEL_URL } from "../routes";
import ImportModal from "../components/modals/ImportModal";
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
import ActivityStatsPanel from "./ActivityStatsPanel";
@@ -37,6 +40,7 @@ export default function StatsPage() {
const [stats, setStats] = useState<FoodStats | undefined>();
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
const navigate = useNavigate();
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
const range = useMemo(() => {
@@ -87,9 +91,14 @@ export default function StatsPage() {
</p>
</div>
{tab === DayTab.PRIJEM && (
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
<div className="d-flex flex-wrap gap-2">
<Button variant="outline-secondary" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit údaje jídel
</Button>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
</div>
)}
</div>
+1
View File
@@ -8,3 +8,4 @@ export const DEN_URL = '/';
export const STATISTIKY_URL = '/statistiky';
export const SABLONY_URL = '/sablony';
export const PROFIL_URL = '/profil';
export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';