From f9a7b791a08fb76f90e79c8148902cbe05e396bf Mon Sep 17 00:00:00 2001 From: batmanisko Date: Wed, 23 Sep 2026 16:26:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20odeb=C3=ADr=C3=A1n=C3=AD=20polo=C5=BEek?= =?UTF-8?q?=20objedn=C3=A1vky=20ve=20sloupci=20Z=20nab=C3=ADdky?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/MenuHintCell.scss | 36 +++++++++++++++++++++++++ client/src/components/MenuHintCell.tsx | 22 +++++++++++++-- client/src/pages/OrderGroupsPage.tsx | 12 ++++++++- client/src/utils/menuMatch.ts | 27 +++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/client/src/components/MenuHintCell.scss b/client/src/components/MenuHintCell.scss index 4fd0aee..ccaa946 100644 --- a/client/src/components/MenuHintCell.scss +++ b/client/src/components/MenuHintCell.scss @@ -59,6 +59,42 @@ } } + .menu-hint-items { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; + } + + .menu-hint-item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 4px 1px 8px; + border-radius: 999px; + background: var(--luncher-bg-hover); + border: 1px solid var(--luncher-border); + font-size: 0.75rem; + line-height: 1.4; + + small { + color: var(--luncher-text-muted); + } + + button { + border: none; + background: none; + padding: 0 4px; + color: var(--luncher-text-muted); + font-weight: 700; + line-height: 1; + + &:hover { + color: var(--luncher-danger); + } + } + } + .menu-hint-price { cursor: help; } diff --git a/client/src/components/MenuHintCell.tsx b/client/src/components/MenuHintCell.tsx index ba9b458..af9a9f9 100644 --- a/client/src/components/MenuHintCell.tsx +++ b/client/src/components/MenuHintCell.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search"; import { StoreMenuDish } from "../../../types"; -import { matchNoteToMenu } from "../utils/menuMatch"; +import { matchNoteToMenu, splitNoteItems } from "../utils/menuMatch"; import "./MenuHintCell.scss"; type Props = { @@ -14,6 +14,8 @@ type Props = { 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; @@ -22,7 +24,7 @@ const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price * 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 }: Readonly) { +export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly) { const options = useMemo(() => { if (!menu?.length) return []; const groups = new Map(); @@ -35,6 +37,7 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs }, [menu]); const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]); + const items = useMemo(() => splitNoteItems(note, menu), [note, menu]); if (!menu?.length) { return —; @@ -60,6 +63,21 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs }} /> )} + {canEdit && items.length > 0 && ( +
+ {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; + return ( + + {text}{price != null && {price} Kč} + + + ); + })} +
+ )} {match && ( updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } })); }; + /** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */ + const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => { + const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []); + const amount = removedPrice != null && member.amount != null + ? Math.max(0, member.amount - Math.round(removedPrice * 100)) + : member.amount; + return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } })); + }; + const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) => refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } })); @@ -789,6 +798,7 @@ export default function OrderGroupsPage() { canEdit={canEdit} onPick={dish => handlePickDish(group, memberLogin, member, dish)} onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)} + onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)} /> ) : null} diff --git a/client/src/utils/menuMatch.ts b/client/src/utils/menuMatch.ts index 252ac48..6de2b7c 100644 --- a/client/src/utils/menuMatch.ts +++ b/client/src/utils/menuMatch.ts @@ -73,3 +73,30 @@ export function countNoteItems(note: string | undefined): number { return note.split('+').map(p => p.trim()).filter(Boolean) .reduce((sum, part) => sum + parseQuantity(part).quantity, 0); } + +/** + * Části poznámky oddělené „+" (tak, jak je uživatel zapsal). Pokud celá poznámka + * odpovídá jednomu jídlu z nabídky (název může obsahovat „+"), je to jediná položka. + */ +export function splitNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): string[] { + const trimmed = (note ?? '').trim(); + if (trimmed && menu.some(d => normalizeDishName(d.name) === normalizeDishName(trimmed))) return [trimmed]; + return trimmed.split('+').map(p => p.trim()).filter(Boolean); +} + +/** + * Odebere z poznámky jeden kus položky na daném indexu: „2x Hranolky" → „Hranolky", + * jinak celou část. Vrátí novou poznámku a cenu odebraného kusu dle nabídky (Kč), je-li známa. + */ +export function removeNoteItem(note: string | undefined, index: number, menu: StoreMenuDish[]): { note: string, removedPrice?: number } { + const parts = splitNoteItems(note, menu); + const part = parts[index]; + if (part == null) return { note: note ?? '' }; + const { quantity, rest } = parseQuantity(part); + const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) })); + const dish = dishes.find(d => d.norm === normalizeDishName(part))?.dish ?? findDish(rest, dishes); + if (quantity > 2) parts[index] = `${quantity - 1}x ${rest.trim()}`; + else if (quantity === 2) parts[index] = rest.trim(); + else parts.splice(index, 1); + return { note: parts.join(' + '), removedPrice: dish?.price }; +}