feat: odebírání položek objednávky ve sloupci Z nabídky
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 58s
CI / Build client (push) Successful in 1m4s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 58s
CI / Build client (push) Successful in 1m4s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
This commit is contained in:
@@ -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 {
|
.menu-hint-price {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
|||||||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||||
import { StoreMenuDish } from "../../../types";
|
import { StoreMenuDish } from "../../../types";
|
||||||
import { matchNoteToMenu } from "../utils/menuMatch";
|
import { matchNoteToMenu, splitNoteItems } from "../utils/menuMatch";
|
||||||
import "./MenuHintCell.scss";
|
import "./MenuHintCell.scss";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,6 +14,8 @@ type Props = {
|
|||||||
canEdit: boolean,
|
canEdit: boolean,
|
||||||
onPick: (dish: StoreMenuDish) => void,
|
onPick: (dish: StoreMenuDish) => void,
|
||||||
onUsePrice: (priceKc: number) => 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;
|
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
|
* 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.
|
* (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<Props>) {
|
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||||||
const options = useMemo<SelectSearchOption[]>(() => {
|
const options = useMemo<SelectSearchOption[]>(() => {
|
||||||
if (!menu?.length) return [];
|
if (!menu?.length) return [];
|
||||||
const groups = new Map<string, SelectSearchOption[]>();
|
const groups = new Map<string, SelectSearchOption[]>();
|
||||||
@@ -35,6 +37,7 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs
|
|||||||
}, [menu]);
|
}, [menu]);
|
||||||
|
|
||||||
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||||||
|
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||||||
|
|
||||||
if (!menu?.length) {
|
if (!menu?.length) {
|
||||||
return <small className="text-muted">—</small>;
|
return <small className="text-muted">—</small>;
|
||||||
@@ -60,6 +63,21 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{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;
|
||||||
|
return (
|
||||||
|
<span key={`${i}-${text}`} className="menu-hint-item">
|
||||||
|
{text}{price != null && <small> {price} Kč</small>}
|
||||||
|
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{match && (
|
{match && (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement="top"
|
placement="top"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import MenuHintCell from '../components/MenuHintCell';
|
import MenuHintCell from '../components/MenuHintCell';
|
||||||
import EaterBadge from '../components/EaterBadge';
|
import EaterBadge from '../components/EaterBadge';
|
||||||
import { countNoteItems } from '../utils/menuMatch';
|
import { countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
@@ -339,6 +339,15 @@ export default function OrderGroupsPage() {
|
|||||||
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
return refresh(() => 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) =>
|
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
|
||||||
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
|
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
|
||||||
|
|
||||||
@@ -789,6 +798,7 @@ export default function OrderGroupsPage() {
|
|||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
|
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
|
||||||
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
|
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
|
||||||
|
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -73,3 +73,30 @@ export function countNoteItems(note: string | undefined): number {
|
|||||||
return note.split('+').map(p => p.trim()).filter(Boolean)
|
return note.split('+').map(p => p.trim()).filter(Boolean)
|
||||||
.reduce((sum, part) => sum + parseQuantity(part).quantity, 0);
|
.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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user