feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
commit
a5711f3e1e
98 files changed
+15505
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user