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

This commit is contained in:
Ondřej Anděl
2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 changed files with 15505 additions and 0 deletions
@@ -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 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)} / 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"></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>
);
}