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

+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>
);
}