feat: limity skupinových objednávek nastavitelné přes env
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
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
This commit is contained in:
@@ -53,6 +53,12 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// Jídlo na limitu kusů — nejde přidat
|
||||
.select-search-option:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-search-group-header {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useMemo } from "react";
|
||||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||
import { StoreMenuDish } from "../../../types";
|
||||
import { matchNoteToMenu, splitNoteItems } from "../utils/menuMatch";
|
||||
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||||
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||||
import "./MenuHintCell.scss";
|
||||
|
||||
type Props = {
|
||||
@@ -25,16 +26,20 @@ const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price
|
||||
* (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, []);
|
||||
groups.get(category)!.push({ name: formatDish(d), value: String(index) });
|
||||
// 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]);
|
||||
}, [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]);
|
||||
@@ -49,7 +54,10 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs
|
||||
|
||||
return (
|
||||
<div className="menu-hint-cell">
|
||||
{canEdit && (
|
||||
{canEdit && itemLimitReached && (
|
||||
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||||
)}
|
||||
{canEdit && !itemLimitReached && (
|
||||
<SelectSearch
|
||||
search
|
||||
options={options}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getGroupLimits, subscribeGroupLimits } from "../utils/appConfig";
|
||||
|
||||
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
|
||||
export function useGroupLimits() {
|
||||
return useSyncExternalStore(subscribeGroupLimits, getGroupLimits);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getConfig } from '../../types/gen/sdk.gen';
|
||||
import { getToken } from './Utils';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { setGroupLimits } from './utils/appConfig';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
@@ -17,8 +18,9 @@ client.setConfig({
|
||||
});
|
||||
|
||||
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env.
|
||||
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
|
||||
getConfig().then(({ data }) => {
|
||||
setGroupLimits(data?.limits?.group);
|
||||
if (data?.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: data.sentry.dsn,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../../types';
|
||||
import MenuHintCell from '../components/MenuHintCell';
|
||||
import EaterBadge from '../components/EaterBadge';
|
||||
import { addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
@@ -333,7 +333,12 @@ export default function OrderGroupsPage() {
|
||||
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
|
||||
|
||||
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
|
||||
const handlePickDish = (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
||||
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
||||
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||
if (limitError) {
|
||||
setPageError(limitError);
|
||||
return false;
|
||||
}
|
||||
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
|
||||
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { GroupLimits } from "../../../types";
|
||||
|
||||
/**
|
||||
* Runtime konfigurace ze serveru (GET /api/config). Do načtení (nebo když endpoint
|
||||
* neodpoví) platí výchozí hodnoty — stejné jako výchozí hodnoty na serveru (server/src/limits.ts).
|
||||
*/
|
||||
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
|
||||
maxNoteLength: 300,
|
||||
maxSurchargeTextLength: 100,
|
||||
maxNameLength: 100,
|
||||
maxUrlLength: 500,
|
||||
maxAmountKc: 10_000,
|
||||
maxItemQuantity: 10,
|
||||
maxItems: 20,
|
||||
};
|
||||
|
||||
let groupLimits: GroupLimits = DEFAULT_GROUP_LIMITS;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getGroupLimits(): GroupLimits {
|
||||
return groupLimits;
|
||||
}
|
||||
|
||||
/** Nastaví limity ze serveru (chybějící hodnoty doplní výchozími) a upozorní odběratele. */
|
||||
export function setGroupLimits(limits: Partial<GroupLimits> | undefined): void {
|
||||
if (!limits) return;
|
||||
groupLimits = { ...DEFAULT_GROUP_LIMITS, ...limits };
|
||||
listeners.forEach(l => l());
|
||||
}
|
||||
|
||||
export function subscribeGroupLimits(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StoreMenuDish } from "../../../types";
|
||||
import type { GroupLimits, StoreMenuDish } from "../../../types";
|
||||
import { getGroupLimits } from "./appConfig";
|
||||
|
||||
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
|
||||
export function normalizeDishName(text: string): string {
|
||||
@@ -121,3 +122,26 @@ export function addNoteItem(note: string | undefined, dishName: string, menu: St
|
||||
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
|
||||
/** Kolik kusů daného jídla už poznámka obsahuje (0 = není tam). */
|
||||
export function noteItemQuantity(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): number {
|
||||
const target = normalizeDishName(dishName);
|
||||
const part = splitNoteItems(note, menu).find(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
|
||||
if (!part) return 0;
|
||||
return normalizeDishName(part) === target ? 1 : parseQuantity(part).quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí důvod, proč jídlo nejde přidat (limit kusů/položek — pojistky proti překlepům,
|
||||
* hodnoty z konfigurace serveru), jinak undefined.
|
||||
*/
|
||||
export function addItemLimitError(note: string | undefined, dishName: string, menu: StoreMenuDish[] = [], limits: GroupLimits = getGroupLimits()): string | undefined {
|
||||
if (noteItemQuantity(note, dishName, menu) >= limits.maxItemQuantity) {
|
||||
return `Jedné položky lze přidat nejvýše ${limits.maxItemQuantity} kusů`;
|
||||
}
|
||||
if (countNoteItems(note, menu) >= limits.maxItems) {
|
||||
return `Objednávka může mít nejvýše ${limits.maxItems} položek`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user