feat(pos): wire POS v2 to live data (board, orders, payments)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m42s
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m42s
POS v2 is now a real, working point of sale at /[locale]/pos2 (was a static
mock). It reuses the existing data layer so it shares the React Query cache and
offline pipeline with the classic POS:
- Table board ← fetchCafeTableBoard (Free/Busy/Reserved/Cleaning, live totals,
guest-QR badge); polls every 15s. Open a free table to start an order; open a
busy table to hydrate its existing order (GET order → cart hydrateFromOrder).
- Order screen ← real branch/café menu + categories, bound to useCartStore
(add/qty/remove). Send via submitOrderToApi (online + offline outbox) then
re-hydrate; "ارسال (n)" shows the pending (unsynced) line count.
- Pay sheet ← POST /orders/{id}/payments. Cash (numpad + change), Card, and a
Split helper (records the full amount; split is cashier guidance for now).
- Online/offline badge, loading/empty states, toasts, busy overlay, and a
"نسخه کلاسیک" link back to /pos.
The static design mock stays at /[locale]/pos2-preview (dev-only, 404 in prod).
tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 /<locale>/pos2
|
||||
* on a tablet/phone to judge the redesigned layout before we decompose + wire it. */
|
||||
export default function Pos2PrototypePage() {
|
||||
return <Pos2Prototype />;
|
||||
/** 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 <Pos2Screen />;
|
||||
}
|
||||
|
||||
@@ -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 /<locale>/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 /<locale>/pos2.
|
||||
*/
|
||||
export default function Pos2PreviewPage() {
|
||||
if (process.env.NODE_ENV === "production") notFound();
|
||||
return <Pos2Prototype />;
|
||||
}
|
||||
@@ -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<string, Line[]> = {
|
||||
"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<string, { guests: number; min: number }> = {
|
||||
"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<Record<string, Line[]>>(SEED_ORDERS);
|
||||
const [bill, setBill] = useState<string[]>(SEED_BILL);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// order-screen UI state
|
||||
const [cat, setCat] = useState("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
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 (
|
||||
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<LayoutGrid className="size-6" />
|
||||
<span className="text-lg font-bold">میزها</span>
|
||||
</div>
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1 text-sm text-muted-foreground">
|
||||
{fmt(occupied)} فعال · {fmt(TABLES.length - occupied)} خالی
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span className="flex items-center gap-1.5 rounded-lg bg-amber-100 px-2.5 py-1.5 text-xs font-medium text-amber-800">
|
||||
<WifiOff className="size-4" /> آفلاین
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTable(TAKEAWAY)}
|
||||
className="flex min-h-[44px] cursor-pointer items-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
<ShoppingBag className="size-5" /> بیرونبر
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{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 (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => openTable(t.id)}
|
||||
className={cn(
|
||||
"flex min-h-[124px] cursor-pointer flex-col justify-between rounded-2xl border-2 p-3 text-start transition-all hover:shadow-md active:scale-[0.97]",
|
||||
billed
|
||||
? "border-amber-300 bg-amber-50"
|
||||
: busy
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-extrabold">{t.name}</span>
|
||||
<Armchair className={cn("size-5", busy ? "text-primary" : "text-muted-foreground/50")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="size-3.5" /> ظرفیت {fmt(t.seats)}
|
||||
</div>
|
||||
{busy ? (
|
||||
<div className="space-y-1">
|
||||
{billed && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-200/70 px-1.5 py-0.5 text-[11px] font-bold text-amber-900">
|
||||
<ReceiptText className="size-3" /> صورتحساب
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3.5" /> {fmt(meta?.min ?? 5)} دقیقه
|
||||
</span>
|
||||
<span className="text-sm font-extrabold text-primary">{fmt(sumLines(ls))}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground">خالی</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ORDER SCREEN ─────────────────────────────────────────────────────────
|
||||
const guests = SEED_META[activeTable.id]?.guests;
|
||||
return (
|
||||
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
|
||||
{/* ── Topbar ───────────────────────────────────────────── */}
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-2.5">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToBoard}
|
||||
className="flex min-h-[44px] cursor-pointer items-center gap-1.5 rounded-xl bg-muted px-3 font-medium text-muted-foreground transition-colors hover:bg-accent active:scale-95"
|
||||
>
|
||||
<ArrowRight className="size-5" /> <span className="hidden sm:inline">میزها</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary/10 px-3 py-1.5 text-primary">
|
||||
<Coffee className="size-5" />
|
||||
<span className="font-bold">میز ۵</span>
|
||||
{isTakeaway ? <ShoppingBag className="size-5" /> : <Coffee className="size-5" />}
|
||||
<span className="font-bold">{activeTable.name}</span>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1.5 text-sm text-muted-foreground sm:flex">
|
||||
<Users className="size-4" /> ۲ نفر
|
||||
</span>
|
||||
<div className="relative mx-2 flex-1">
|
||||
{!isTakeaway && guests != null && (
|
||||
<span className="hidden items-center gap-1.5 text-sm text-muted-foreground sm:flex">
|
||||
<Users className="size-4" /> {fmt(guests)} نفر
|
||||
</span>
|
||||
)}
|
||||
<div className="relative mx-1 flex-1">
|
||||
<Search className="pointer-events-none absolute end-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={q}
|
||||
@@ -107,16 +243,13 @@ export function Pos2Prototype() {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 rounded-lg bg-amber-100 px-2.5 py-1.5 text-xs font-medium text-amber-800">
|
||||
<span className="hidden items-center gap-1.5 rounded-lg bg-amber-100 px-2.5 py-1.5 text-xs font-medium text-amber-800 sm:flex">
|
||||
<WifiOff className="size-4" /> آفلاین
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ── Body: menu zone + ticket (lg side panel) ─────────── */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* MENU ZONE */}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* category chips — horizontal scroll, works all sizes */}
|
||||
<div className="flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
@@ -124,17 +257,14 @@ export function Pos2Prototype() {
|
||||
type="button"
|
||||
onClick={() => setCat(c.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-4 py-2.5 text-sm font-medium transition-colors min-h-[44px] cursor-pointer",
|
||||
cat === c.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
"min-h-[44px] shrink-0 cursor-pointer rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
cat === c.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* item grid */}
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
@@ -158,23 +288,15 @@ export function Pos2Prototype() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ORDER TICKET — side panel on lg+, hidden below */}
|
||||
<aside className="hidden w-[360px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
|
||||
<Ticket
|
||||
lines={lines}
|
||||
subtotal={subtotal}
|
||||
tax={tax}
|
||||
total={total}
|
||||
count={count}
|
||||
onBump={bump}
|
||||
onRemove={remove}
|
||||
onSend={send}
|
||||
onPay={pay}
|
||||
lines={lines} subtotal={subtotal} tax={tax} total={total} count={count}
|
||||
onBump={bump} onRemove={remove} onSend={send} onHold={hold}
|
||||
onPay={() => openPay("cash")} onSplit={() => openPay("split")}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile/portrait: sticky "view order" bar ─────────── */}
|
||||
{count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -190,45 +312,49 @@ export function Pos2Prototype() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Mobile/portrait: slide-over ticket ───────────────── */}
|
||||
{cartOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setCartOpen(false)} />
|
||||
<div className="absolute inset-y-0 end-0 flex w-full max-w-sm flex-col bg-card shadow-xl" dir="rtl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="font-bold">سفارش میز ۵</span>
|
||||
<span className="font-bold">سفارش {activeTable.name}</span>
|
||||
<button type="button" onClick={() => setCartOpen(false)} className="rounded-lg p-2 hover:bg-accent">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Ticket
|
||||
lines={lines}
|
||||
subtotal={subtotal}
|
||||
tax={tax}
|
||||
total={total}
|
||||
count={count}
|
||||
onBump={bump}
|
||||
onRemove={remove}
|
||||
onSend={send}
|
||||
onPay={pay}
|
||||
lines={lines} subtotal={subtotal} tax={tax} total={total} count={count}
|
||||
onBump={bump} onRemove={remove} onSend={send} onHold={hold}
|
||||
onPay={() => openPay("cash")} onSplit={() => openPay("split")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payOpen && (
|
||||
<PaySheet
|
||||
tableName={activeTable.name}
|
||||
total={total}
|
||||
guests={guests}
|
||||
initialMethod={payInit}
|
||||
onClose={() => setPayOpen(false)}
|
||||
onConfirm={confirmPay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* line items */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
@@ -262,7 +388,6 @@ function Ticket({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* totals */}
|
||||
<div className="space-y-1 border-t border-border px-4 py-3 text-sm">
|
||||
<Row label="جمع" value={`${fmt(subtotal)} تومان`} />
|
||||
<Row label="مالیات ۹٪" value={`${fmt(tax)} تومان`} />
|
||||
@@ -272,7 +397,6 @@ function Ticket({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* action bar */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border p-3">
|
||||
<button type="button" disabled={!count} onClick={onSend}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]">
|
||||
@@ -282,10 +406,12 @@ function Ticket({
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
|
||||
<CreditCard className="size-5" /> پرداخت
|
||||
</button>
|
||||
<button type="button" disabled={!count} className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
|
||||
<button type="button" disabled={!count} onClick={onHold}
|
||||
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
|
||||
<Pause className="size-4" /> نگهداشتن
|
||||
</button>
|
||||
<button type="button" disabled={!count} className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
|
||||
<button type="button" disabled={!count} onClick={onSplit}
|
||||
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
|
||||
<SplitSquareHorizontal className="size-4" /> تقسیم
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div dir="rtl" className="fixed inset-0 z-[60] flex items-stretch justify-center sm:items-center sm:p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative flex w-full flex-col bg-card shadow-2xl sm:max-w-md sm:rounded-2xl">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">پرداخت — {tableName}</p>
|
||||
<p className="text-xl font-extrabold text-primary">{fmt(total)} تومان</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-2 hover:bg-accent" aria-label="بستن">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* method tabs */}
|
||||
<div className="grid grid-cols-3 gap-2 p-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setMethod(t.id)}
|
||||
className={cn(
|
||||
"flex min-h-[52px] cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 text-sm font-bold transition-colors",
|
||||
method === t.id ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<t.icon className="size-5" /> {t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
|
||||
{/* CASH */}
|
||||
{method === "cash" && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl bg-muted/60 p-3">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>دریافتی</span>
|
||||
<span className="text-lg font-extrabold text-foreground">{fmt(received)} تومان</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{change >= 0 ? "باقیمانده (بازگشت)" : "کسری پرداخت"}</span>
|
||||
<span className={cn("text-lg font-extrabold", change >= 0 ? "text-emerald-600" : "text-red-500")}>
|
||||
{fmt(Math.abs(change))} تومان
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Chip onClick={() => setRecv(String(total))}>مبلغ دقیق</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))}</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))}</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(500000))) }>{fmt(roundUp(500000))}</Chip>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => (
|
||||
<Key key={d} onClick={() => press(d)}>{fmt(Number(d))}</Key>
|
||||
))}
|
||||
<Key onClick={() => press("000")}>۰۰۰</Key>
|
||||
<Key onClick={() => press("0")}>۰</Key>
|
||||
<Key onClick={backspace} aria-label="حذف"><Delete className="size-5" /></Key>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CARD */}
|
||||
{method === "card" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<CreditCard className="size-8" />
|
||||
</div>
|
||||
<p className="font-medium">مبلغ روی دستگاه کارتخوان ارسال میشود</p>
|
||||
<p className="text-2xl font-extrabold text-primary">{fmt(total)} تومان</p>
|
||||
<p className="text-sm text-muted-foreground">پس از تأیید تراکنش، دکمهٔ زیر را بزنید (نمونه)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPLIT */}
|
||||
{method === "split" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">تقسیم بین</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{[2, 3, 4, 5, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setSplitN(n)}
|
||||
className={cn(
|
||||
"size-11 cursor-pointer rounded-xl border-2 font-bold transition-colors",
|
||||
splitN === n ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{fmt(n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/60 p-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">سهم هر نفر</p>
|
||||
<p className="text-2xl font-extrabold text-primary">{fmt(perPerson)} تومان</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{Array.from({ length: splitN }, (_, i) => (
|
||||
<li key={i} className="flex items-center justify-between rounded-xl border border-border/70 px-3 py-2.5">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">{fmt(i + 1)}</span>
|
||||
نفر {fmt(i + 1)}
|
||||
</span>
|
||||
<span className="font-bold">{fmt(perPerson)} تومان</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* confirm */}
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canConfirm}
|
||||
onClick={() => onConfirm(method)}
|
||||
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]"
|
||||
>
|
||||
<Check className="size-6" />
|
||||
{method === "cash" && change >= 0
|
||||
? `تأیید — بازگشت ${fmt(change)}`
|
||||
: `تأیید پرداخت ${fmt(total)} تومان`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex min-h-[56px] cursor-pointer items-center justify-center rounded-xl bg-muted text-xl font-bold transition-colors hover:bg-accent active:scale-95"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="cursor-pointer rounded-full border border-border bg-background px-3 py-2 text-sm font-medium transition-colors hover:border-primary hover:text-primary active:scale-95"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
|
||||
@@ -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<TableBoardItem | null>(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<Order | null>(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<Order>(`/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<Order | null> => {
|
||||
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<Order>(`/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 (
|
||||
<div className="flex h-svh items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const offlineBadge = !online && (
|
||||
<span className="flex items-center gap-1.5 rounded-lg bg-amber-100 px-2.5 py-1.5 text-xs font-medium text-amber-800">
|
||||
<WifiOff className="size-4" /> آفلاین
|
||||
</span>
|
||||
);
|
||||
|
||||
// ── TABLE BOARD ────────────────────────────────────────────────────────────
|
||||
if (view === "board") {
|
||||
const list = tables ?? [];
|
||||
const occupied = list.filter((t) => t.status === "Busy").length;
|
||||
return (
|
||||
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
|
||||
{busy && <BusyOverlay />}
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<LayoutGrid className="size-6" />
|
||||
<span className="text-lg font-bold">میزها</span>
|
||||
</div>
|
||||
<span className="hidden rounded-lg bg-muted px-2.5 py-1 text-sm text-muted-foreground sm:inline">
|
||||
{fmt(occupied)} فعال · {fmt(Math.max(0, list.length - occupied))} خالی
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{offlineBadge}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/pos")}
|
||||
className="hidden min-h-[40px] cursor-pointer items-center gap-1.5 rounded-xl px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent sm:flex"
|
||||
>
|
||||
<RotateCcw className="size-4" /> نسخه کلاسیک
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTakeaway}
|
||||
className="flex min-h-[44px] cursor-pointer items-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
<ShoppingBag className="size-5" /> بیرونبر
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{tablesLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center text-muted-foreground">
|
||||
<Armchair className="size-12 opacity-40" />
|
||||
<p>هنوز میزی تعریف نشده است.</p>
|
||||
<button type="button" onClick={openTakeaway} className="rounded-xl bg-primary px-5 py-2.5 font-bold text-primary-foreground">
|
||||
شروع سفارش بیرونبر
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{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 (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={open}
|
||||
className={cn(
|
||||
"flex min-h-[124px] cursor-pointer flex-col justify-between rounded-2xl border-2 p-3 text-start transition-all hover:shadow-md active:scale-[0.97]",
|
||||
busyT ? "border-primary/40 bg-primary/5"
|
||||
: reserved ? "border-amber-300 bg-amber-50"
|
||||
: cleaning ? "border-border bg-muted/50"
|
||||
: "border-border bg-card hover:border-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-extrabold">میز {t.number}</span>
|
||||
<Armchair className={cn("size-5", busyT ? "text-primary" : "text-muted-foreground/50")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="size-3.5" /> ظرفیت {fmt(t.capacity)}
|
||||
{t.sectionName ? <span className="truncate">· {t.sectionName}</span> : null}
|
||||
</div>
|
||||
{busyT ? (
|
||||
<div className="flex items-center justify-between">
|
||||
{t.currentOrder?.source === "GuestQr" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-primary/15 px-1.5 py-0.5 text-[11px] font-bold text-primary">
|
||||
<ReceiptText className="size-3" /> مهمان
|
||||
</span>
|
||||
) : <span className="text-xs text-muted-foreground">باز</span>}
|
||||
<span className="text-sm font-extrabold text-primary">{fmt(t.currentOrder?.total ?? 0)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className={cn("text-sm font-medium", reserved ? "text-amber-700" : cleaning ? "text-muted-foreground" : "text-muted-foreground")}>
|
||||
{reserved ? "رزرو" : cleaning ? "در حال تمیزکاری" : "خالی"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ORDER SCREEN ───────────────────────────────────────────────────────────
|
||||
const title = takeaway ? "بیرونبر" : `میز ${activeTable?.number ?? ""}`;
|
||||
return (
|
||||
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
|
||||
{busy && <BusyOverlay />}
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToBoard}
|
||||
className="flex min-h-[44px] cursor-pointer items-center gap-1.5 rounded-xl bg-muted px-3 font-medium text-muted-foreground transition-colors hover:bg-accent active:scale-95"
|
||||
>
|
||||
<ArrowRight className="size-5" /> <span className="hidden sm:inline">میزها</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary/10 px-3 py-1.5 text-primary">
|
||||
{takeaway ? <ShoppingBag className="size-5" /> : <Coffee className="size-5" />}
|
||||
<span className="font-bold">{title}</span>
|
||||
</div>
|
||||
{activeOrderNo ? (
|
||||
<span className="hidden rounded-lg bg-muted px-2 py-1 text-xs font-bold text-muted-foreground sm:inline">
|
||||
سفارش #{fmt(activeOrderNo)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative mx-1 flex-1">
|
||||
<Search className="pointer-events-none absolute end-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{offlineBadge}
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||||
{catChips.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setCat(c.id)}
|
||||
className={cn(
|
||||
"min-h-[44px] shrink-0 cursor-pointer rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
cat === c.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{menuLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{visibleItems.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => addItem(it)}
|
||||
className="flex min-h-[104px] cursor-pointer flex-col justify-between rounded-2xl border border-border bg-card p-3 text-start shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Coffee className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-1 font-semibold">{it.name}</p>
|
||||
<p className="mt-0.5 text-sm font-bold text-primary">{fmt(it.price)}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{visibleItems.length === 0 && (
|
||||
<p className="col-span-full py-10 text-center text-muted-foreground">آیتمی یافت نشد</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="hidden w-[360px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
|
||||
<Ticket
|
||||
lines={live} subtotal={subtotal} tax={tax} total={total} count={count}
|
||||
pendingCount={pendingCount}
|
||||
onBump={(id, d) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }}
|
||||
onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCartOpen(true)}
|
||||
className="flex shrink-0 cursor-pointer items-center justify-between gap-3 bg-primary px-4 py-3 text-primary-foreground lg:hidden"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<ShoppingCart className="size-5" />
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-white/25 text-sm">{fmt(count)}</span>
|
||||
مشاهده سفارش
|
||||
</span>
|
||||
<span className="text-lg font-extrabold">{fmt(total)} تومان</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{cartOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setCartOpen(false)} />
|
||||
<div className="absolute inset-y-0 end-0 flex w-full max-w-sm flex-col bg-card shadow-xl" dir="rtl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="font-bold">سفارش {title}</span>
|
||||
<button type="button" onClick={() => setCartOpen(false)} className="rounded-lg p-2 hover:bg-accent">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Ticket
|
||||
lines={live} subtotal={subtotal} tax={tax} total={total} count={count}
|
||||
pendingCount={pendingCount}
|
||||
onBump={(id, d) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }}
|
||||
onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payTarget && (
|
||||
<Pos2PaySheet
|
||||
tableName={title}
|
||||
amountDue={orderAmountDue(payTarget) || total}
|
||||
onClose={() => setPayTarget(null)}
|
||||
onConfirm={confirmPay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ShoppingCart className="size-10 opacity-40" />
|
||||
<p>سبد خالی است</p>
|
||||
<p className="text-xs">برای افزودن، روی آیتمها بزنید</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{lines.map((l) => (
|
||||
<li key={l.menuItem.id} className="flex items-center gap-2 rounded-xl border border-border/70 p-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-1 font-medium">{l.menuItem.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => onBump(l.menuItem.id, -1)} className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95" aria-label="کم">
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
|
||||
<button type="button" onClick={() => onBump(l.menuItem.id, 1)} className="flex size-11 items-center justify-center rounded-lg bg-primary/10 text-primary hover:bg-primary/20 active:scale-95" aria-label="زیاد">
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => onRemove(l.menuItem.id)} className="flex size-9 items-center justify-center rounded-lg text-red-500 hover:bg-red-50" aria-label="حذف">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t border-border px-4 py-3 text-sm">
|
||||
<Row label="جمع" value={`${fmt(subtotal)} تومان`} />
|
||||
<Row label="مالیات ۹٪" value={`${fmt(tax)} تومان`} />
|
||||
<div className="flex items-center justify-between pt-1 text-lg font-extrabold">
|
||||
<span>مبلغ کل</span>
|
||||
<span className="text-primary">{fmt(total)} تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border p-3">
|
||||
<button type="button" disabled={pendingCount === 0} onClick={onSend}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]">
|
||||
<Send className="size-5" /> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""}
|
||||
</button>
|
||||
<button type="button" disabled={count === 0} onClick={onPay}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
|
||||
<CreditCard className="size-5" /> پرداخت
|
||||
</button>
|
||||
<button type="button" disabled className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground opacity-50">
|
||||
<Pause className="size-4" /> نگهداشتن
|
||||
</button>
|
||||
<button type="button" disabled={count === 0} onClick={onSplit}
|
||||
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
|
||||
<SplitSquareHorizontal className="size-4" /> تقسیم
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div dir="rtl" className="fixed inset-0 z-[60] flex items-stretch justify-center sm:items-center sm:p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative flex w-full flex-col bg-card shadow-2xl sm:max-w-md sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">پرداخت — {tableName}</p>
|
||||
<p className="text-xl font-extrabold text-primary">{fmt(amountDue)} تومان</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-2 hover:bg-accent" aria-label="بستن">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 p-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setMethod(t.id)}
|
||||
className={cn(
|
||||
"flex min-h-[52px] cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 text-sm font-bold transition-colors",
|
||||
method === t.id ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<t.icon className="size-5" /> {t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
|
||||
{method === "cash" && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl bg-muted/60 p-3">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>دریافتی</span>
|
||||
<span className="text-lg font-extrabold text-foreground">{fmt(received)} تومان</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{change >= 0 ? "باقیمانده (بازگشت)" : "کسری پرداخت"}</span>
|
||||
<span className={cn("text-lg font-extrabold", change >= 0 ? "text-emerald-600" : "text-red-500")}>
|
||||
{fmt(Math.abs(change))} تومان
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Chip onClick={() => setRecv(String(amountDue))}>مبلغ دقیق</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))}</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))}</Chip>
|
||||
<Chip onClick={() => setRecv(String(roundUp(500000)))}>{fmt(roundUp(500000))}</Chip>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => (
|
||||
<Key key={d} onClick={() => press(d)}>{fmt(Number(d))}</Key>
|
||||
))}
|
||||
<Key onClick={() => press("000")}>۰۰۰</Key>
|
||||
<Key onClick={() => press("0")}>۰</Key>
|
||||
<Key onClick={backspace} aria-label="حذف"><Delete className="size-5" /></Key>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "card" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<CreditCard className="size-8" />
|
||||
</div>
|
||||
<p className="font-medium">مبلغ روی دستگاه کارتخوان دریافت شود</p>
|
||||
<p className="text-2xl font-extrabold text-primary">{fmt(amountDue)} تومان</p>
|
||||
<p className="text-sm text-muted-foreground">پس از تأیید تراکنش، دکمهٔ زیر را بزنید</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "split" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">تقسیم بین</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{[2, 3, 4, 5, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setSplitN(n)}
|
||||
className={cn(
|
||||
"size-11 cursor-pointer rounded-xl border-2 font-bold transition-colors",
|
||||
splitN === n ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{fmt(n)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/60 p-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">سهم هر نفر</p>
|
||||
<p className="text-2xl font-extrabold text-primary">{fmt(perPerson)} تومان</p>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
کل مبلغ یکجا ثبت میشود؛ تقسیم صرفاً برای راهنمایی صندوقدار است.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canConfirm}
|
||||
onClick={confirm}
|
||||
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]"
|
||||
>
|
||||
<Check className="size-6" />
|
||||
{method === "cash" && change >= 0 && received > 0
|
||||
? `تأیید — بازگشت ${fmt(change)}`
|
||||
: `تأیید پرداخت ${fmt(amountDue)} تومان`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BusyOverlay() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/20">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 shadow-lg">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
<span className="font-medium">در حال پردازش…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex min-h-[56px] cursor-pointer items-center justify-center rounded-xl bg-muted text-xl font-bold transition-colors hover:bg-accent active:scale-95"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="cursor-pointer rounded-full border border-border bg-background px-3 py-2 text-sm font-medium transition-colors hover:border-primary hover:text-primary active:scale-95"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MenuCategory[]>(`/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<MenuItem[]> => {
|
||||
if (branchId) {
|
||||
const rows = await getBranchMenu(cafeId as string, branchId);
|
||||
return rows.map(branchMenuItemToMenuItem);
|
||||
}
|
||||
return apiGet<MenuItem[]>(`/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<string, MenuItem> {
|
||||
return useMemo(() => {
|
||||
const m = new Map<string, MenuItem>();
|
||||
for (const it of items ?? []) m.set(it.id, it);
|
||||
return m;
|
||||
}, [items]);
|
||||
}
|
||||
|
||||
export type { MenuCategory, MenuItem, TableBoardItem };
|
||||
Reference in New Issue
Block a user