CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 3m55s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
121 lines
5.9 KiB
TypeScript
121 lines
5.9 KiB
TypeScript
import { useMemo } from "react";
|
||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||
import { StoreMenuDish } from "../../../types";
|
||
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||
import "./MenuHintCell.scss";
|
||
|
||
type Props = {
|
||
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
|
||
menu?: StoreMenuDish[],
|
||
note?: string,
|
||
/** Zadaná částka člena v haléřích */
|
||
amount?: number,
|
||
canEdit: boolean,
|
||
onPick: (dish: StoreMenuDish) => void,
|
||
onUsePrice: (priceKc: number) => void,
|
||
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
|
||
onRemoveItem: (index: number) => void,
|
||
};
|
||
|
||
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
|
||
|
||
/**
|
||
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
|
||
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
||
*/
|
||
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||
const { maxItemQuantity, maxItems } = useGroupLimits();
|
||
const options = useMemo<SelectSearchOption[]>(() => {
|
||
if (!menu?.length) return [];
|
||
const groups = new Map<string, SelectSearchOption[]>();
|
||
menu.forEach((d, index) => {
|
||
const category = d.category || 'Ostatní';
|
||
if (!groups.has(category)) groups.set(category, []);
|
||
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
|
||
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
|
||
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
|
||
});
|
||
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
||
}, [menu, note, maxItemQuantity]);
|
||
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
|
||
|
||
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||
|
||
if (!menu?.length) {
|
||
return <small className="text-muted">—</small>;
|
||
}
|
||
|
||
const menuPrice = match?.total;
|
||
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
|
||
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
|
||
|
||
return (
|
||
<div className="menu-hint-cell">
|
||
{canEdit && itemLimitReached && (
|
||
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||
)}
|
||
{canEdit && !itemLimitReached && (
|
||
<SelectSearch
|
||
search
|
||
options={options}
|
||
placeholder="Přidat z nabídky…"
|
||
value=""
|
||
onBlur={() => { }}
|
||
onFocus={() => { }}
|
||
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
|
||
const dish = menu[Number(value)];
|
||
if (dish) onPick(dish);
|
||
}}
|
||
/>
|
||
)}
|
||
{canEdit && items.length > 0 && (
|
||
<div className="menu-hint-items">
|
||
{items.map((text, i) => {
|
||
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
|
||
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
|
||
const price = part?.dish?.price;
|
||
const quantity = part?.quantity ?? 1;
|
||
return (
|
||
<span key={`${i}-${text}`} className="menu-hint-item">
|
||
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
|
||
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{match && (
|
||
<OverlayTrigger
|
||
placement="top"
|
||
overlay={
|
||
<Tooltip>
|
||
{match.parts.map((p, i) => (
|
||
<div key={i}>
|
||
{p.dish
|
||
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
|
||
: `„${p.text}" — v nabídce nenalezeno`}
|
||
</div>
|
||
))}
|
||
</Tooltip>
|
||
}
|
||
>
|
||
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
|
||
{menuPrice != null
|
||
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
|
||
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
|
||
{differs && canEdit && (
|
||
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
|
||
title="Nastavit částku podle ceny v nabídce">
|
||
použít
|
||
</button>
|
||
)}
|
||
</small>
|
||
</OverlayTrigger>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|