feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl committed 2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 files changed
+15505

No files matched your search

@@ -0,0 +1,208 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nová aktivita. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: ActivityEntry,
onClose: () => void,
onSaved: (day: DayOverview) => void,
};
/** Prázdný formulář pro novou aktivitu v daném dni. */
function emptyForm(date: string) {
return {
date,
name: '',
unit: ActivityUnit.KROKY as ActivityUnit,
quantity: '',
caloriesPer100Units: '',
calories: '',
note: '',
};
}
/**
* Dialog pro přidání nebo úpravu pohybové aktivity.
*
* 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.
*/
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
name: entry.name,
unit: entry.unit,
quantity: String(entry.quantity),
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Výběr z nabídky předvyplní jednotku i orientační sazbu. */
const applyPreset = (name: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === name);
if (!preset) {
update({ name });
return;
}
update({
name: preset.name,
unit: preset.unit,
caloriesPer100Units: String(preset.caloriesPer100Units),
});
};
const quantity = parseIntInput(form.quantity);
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derivedCalories ?? manualCalories;
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
name: form.name.trim(),
unit: form.unit,
quantity,
caloriesPer100Units,
// 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,
};
const response = entry
? await updateActivity({ body: { id: entry.id, ...body } })
: await addActivity({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava aktivity' : 'Nová aktivita'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<div className="row g-3 mb-3">
<div className="col-sm-8">
<Form.Label>Aktivita</Form.Label>
<Form.Control
autoFocus
list="activity-presets"
value={form.name}
placeholder="Např. Chůze"
onChange={event => applyPreset(event.target.value)}
/>
<datalist id="activity-presets">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Množství a spotřeba</legend>
<div className="row g-3">
<div className="col-sm-3">
<Form.Label>Množství</Form.Label>
<Form.Control
inputMode="numeric"
value={form.quantity}
placeholder="10000"
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Jednotka</Form.Label>
<Form.Select
value={form.unit}
onChange={event => update({ unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
</div>
<div className="col-sm-3">
<Form.Label>Kcal na 100 jedn.</Form.Label>
<Form.Control
inputMode="numeric"
value={form.caloriesPer100Units}
placeholder="např. 4"
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Spáleno</Form.Label>
{derivedCalories != null ? (
<div className="derived-value">{formatCalories(derivedCalories)}</div>
) : (
<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>
)}
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,183 @@
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";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Zavolá se po dokončení importu, aby se přepočítaly statistiky. */
onImported: () => void,
};
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
const PREVIEW_LIMIT = 8;
/**
* 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ží.
*/
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
const [file, setFile] = useState<File | undefined>();
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
const [preview, setPreview] = useState<ImportResult | undefined>();
const [result, setResult] = useState<ImportResult | undefined>();
const [busy, setBusy] = useState(false);
// Každé otevření začíná načisto, aby nezůstal viset náhled z minula
useEffect(() => {
if (!isOpen) return;
setFile(undefined);
setPreview(undefined);
setResult(undefined);
setBusy(false);
}, [isOpen]);
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
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 },
});
if (!response.data) return;
if (dryRun) {
setPreview(response.data);
} else {
setResult(response.data);
setPreview(undefined);
onImported();
}
} finally {
setBusy(false);
}
};
const chooseFile = (chosen?: File) => {
setFile(chosen);
setPreview(undefined);
setResult(undefined);
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Modal.Title>
</Modal.Header>
<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.
</p>
<Form.Group className="mb-3">
<Form.Label>Soubor s přehledem</Form.Label>
<Form.Control
type="file"
accept=".xlsx,.csv,.json"
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
/>
</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>
))}
</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>
{busy && (
<div className="text-center py-3">
<Spinner animation="border" variant="success" />
</div>
)}
{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>
{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>
)}
</ul>
)}
</Alert>
)}
{result && !busy && (
<Alert variant="success">
<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í</>}.
Celkem za {formatPrice(result.totalPrice)}.
</div>
</Alert>
)}
{(preview ?? result)?.warnings.map(warning => (
<div className="import-warning" key={warning}>
<FontAwesomeIcon icon={faTriangleExclamation} /> {warning}
</div>
))}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>
{result ? 'Zavřít' : 'Zrušit'}
</Button>
{!result && !preview && (
<Button onClick={() => send(true)} disabled={!file || busy}>
Zobrazit náhled
</Button>
)}
{!result && preview && (
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
Naimportovat {preview.imported} záznamů
</Button>
)}
</Modal.Footer>
</Modal>
);
}
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nové jídlo. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
};
/** Prázdný formulář pro nové jídlo v daném dni. */
function emptyForm(date: string) {
return {
date,
mealType: MealType.OBED as MealType,
name: '',
source: '',
price: '',
pricePer100g: '',
weight: '',
caloriesPer100g: '',
calories: '',
note: '',
};
}
/** 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();
}
/**
* Dialog pro přidání nebo úpravu jídla.
*
* Kromě ceny umí i gramáž — u podniků účtujících podle váhy (TechTower, 44 Kč/100 g)
* 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.
*/
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);
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
mealType: entry.mealType,
name: entry.name,
source: entry.source ?? '',
price: formatPriceInput(entry.price),
pricePer100g: formatPriceInput(entry.pricePer100g),
weight: entry.weight != null ? String(entry.weight) : '',
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
if (!isOpen || settings) return;
getSettings().then(response => setSettings(response.data));
}, [isOpen, settings]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
const rateForSource = useMemo(() => {
const key = normalizeSource(form.source);
if (!key.length) return undefined;
return settings?.sourceRates.find(rate => normalizeSource(rate.source) === key)?.pricePer100g;
}, [settings, form.source]);
/**
* Doplní sazbu podle zdroje, jakmile ji uživatel vybere a pole je prázdné.
* Ručně zadanou sazbu nepřepisujeme — u salátu bývá jiná než u hlavního jídla.
*/
useEffect(() => {
if (rateForSource && !form.pricePer100g.trim().length) {
setForm(current => ({ ...current, pricePer100g: formatPriceInput(rateForSource) }));
}
}, [rateForSource, form.pricePer100g]);
const price = parsePriceInput(form.price);
const pricePer100g = parsePriceInput(form.pricePer100g);
const manualWeight = parseIntInput(form.weight);
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
const derived = deriveAmounts(price, pricePer100g, manualWeight, caloriesPer100g);
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derived.calories ?? manualCalories;
const canSave = form.name.trim().length > 0 && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
mealType: form.mealType,
name: form.name.trim(),
source: form.source.trim() || undefined,
price,
pricePer100g,
weight: manualWeight,
caloriesPer100g,
// 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,
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
: await addMeal({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava jídla' : 'Nové jídlo'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<Form.Group className="mb-3">
<Form.Label>Jídlo</Form.Label>
<Form.Control
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => update({ name: event.target.value })}
/>
</Form.Group>
<div className="row g-3 mb-3">
<div className="col-sm-4">
<Form.Label>Chod</Form.Label>
<Form.Select
value={form.mealType}
onChange={event => update({ mealType: event.target.value as MealType })}
>
{MEAL_TYPES_IN_ORDER.map(mealType => (
<option key={mealType} value={mealType}>
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
</option>
))}
</Form.Select>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
list="known-sources"
value={form.source}
placeholder="TechTower, doma…"
onChange={event => update({ source: event.target.value })}
/>
<datalist id="known-sources">
{settings?.sourceRates.map(rate => (
<option key={rate.source} value={rate.source} />
))}
</datalist>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Cena a gramáž</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Cena</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.price}
placeholder="0,00"
onChange={event => update({ price: event.target.value })}
/>
<span className="input-group-text">Kč</span>
</div>
</div>
<div className="col-sm-4">
<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>
{rateForSource != null && (
<Form.Text>sazba zdroje {form.source.trim()}</Form.Text>
)}
</div>
<div className="col-sm-4">
<Form.Label>Gramáž</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.weight}
placeholder={derived.weight != null ? String(derived.weight) : 'volitelné'}
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
{manualWeight == null && derived.weight != null && (
<Form.Text>dopočteno z ceny</Form.Text>
)}
{manualWeight != null && pricePer100g != null && (
<Form.Text>zadáno ručně, cena se nemění</Form.Text>
)}
</div>
</div>
</fieldset>
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<div className="col-sm-4">
<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-4">
<Form.Label>Kalorie celkem</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
<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>
)}
{derived.calories != null && (
<Form.Text>
{formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g
</Form.Text>
)}
</div>
<div className="col-sm-4 d-flex align-items-end">
<CalorieLookup
name={form.name}
onPick={value => update({ caloriesPer100g: String(value) })}
/>
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
type Props = {
isOpen: boolean,
onClose: () => void,
};
/**
* Nastavení sazeb za 100 g u podniků, které účtují podle váhy.
*
* Sazba slouží jako výchozí hodnota při zadávání jídla — u konkrétní položky
* ji jde přepsat, protože salát bývá účtovaný jinak než hlavní jídlo.
*/
export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
const [settings, setSettings] = useState<UserSettings | undefined>();
const [newSource, setNewSource] = useState('');
const [newRate, setNewRate] = useState('');
const [basal, setBasal] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setNewSource('');
setNewRate('');
getSettings().then(response => {
setSettings(response.data);
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
});
}, [isOpen]);
const save = async (source: string, pricePer100g: number | null) => {
setSaving(true);
const response = await saveSourceRate({ body: { source, pricePer100g } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
const saveBasal = async () => {
setSaving(true);
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
const addRate = async () => {
const rate = parsePriceInput(newRate);
if (!newSource.trim().length || !rate) return;
await save(newSource.trim(), rate);
setNewSource('');
setNewRate('');
};
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>Nastavení</Modal.Title>
</Modal.Header>
<Modal.Body>
<h3 className="settings-section-title">Klidový výdej</h3>
<p className="text-body-secondary small">
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
</p>
<div className="rate-form mb-4">
<div className="input-group">
<Form.Control
inputMode="numeric"
value={basal}
placeholder="např. 1600"
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
/>
<span className="input-group-text">kcal / den</span>
</div>
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
</div>
<h3 className="settings-section-title">Cena za 100 g</h3>
<p className="text-body-secondary small">
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
gramáž a z ní kalorie. U konkrétního jídla jde sazbu vždy přepsat.
</p>
{settings?.sourceRates.length === 0 && (
<p className="text-body-secondary small fst-italic">Zatím nemáte žádnou sazbu.</p>
)}
{settings?.sourceRates.map(rate => (
<div className="rate-row" key={rate.source}>
<span className="rate-source">{rate.source}</span>
<span className="rate-value">{formatPriceInput(rate.pricePer100g)} Kč / 100 g</span>
<button
className="btn-icon danger"
onClick={() => save(rate.source, null)}
disabled={saving}
title="Odebrat sazbu"
aria-label={`Odebrat sazbu ${rate.source}`}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<Form
className="rate-form"
onSubmit={event => { event.preventDefault(); addRate(); }}
>
<Form.Control
value={newSource}
placeholder="Podnik, např. TechTower"
onChange={event => setNewSource(event.target.value)}
/>
<div className="input-group rate-form-price">
<Form.Control
inputMode="decimal"
value={newRate}
placeholder="44,00"
onChange={event => setNewRate(event.target.value)}
/>
<span className="input-group-text">Kč</span>
</div>
<Button
onClick={addRate}
disabled={saving || !newSource.trim().length || !parsePriceInput(newRate)}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import {
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
} from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se šablona použije. */
date: string,
onClose: () => void,
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
onApplied: (day: DayOverview) => void,
};
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
type DraftItem = {
name: string,
unit: ActivityUnit,
quantity: string,
caloriesPer100Units: string,
};
function emptyItem(): DraftItem {
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
}
/**
* Správa šablon tréninků a jejich použití na den.
*
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
*/
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
const [editedId, setEditedId] = useState<string | undefined>();
const [name, setName] = useState('');
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
const [busy, setBusy] = useState(false);
const resetForm = () => {
setEditedId(undefined);
setName('');
setItems([emptyItem()]);
};
useEffect(() => {
if (!isOpen) return;
resetForm();
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen]);
const updateItem = (index: number, changes: Partial<DraftItem>) =>
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
/** Převede rozpracované položky na ty, které jdou uložit. */
const toItems = (): WorkoutTemplateItem[] => items
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
.map(item => ({
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(parseIntInput(item.caloriesPer100Units) != null
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
: {}),
}));
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
resetForm();
}
};
const remove = async (id: string) => {
setBusy(true);
const response = await deleteWorkoutTemplate({ body: { id } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
if (editedId === id) resetForm();
}
};
const apply = async (id: string) => {
setBusy(true);
const response = await applyWorkoutTemplate({ body: { id, date } });
setBusy(false);
if (response.data) {
onApplied(response.data);
}
};
/** Načte šablonu do formuláře k úpravě. */
const edit = (template: WorkoutTemplate) => {
setEditedId(template.id);
setName(template.name);
setItems(template.items.map(item => ({
name: item.name,
unit: item.unit,
quantity: String(item.quantity),
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
})));
};
/** Průběžný odhad kalorií rozpracované šablony. */
const draftCalories = items.reduce((sum, item) =>
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Šablony tréninků</Modal.Title>
</Modal.Header>
<Modal.Body>
{templates.length === 0 && (
<p className="text-body-secondary small fst-italic">
Zatím nemáte žádnou šablonu. Sestavte si ji níže — příště pak stačí jedno kliknutí.
</p>
)}
{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">
{template.items.map(item =>
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
).join(' · ')}
</div>
</div>
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => remove(template.id)}
disabled={busy}
title="Smazat"
aria-label="Smazat šablonu"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<fieldset className="meal-fieldset mt-3">
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder="Např. Workout day 1"
onChange={event => setName(event.target.value)}
/>
</Form.Group>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
<Form.Control
list="activity-presets-workout"
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<button
className="btn-icon danger"
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<datalist id="activity-presets-workout">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyItem()])}
>
<FontAwesomeIcon icon={faPlus} /> Další cvik
</Button>
{draftCalories > 0 && (
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
)}
{editedId && (
<Button variant="outline-secondary" size="sm" onClick={resetForm}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={save} disabled={!canSave}>
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
</Button>
</div>
</fieldset>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}