fix: split umožní hledat i v prvním sloupci, kopírují se i podrobné hodnoty
fix: tlačítka z headeru jdou i přes kolečko feat: nový mode zobrazení
This commit is contained in:
1 parent
e13f7ce8e5
commit
b637e243d5
14 files changed
+553
-67
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||
import {
|
||||
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, getMealHistory, getMealTemplates, splitMeal,
|
||||
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, Nutrients, getMealHistory, getMealTemplates,
|
||||
splitMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import {
|
||||
@@ -31,8 +32,16 @@ type SplitForm = {
|
||||
caloriesPer100g: string,
|
||||
calories: string,
|
||||
note: string,
|
||||
/** Živiny se v dialogu neukazují, jen se přenesou z původního jídla nebo z vybraného návrhu. */
|
||||
nutrientsPer100g?: Nutrients,
|
||||
};
|
||||
|
||||
/** Pole části, která se v dialogu píšou jako text. */
|
||||
type SplitTextKey = Exclude<keyof SplitForm, 'nutrientsPer100g'>;
|
||||
|
||||
/** Návrh, ze kterého se část vyplní — hodnoty už převedené do podoby formuláře. */
|
||||
type SplitSuggestion = Partial<SplitForm> & { name: 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.
|
||||
@@ -53,6 +62,7 @@ const createOriginalForm = (source: MealEntry, name: string): SplitForm => ({
|
||||
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
|
||||
calories: source.calories != null ? String(source.calories) : '',
|
||||
note: source.note ?? '',
|
||||
nutrientsPer100g: source.nutrientsPer100g,
|
||||
});
|
||||
|
||||
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
|
||||
@@ -97,23 +107,48 @@ const createMealInput = (form: SplitForm, date: string): MealInput => {
|
||||
// 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,
|
||||
nutrientsPer100g: form.nutrientsPer100g,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Vyplní část hodnotami z návrhu. Nová část je prázdná, takže ji návrh přepíše
|
||||
* celou a uživatel může mezi návrhy přeskakovat. U původního jídla jen doplní
|
||||
* prázdná pole — co přišlo z importu, je skutečné. Cena se z návrhu nepřebírá
|
||||
* nikdy, 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;
|
||||
const mergeSuggestion = (current: SplitForm, values: SplitSuggestion, onlyEmpty: boolean): SplitForm => {
|
||||
if (!onlyEmpty) return { ...current, ...values };
|
||||
const result = { ...current, name: values.name };
|
||||
for (const [key, value] of Object.entries(values) as [keyof SplitForm, SplitForm[keyof SplitForm]][]) {
|
||||
if (key === 'nutrientsPer100g') {
|
||||
result.nutrientsPer100g = current.nutrientsPer100g ?? (value as Nutrients | undefined);
|
||||
} else if (!String(current[key] ?? '').trim().length) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const fromHistory = (suggestion: MealSuggestion): SplitSuggestion => ({
|
||||
name: suggestion.name,
|
||||
source: suggestion.source ?? '',
|
||||
pricePer100g: formatPriceInput(suggestion.pricePer100g),
|
||||
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
||||
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
||||
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
||||
nutrientsPer100g: suggestion.nutrientsPer100g,
|
||||
});
|
||||
|
||||
/** Recept nemá živiny na 100 g ani zdroj, převezme se jen jedna porce. */
|
||||
const fromRecipe = (recipe: MealTemplate): SplitSuggestion => ({
|
||||
name: recipe.name,
|
||||
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||
calories: recipe.calories != null ? String(recipe.calories) : '',
|
||||
nutrientsPer100g: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -127,7 +162,9 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
const [split, setSplit] = useState<SplitForm | undefined>();
|
||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
|
||||
/** Názvy návrhů, ze kterých se sloupce naposledy vyplnily — pak už se návrhy nenabízejí. */
|
||||
const [pickedOriginal, setPickedOriginal] = useState<string | undefined>();
|
||||
const [pickedSplit, setPickedSplit] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,7 +172,8 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
const [originalName, splitPartName] = splitName(entry.name);
|
||||
setOriginal(createOriginalForm(entry, originalName));
|
||||
setSplit(createSplitForm(entry, splitPartName));
|
||||
setPickedSuggestion(undefined);
|
||||
setPickedOriginal(undefined);
|
||||
setPickedSplit(undefined);
|
||||
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||
getMealHistory().then(response => setHistory(response.data ?? []));
|
||||
}, [isOpen, entry]);
|
||||
@@ -144,7 +182,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
|
||||
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] });
|
||||
const copy = (key: SplitTextKey) => 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 = () => {
|
||||
@@ -154,31 +192,24 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
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 applyToOriginal = (values: SplitSuggestion) => {
|
||||
setPickedOriginal(values.name);
|
||||
setOriginal(current => current && mergeSuggestion(current, values, true));
|
||||
};
|
||||
|
||||
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 applyToSplit = (values: SplitSuggestion) => {
|
||||
setPickedSplit(values.name);
|
||||
setSplit(current => current && mergeSuggestion(current, values, false));
|
||||
};
|
||||
|
||||
// Rozdělované jídlo samo sebe nenabízí
|
||||
const otherHistory = history.filter(item => getMealKey(item) !== getMealKey(entry));
|
||||
|
||||
const originalValues = deriveForm(original);
|
||||
const splitValues = deriveForm(split);
|
||||
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
|
||||
const priceChanged = totalPrice !== (entry.price ?? 0);
|
||||
const nutrientsStay = !!original.nutrientsPer100g && !split.nutrientsPer100g;
|
||||
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
|
||||
|
||||
const save = async () => {
|
||||
@@ -190,8 +221,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
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,
|
||||
// Násobek se v dialogu nemění a zůstává původnímu jídlu
|
||||
multiplier: entry.multiplier,
|
||||
},
|
||||
split: createMealInput(split, entry.date),
|
||||
@@ -203,7 +233,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
|
||||
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
|
||||
const textInput = (
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: keyof SplitForm,
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: SplitTextKey,
|
||||
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
|
||||
) => {
|
||||
const control = (
|
||||
@@ -235,6 +265,36 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
</Form.Select>
|
||||
);
|
||||
|
||||
/** Název části s návrhy z receptů a historie; vybraný návrh vyplní i energii a živiny. */
|
||||
const nameInput = (
|
||||
form: SplitForm, picked: string | undefined, apply: (values: SplitSuggestion) => void,
|
||||
onChange: (name: string) => void, options: { autoFocus?: boolean, placeholder?: string } = {},
|
||||
) => (
|
||||
<>
|
||||
<Form.Control
|
||||
autoFocus={options.autoFocus}
|
||||
value={form.name}
|
||||
placeholder={options.placeholder}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
/>
|
||||
{picked !== form.name && (
|
||||
<MealSuggestions
|
||||
recipes={recipes}
|
||||
history={otherHistory}
|
||||
query={form.name}
|
||||
onPickRecipe={recipe => apply(fromRecipe(recipe))}
|
||||
onPickHistory={suggestion => apply(fromHistory(suggestion))}
|
||||
/>
|
||||
)}
|
||||
{picked === form.name && (
|
||||
<Form.Text>
|
||||
{form === original ? 'doplněna prázdná pole z dřívějška, cena ne' : 'vyplněno z dřívějška, cena ne'}
|
||||
{form.nutrientsPer100g && ', živiny převzaty'}
|
||||
</Form.Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
/** 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>,
|
||||
@@ -260,30 +320,14 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
|
||||
<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 => getMealKey(item) !== getMealKey(entry))}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
original={nameInput(original, pickedOriginal, applyToOriginal, name => {
|
||||
updateOriginal({ name });
|
||||
setPickedOriginal(undefined);
|
||||
})}
|
||||
split={nameInput(split, pickedSplit, applyToSplit, name => {
|
||||
updateSplit({ name });
|
||||
setPickedSplit(undefined);
|
||||
}, { autoFocus: true, placeholder: 'např. Garlic naan' })}
|
||||
onCopy={() => copy('name')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
@@ -351,11 +395,11 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
{(entry.nutrientsPer100g || entry.multiplier != null) && (
|
||||
{(nutrientsStay || 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')}
|
||||
{entry.multiplier != null && nutrientsStay && ' a '}
|
||||
{nutrientsStay && (entry.multiplier != null ? 'živiny' : 'Živiny')}
|
||||
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
|
||||
import { formatPriceInput, parsePriceInput } from "../../Utils";
|
||||
import { STYLE_THEME_NAMES, StyleTheme } from "../../enums";
|
||||
import { useSettings } from "../../context/settings";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -11,7 +13,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Nastavení sazeb za 100 g u podniků, které účtují podle váhy.
|
||||
* Nastavení vzhledu a 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.
|
||||
@@ -21,6 +23,7 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
const [newSource, setNewSource] = useState('');
|
||||
const [newRate, setNewRate] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const appSettings = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -52,6 +55,19 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
<Modal.Title>Nastavení</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<h3 className="settings-section-title">Vzhled</h3>
|
||||
<Form.Group className="mb-4" controlId="settings-style-theme">
|
||||
<Form.Label>Styl</Form.Label>
|
||||
<Form.Select
|
||||
value={appSettings?.styleTheme}
|
||||
onChange={event => appSettings?.setStyleTheme(event.target.value as StyleTheme)}
|
||||
>
|
||||
{Object.values(StyleTheme).map(value => (
|
||||
<option key={value} value={value}>{STYLE_THEME_NAMES[value]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
|
||||
<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á
|
||||
|
||||
Reference in new issue
Block a user