feat: split jídel
feat: graf příjmů
This commit is contained in:
1 parent
bc85a37fc0
commit
b880b36edd
32 files changed
+2048
-262
No files matched your search
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||
import {
|
||||
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, getMealHistory, getMealTemplates, splitMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import {
|
||||
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, normalizeSearch,
|
||||
parseIntInput, parsePriceInput,
|
||||
} from "../../Utils";
|
||||
import MealSplitRow from "../MealSplitRow";
|
||||
import MealSuggestions from "../MealSuggestions";
|
||||
import SourceInput from "../SourceInput";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Rozdělované jídlo; bez něj je dialog zavřený. */
|
||||
entry?: MealEntry,
|
||||
onClose: () => void,
|
||||
onSaved: (day: DayRecord) => void,
|
||||
};
|
||||
|
||||
/** Údaje jedné části rozdělovaného jídla tak, jak je uživatel píše. */
|
||||
type SplitForm = {
|
||||
name: string,
|
||||
mealType: MealType,
|
||||
source: string,
|
||||
price: string,
|
||||
pricePer100g: string,
|
||||
weight: string,
|
||||
caloriesPer100g: string,
|
||||
calories: string,
|
||||
note: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rozdělí název podle prvního "+" — Luncher tak píše jídlo s přílohou
|
||||
* ("Chicken tikka massala+garlic nun"). Bez "+" zůstane celý název vlevo.
|
||||
*/
|
||||
const splitName = (name: string): [string, string] => {
|
||||
const index = name.indexOf('+');
|
||||
if (index < 0) return [name, ''];
|
||||
return [name.substring(0, index).trim(), name.substring(index + 1).trim()];
|
||||
};
|
||||
|
||||
const createOriginalForm = (source: MealEntry, name: string): SplitForm => ({
|
||||
name,
|
||||
mealType: source.mealType,
|
||||
source: source.source ?? '',
|
||||
price: formatPriceInput(source.price),
|
||||
pricePer100g: formatPriceInput(source.pricePer100g),
|
||||
weight: source.weight != null ? String(source.weight) : '',
|
||||
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
|
||||
calories: source.calories != null ? String(source.calories) : '',
|
||||
note: source.note ?? '',
|
||||
});
|
||||
|
||||
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
|
||||
const createSplitForm = (source: MealEntry, name: string): SplitForm => ({
|
||||
name,
|
||||
mealType: source.mealType,
|
||||
source: source.source ?? '',
|
||||
price: '',
|
||||
pricePer100g: '',
|
||||
weight: '',
|
||||
caloriesPer100g: '',
|
||||
calories: '',
|
||||
note: '',
|
||||
});
|
||||
|
||||
/** Dopočty části — stejně jako ve formuláři jídla. */
|
||||
const deriveForm = (form: SplitForm) => {
|
||||
const price = parsePriceInput(form.price);
|
||||
const pricePer100g = parsePriceInput(form.pricePer100g);
|
||||
const weight = parseIntInput(form.weight);
|
||||
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
|
||||
const derived = deriveAmounts(price, pricePer100g, weight, caloriesPer100g);
|
||||
const manualCalories = parseIntInput(form.calories);
|
||||
return {
|
||||
price, pricePer100g, weight, caloriesPer100g, manualCalories, derived,
|
||||
calories: derived.calories ?? manualCalories,
|
||||
};
|
||||
};
|
||||
|
||||
/** Sestaví vstup serveru z části formuláře. Datum doplní server podle rozdělovaného jídla. */
|
||||
const createMealInput = (form: SplitForm, date: string): MealInput => {
|
||||
const values = deriveForm(form);
|
||||
return {
|
||||
date,
|
||||
mealType: form.mealType,
|
||||
name: form.name.trim(),
|
||||
source: form.source.trim() || undefined,
|
||||
price: values.price,
|
||||
pricePer100g: values.pricePer100g,
|
||||
weight: values.weight,
|
||||
caloriesPer100g: values.caloriesPer100g,
|
||||
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
||||
calories: values.derived.calories != null ? undefined : values.manualCalories,
|
||||
note: form.note.trim() || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní do nové části hodnoty z návrhu, ale jen do prázdných polí. Cena se
|
||||
* nepřebírá — za jídlo se platilo jednou a cena z jiného dne by ji zdvojila.
|
||||
*/
|
||||
const fillEmpty = (current: SplitForm, values: Partial<SplitForm>): SplitForm => {
|
||||
const result = { ...current };
|
||||
for (const [key, value] of Object.entries(values) as [keyof SplitForm, string][]) {
|
||||
if (key === 'name' || !String(current[key]).trim().length) {
|
||||
(result as Record<string, string>)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rozdělení jídla, které je zapsané jako jedno, na dvě — typicky import
|
||||
* z Luncheru, kde je jídlo s přílohou v jednom řádku. Vlevo jsou hodnoty
|
||||
* původního jídla, vpravo nové části; šipka zkopíruje údaj zleva doprava.
|
||||
*
|
||||
* Nic se nedopočítává mezi sloupci, cena se rozdělí jen na vyžádání — někdy
|
||||
* zůstane celá u původního jídla, jindy se dělí.
|
||||
*/
|
||||
const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) => {
|
||||
const [original, setOriginal] = useState<SplitForm | undefined>();
|
||||
const [split, setSplit] = useState<SplitForm | undefined>();
|
||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !entry) return;
|
||||
const [originalName, splitPartName] = splitName(entry.name);
|
||||
setOriginal(createOriginalForm(entry, originalName));
|
||||
setSplit(createSplitForm(entry, splitPartName));
|
||||
setPickedSuggestion(undefined);
|
||||
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||
getMealHistory().then(response => setHistory(response.data ?? []));
|
||||
}, [isOpen, entry]);
|
||||
|
||||
if (!entry || !original || !split) return null;
|
||||
|
||||
const updateOriginal = (changes: Partial<SplitForm>) => setOriginal(current => current && { ...current, ...changes });
|
||||
const updateSplit = (changes: Partial<SplitForm>) => setSplit(current => current && { ...current, ...changes });
|
||||
const copy = (key: keyof SplitForm) => updateSplit({ [key]: original[key] });
|
||||
|
||||
/** Rozdělí současný součet ceny obou částí napůl; lichý haléř zůstane u původního jídla. */
|
||||
const halvePrice = () => {
|
||||
const total = (parsePriceInput(original.price) ?? 0) + (parsePriceInput(split.price) ?? 0);
|
||||
const half = Math.floor(total / 2);
|
||||
updateOriginal({ price: formatPriceInput(total - half) });
|
||||
updateSplit({ price: formatPriceInput(half) });
|
||||
};
|
||||
|
||||
const applyHistory = (suggestion: MealSuggestion) => {
|
||||
setPickedSuggestion(suggestion.name);
|
||||
setSplit(current => current && fillEmpty(current, {
|
||||
name: suggestion.name,
|
||||
source: suggestion.source ?? '',
|
||||
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
||||
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
||||
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const applyRecipe = (recipe: MealTemplate) => {
|
||||
setPickedSuggestion(recipe.name);
|
||||
setSplit(current => current && fillEmpty(current, {
|
||||
name: recipe.name,
|
||||
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||
calories: recipe.calories != null ? String(recipe.calories) : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const originalValues = deriveForm(original);
|
||||
const splitValues = deriveForm(split);
|
||||
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
|
||||
const priceChanged = totalPrice !== (entry.price ?? 0);
|
||||
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
const response = await splitMeal({
|
||||
body: {
|
||||
id: entry.id,
|
||||
date: entry.date,
|
||||
original: {
|
||||
...createMealInput(original, entry.date),
|
||||
// Živiny a násobek se v dialogu nemění a zůstávají původnímu jídlu
|
||||
nutrientsPer100g: entry.nutrientsPer100g,
|
||||
multiplier: entry.multiplier,
|
||||
},
|
||||
split: createMealInput(split, entry.date),
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
if (response.data) onSaved(response.data);
|
||||
};
|
||||
|
||||
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
|
||||
const textInput = (
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: keyof SplitForm,
|
||||
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
|
||||
) => {
|
||||
const control = (
|
||||
<Form.Control
|
||||
inputMode={options.numeric ? 'numeric' : (options.unit ? 'decimal' : undefined)}
|
||||
value={form[key]}
|
||||
placeholder={options.placeholder}
|
||||
onChange={event => update({
|
||||
[key]: options.numeric ? event.target.value.replace(/\D/g, '') : event.target.value,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
if (!options.unit) return control;
|
||||
return (
|
||||
<div className="input-group">
|
||||
{control}
|
||||
<span className="input-group-text">{options.unit}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mealTypeSelect = (form: SplitForm, update: (changes: Partial<SplitForm>) => void) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
/** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */
|
||||
const caloriesCell = (
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, values: ReturnType<typeof deriveForm>,
|
||||
) => values.derived.calories != null
|
||||
? <div className="derived-value">{formatCalories(values.derived.calories)}</div>
|
||||
: textInput(form, update, 'calories', { numeric: true, unit: 'kcal', placeholder: 'volitelné' });
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Rozdělit jídlo</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="meal-split-intro">
|
||||
Z jednoho záznamu vzniknou dva ve stejném dni. Šipkou zkopírujete údaj z původního jídla do nového.
|
||||
</p>
|
||||
<Form onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<div className="meal-split-grid">
|
||||
<div />
|
||||
<div className="meal-split-head">Původní jídlo</div>
|
||||
<div />
|
||||
<div className="meal-split-head">Nové jídlo</div>
|
||||
|
||||
<MealSplitRow
|
||||
label="Jídlo"
|
||||
original={textInput(original, updateOriginal, 'name')}
|
||||
split={
|
||||
<>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
value={split.name}
|
||||
placeholder="např. Garlic naan"
|
||||
onChange={event => { updateSplit({ name: event.target.value }); setPickedSuggestion(undefined); }}
|
||||
/>
|
||||
{pickedSuggestion !== split.name && (
|
||||
<MealSuggestions
|
||||
recipes={recipes}
|
||||
// Rozdělované jídlo samo sebe nenabízí
|
||||
history={history.filter(item =>
|
||||
normalizeSearch(item.name) !== normalizeSearch(entry.name))}
|
||||
query={split.name}
|
||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||
onPickHistory={suggestion => applyHistory(suggestion)}
|
||||
/>
|
||||
)}
|
||||
{pickedSuggestion === split.name && (
|
||||
<Form.Text>doplněna prázdná pole z dřívějška, cena ne</Form.Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onCopy={() => copy('name')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Chod"
|
||||
original={mealTypeSelect(original, updateOriginal)}
|
||||
split={mealTypeSelect(split, updateSplit)}
|
||||
onCopy={() => copy('mealType')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Zdroj"
|
||||
original={<SourceInput value={original.source} onChange={source => updateOriginal({ source })} />}
|
||||
split={<SourceInput value={split.source} onChange={source => updateSplit({ source })} />}
|
||||
onCopy={() => copy('source')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Cena"
|
||||
original={textInput(original, updateOriginal, 'price', { unit: 'Kč', placeholder: '0,00' })}
|
||||
split={textInput(split, updateSplit, 'price', { unit: 'Kč', placeholder: '0,00' })}
|
||||
onCopy={() => copy('price')}
|
||||
extra={
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon meal-split-half"
|
||||
title="Rozdělit cenu napůl"
|
||||
aria-label="Rozdělit cenu napůl"
|
||||
onClick={() => halvePrice()}
|
||||
>
|
||||
½
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Cena za 100 g"
|
||||
original={textInput(original, updateOriginal, 'pricePer100g', { unit: 'Kč' })}
|
||||
split={textInput(split, updateSplit, 'pricePer100g', { unit: 'Kč' })}
|
||||
onCopy={() => copy('pricePer100g')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Gramáž"
|
||||
original={textInput(original, updateOriginal, 'weight', {
|
||||
numeric: true, unit: 'g', placeholder: originalValues.derived.weight?.toString() ?? 'volitelné',
|
||||
})}
|
||||
split={textInput(split, updateSplit, 'weight', {
|
||||
numeric: true, unit: 'g', placeholder: splitValues.derived.weight?.toString() ?? 'volitelné',
|
||||
})}
|
||||
onCopy={() => copy('weight')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Energie na 100 g"
|
||||
original={textInput(original, updateOriginal, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
|
||||
split={textInput(split, updateSplit, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
|
||||
onCopy={() => copy('caloriesPer100g')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Kalorie porce"
|
||||
original={caloriesCell(original, updateOriginal, originalValues)}
|
||||
split={caloriesCell(split, updateSplit, splitValues)}
|
||||
onCopy={() => copy('calories')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Poznámka"
|
||||
original={textInput(original, updateOriginal, 'note')}
|
||||
split={textInput(split, updateSplit, 'note')}
|
||||
onCopy={() => copy('note')}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
{(entry.nutrientsPer100g || entry.multiplier != null) && (
|
||||
<p className="usage-note">
|
||||
{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`}
|
||||
{entry.multiplier != null && entry.nutrientsPer100g && ' a '}
|
||||
{entry.nutrientsPer100g && (entry.multiplier != null ? 'živiny' : 'Živiny')}
|
||||
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
|
||||
</p>
|
||||
)}
|
||||
{priceChanged && (
|
||||
<Alert variant="warning" className="meal-split-alert">
|
||||
Součet cen {formatPrice(totalPrice)} nesedí na původní {formatPrice(entry.price ?? 0)}.
|
||||
</Alert>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="meal-modal-summary">
|
||||
<span>{formatPrice(originalValues.price ?? 0)} + {formatPrice(splitValues.price ?? 0)}</span>
|
||||
{(originalValues.calories != null || splitValues.calories != null) && (
|
||||
<span>
|
||||
{formatCalories(originalValues.calories)} + {formatCalories(splitValues.calories)}
|
||||
</span>
|
||||
)}
|
||||
{splitValues.derived.weight != null && <span>nové {formatWeight(splitValues.derived.weight)}</span>}
|
||||
</div>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||
<Button onClick={() => save()} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Rozdělit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealSplitModal;
|
||||
Reference in new issue
Block a user