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
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user