From 6778c320289b6a2fed59dbc1f746964938c231a1 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 05:16:52 +0330 Subject: [PATCH] feat(pos): POS v2 feature parity + promote to default /pos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the four POS v2 roadmap items: 1. Real split payments — split tab records N separate payment rows (equal split, last row takes the remainder), each row toggles Cash/Card; posts payments[]. 2. Card-terminal push — confirmPay sums Card amounts and calls requestPosPayment (POS device) before recording; surfaces POS_DEVICE_* errors. 3. Customer + coupons + loyalty — reuses PosCustomerPicker (attach/search/create) and validates coupons via /coupons/validate (discount in totals). Pay sheet offers loyalty redemption (1 point = 100 toman) when a customer is attached. 4. Promote to default — /pos now renders POS v2 (full-screen, café-themed); the classic terminal moves to /pos-classic with its sidebar+topbar chrome. The "نسخه کلاسیک" link points there. Order submission already carried customerId/guestName/guestPhone/couponId via the shared cart store, so customer + coupon flow straight through send + pay. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 --- .../(fullscreen)/pos-classic/layout.tsx | 50 +++ .../(fullscreen)/pos-classic/page.tsx | 12 + .../app/[locale]/(fullscreen)/pos/layout.tsx | 49 +-- .../app/[locale]/(fullscreen)/pos/page.tsx | 13 +- .../src/components/pos2/pos2-screen.tsx | 316 ++++++++++++++---- 5 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx create mode 100644 web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx new file mode 100644 index 0000000..0690187 --- /dev/null +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useLocale } from "next-intl"; +import { Sidebar } from "@/components/layout/sidebar"; +import { Topbar } from "@/components/layout/topbar"; +import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; + +/** + * Classic POS route layout — wraps the terminal in the standard dashboard + * chrome (collapsible sidebar + topbar) but keeps the main content area + * overflow-hidden so PosScreen can manage its own internal scrolling. + */ +export default function PosClassicLayout({ + children, +}: { + children: React.ReactNode; +}) { + const locale = useLocale(); + const isRtl = locale !== "en"; + + const mainColumn = ( +
+ +
+ {children} +
+
+ ); + + return ( + +
+ {isRtl ? ( + <> + + {mainColumn} + + ) : ( + <> + + {mainColumn} + + )} +
+
+ ); +} diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx new file mode 100644 index 0000000..3a52fc3 --- /dev/null +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { PosScreen } from "@/components/pos/pos-screen"; + +/** Classic POS terminal — chrome (sidebar + topbar) is provided by layout.tsx. + * Kept as a fallback while POS v2 (at /pos) is piloted. */ +export default function PosClassicPage() { + return ( + + + + ); +} diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx index 2fd3f63..6b107e1 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx @@ -1,50 +1,13 @@ "use client"; -import { useLocale } from "next-intl"; -import { Sidebar } from "@/components/layout/sidebar"; -import { Topbar } from "@/components/layout/topbar"; import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; /** - * POS route layout — wraps the terminal in the standard dashboard chrome - * (collapsible sidebar + topbar) but keeps the main content area - * overflow-hidden so PosScreen can manage its own internal scrolling. + * POS v2 layout — the redesigned terminal is full-screen (its own topbar + + * order ticket), so no dashboard sidebar/topbar chrome here. Café theming + * still applies. Auth guarding comes from the parent (fullscreen) layout. + * The classic POS keeps its chrome under /pos-classic. */ -export default function PosLayout({ - children, -}: { - children: React.ReactNode; -}) { - const locale = useLocale(); - const isRtl = locale !== "en"; - - const mainColumn = ( -
- -
- {children} -
-
- ); - - return ( - -
- {isRtl ? ( - <> - - {mainColumn} - - ) : ( - <> - - {mainColumn} - - )} -
-
- ); +export default function PosLayout({ children }: { children: React.ReactNode }) { + return {children}; } diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx index c73a4a7..6e1d92d 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx @@ -1,11 +1,8 @@ -import { Suspense } from "react"; -import { PosScreen } from "@/components/pos/pos-screen"; +import { Pos2Screen } from "@/components/pos2/pos2-screen"; -/** POS terminal — chrome (sidebar + topbar) is provided by layout.tsx */ +/** Default POS terminal — redesigned v2, wired to live data (menu, tables, + * orders, payments) via the shared cart store + offline submit pipeline. + * The classic POS remains available at /[locale]/pos-classic. */ export default function PosPage() { - return ( - - - - ); + return ; } diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx index e56ff4b..7f94979 100644 --- a/web/dashboard/src/components/pos2/pos2-screen.tsx +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -4,15 +4,16 @@ // POS v2 — WIRED to live data. Reuses the existing data layer + cart store + // submit/payment endpoints (shares React Query cache with the classic POS). // Flow: table board → order screen → pay sheet → back to board. -// Mounted at /[locale]/pos2. Design mirrors components/pos2/pos2-prototype.tsx. +// Mounted at /[locale]/pos (and /pos2). Design mirrors pos2-prototype.tsx. // ───────────────────────────────────────────────────────────────────────────── import { useEffect, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal, X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair, Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw, + BadgePercent, Sparkles, } from "lucide-react"; import { cn } from "@/lib/utils"; import { notify } from "@/lib/notify"; @@ -22,13 +23,42 @@ import { useBranchStore } from "@/lib/stores/branch.store"; import { useCartStore } from "@/lib/stores/cart.store"; import { apiGet, apiPost, ApiClientError } from "@/lib/api/client"; import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order"; -import type { MenuItem, Order, TableBoardItem } from "@/lib/api/types"; +import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; +import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; +import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types"; import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; const fmt = (n: number) => Math.round(n).toLocaleString("fa-IR"); const TAX = 0.09; +const POINT_VALUE = 100; // 1 loyalty point = 100 toman (matches classic POS) +type Method = "Cash" | "Card" | "Credit"; +type Payment = { method: Method; amount: number }; + const errMsg = (e: unknown, fb: string) => (e instanceof ApiClientError ? e.message || fb : fb); +const COUPON_FA: Record = { + COUPON_NOT_FOUND: "کد تخفیف نامعتبر است", + COUPON_INACTIVE: "کد تخفیف غیرفعال است", + COUPON_EXPIRED: "کد تخفیف منقضی شده است", + COUPON_NOT_STARTED: "کد تخفیف هنوز فعال نشده است", + COUPON_LIMIT_REACHED: "سقف استفاده از این کد پر شده است", + COUPON_MIN_ORDER: "حداقل مبلغ سفارش رعایت نشده است", + CART_EMPTY: "سبد خالی است", + COUPON_REQUIRED: "کد تخفیف را وارد کنید", + COUPON_NO_DISCOUNT: "کد تخفیف نامعتبر است", +}; +const couponErr = (e: unknown) => + e instanceof ApiClientError ? COUPON_FA[e.code] ?? "کد تخفیف نامعتبر است" : "کد تخفیف نامعتبر است"; + +const POS_DEVICE_FA: Record = { + posDeviceNotConfigured: "دستگاه کارت‌خوان تنظیم نشده است", + posDeviceConnectionFailed: "اتصال به کارت‌خوان ناموفق بود", + posDeviceTimeout: "زمان پاسخ کارت‌خوان به پایان رسید", + posDeviceRejected: "تراکنش توسط کارت‌خوان رد شد", + posDeviceError: "خطای کارت‌خوان", +}; +const posDeviceMsg = (e: unknown) => posDeviceErrorMessage(e, (k) => POS_DEVICE_FA[k] ?? "خطای کارت‌خوان"); + export function Pos2Screen() { const router = useRouter(); const queryClient = useQueryClient(); @@ -48,10 +78,10 @@ export function Pos2Screen() { const removeItem = useCartStore((s) => s.removeItem); const setTableId = useCartStore((s) => s.setTableId); const setOrderType = useCartStore((s) => s.setOrderType); - const setActiveOrderId = useCartStore((s) => s.setActiveOrderId); const hydrateFromOrder = useCartStore((s) => s.hydrateFromOrder); const clearSession = useCartStore((s) => s.clearSession); const activeOrderNo = useCartStore((s) => s.activeOrderDisplayNumber); + const appliedCoupon = useCartStore((s) => s.appliedCoupon); // local view state const [view, setView] = useState<"board" | "order">("board"); @@ -62,6 +92,7 @@ export function Pos2Screen() { const [cartOpen, setCartOpen] = useState(false); const [busy, setBusy] = useState(false); const [payTarget, setPayTarget] = useState(null); + const [payLoyalty, setPayLoyalty] = useState(0); const [online, setOnline] = useState(true); useEffect(() => { @@ -74,8 +105,10 @@ export function Pos2Screen() { const live = items.filter((l) => !l.isVoided); const subtotal = live.reduce((s, l) => s + l.menuItem.price * l.quantity, 0); - const tax = Math.round(subtotal * TAX); - const total = subtotal + tax; + const discount = appliedCoupon?.discountAmount ?? 0; + const taxable = Math.max(0, subtotal - discount); + const tax = Math.round(taxable * TAX); + const total = taxable + tax; const count = live.reduce((s, l) => s + l.quantity, 0); const pendingCount = items.reduce( (n, l) => n + Math.max(0, l.quantity - (syncedQty[l.menuItem.id] ?? 0)), @@ -183,17 +216,26 @@ export function Pos2Screen() { } } if (!order) { - // offline/local order — pay against the optimistic total const cart = useCartStore.getState(); order = { id: cart.activeOrderId ?? "local_pending", cafeId: cafeId as string, orderType: "DineIn", status: "Open", - subtotal, taxTotal: tax, discountAmount: 0, total, + subtotal, taxTotal: tax, discountAmount: discount, total, paidAmount: 0, createdAt: new Date().toISOString(), displayNumber: 0, items: [], payments: [], }; } + // loyalty points available on the attached customer (1 pt = 100 toman) + const customerId = useCartStore.getState().customerId; + let pts = 0; + if (customerId && !isLocalOrder(order.id)) { + try { + const c = await apiGet(`/api/cafes/${cafeId}/customers/${customerId}`); + pts = c.loyaltyPoints ?? 0; + } catch { pts = 0; } + } + setPayLoyalty(pts); setPayTarget(order); } catch (e) { notify.error(errMsg(e, "آماده‌سازی پرداخت ناموفق بود")); @@ -202,19 +244,33 @@ export function Pos2Screen() { } }; - const confirmPay = async (method: "Cash" | "Card", amount: number) => { + const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => { if (!payTarget) return; setBusy(true); try { + const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0); + const payBranchId = payTarget.branchId ?? branchId ?? undefined; + if (cardTotal > 0 && payBranchId) { + // push the card amount to the configured terminal (no-op/skip if none) + await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal); + } await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, { - payments: [{ method, amount }], + payments, + loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined, }); - notify.success(`پرداخت ${fmt(amount)} تومان ثبت شد`); + const paid = payments.reduce((s, p) => s + p.amount, 0); + notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`); queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); backToBoard(); } catch (e) { - notify.error(errMsg(e, "ثبت پرداخت ناموفق بود")); + if (e instanceof ApiClientError && e.code.startsWith("POS_DEVICE")) { + notify.error(posDeviceMsg(e)); + } else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") { + notify.error("برای پرداخت باید شیفت باز باشد"); + } else { + notify.error(errMsg(e, "ثبت پرداخت ناموفق بود")); + } } finally { setBusy(false); } @@ -235,6 +291,12 @@ export function Pos2Screen() { ); + const ticketProps = { + cafeId, lines: live, subtotal, discount, tax, total, count, pendingCount, + onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }, + onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay, + }; + // ── TABLE BOARD ──────────────────────────────────────────────────────────── if (view === "board") { const list = tables ?? []; @@ -254,7 +316,7 @@ export function Pos2Screen() { {offlineBadge} - { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }} - onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay} - /> + )} @@ -460,6 +512,7 @@ export function Pos2Screen() { setPayTarget(null)} onConfirm={confirmPay} /> @@ -468,12 +521,94 @@ export function Pos2Screen() { ); } +// ── Customer + coupon controls (self-contained: reads/writes the cart store) ── +function Pos2Extras({ cafeId }: { cafeId: string }) { + const guestName = useCartStore((s) => s.guestName); + const guestPhone = useCartStore((s) => s.guestPhone); + const customerId = useCartStore((s) => s.customerId); + const setGuestName = useCartStore((s) => s.setGuestName); + const setGuestPhone = useCartStore((s) => s.setGuestPhone); + const setCustomer = useCartStore((s) => s.setCustomer); + const clearCustomer = useCartStore((s) => s.clearCustomer); + const couponCode = useCartStore((s) => s.couponCode); + const appliedCoupon = useCartStore((s) => s.appliedCoupon); + const setCouponCode = useCartStore((s) => s.setCouponCode); + const setAppliedCoupon = useCartStore((s) => s.setAppliedCoupon); + const clearCoupon = useCartStore((s) => s.clearCoupon); + const subtotalFn = useCartStore((s) => s.subtotal); + const [msg, setMsg] = useState(null); + + const apply = useMutation({ + mutationFn: () => + apiPost<{ couponId: string; code: string; discountAmount: number }>( + `/api/cafes/${cafeId}/coupons/validate`, + { code: couponCode.trim(), subtotal: subtotalFn() }, + ), + onSuccess: (d) => { + setAppliedCoupon({ id: d.couponId, code: d.code, discountAmount: d.discountAmount }); + setMsg(null); + }, + onError: (e) => { setAppliedCoupon(null); setMsg(couponErr(e)); }, + }); + + return ( +
+ + + {appliedCoupon ? ( +
+ + {appliedCoupon.code} + (−{fmt(appliedCoupon.discountAmount)}) + + +
+ ) : ( +
+
+ + { setCouponCode(e.target.value); setMsg(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && couponCode.trim() && !apply.isPending) apply.mutate(); }} + placeholder="کد تخفیف" + className="h-10 w-full rounded-lg border border-border bg-background pe-8 ps-3 text-sm outline-none focus:ring-2 focus:ring-primary/40" + /> +
+ +
+ )} + {msg ?

{msg}

: null} +
+ ); +} + // ── Order ticket ───────────────────────────────────────────────────────────── type TicketLine = { menuItem: MenuItem; quantity: number }; function Ticket({ - lines, subtotal, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit, + cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit, }: { - lines: TicketLine[]; subtotal: number; tax: number; total: number; count: number; pendingCount: number; + cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number; + count: number; pendingCount: number; onBump: (id: string, d: number) => void; onRemove: (id: string) => void; onSend: () => void; onPay: () => void; onSplit: () => void; }) { @@ -512,8 +647,11 @@ function Ticket({ )} + +
+ {discount > 0 && }
مبلغ کل @@ -542,25 +680,50 @@ function Ticket({ ); } -// ── Payment sheet ──────────────────────────────────────────────────────────── +// ── Payment sheet (cash / card / split + numpad + loyalty) ─────────────────── function Pos2PaySheet({ - tableName, amountDue, onClose, onConfirm, + tableName, amountDue, loyaltyPoints, onClose, onConfirm, }: { - tableName: string; amountDue: number; + tableName: string; amountDue: number; loyaltyPoints: number; onClose: () => void; - onConfirm: (method: "Cash" | "Card", amount: number) => void; + onConfirm: (payments: Payment[], loyaltyRedeem: number) => void; }) { const [method, setMethod] = useState<"cash" | "card" | "split">("cash"); const [recv, setRecv] = useState(""); const [splitN, setSplitN] = useState(2); + const [splitMethods, setSplitMethods] = useState(["Cash", "Cash"]); + const [useLoyalty, setUseLoyalty] = useState(false); + + useEffect(() => { + setSplitMethods((prev) => Array.from({ length: splitN }, (_, i) => prev[i] ?? "Cash")); + }, [splitN]); + + const maxRedeem = Math.min(loyaltyPoints, Math.floor(amountDue / POINT_VALUE)); + const redeem = useLoyalty ? maxRedeem : 0; + const due = Math.max(0, amountDue - redeem * POINT_VALUE); const received = Number(recv || 0); - const change = received - amountDue; + const change = received - due; const press = (d: string) => setRecv((r) => (r + d).replace(/^0+(?=\d)/, "").slice(0, 12)); const backspace = () => setRecv((r) => r.slice(0, -1)); - const roundUp = (step: number) => Math.ceil(amountDue / step) * step; - const perPerson = Math.ceil(amountDue / splitN / 1000) * 1000; - const canConfirm = method === "cash" ? received >= amountDue : true; + const roundUp = (step: number) => Math.ceil(due / step) * step; + + const splitAmounts = useMemo(() => { + const base = Math.floor(due / splitN); + const arr = Array(splitN).fill(base); + arr[splitN - 1] += due - base * splitN; + return arr; + }, [due, splitN]); + + const canConfirm = method === "cash" ? received >= due || due === 0 : true; + + const confirm = () => { + let payments: Payment[]; + if (method === "cash") payments = [{ method: "Cash", amount: due }]; + else if (method === "card") payments = [{ method: "Card", amount: due }]; + else payments = splitAmounts.map((a, i) => ({ method: splitMethods[i] ?? "Cash", amount: a })); + onConfirm(payments.filter((p) => p.amount > 0), redeem); + }; const TABS = [ { id: "cash", name: "نقدی", icon: Banknote }, @@ -568,16 +731,17 @@ function Pos2PaySheet({ { id: "split", name: "تقسیم", icon: SplitSquareHorizontal }, ] as const; - const confirm = () => onConfirm(method === "card" ? "Card" : "Cash", amountDue); - return (
-
+

پرداخت — {tableName}

-

{fmt(amountDue)} تومان

+

+ {fmt(due)} تومان + {redeem > 0 && {fmt(amountDue)}} +

-
+
+ {loyaltyPoints > 0 && ( + + )} + {method === "cash" && (
@@ -616,7 +800,7 @@ function Pos2PaySheet({
- setRecv(String(amountDue))}>مبلغ دقیق + setRecv(String(due))}>مبلغ دقیق setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))} setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))} setRecv(String(roundUp(500000)))}>{fmt(roundUp(500000))} @@ -637,8 +821,8 @@ function Pos2PaySheet({
-

مبلغ روی دستگاه کارت‌خوان دریافت شود

-

{fmt(amountDue)} تومان

+

مبلغ به دستگاه کارت‌خوان ارسال می‌شود

+

{fmt(due)} تومان

پس از تأیید تراکنش، دکمهٔ زیر را بزنید

)} @@ -646,7 +830,7 @@ function Pos2PaySheet({ {method === "split" && (
- تقسیم بین + تعداد نفرات
{[2, 3, 4, 5, 6].map((n) => (
-
-

سهم هر نفر

-

{fmt(perPerson)} تومان

-
-

- کل مبلغ یکجا ثبت می‌شود؛ تقسیم صرفاً برای راهنمایی صندوق‌دار است. -

+
    + {splitAmounts.map((amt, i) => ( +
  • + + {fmt(i + 1)} + {fmt(amt)} تومان + +
    + {(["Cash", "Card"] as const).map((m) => ( + + ))} +
    +
  • + ))} +
)}
@@ -682,9 +884,9 @@ function Pos2PaySheet({ className="flex min-h-[56px] w-full items-center justify-center gap-2 rounded-xl bg-primary text-lg font-extrabold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.99]" > - {method === "cash" && change >= 0 && received > 0 + {method === "cash" && change > 0 && received > 0 ? `تأیید — بازگشت ${fmt(change)}` - : `تأیید پرداخت ${fmt(amountDue)} تومان`} + : `تأیید پرداخت ${fmt(due)} تومان`}
@@ -728,9 +930,9 @@ function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => ); } -function Row({ label, value }: { label: string; value: string }) { +function Row({ label, value, accent }: { label: string; value: string; accent?: boolean }) { return ( -
+
{label} {value}