diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos2/page.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos2/page.tsx index 6968232..4997dbd 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos2/page.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos2/page.tsx @@ -1,7 +1,9 @@ -import { Pos2Prototype } from "@/components/pos2/pos2-prototype"; +import { Pos2Screen } from "@/components/pos2/pos2-screen"; -/** POS v2 — clickable static prototype (mock data, no backend). Open at //pos2 - * on a tablet/phone to judge the redesigned layout before we decompose + wire it. */ -export default function Pos2PrototypePage() { - return ; +/** POS v2 — redesigned point of sale, wired to live data (menu, tables, orders, + * payments) via the shared cart store + offline-capable submit pipeline. + * Auth-guarded by the (fullscreen) layout. The static design mock lives at + * /[locale]/pos2-preview. */ +export default function Pos2Page() { + return ; } diff --git a/web/dashboard/src/app/[locale]/pos2-preview/page.tsx b/web/dashboard/src/app/[locale]/pos2-preview/page.tsx new file mode 100644 index 0000000..6b9a748 --- /dev/null +++ b/web/dashboard/src/app/[locale]/pos2-preview/page.tsx @@ -0,0 +1,13 @@ +import { notFound } from "next/navigation"; +import { Pos2Prototype } from "@/components/pos2/pos2-prototype"; + +/** + * Local, no-login preview of the POS v2 prototype. + * Dev-only — returns 404 in production. Open at //pos2-preview + * (e.g. http://localhost:3000/fa/pos2-preview) to judge the redesign with + * zero auth and zero backend. The real, auth-guarded route is //pos2. + */ +export default function Pos2PreviewPage() { + if (process.env.NODE_ENV === "production") notFound(); + return ; +} diff --git a/web/dashboard/src/components/pos2/pos2-prototype.tsx b/web/dashboard/src/components/pos2/pos2-prototype.tsx index 3d9317b..3f2ede1 100644 --- a/web/dashboard/src/components/pos2/pos2-prototype.tsx +++ b/web/dashboard/src/components/pos2/pos2-prototype.tsx @@ -2,19 +2,22 @@ // ───────────────────────────────────────────────────────────────────────────── // POS v2 — clickable STATIC prototype (mock data, local state only). -// Zero coupling to the live POS: no API, no stores, no SignalR. Purpose: judge the -// layout/feel on real devices before we decompose + wire. Route: /[locale]/pos2 +// Zero coupling to the live POS: no API, no stores, no SignalR. Purpose: walk the +// full journey — table board → order → pay — on real devices before we wire it. +// Routes: /[locale]/pos2 (auth-guarded) · /[locale]/pos2-preview (dev, no login) // ───────────────────────────────────────────────────────────────────────────── import { useMemo, useState } from "react"; import { Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal, - X, WifiOff, ShoppingCart, Users, Coffee, + X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair, + Banknote, Check, Delete, Clock, ReceiptText, ShoppingBag, } from "lucide-react"; import { cn } from "@/lib/utils"; import { notify } from "@/lib/notify"; type Item = { id: string; name: string; price: number; cat: string }; +type Line = { item: Item; qty: number }; const CATEGORIES = [ { id: "all", name: "همه" }, @@ -44,25 +47,67 @@ const ITEMS: Item[] = [ { id: "16", name: "تیرامیسو", price: 155000, cat: "dessert" }, ]; -type Line = { item: Item; qty: number }; const fmt = (n: number) => n.toLocaleString("fa-IR"); const TAX = 0.09; +const TAKEAWAY = "to"; + +type Table = { id: string; name: string; seats: number }; +const TABLES: Table[] = Array.from({ length: 12 }, (_, i) => ({ + id: String(i + 1), + name: `میز ${fmt(i + 1)}`, + seats: [2, 2, 4, 4, 2, 6, 4, 4, 2, 6, 8, 4][i], +})); + +// Seeded "live" tables so the board looks real on open. +const SEED_ORDERS: Record = { + "2": [{ item: ITEMS[2], qty: 2 }, { item: ITEMS[8], qty: 1 }], + "5": [{ item: ITEMS[3], qty: 1 }, { item: ITEMS[10], qty: 1 }, { item: ITEMS[12], qty: 2 }], + "8": [{ item: ITEMS[0], qty: 3 }, { item: ITEMS[13], qty: 1 }], + "11": [{ item: ITEMS[1], qty: 4 }, { item: ITEMS[9], qty: 2 }, { item: ITEMS[15], qty: 2 }], +}; +const SEED_META: Record = { + "2": { guests: 2, min: 12 }, "5": { guests: 4, min: 35 }, + "8": { guests: 3, min: 48 }, "11": { guests: 7, min: 21 }, +}; +const SEED_BILL = ["8"]; + +const sumLines = (ls: Line[]) => ls.reduce((s, l) => s + l.qty * l.item.price, 0); export function Pos2Prototype() { + const [orders, setOrders] = useState>(SEED_ORDERS); + const [bill, setBill] = useState(SEED_BILL); + const [activeId, setActiveId] = useState(null); + + // order-screen UI state const [cat, setCat] = useState("all"); const [q, setQ] = useState(""); - const [lines, setLines] = useState([]); - const [cartOpen, setCartOpen] = useState(false); // mobile/portrait slide-over + const [cartOpen, setCartOpen] = useState(false); + const [payOpen, setPayOpen] = useState(false); + const [payInit, setPayInit] = useState<"cash" | "split">("cash"); + const isTakeaway = activeId === TAKEAWAY; + const activeTable: Table | null = !activeId + ? null + : isTakeaway + ? { id: TAKEAWAY, name: "بیرون‌بر", seats: 0 } + : TABLES.find((t) => t.id === activeId) ?? null; + + const lines = (activeId && orders[activeId]) || []; const items = useMemo( () => ITEMS.filter((i) => (cat === "all" || i.cat === cat) && (q === "" || i.name.includes(q))), [cat, q], ); const count = lines.reduce((s, l) => s + l.qty, 0); - const subtotal = lines.reduce((s, l) => s + l.qty * l.item.price, 0); + const subtotal = sumLines(lines); const tax = Math.round(subtotal * TAX); const total = subtotal + tax; + const openTable = (id: string) => { setActiveId(id); setCat("all"); setQ(""); setCartOpen(false); }; + const backToBoard = () => { setActiveId(null); setPayOpen(false); setCartOpen(false); }; + + const setLines = (fn: (ls: Line[]) => Line[]) => + setOrders((o) => ({ ...o, [activeId!]: fn(o[activeId!] ?? []) })); + const add = (it: Item) => setLines((ls) => { const e = ls.find((l) => l.item.id === it.id); @@ -76,29 +121,120 @@ export function Pos2Prototype() { const send = () => { if (!count) return; - notify.success("سفارش به آشپزخانه ارسال شد (نمونه)"); - setLines([]); + notify.success(`سفارش ${activeTable?.name} به آشپزخانه ارسال شد (نمونه)`); setCartOpen(false); }; - const pay = () => { - if (!count) return; - notify.success(`پرداخت ${fmt(total)} تومان (نمونه)`); - setLines([]); - setCartOpen(false); + const hold = () => { if (count) notify.success("سفارش نگه داشته شد (نمونه)"); }; + const openPay = (method: "cash" | "split") => { if (!count) return; setPayInit(method); setPayOpen(true); }; + + const confirmPay = (method: "cash" | "card" | "split") => { + const label = { cash: "پرداخت نقدی", card: "پرداخت با کارت", split: "پرداخت تقسیمی" }[method]; + notify.success(`${label} ${fmt(total)} تومان ثبت شد — ${activeTable?.name} (نمونه)`); + setOrders((o) => { const n = { ...o }; delete n[activeId!]; return n; }); + setBill((b) => b.filter((x) => x !== activeId)); + backToBoard(); }; + // ── TABLE BOARD (entry) ────────────────────────────────────────────────── + if (!activeTable) { + const occupied = TABLES.filter((t) => (orders[t.id]?.length ?? 0) > 0).length; + return ( +
+
+
+ + میزها +
+ + {fmt(occupied)} فعال · {fmt(TABLES.length - occupied)} خالی + +
+ + آفلاین + + +
+ +
+ {TABLES.map((t) => { + const ls = orders[t.id] ?? []; + const busy = ls.length > 0; + const billed = bill.includes(t.id); + const meta = SEED_META[t.id]; + return ( + + ); + })} +
+
+ ); + } + + // ── ORDER SCREEN ───────────────────────────────────────────────────────── + const guests = SEED_META[activeTable.id]?.guests; return (
- {/* ── Topbar ───────────────────────────────────────────── */} -
+
+
- - میز ۵ + {isTakeaway ? : } + {activeTable.name}
- - ۲ نفر - -
+ {!isTakeaway && guests != null && ( + + {fmt(guests)} نفر + + )} +
- + آفلاین
- {/* ── Body: menu zone + ticket (lg side panel) ─────────── */}
- {/* MENU ZONE */}
- {/* category chips — horizontal scroll, works all sizes */}
{CATEGORIES.map((c) => ( ))}
- {/* item grid */}
{items.map((it) => (
- {/* ORDER TICKET — side panel on lg+, hidden below */}
- {/* ── Mobile/portrait: sticky "view order" bar ─────────── */} {count > 0 && (
openPay("cash")} onSplit={() => openPay("split")} /> )} + + {payOpen && ( + setPayOpen(false)} + onConfirm={confirmPay} + /> + )} ); } +// ── Order ticket ───────────────────────────────────────────────────────────── function Ticket({ - lines, subtotal, tax, total, count, onBump, onRemove, onSend, onPay, + lines, subtotal, tax, total, count, onBump, onRemove, onSend, onHold, onPay, onSplit, }: { lines: Line[]; subtotal: number; tax: number; total: number; count: number; onBump: (id: string, d: number) => void; onRemove: (id: string) => void; - onSend: () => void; onPay: () => void; + onSend: () => void; onHold: () => void; onPay: () => void; onSplit: () => void; }) { return (
- {/* line items */}
{lines.length === 0 ? (
@@ -262,7 +388,6 @@ function Ticket({ )}
- {/* totals */}
@@ -272,7 +397,6 @@ function Ticket({
- {/* action bar */}
- -
@@ -293,6 +419,196 @@ function Ticket({ ); } +// ── Payment sheet (cash / card / split + numpad + change) ──────────────────── +function PaySheet({ + tableName, total, guests, initialMethod, onClose, onConfirm, +}: { + tableName: string; total: number; guests?: number; + initialMethod: "cash" | "split"; + onClose: () => void; + onConfirm: (method: "cash" | "card" | "split") => void; +}) { + const [method, setMethod] = useState<"cash" | "card" | "split">(initialMethod); + const [recv, setRecv] = useState(""); + const [splitN, setSplitN] = useState(Math.min(Math.max(guests ?? 2, 2), 6)); + + const received = Number(recv || 0); + const change = received - total; + 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(total / step) * step; + const perPerson = Math.ceil(total / splitN / 1000) * 1000; + const canConfirm = method === "cash" ? received >= total : true; + + const TABS = [ + { id: "cash", name: "نقدی", icon: Banknote }, + { id: "card", name: "کارت", icon: CreditCard }, + { id: "split", name: "تقسیم", icon: SplitSquareHorizontal }, + ] as const; + + return ( +
+
+
+ {/* header */} +
+
+

پرداخت — {tableName}

+

{fmt(total)} تومان

+
+ +
+ + {/* method tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {/* CASH */} + {method === "cash" && ( +
+
+
+ دریافتی + {fmt(received)} تومان +
+
+ {change >= 0 ? "باقی‌مانده (بازگشت)" : "کسری پرداخت"} + = 0 ? "text-emerald-600" : "text-red-500")}> + {fmt(Math.abs(change))} تومان + +
+
+ +
+ setRecv(String(total))}>مبلغ دقیق + setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))} + setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))} + setRecv(String(roundUp(500000))) }>{fmt(roundUp(500000))} +
+ +
+ {["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => ( + press(d)}>{fmt(Number(d))} + ))} + press("000")}>۰۰۰ + press("0")}>۰ + +
+
+ )} + + {/* CARD */} + {method === "card" && ( +
+
+ +
+

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

+

{fmt(total)} تومان

+

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

+
+ )} + + {/* SPLIT */} + {method === "split" && ( +
+
+ تقسیم بین +
+ {[2, 3, 4, 5, 6].map((n) => ( + + ))} +
+
+
+

سهم هر نفر

+

{fmt(perPerson)} تومان

+
+
    + {Array.from({ length: splitN }, (_, i) => ( +
  • + + {fmt(i + 1)} + نفر {fmt(i + 1)} + + {fmt(perPerson)} تومان +
  • + ))} +
+
+ )} +
+ + {/* confirm */} +
+ +
+
+
+ ); +} + +function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes) { + return ( + + ); +} + +function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { + return ( + + ); +} + function Row({ label, value }: { label: string; value: string }) { return (
diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx new file mode 100644 index 0000000..e56ff4b --- /dev/null +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -0,0 +1,738 @@ +"use client"; + +// ───────────────────────────────────────────────────────────────────────────── +// 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. +// ───────────────────────────────────────────────────────────────────────────── + +import { useEffect, useMemo, useState } from "react"; +import { 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, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { notify } from "@/lib/notify"; +import { useRouter } from "@/i18n/routing"; +import { useAuthStore } from "@/lib/stores/auth.store"; +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 { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; + +const fmt = (n: number) => Math.round(n).toLocaleString("fa-IR"); +const TAX = 0.09; +const errMsg = (e: unknown, fb: string) => (e instanceof ApiClientError ? e.message || fb : fb); + +export function Pos2Screen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const cafeId = useAuthStore((s) => s.user?.cafeId); + const branchId = useBranchStore((s) => s.branchId); + + const { data: categories } = usePos2Categories(cafeId); + const { data: menu, isLoading: menuLoading } = usePos2Menu(cafeId, branchId); + const { data: tables, isLoading: tablesLoading, refetch: refetchTables } = usePos2Tables(cafeId, branchId); + const menuById = useMenuById(menu); + + // cart store slices + const items = useCartStore((s) => s.items); + const syncedQty = useCartStore((s) => s.syncedQtyByMenuId); + const addItem = useCartStore((s) => s.addItem); + const updateQty = useCartStore((s) => s.updateQty); + 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); + + // local view state + const [view, setView] = useState<"board" | "order">("board"); + const [activeTable, setActiveTable] = useState(null); + const [takeaway, setTakeaway] = useState(false); + const [cat, setCat] = useState("all"); + const [q, setQ] = useState(""); + const [cartOpen, setCartOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [payTarget, setPayTarget] = useState(null); + + const [online, setOnline] = useState(true); + useEffect(() => { + const u = () => setOnline(typeof navigator === "undefined" ? true : navigator.onLine); + u(); + window.addEventListener("online", u); + window.addEventListener("offline", u); + return () => { window.removeEventListener("online", u); window.removeEventListener("offline", u); }; + }, []); + + 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 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)), + 0, + ); + + const visibleItems = useMemo(() => { + const list = (menu ?? []).filter((i) => i.isAvailable !== false); + return list.filter( + (i) => + (cat === "all" || i.categoryId === cat) && + (q === "" || i.name.includes(q) || (i.nameEn ?? "").toLowerCase().includes(q.toLowerCase())), + ); + }, [menu, cat, q]); + + const catChips = useMemo( + () => [{ id: "all", name: "همه" }, ...(categories ?? []).map((c) => ({ id: c.id, name: c.name }))], + [categories], + ); + + // ── navigation ─────────────────────────────────────────────────────────── + const openFreeTable = (t: TableBoardItem) => { + clearSession(); + setTableId(t.id); + setOrderType("table"); + setActiveTable(t); setTakeaway(false); setCat("all"); setQ(""); setCartOpen(false); + setView("order"); + }; + + const openBusyTable = async (t: TableBoardItem) => { + const oid = t.currentOrder?.orderId; + if (!oid) return openFreeTable(t); + setBusy(true); + try { + const order = await apiGet(`/api/cafes/${cafeId}/orders/${oid}`); + hydrateFromOrder(order, menuById); + setOrderType("table"); + setActiveTable(t); setTakeaway(false); setCat("all"); setQ(""); setCartOpen(false); + setView("order"); + } catch (e) { + notify.error(errMsg(e, "بارگذاری سفارش میز ناموفق بود")); + } finally { + setBusy(false); + } + }; + + const openTakeaway = () => { + clearSession(); + setOrderType("takeaway"); + setActiveTable(null); setTakeaway(true); setCat("all"); setQ(""); setCartOpen(false); + setView("order"); + }; + + const backToBoard = () => { + clearSession(); + setActiveTable(null); setTakeaway(false); setPayTarget(null); setCartOpen(false); + setView("board"); + refetchTables(); + }; + + // ── actions ────────────────────────────────────────────────────────────── + const submitPending = async (): Promise => { + const cart = useCartStore.getState(); + if (cart.getPendingLines().length === 0) return null; + const order = await submitOrderToApi({ + cafeId: cafeId as string, + orderBranchId: branchId ?? undefined, + cart, + reservationId: null, + cartItems: cart.items, + }); + hydrateFromOrder(order, menuById); + queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); + return order; + }; + + const send = async () => { + if (pendingCount === 0) { notify.error("آیتمی برای ارسال نیست"); return; } + setBusy(true); + try { + const order = await submitPending(); + if (order) { + notify.success( + isLocalOrder(order.id) + ? "سفارش آفلاین ذخیره شد و هنگام اتصال ارسال می‌شود" + : "سفارش به آشپزخانه ارسال شد", + ); + } + } catch (e) { + notify.error(errMsg(e, "ارسال سفارش ناموفق بود")); + } finally { + setBusy(false); + } + }; + + const openPay = async () => { + if (count === 0) { notify.error("سبد خالی است"); return; } + setBusy(true); + try { + let order = await submitPending(); + if (!order) { + const cart = useCartStore.getState(); + if (cart.activeOrderId && !isLocalOrder(cart.activeOrderId)) { + order = await apiGet(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}`); + } + } + 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, + paidAmount: 0, createdAt: new Date().toISOString(), displayNumber: 0, + items: [], payments: [], + }; + } + setPayTarget(order); + } catch (e) { + notify.error(errMsg(e, "آماده‌سازی پرداخت ناموفق بود")); + } finally { + setBusy(false); + } + }; + + const confirmPay = async (method: "Cash" | "Card", amount: number) => { + if (!payTarget) return; + setBusy(true); + try { + await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, { + payments: [{ method, amount }], + }); + notify.success(`پرداخت ${fmt(amount)} تومان ثبت شد`); + queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); + queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); + backToBoard(); + } catch (e) { + notify.error(errMsg(e, "ثبت پرداخت ناموفق بود")); + } finally { + setBusy(false); + } + }; + + // ── guards ─────────────────────────────────────────────────────────────── + if (!cafeId) { + return ( +
+ +
+ ); + } + + const offlineBadge = !online && ( + + آفلاین + + ); + + // ── TABLE BOARD ──────────────────────────────────────────────────────────── + if (view === "board") { + const list = tables ?? []; + const occupied = list.filter((t) => t.status === "Busy").length; + return ( +
+ {busy && } +
+
+ + میزها +
+ + {fmt(occupied)} فعال · {fmt(Math.max(0, list.length - occupied))} خالی + +
+ {offlineBadge} + + +
+ + {tablesLoading ? ( +
+ +
+ ) : list.length === 0 ? ( +
+ +

هنوز میزی تعریف نشده است.

+ +
+ ) : ( +
+ {list.map((t) => { + const busyT = t.status === "Busy"; + const reserved = t.status === "Reserved"; + const cleaning = t.status === "Cleaning" || t.isCleaning; + const open = () => (busyT ? openBusyTable(t) : openFreeTable(t)); + return ( + + ); + })} +
+ )} +
+ ); + } + + // ── ORDER SCREEN ─────────────────────────────────────────────────────────── + const title = takeaway ? "بیرون‌بر" : `میز ${activeTable?.number ?? ""}`; + return ( +
+ {busy && } +
+ +
+ {takeaway ? : } + {title} +
+ {activeOrderNo ? ( + + سفارش #{fmt(activeOrderNo)} + + ) : null} +
+ + setQ(e.target.value)} + placeholder="جستجوی آیتم…" + className="h-11 w-full rounded-xl border border-border bg-background pe-10 ps-4 text-base outline-none focus:ring-2 focus:ring-primary/40" + /> +
+ {offlineBadge} +
+ +
+
+
+ {catChips.map((c) => ( + + ))} +
+ {menuLoading ? ( +
+ +
+ ) : ( +
+ {visibleItems.map((it) => ( + + ))} + {visibleItems.length === 0 && ( +

آیتمی یافت نشد

+ )} +
+ )} +
+ + +
+ + {count > 0 && ( + + )} + + {cartOpen && ( +
+
setCartOpen(false)} /> +
+
+ سفارش {title} + +
+ { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }} + onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay} + /> +
+
+ )} + + {payTarget && ( + setPayTarget(null)} + onConfirm={confirmPay} + /> + )} +
+ ); +} + +// ── Order ticket ───────────────────────────────────────────────────────────── +type TicketLine = { menuItem: MenuItem; quantity: number }; +function Ticket({ + lines, subtotal, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit, +}: { + lines: TicketLine[]; subtotal: 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; +}) { + return ( +
+
+ {lines.length === 0 ? ( +
+ +

سبد خالی است

+

برای افزودن، روی آیتم‌ها بزنید

+
+ ) : ( +
    + {lines.map((l) => ( +
  • +
    +

    {l.menuItem.name}

    +

    {fmt(l.menuItem.price)} تومان

    +
    +
    + + {fmt(l.quantity)} + +
    + +
  • + ))} +
+ )} +
+ +
+ + +
+ مبلغ کل + {fmt(total)} تومان +
+
+ +
+ + + + +
+
+ ); +} + +// ── Payment sheet ──────────────────────────────────────────────────────────── +function Pos2PaySheet({ + tableName, amountDue, onClose, onConfirm, +}: { + tableName: string; amountDue: number; + onClose: () => void; + onConfirm: (method: "Cash" | "Card", amount: number) => void; +}) { + const [method, setMethod] = useState<"cash" | "card" | "split">("cash"); + const [recv, setRecv] = useState(""); + const [splitN, setSplitN] = useState(2); + + const received = Number(recv || 0); + const change = received - amountDue; + 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 TABS = [ + { id: "cash", name: "نقدی", icon: Banknote }, + { id: "card", name: "کارت", icon: CreditCard }, + { id: "split", name: "تقسیم", icon: SplitSquareHorizontal }, + ] as const; + + const confirm = () => onConfirm(method === "card" ? "Card" : "Cash", amountDue); + + return ( +
+
+
+
+
+

پرداخت — {tableName}

+

{fmt(amountDue)} تومان

+
+ +
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {method === "cash" && ( +
+
+
+ دریافتی + {fmt(received)} تومان +
+
+ {change >= 0 ? "باقی‌مانده (بازگشت)" : "کسری پرداخت"} + = 0 ? "text-emerald-600" : "text-red-500")}> + {fmt(Math.abs(change))} تومان + +
+
+
+ setRecv(String(amountDue))}>مبلغ دقیق + setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))} + setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))} + setRecv(String(roundUp(500000)))}>{fmt(roundUp(500000))} +
+
+ {["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => ( + press(d)}>{fmt(Number(d))} + ))} + press("000")}>۰۰۰ + press("0")}>۰ + +
+
+ )} + + {method === "card" && ( +
+
+ +
+

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

+

{fmt(amountDue)} تومان

+

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

+
+ )} + + {method === "split" && ( +
+
+ تقسیم بین +
+ {[2, 3, 4, 5, 6].map((n) => ( + + ))} +
+
+
+

سهم هر نفر

+

{fmt(perPerson)} تومان

+
+

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

+
+ )} +
+ +
+ +
+
+
+ ); +} + +function BusyOverlay() { + return ( +
+
+ + در حال پردازش… +
+
+ ); +} + +function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes) { + return ( + + ); +} + +function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { + return ( + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/dashboard/src/lib/pos2/use-pos2.ts b/web/dashboard/src/lib/pos2/use-pos2.ts new file mode 100644 index 0000000..d947a24 --- /dev/null +++ b/web/dashboard/src/lib/pos2/use-pos2.ts @@ -0,0 +1,58 @@ +"use client"; + +// ───────────────────────────────────────────────────────────────────────────── +// POS v2 data hooks — thin wrappers over the EXISTING data layer so the new POS +// UI reuses the same endpoints/query keys as the classic POS (cache-shared). +// ───────────────────────────────────────────────────────────────────────────── + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "@/lib/api/client"; +import { getBranchMenu, branchMenuItemToMenuItem } from "@/lib/api/branch-menu"; +import { fetchCafeTableBoard } from "@/lib/api/branch-tables"; +import type { MenuCategory, MenuItem, TableBoardItem } from "@/lib/api/types"; + +export function usePos2Categories(cafeId?: string | null) { + return useQuery({ + queryKey: ["menu-categories", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/menu/categories`), + enabled: !!cafeId, + staleTime: 60_000, + }); +} + +/** Branch-scoped menu (effective prices) when a branch is selected; otherwise the + * café-wide menu. Both normalize to MenuItem so the cart store can consume them. */ +export function usePos2Menu(cafeId?: string | null, branchId?: string | null) { + return useQuery({ + queryKey: ["pos2-menu", cafeId, branchId ?? "cafe"], + queryFn: async (): Promise => { + if (branchId) { + const rows = await getBranchMenu(cafeId as string, branchId); + return rows.map(branchMenuItemToMenuItem); + } + return apiGet(`/api/cafes/${cafeId}/menu/items`); + }, + enabled: !!cafeId, + staleTime: 30_000, + }); +} + +export function usePos2Tables(cafeId?: string | null, branchId?: string | null) { + return useQuery({ + queryKey: ["tables-board", cafeId, branchId, "pos"], + queryFn: () => fetchCafeTableBoard(cafeId as string, branchId ?? undefined), + enabled: !!cafeId, + refetchInterval: 15_000, + }); +} + +export function useMenuById(items: MenuItem[] | undefined): Map { + return useMemo(() => { + const m = new Map(); + for (const it of items ?? []) m.set(it.id, it); + return m; + }, [items]); +} + +export type { MenuCategory, MenuItem, TableBoardItem };