From 79deab543a0150e23159009c0a587def09e5f5a3 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 28 May 2026 00:07:58 +0330 Subject: [PATCH] Redesign POS order flow with order type picker and counter/takeaway support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OrderTypePicker screen: Table / Counter / Takeaway cards shown when no active session, replacing the old always-visible table board - Move PosTableBoard into a modal overlay (opens on Table selection or "Assign Table" for counter orders) - Add orderType field + setOrderType action to cart store - Counter and Takeaway orders no longer require a table to submit - Add "Assign Table →" button in cart for counter orders with active session - Rewrite category tabs as horizontal scrollable row (no wrapping) - Larger product cards with 4:3 thumbnail + quantity badge overlay - Bigger quantity controls (h-8 w-8) and "New order" back button in header - Add i18n keys for order types in en/fa/ar Co-Authored-By: Claude Sonnet 4.6 --- web/dashboard/messages/ar.json | 13 +- web/dashboard/messages/en.json | 13 +- web/dashboard/messages/fa.json | 13 +- .../src/components/pos/pos-screen.tsx | 1187 +++++++++++------ web/dashboard/src/lib/stores/cart.store.ts | 7 + 5 files changed, 807 insertions(+), 426 deletions(-) diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 621a063..fd6e558 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -229,7 +229,18 @@ "customerSaveError": "تعذّر حفظ العميل", "customerPhoneExists": "الهاتف مسجّل مسبقاً — ابحث واختر", "newCustomerHint": "للطلب الحالي فقط، أو احفظ في CRM عبر «إضافة عميل»", - "offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال" + "offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال", + "orderTypePicker": "كيف تريد تسجيل هذا الطلب؟", + "orderTypeTable": "طاولة", + "orderTypeTableDesc": "إجلاس الضيف على طاولة", + "orderTypeCounter": "كاونتر", + "orderTypeCounterDesc": "دون تخصيص طاولة", + "orderTypeTakeaway": "تيك أواي", + "orderTypeTakeawayDesc": "طلب للخارج", + "counterBadge": "كاونتر", + "takeawayBadge": "تيك أواي", + "assignTable": "تعيين طاولة", + "newOrder": "طلب جديد" }, "print": { "printReceipt": "طباعة الإيصال", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index d22f84e..868f3ea 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -231,7 +231,18 @@ "customerSaveError": "Could not save customer", "customerPhoneExists": "Phone already registered — search and select", "newCustomerHint": "Use for this order only, or tap Add customer to save to CRM", - "offlineQueueNotice": "Offline — order saved in queue and will sync when connected" + "offlineQueueNotice": "Offline — order saved in queue and will sync when connected", + "orderTypePicker": "How would you like to take this order?", + "orderTypeTable": "Table", + "orderTypeTableDesc": "Seat guest at a specific table", + "orderTypeCounter": "Counter", + "orderTypeCounterDesc": "Walk-in, no table yet", + "orderTypeTakeaway": "Takeaway", + "orderTypeTakeawayDesc": "Order to go", + "counterBadge": "Counter", + "takeawayBadge": "Takeaway", + "assignTable": "Assign table", + "newOrder": "New order" }, "print": { "printReceipt": "Print receipt", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index ad3d682..056f493 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -231,7 +231,18 @@ "customerSaveError": "خطا در ذخیره مشتری", "customerPhoneExists": "این موبایل قبلاً ثبت شده — از جستجو انتخاب کنید", "newCustomerHint": "می‌توانید فقط برای این سفارش نام بزنید یا با «افزودن مشتری» در CRM ذخیره کنید", - "offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود" + "offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود", + "orderTypePicker": "سفارش چطور ثبت می‌شود؟", + "orderTypeTable": "میز", + "orderTypeTableDesc": "مهمان روی میز می‌نشیند", + "orderTypeCounter": "پیشخوان", + "orderTypeCounterDesc": "بدون تخصیص میز", + "orderTypeTakeaway": "بیرون‌بر", + "orderTypeTakeawayDesc": "سفارش برای بیرون", + "counterBadge": "پیشخوان", + "takeawayBadge": "بیرون‌بر", + "assignTable": "تخصیص میز", + "newOrder": "سفارش جدید" }, "print": { "printReceipt": "چاپ رسید", diff --git a/web/dashboard/src/components/pos/pos-screen.tsx b/web/dashboard/src/components/pos/pos-screen.tsx index 8790a7f..d897bb1 100644 --- a/web/dashboard/src/components/pos/pos-screen.tsx +++ b/web/dashboard/src/components/pos/pos-screen.tsx @@ -1,10 +1,23 @@ -"use client"; +"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslations, useLocale } from "next-intl"; -import { Minus, Plus, Search, Trash2, Video, X } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + Minus, + Package, + Plus, + Search, + ShoppingCart, + Trash2, + UtensilsCrossed, + Users, + Video, + X, +} from "lucide-react"; import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client"; import type { MenuCategory, @@ -17,6 +30,7 @@ import type { import { useAuthStore } from "@/lib/stores/auth.store"; import { useCafeSettings } from "@/lib/hooks/use-cafe-settings"; import { useCartStore, type CartItem } from "@/lib/stores/cart.store"; +import type { OrderType } from "@/lib/stores/cart.store"; import { formatCurrency, formatNumber } from "@/lib/format"; import { formatOrderNumber } from "@/lib/order-number"; import { iranMobileForApi } from "@/lib/phone"; @@ -77,31 +91,129 @@ function cartToKitchenLines(cartItems: CartItem[]): KitchenSlipLine[] { })); } -/** Small square thumb on menu tiles: text | img (RTL-aware via flex order). */ -function PosMenuThumbnail({ - imageUrl, - kind, - hasVideo, +// ─── Order Type Picker ─────────────────────────────────────────────────────── + +function OrderTypePicker({ + onSelect, + t, }: { - imageUrl?: string | null; - kind: ReturnType; - hasVideo?: boolean; + onSelect: (type: OrderType) => void; + t: ReturnType>; }) { return ( -
- - {hasVideo ? ( - +
+

+ {t("orderTypePicker")} +

+
+ +
+ {/* Table */} + + + {/* Counter */} + + + {/* Takeaway */} + +
); } +// ─── Order Type Badge ───────────────────────────────────────────────────────── + +function OrderTypeBadge({ + orderType, + tableNumber, + onClick, + t, +}: { + orderType: OrderType; + tableNumber?: number | string | null; + onClick?: () => void; + t: ReturnType>; +}) { + const label = + orderType === "table" + ? `${t("table")} ${tableNumber ?? "—"}` + : orderType === "counter" + ? t("counterBadge") + : t("takeawayBadge"); + + const cls = + orderType === "table" + ? "border-primary bg-primary/10 text-primary hover:bg-primary/20" + : orderType === "counter" + ? "border-blue-400 bg-blue-50 text-blue-700 hover:bg-blue-100" + : "border-amber-400 bg-amber-50 text-amber-700 hover:bg-amber-100"; + + return ( + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + export function PosScreen() { const t = useTranslations("pos"); const tQueue = useTranslations("queue"); @@ -135,6 +247,8 @@ export function PosScreen() { guestName?: string | null; } | null>(null); const [posMode, setPosMode] = useState<"order" | "pay">("order"); + const [showTablePicker, setShowTablePicker] = useState(false); + const [showTransferPicker, setShowTransferPicker] = useState(false); const { items, @@ -148,6 +262,8 @@ export function PosScreen() { clearCoupon, tableId, setTableId, + orderType, + setOrderType, activeOrderId, activeOrderDisplayNumber, setActiveOrderId, @@ -178,10 +294,14 @@ export function PosScreen() { [pathname, router, searchParams] ); + // Restore tableId + infer orderType from URL params useEffect(() => { const tid = searchParams.get("tableId"); - if (tid) setTableId(tid); - }, [searchParams, setTableId]); + if (tid) { + setTableId(tid); + if (!orderType) setOrderType("table"); + } + }, [searchParams, setTableId, setOrderType, orderType]); useEffect(() => { if (urlOrderId) setActiveOrderId(urlOrderId); @@ -205,7 +325,6 @@ export function PosScreen() { type: "success" | "error"; text: string; } | null>(null); - const [showTransferPicker, setShowTransferPicker] = useState(false); const { data: branches = [] } = useQuery({ queryKey: ["branches", cafeId], @@ -279,13 +398,19 @@ export function PosScreen() { return map; }, [allMenuItems, menuItems]); + // Hydrate from URL order useEffect(() => { if (!cafeId || !urlOrderId || menuById.size === 0) return; if (activeOrderId === urlOrderId && items.length > 0) return; apiGet(`/api/cafes/${cafeId}/orders/${urlOrderId}`) .then((order) => { hydrateFromOrder(order, menuById); - if (order.tableId) setTableId(order.tableId); + if (order.tableId) { + setTableId(order.tableId); + setOrderType("table"); + } else if (!orderType) { + setOrderType("counter"); + } }) .catch(() => undefined); }, [ @@ -293,10 +418,11 @@ export function PosScreen() { urlOrderId, menuById, activeOrderId, - activeOrderDisplayNumber, items.length, hydrateFromOrder, setTableId, + setOrderType, + orderType, ]); const sessionPatchRef = useRef | null>(null); @@ -317,6 +443,8 @@ export function PosScreen() { const handleTableSelect = useCallback( (table: TableBoardItem, activeOrder: Order | null) => { + setShowTablePicker(false); + if (activeOrder) { setTableId(table.id); hydrateFromOrder(activeOrder, menuById); @@ -342,12 +470,30 @@ export function PosScreen() { menuById, syncUrl, setActiveOrderId, - activeOrderDisplayNumber, clearSession, t, ] ); + // Handle order type selection from the picker + const handleOrderTypeSelect = useCallback( + (type: OrderType) => { + setOrderType(type); + if (type === "table") { + setShowTablePicker(true); + } + }, + [setOrderType] + ); + + // Go back to type picker (with clear) + const handleBackToTypePicker = useCallback(() => { + clearSession(); + syncUrl(null, null); + setOrderMessage(null); + setCouponMessage(null); + }, [clearSession, syncUrl]); + const tablesQuery = orderBranchId ? `?branchId=${encodeURIComponent(orderBranchId)}` : ""; @@ -436,13 +582,7 @@ export function PosScreen() { if (!isSearchingItems) return true; return menuItemMatchesSearch(i, itemSearchQuery, locale); }); - }, [ - catalogForSearch, - menuItems, - isSearchingItems, - itemSearchQuery, - locale, - ]); + }, [catalogForSearch, menuItems, isSearchingItems, itemSearchQuery, locale]); const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems; @@ -454,10 +594,6 @@ export function PosScreen() { const itemVisualKind = (item: MenuItem) => inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId)); - const handleSelectMenuItem = (item: MenuItem) => { - addItem(item); - }; - const sub = subtotal(); const discount = appliedCoupon?.discountAmount ?? 0; const taxable = Math.max(0, sub - discount); @@ -566,7 +702,7 @@ export function PosScreen() { }) .then((ticket) => { setOrderMessage( - `${baseMsg} · ${t("queueNumber", { number: ticket.number })}` + `${baseMsg} · ${t("queueNumber", { number: ticket.number })}` ); queryClient.invalidateQueries({ queryKey: ["queue-today"] }); }) @@ -632,7 +768,6 @@ export function PosScreen() { cartItems: cart.items, }); const due = orderAmountDue(order); - // Can't process payment for a local (offline) order if (isLocalOrder(order.id)) return { order, kitchenLines }; const payBranchId = order.branchId ?? orderBranchId; if (due > 0) { @@ -720,18 +855,36 @@ export function PosScreen() { const pendingCount = getPendingLines().length; const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending; + + // Counter/takeaway orders don't require a table const canSubmitOrder = pendingCount > 0 && - (!!tableId || !!customerId || guestName.trim().length > 0); + (orderType === "counter" || + orderType === "takeaway" || + !!tableId || + !!customerId || + guestName.trim().length > 0); + + // Show order type picker when there's no active session + const showTypePicker = + posMode === "order" && + orderType === null && + items.length === 0 && + !urlOrderId && + !activeOrderId; + + // The current table number for display + const currentTableNumber = tables?.find((tbl) => tbl.id === tableId)?.number; if (!cafeId) return null; return (
-
+ {/* ── Top bar: mode switcher ─────────────────────────────────────────── */} +
+ {/* ── Pay mode ──────────────────────────────────────────────────────── */} {posMode === "pay" ? ( + ) : showTypePicker ? ( + /* ── Order type picker ──────────────────────────────────────────── */ + ) : ( -
- {cafeId ? : null} -
- {/* Menu panel */} -
- {cafeId ? ( - - ) : null} - {activeOrderId ? ( -

- {t("sessionActive")} · {t("order")}{" "} - {activeOrderDisplayNumber - ? String(activeOrderDisplayNumber) - : formatOrderNumber({ id: activeOrderId })} -

- ) : null} - {reservationId && reservationGuest ? ( -
- {t("reservationBanner", { name: reservationGuest })} -
- ) : null} -
- - setItemSearch(e.target.value)} - placeholder={t("searchItemsPlaceholder")} - aria-label={t("searchItems")} - className="h-9 ps-9 pe-9" - /> - {itemSearch ? ( - - ) : null} -
-
- - {loadingCategories - ? Array.from({ length: 3 }).map((_, i) => ( - - )) - : categories?.map((c) => ( - - ))} -
+ {isRtl ? ( + + ) : ( + + )} + {t("newOrder")} + -
-
- {showItemsLoading - ? Array.from({ length: 8 }).map((_, i) => ( - - )) - : filteredItems.length === 0 && isSearchingItems - ? ( -

- {t("searchNoResults")} -

- ) - : filteredItems.map((item) => ( - - ))} -
-
-
+ {/* Order type badge — tappable to change table */} + {orderType ? ( + setShowTablePicker(true) + : undefined + } + t={t} + /> + ) : null} - {/* Cart sidebar */} - - -
- {t("takeOrder")} - {tableId ? ( - - {t("table")}{" "} - {tables?.find((tbl) => tbl.id === tableId)?.number ?? "—"} + {/* Active order number */} + {activeOrderId ? ( + + # + {activeOrderDisplayNumber + ? String(activeOrderDisplayNumber) + : formatOrderNumber({ id: activeOrderId })} - ) : ( - - {t("selectTableBoard")} - - )} -
- {cafeId ? ( - - ) : null} - {activeOrderId && tableId ? ( - - ) : null} -
+ ) : null} - -
- {items.length === 0 ? ( -

{t("emptyCart")}

- ) : ( - items.map((line) => ( -
-
- -

- {line.isVoided ? ( - {t("voided")} - ) : ( - formatCurrency( - line.menuItem.price * line.quantity, - numberLocale - ) - )} -

-
-
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} +
+ + {/* Queue bar */} + {cafeId ? ( + + ) : null} +
+ + {/* Reservation banner */} + {reservationId && reservationGuest ? ( +
+ {t("reservationBanner", { name: reservationGuest })} +
+ ) : null} + + {/* ── Main split: menu + cart ──────────────────────────────────── */} +
+ {/* ── Menu panel ────────────────────────────────────────────── */} +
+ {/* Search bar */} +
+ + setItemSearch(e.target.value)} + placeholder={t("searchItemsPlaceholder")} + aria-label={t("searchItems")} + className="h-10 ps-9 pe-9" + /> + {itemSearch ? ( + + ) : null} +
+ + {/* Horizontal scrolling category tabs — no wrap */} +
+ + {loadingCategories + ? Array.from({ length: 4 }).map((_, i) => ( + + )) + : categories?.map((c) => ( + + ))} +
+ + {/* Product grid — bigger cards with image area */} +
+
+ {showItemsLoading + ? Array.from({ length: 8 }).map((_, i) => ( + + )) + : filteredItems.length === 0 && isSearchingItems + ? ( +

+ {t("searchNoResults")} +

+ ) + : filteredItems.map((item) => { + const qty = items.find( + (ci) => ci.menuItem.id === item.id + )?.quantity; + return ( + + ); + })} +
+
+
+ + {/* ── Cart sidebar ──────────────────────────────────────────── */} + + + {/* Cart header: title + table/type badge */} +
+
+ + {t("takeOrder")} +
+ + {orderType === "table" ? ( + tableId ? ( - ) : null} - {!line.isVoided ? ( - <> - + ) + ) : orderType ? ( + - - - - {formatNumber(line.quantity, numberLocale)} + {orderType === "counter" + ? t("counterBadge") + : t("takeawayBadge")} + ) : null} +
+ + {/* Customer picker */} + {cafeId ? ( + + ) : null} + + {/* Transfer table (for table orders with active session) */} + {activeOrderId && tableId ? ( + + ) : null} + + {/* Assign table button (for counter orders) */} + {orderType === "counter" && activeOrderId ? ( + + ) : null} +
+ + + {/* Cart items */} +
+ {items.length === 0 ? ( +

+ {t("emptyCart")} +

+ ) : ( + items.map((line) => ( +
+
+ +

+ {line.isVoided ? ( + {t("voided")} + ) : ( + formatCurrency( + line.menuItem.price * line.quantity, + numberLocale + ) + )} +

+
+ +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {isManager && + line.orderItemId && + !line.isVoided && + activeOrderId ? ( + + ) : null} + {!line.isVoided ? ( + <> + + + {formatNumber(line.quantity, numberLocale)} + + + + + ) : null} +
+
+ )) + )} +
+ + {/* Totals + actions */} +
+ {/* Coupon row */} +
+ + { + setCouponCode(e.target.value); + if (couponMessage) setCouponMessage(null); + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !appliedCoupon && + couponCode.trim() && + !validateCoupon.isPending + ) { + e.preventDefault(); + validateCoupon.mutate(); + } + }} + disabled={!!appliedCoupon} + dir="ltr" + className="h-8 text-end text-sm" + /> + + {appliedCoupon ? ( + + ) : ( + + )} +
+ + {couponMessage ? ( +

+ {couponMessage.text} +

+ ) : null} + + {appliedCoupon ? ( +
+ {t("couponActive", { code: appliedCoupon.code })} + -{formatCurrency(discount, numberLocale)} +
+ ) : null} + +
+ {t("subtotal")} + {formatCurrency(sub, numberLocale)} +
+ {discount > 0 ? ( +
+ {t("discount")} + -{formatCurrency(discount, numberLocale)} +
+ ) : null} +
+ {t("tax")} + {formatCurrency(tax, numberLocale)} +
+
+ {t("total")} + {formatCurrency(total, numberLocale)} +
+ + {!isOnline ? ( +

+ {t("offlineQueueNotice")} +

+ ) : null} + + {orderMessage ? ( +

+ {orderMessage} +

+ ) : null} + + {!canSubmitOrder && + items.length > 0 && + orderType !== "counter" && + orderType !== "takeaway" ? ( +

+ {t("needTableOrName")} +

+ ) : null} + + {items.some((line) => !line.isVoided) ? ( + ) : null} + +
- - ) : null} +
+ + +
- )) - )} +
+
- -
-
- - { - setCouponCode(e.target.value); - if (couponMessage) setCouponMessage(null); - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !appliedCoupon && - couponCode.trim() && - !validateCoupon.isPending - ) { - e.preventDefault(); - validateCoupon.mutate(); - } - }} - disabled={!!appliedCoupon} - dir="ltr" - className="h-8 text-end text-sm" - /> - - {appliedCoupon ? ( - - ) : ( - - )} -
- {couponMessage ? ( -

- {couponMessage.text} -

- ) : null} - {appliedCoupon ? ( -
- {t("couponActive", { code: appliedCoupon.code })} - -{formatCurrency(discount, numberLocale)} -
- ) : null} -
- {t("subtotal")} - {formatCurrency(sub, numberLocale)} -
- {discount > 0 ? ( -
- {t("discount")} - -{formatCurrency(discount, numberLocale)} -
- ) : null} -
- {t("tax")} - {formatCurrency(tax, numberLocale)} -
-
- {t("total")} - {formatCurrency(total, numberLocale)} -
- {!isOnline ? ( -

- {t("offlineQueueNotice")} -

- ) : null} - {orderMessage ? ( -

{orderMessage}

- ) : null} - {!canSubmitOrder && items.length > 0 ? ( -

{t("needTableOrName")}

- ) : null} - {items.some((line) => !line.isVoided) ? ( - - ) : null} -
- -
- - -
-
-
- - -
-
+
)} + {/* ── Table picker modal (for Table orders & counter assign) ────────── */} + {showTablePicker ? ( +
+
+
+

{t("selectTableBoard")}

+ +
+ {cafeId ? ( + + ) : null} +
+
+ ) : null} + + {/* ── Kitchen slip modal ────────────────────────────────────────────── */} {kitchenSlip ? ( ) : null} + {/* ── Transfer table modal ──────────────────────────────────────────── */} {showTransferPicker ? (

{t("selectTargetTable")}

{freeTransferTables.length === 0 ? ( -

{t("noOrderOnTable")}

+

+ {t("noOrderOnTable")} +

) : ( freeTransferTables.map((tbl) => (