feat: import jen některých dnů

feat: násobení jídel/cviků
feat: refaktor napojování šablon
This commit is contained in:
Ondřej Anděl committed 2026-09-30 19:10:54 +02:00
1 parent c3990d80f8
commit 8dac67bdd1
37 files changed
+2314 -481

No files matched your search

+50 -3
View File
@@ -5,7 +5,10 @@ import {
addActivity, createWorkoutTemplateFromActivities, updateActivity,
} from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
import {
applyMultiplier, deriveActivityCalories, formatCalories, formatKgInput, formatMultiplier,
formatMultiplierInput, parseIntInput, parseMultiplierInput, parseWeightKgInput,
} from "../../Utils";
type Props = {
isOpen: boolean,
@@ -28,6 +31,8 @@ function emptyForm(date: string) {
caloriesPer100Units: '',
calories: '',
note: '',
extraWeight: '',
multiplier: '',
};
}
@@ -37,6 +42,9 @@ function emptyForm(date: string) {
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
* jednotku i orientační sazbu.
*
* Dodatečná zátěž (činky, vesta) se eviduje jako informace o tréninku, kalorie
* nemění — spotřebu se zátěží si uživatel promítne do sazby.
*/
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
@@ -56,6 +64,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
extraWeight: formatKgInput(entry.extraWeightGrams),
multiplier: formatMultiplierInput(entry.multiplier),
}
: emptyForm(date));
}, [isOpen, entry, date]);
@@ -82,8 +92,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derivedCalories ?? manualCalories;
const extraWeightGrams = parseWeightKgInput(form.extraWeight);
const multiplier = parseMultiplierInput(form.multiplier);
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
const canSave = form.name.trim().length > 0 && quantity != null && !multiplierInvalid && !saving;
const save = async () => {
if (!canSave) return;
@@ -98,6 +112,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
calories: derivedCalories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
extraWeightGrams,
multiplier,
};
const response = entry
? await updateActivity({ body: { id: entry.id, ...body } })
@@ -213,6 +229,32 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
)}
</div>
</div>
<div className="row g-3 mt-0">
<div className="col-sm-3">
<Form.Label>Zátěž</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.extraWeight}
placeholder="bez zátěže"
title="Dodatečná váha — činky, zátěžová vesta. Kalorie nemění."
onChange={event => update({ extraWeight: event.target.value })}
/>
<span className="input-group-text">kg</span>
</div>
</div>
<div className="col-sm-2">
<Form.Label>Násobek</Form.Label>
<Form.Control
inputMode="decimal"
value={form.multiplier}
placeholder="1"
isInvalid={multiplierInvalid}
title="Kolikrát jste aktivitu provedli, třeba 2 nebo 0,5"
onChange={event => update({ multiplier: event.target.value })}
/>
</div>
</div>
</fieldset>
<Form.Group>
@@ -235,7 +277,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
onChange={event => setAlsoTemplate(event.target.checked)}
/>
<div className="meal-modal-summary">
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
{totalCalories != null && (
<span>
{formatCalories(totalCalories)}
{multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`}
</span>
)}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
+89 -41
View File
@@ -4,8 +4,10 @@ import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { deriveAmounts, formatCalories, formatPrice, formatWeight } from "../../Utils";
import {
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
} from "../../enums";
import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
import { SABLONY_URL } from "../../routes";
type Props = {
@@ -17,28 +19,19 @@ type Props = {
onApplied: (day: DayOverview) => void,
};
/** Popíše šablonu jednou řádkou — gramáž, kalorie a cena, co je vyplněné. */
const describe = (template: MealTemplate): string => {
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
const calories = derived.calories ?? template.calories;
return [
template.source,
derived.weight != null ? formatWeight(derived.weight) : undefined,
calories != null ? formatCalories(calories) : undefined,
template.price != null ? formatPrice(template.price) : undefined,
].filter(Boolean).join(' · ');
};
/**
* Výběr šablony jídla k založení do dne.
*
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
* vybrat hotové jídlo. Chod lze při zakládání přepsat, protože totéž jídlo může
* být jednou oběd a podruhé večeře.
* vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat,
* protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se
* zapíší třeba dvě stejné sušenky naráz.
*/
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
const [templates, setTemplates] = useState<MealTemplate[]>([]);
const [mealType, setMealType] = useState<MealType | ''>('');
const [multiplierInput, setMultiplierInput] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
@@ -47,16 +40,21 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
if (!isOpen) return;
setLoading(true);
setMealType('');
setMultiplierInput('');
setSearch('');
getMealTemplates().then(response => {
setTemplates(response.data ?? []);
setLoading(false);
});
}, [isOpen]);
const multiplier = parseMultiplierInput(multiplierInput);
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
const apply = async (id: string) => {
setBusy(true);
const response = await applyMealTemplate({
body: { id, date, ...(mealType ? { mealType } : {}) },
body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) },
});
setBusy(false);
if (response.data) {
@@ -64,6 +62,15 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
}
};
const key = normalizeSearch(search);
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
.map(kind => ({
kind,
templates: templates.filter(template => template.kind === kind
&& (!key.length || normalizeSearch(template.name).includes(key))),
}))
.filter(group => group.templates.length > 0);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
@@ -80,36 +87,77 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
<div className="empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
<p className="empty-description">
Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem
„Uložit jako šablonu".
</p>
</div>
)}
{!loading && !!templates.length && (
<>
<Form.Group className="mb-3">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => setMealType(event.target.value as MealType | '')}
>
<option value="">— chod ze šablony —</option>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
</option>
))}
</Form.Select>
</Form.Group>
<div className="row g-3 mb-3">
<div className="col-sm-5">
<Form.Label>Hledat</Form.Label>
<Form.Control
autoFocus
value={search}
placeholder="Název jídla"
onChange={event => setSearch(event.target.value)}
/>
</div>
<div className="col-sm-4">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => setMealType(event.target.value as MealType | '')}
>
<option value="">— chod ze šablony —</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 className="col-sm-3">
<Form.Label>Násobek</Form.Label>
<Form.Control
inputMode="decimal"
value={multiplierInput}
placeholder="1"
isInvalid={multiplierInvalid}
title="Kolik porcí jste snědli, třeba 2 nebo 0,5"
onChange={event => setMultiplierInput(event.target.value)}
/>
</div>
</div>
{templates.map(template => (
<div className="workout-row" key={template.id}>
<div className="workout-info">
<div className="workout-name">{template.name}</div>
<div className="workout-items">{describe(template)}</div>
</div>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
{!groupList.length && (
<p className="text-body-secondary small">Žádná šablona neodpovídá hledání.</p>
)}
{groupList.map(group => (
<div className="apply-template-group" key={group.kind}>
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
{group.templates.map(template => (
<div className="workout-row" key={template.id}>
<div className="workout-info">
<div className="workout-name">{template.name}</div>
<div className="workout-items">
{describeMealTemplate(template)}
{!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`}
</div>
</div>
<Button
size="sm"
onClick={() => apply(template.id)}
disabled={busy || multiplierInvalid}
>
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
</div>
))}
</div>
))}
</>
+140 -61
View File
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react";
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ImportResult, MealType, importLuncher } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils";
import { ImportMode, ImportResult, MealEntry, MealType, importLuncher } from "../../../../types";
import { IMPORT_MODE_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { formatPrice, readFileAsBase64 } from "../../Utils";
import ImportPreview from "../ImportPreview";
type Props = {
isOpen: boolean,
@@ -13,19 +14,24 @@ type Props = {
onImported: () => void,
};
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
const PREVIEW_LIMIT = 8;
/** Označení záznamů z importu Luncheru — shodné s `IMPORT_SOURCE` na serveru. */
const LUNCHER_IMPORT_SOURCE = 'luncher';
const IMPORT_MODES_IN_ORDER: ImportMode[] = [ImportMode.PRIDAT, ImportMode.NAHRADIT];
/**
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
*
* Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se
* založí a kolik řádků už má, a teprve po potvrzení se data uloží.
* Import běží dvoufázově — nejdřív náhled (dryRun), ve kterém uživatel vidí,
* co se založí i co už v dotčených dnech má, a může jednotlivé řádky odškrtnout.
* Teprve po potvrzení se uloží vybrané řádky.
*/
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
const [file, setFile] = useState<File | undefined>();
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
const [mode, setMode] = useState<ImportMode>(ImportMode.PRIDAT);
const [preview, setPreview] = useState<ImportResult | undefined>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [result, setResult] = useState<ImportResult | undefined>();
const [busy, setBusy] = useState(false);
@@ -35,21 +41,30 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
setFile(undefined);
setPreview(undefined);
setResult(undefined);
setSelected(new Set());
setBusy(false);
}, [isOpen]);
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import vybraných řádků. */
const send = async (dryRun: boolean) => {
if (!file) return;
setBusy(true);
try {
const content = await readFileAsBase64(file);
const response = await importLuncher({
body: { fileName: file.name, content, defaultMealType: mealType, dryRun },
body: {
fileName: file.name,
content,
defaultMealType: mealType,
dryRun,
mode,
...(dryRun ? {} : { selectedKeys: Array.from(selected) }),
},
});
if (!response.data) return;
if (dryRun) {
setPreview(response.data);
setSelected(new Set(response.data.entries.map(entry => entry.importKey!)));
} else {
setResult(response.data);
setPreview(undefined);
@@ -60,14 +75,49 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
}
};
const chooseFile = (chosen?: File) => {
setFile(chosen);
/** Změna souboru, chodu nebo režimu mění, co se založí — náhled je pak neplatný. */
const invalidate = () => {
setPreview(undefined);
setResult(undefined);
};
const toggle = (key: string) => setSelected(current => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
const toggleDay = (date: string, checked: boolean) => setSelected(current => {
const next = new Set(current);
preview?.entries
.filter(entry => entry.date === date)
.forEach(entry => (checked ? next.add(entry.importKey!) : next.delete(entry.importKey!)));
return next;
});
/**
* Odškrtne řádky ve dnech, kde už je ručně zadané jídlo stejného chodu.
* Typicky oběd, který si uživatel zapsal dřív, než udělal import.
*/
const uncheckManualDays = () => {
const manualDays = new Set((preview?.existing ?? [])
.filter(entry => !entry.importSource && entry.mealType === mealType)
.map(entry => entry.date));
setSelected(current => new Set(Array.from(current).filter(key =>
!manualDays.has(preview?.entries.find(entry => entry.importKey === key)?.date ?? ''))));
};
const selectedEntries = preview?.entries.filter(entry => selected.has(entry.importKey!)) ?? [];
const selectedDays = new Set(selectedEntries.map(entry => entry.date));
const isReplaced = (entry: MealEntry) =>
mode === ImportMode.NAHRADIT && entry.importSource === LUNCHER_IMPORT_SOURCE;
const replacedCount = (preview?.existing ?? [])
.filter(entry => isReplaced(entry) && selectedDays.has(entry.date)).length;
const hasManualConflict = (preview?.existing ?? [])
.some(entry => !entry.importSource && entry.mealType === mealType);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal show={isOpen} onHide={onClose} centered size="lg" scrollable>
<Modal.Header closeButton>
<Modal.Title>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
@@ -76,8 +126,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
<Modal.Body>
<p className="text-body-secondary small">
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
a nahrajte ho sem. Záznamy se založí do dnů, ke kterým patří. Opakovaný import
stejného měsíce data nezduplikuje.
a nahrajte ho sem. V náhledu uvidíte, co se založí i co už v jednotlivých dnech máte,
a řádky, které nechcete, můžete odškrtnout.
</p>
<Form.Group className="mb-3">
@@ -85,27 +135,45 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
<Form.Control
type="file"
accept=".xlsx,.csv,.json"
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
onChange={event => { setFile((event.target as HTMLInputElement).files?.[0]); invalidate(); }}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => { setMealType(event.target.value as MealType); setPreview(undefined); }}
>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
</option>
<div className="row g-3 mb-3">
<div className="col-sm-6">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => { setMealType(event.target.value as MealType); invalidate(); }}
>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
</option>
))}
</Form.Select>
<Form.Text>Luncher řeší výběr obědů, proto je předvolený oběd.</Form.Text>
</div>
<div className="col-sm-6">
<Form.Label>Dřívější import stejných dnů</Form.Label>
{IMPORT_MODES_IN_ORDER.map(item => (
<Form.Check
key={item}
type="radio"
id={`import-mode-${item}`}
name="import-mode"
checked={mode === item}
label={IMPORT_MODE_NAMES[item]}
onChange={() => { setMode(item); invalidate(); }}
/>
))}
</Form.Select>
<Form.Text>
Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak
můžete v přehledu dne upravit nebo doplnit o další.
</Form.Text>
</Form.Group>
<Form.Text>
{mode === ImportMode.PRIDAT
? 'Už naimportované řádky se přeskočí, nic se nemaže.'
: 'Dřív naimportované záznamy dotčených dnů se smažou a založí znovu. Ručně zadaná jídla zůstanou.'}
</Form.Text>
</div>
</div>
{busy && (
<div className="text-center py-3">
@@ -114,36 +182,46 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
)}
{preview && !busy && (
<Alert variant={preview.imported ? 'success' : 'secondary'}>
<strong>Náhled importu</strong>
<div className="small mt-1">
V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů
{preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože už je máte</>}.
{preview.days.length > 0 && (
<> Dotčené dny: {formatDateString(preview.days[0])}
{preview.days.length > 1 && <> – {formatDateString(preview.days[preview.days.length - 1])}</>}
{' '}({preview.days.length}).</>
)}
{' '}Celkem za {formatPrice(preview.totalPrice)}.
</div>
<>
<Alert variant={selectedEntries.length ? 'success' : 'secondary'} className="py-2">
<strong>Náhled importu</strong>
<div className="small mt-1">
V souboru je {preview.rowCount} řádků. Vybráno {selectedEntries.length}
{' '}z {preview.entries.length} za{' '}
{formatPrice(selectedEntries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
{selectedDays.size > 0 && <> ve {selectedDays.size} dnech</>}.
{replacedCount > 0 && <> Nahradí se {replacedCount} dřív naimportovaných záznamů.</>}
</div>
</Alert>
{preview.entries.length > 0 && (
<ul className="import-preview-list">
{preview.entries.slice(0, PREVIEW_LIMIT).map(entry => (
<li key={entry.id}>
<span className="import-preview-date">{formatDateString(entry.date)}</span>
{entry.name}
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
<span className="import-preview-price">{formatPrice(entry.price)}</span>
</li>
))}
{preview.entries.length > PREVIEW_LIMIT && (
<li className="text-body-secondary">
… a dalších {preview.entries.length - PREVIEW_LIMIT}
</li>
<div className="import-preview-toolbar">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setSelected(new Set(preview.entries.map(entry => entry.importKey!)))}
>
Vybrat vše
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => setSelected(new Set())}>
Zrušit výběr
</Button>
{hasManualConflict && (
<Button variant="outline-secondary" size="sm" onClick={() => uncheckManualDays()}>
Odškrtnout dny, kde už máte {MEAL_TYPE_NAMES[mealType].toLowerCase()}
</Button>
)}
</ul>
</div>
)}
</Alert>
<ImportPreview
preview={preview}
selected={selected}
onToggle={toggle}
onToggleDay={toggleDay}
isReplaced={isReplaced}
/>
</>
)}
{result && !busy && (
@@ -151,7 +229,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
<strong>Import dokončen</strong>
<div className="small mt-1">
Založeno {result.imported} záznamů ve {result.days.length} dnech
{result.skipped > 0 && <>, {result.skipped} přeskočeno jako již existující</>}.
{result.skipped > 0 && <>, {result.skipped} přeskočeno</>}
{result.replaced > 0 && <>, {result.replaced} dřívějších nahrazeno</>}.
Celkem za {formatPrice(result.totalPrice)}.
</div>
</Alert>
@@ -173,8 +252,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
</Button>
)}
{!result && preview && (
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
Naimportovat {preview.imported} záznamů
<Button onClick={() => send(false)} disabled={busy || !selectedEntries.length}>
Naimportovat {selectedEntries.length} záznamů
</Button>
)}
</Modal.Footer>
+72 -11
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import {
DayRecord, MealEntry, MealType, UserSettings,
addMeal, createMealTemplateFromMeal, getSettings, updateMeal,
DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings,
addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
import TemplateSuggestions from "../TemplateSuggestions";
type Props = {
isOpen: boolean,
@@ -33,12 +35,13 @@ function emptyForm(date: string) {
caloriesPer100g: '',
calories: '',
note: '',
multiplier: '',
};
}
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
}
/**
@@ -48,17 +51,24 @@ function normalizeSource(source: string): string {
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
* s jiným cenováním můžou být ve stejném dni vedle sebe.
*
* U nového jídla se k rozepsanému názvu nabízejí šablony, takže jídlo, které už
* uživatel někdy měl, nemusí dohledávat znovu.
*/
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [settings, setSettings] = useState<UserSettings | undefined>();
const [saving, setSaving] = useState(false);
const [alsoTemplate, setAlsoTemplate] = useState(false);
const [templates, setTemplates] = useState<MealTemplate[]>([]);
/** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
const [pickedTemplate, setPickedTemplate] = useState<string | undefined>();
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setAlsoTemplate(false);
setPickedTemplate(undefined);
setForm(entry
? {
date: entry.date,
@@ -71,10 +81,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
multiplier: formatMultiplierInput(entry.multiplier),
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Šablony se nabízejí jen při zakládání; načtou se při každém otevření, mohly přibýt
useEffect(() => {
if (!isOpen || entry) return;
getMealTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen, entry]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
if (!isOpen || settings) return;
@@ -83,6 +100,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */
const applyTemplate = (template: MealTemplate) => {
setPickedTemplate(template.name);
setForm(current => ({
...current,
name: template.name,
mealType: template.mealType ?? current.mealType,
source: template.source ?? '',
price: formatPriceInput(template.price),
pricePer100g: formatPriceInput(template.pricePer100g),
weight: template.weight != null ? String(template.weight) : '',
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
calories: template.calories != null ? String(template.calories) : '',
// Postup receptu do záznamu dne nepatří, zůstává u šablony
note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '',
}));
};
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
const rateForSource = useMemo(() => {
const key = normalizeSource(form.source);
@@ -109,8 +144,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derived.calories ?? manualCalories;
const multiplier = parseMultiplierInput(form.multiplier);
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
const canSave = form.name.trim().length > 0 && !saving;
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving;
const save = async () => {
if (!canSave) return;
@@ -127,6 +165,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
calories: derived.calories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
multiplier,
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
@@ -159,8 +198,14 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => update({ name: event.target.value })}
onChange={event => { update({ name: event.target.value }); setPickedTemplate(undefined); }}
/>
{!entry && pickedTemplate !== form.name && (
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
)}
{!entry && pickedTemplate === form.name && (
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
)}
</Form.Group>
<div className="row g-3 mb-3">
@@ -255,7 +300,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<div className="col-sm-4">
<div className="col-sm-3">
<Form.Label>Energie na 100 g</Form.Label>
<div className="input-group">
<Form.Control
@@ -267,8 +312,8 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
<span className="input-group-text">kcal</span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Kalorie celkem</Form.Label>
<div className="col-sm-3">
<Form.Label>Kalorie porce</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
@@ -288,6 +333,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</Form.Text>
)}
</div>
<div className="col-sm-2">
<Form.Label>Násobek</Form.Label>
<Form.Control
inputMode="decimal"
value={form.multiplier}
placeholder="1"
isInvalid={multiplierInvalid}
title="Kolik porcí jste snědli, třeba 2 nebo 0,5. Cena se nenásobí."
onChange={event => update({ multiplier: event.target.value })}
/>
</div>
<div className="col-sm-4 d-flex align-items-end">
<CalorieLookup
name={form.name}
@@ -318,10 +374,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
/>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
{totalCalories != null && (
<span>
{formatCalories(totalCalories)}
{multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`}
</span>
)}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
<Button onClick={() => save()} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>