2026-06-04 00:16:37 +03:30
|
|
|
|
"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.
|
2026-06-04 05:16:52 +03:30
|
|
|
|
// Mounted at /[locale]/pos (and /pos2). Design mirrors pos2-prototype.tsx.
|
2026-06-04 00:16:37 +03:30
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-06-04 05:16:52 +03:30
|
|
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
2026-06-04 00:16:37 +03:30
|
|
|
|
import {
|
|
|
|
|
|
Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal,
|
|
|
|
|
|
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
|
|
|
|
|
|
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw,
|
2026-06-04 05:16:52 +03:30
|
|
|
|
BadgePercent, Sparkles,
|
2026-06-04 00:16:37 +03:30
|
|
|
|
} 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";
|
2026-06-04 05:16:52 +03:30
|
|
|
|
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
|
|
|
|
|
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
|
|
|
|
|
|
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
|
2026-06-04 00:16:37 +03:30
|
|
|
|
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
|
|
|
|
|
|
|
|
|
|
|
|
const fmt = (n: number) => Math.round(n).toLocaleString("fa-IR");
|
|
|
|
|
|
const TAX = 0.09;
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const POINT_VALUE = 100; // 1 loyalty point = 100 toman (matches classic POS)
|
|
|
|
|
|
type Method = "Cash" | "Card" | "Credit";
|
|
|
|
|
|
type Payment = { method: Method; amount: number };
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
const errMsg = (e: unknown, fb: string) => (e instanceof ApiClientError ? e.message || fb : fb);
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const COUPON_FA: Record<string, string> = {
|
|
|
|
|
|
COUPON_NOT_FOUND: "کد تخفیف نامعتبر است",
|
|
|
|
|
|
COUPON_INACTIVE: "کد تخفیف غیرفعال است",
|
|
|
|
|
|
COUPON_EXPIRED: "کد تخفیف منقضی شده است",
|
|
|
|
|
|
COUPON_NOT_STARTED: "کد تخفیف هنوز فعال نشده است",
|
|
|
|
|
|
COUPON_LIMIT_REACHED: "سقف استفاده از این کد پر شده است",
|
|
|
|
|
|
COUPON_MIN_ORDER: "حداقل مبلغ سفارش رعایت نشده است",
|
|
|
|
|
|
CART_EMPTY: "سبد خالی است",
|
|
|
|
|
|
COUPON_REQUIRED: "کد تخفیف را وارد کنید",
|
|
|
|
|
|
COUPON_NO_DISCOUNT: "کد تخفیف نامعتبر است",
|
|
|
|
|
|
};
|
|
|
|
|
|
const couponErr = (e: unknown) =>
|
|
|
|
|
|
e instanceof ApiClientError ? COUPON_FA[e.code] ?? "کد تخفیف نامعتبر است" : "کد تخفیف نامعتبر است";
|
|
|
|
|
|
|
|
|
|
|
|
const POS_DEVICE_FA: Record<string, string> = {
|
|
|
|
|
|
posDeviceNotConfigured: "دستگاه کارتخوان تنظیم نشده است",
|
|
|
|
|
|
posDeviceConnectionFailed: "اتصال به کارتخوان ناموفق بود",
|
|
|
|
|
|
posDeviceTimeout: "زمان پاسخ کارتخوان به پایان رسید",
|
|
|
|
|
|
posDeviceRejected: "تراکنش توسط کارتخوان رد شد",
|
|
|
|
|
|
posDeviceError: "خطای کارتخوان",
|
|
|
|
|
|
};
|
|
|
|
|
|
const posDeviceMsg = (e: unknown) => posDeviceErrorMessage(e, (k) => POS_DEVICE_FA[k] ?? "خطای کارتخوان");
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
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 hydrateFromOrder = useCartStore((s) => s.hydrateFromOrder);
|
|
|
|
|
|
const clearSession = useCartStore((s) => s.clearSession);
|
|
|
|
|
|
const activeOrderNo = useCartStore((s) => s.activeOrderDisplayNumber);
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
|
2026-06-04 00:16:37 +03:30
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const [payLoyalty, setPayLoyalty] = useState(0);
|
2026-06-04 00:16:37 +03:30
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const discount = appliedCoupon?.discountAmount ?? 0;
|
|
|
|
|
|
const taxable = Math.max(0, subtotal - discount);
|
|
|
|
|
|
const tax = Math.round(taxable * TAX);
|
|
|
|
|
|
const total = taxable + tax;
|
2026-06-04 00:16:37 +03:30
|
|
|
|
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) {
|
|
|
|
|
|
const cart = useCartStore.getState();
|
|
|
|
|
|
order = {
|
|
|
|
|
|
id: cart.activeOrderId ?? "local_pending",
|
|
|
|
|
|
cafeId: cafeId as string,
|
|
|
|
|
|
orderType: "DineIn", status: "Open",
|
2026-06-04 05:16:52 +03:30
|
|
|
|
subtotal, taxTotal: tax, discountAmount: discount, total,
|
2026-06-04 00:16:37 +03:30
|
|
|
|
paidAmount: 0, createdAt: new Date().toISOString(), displayNumber: 0,
|
|
|
|
|
|
items: [], payments: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-04 05:16:52 +03:30
|
|
|
|
// loyalty points available on the attached customer (1 pt = 100 toman)
|
|
|
|
|
|
const customerId = useCartStore.getState().customerId;
|
|
|
|
|
|
let pts = 0;
|
|
|
|
|
|
if (customerId && !isLocalOrder(order.id)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const c = await apiGet<Customer>(`/api/cafes/${cafeId}/customers/${customerId}`);
|
|
|
|
|
|
pts = c.loyaltyPoints ?? 0;
|
|
|
|
|
|
} catch { pts = 0; }
|
|
|
|
|
|
}
|
|
|
|
|
|
setPayLoyalty(pts);
|
2026-06-04 00:16:37 +03:30
|
|
|
|
setPayTarget(order);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
notify.error(errMsg(e, "آمادهسازی پرداخت ناموفق بود"));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => {
|
2026-06-04 00:16:37 +03:30
|
|
|
|
if (!payTarget) return;
|
|
|
|
|
|
setBusy(true);
|
|
|
|
|
|
try {
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
|
|
|
|
|
|
const payBranchId = payTarget.branchId ?? branchId ?? undefined;
|
|
|
|
|
|
if (cardTotal > 0 && payBranchId) {
|
|
|
|
|
|
// push the card amount to the configured terminal (no-op/skip if none)
|
|
|
|
|
|
await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
|
|
|
|
|
|
}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, {
|
2026-06-04 05:16:52 +03:30
|
|
|
|
payments,
|
|
|
|
|
|
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
|
2026-06-04 00:16:37 +03:30
|
|
|
|
});
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const paid = payments.reduce((s, p) => s + p.amount, 0);
|
|
|
|
|
|
notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`);
|
2026-06-04 00:16:37 +03:30
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
|
|
|
|
|
backToBoard();
|
|
|
|
|
|
} catch (e) {
|
2026-06-04 05:16:52 +03:30
|
|
|
|
if (e instanceof ApiClientError && e.code.startsWith("POS_DEVICE")) {
|
|
|
|
|
|
notify.error(posDeviceMsg(e));
|
|
|
|
|
|
} else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") {
|
|
|
|
|
|
notify.error("برای پرداخت باید شیفت باز باشد");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
|
|
|
|
|
|
}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
} 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const ticketProps = {
|
|
|
|
|
|
cafeId, lines: live, subtotal, discount, tax, total, count, pendingCount,
|
|
|
|
|
|
onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); },
|
|
|
|
|
|
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
// ── 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"
|
2026-06-04 05:16:52 +03:30
|
|
|
|
onClick={() => router.push("/pos-classic")}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
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>
|
|
|
|
|
|
) : (
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<span className={cn("text-sm font-medium", reserved ? "text-amber-700" : "text-muted-foreground")}>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
{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>
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<aside className="hidden w-[380px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
|
|
|
|
|
|
<Ticket {...ticketProps} />
|
2026-06-04 00:16:37 +03:30
|
|
|
|
</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>
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<Ticket {...ticketProps} />
|
2026-06-04 00:16:37 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{payTarget && (
|
|
|
|
|
|
<Pos2PaySheet
|
|
|
|
|
|
tableName={title}
|
|
|
|
|
|
amountDue={orderAmountDue(payTarget) || total}
|
2026-06-04 05:16:52 +03:30
|
|
|
|
loyaltyPoints={payLoyalty}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
onClose={() => setPayTarget(null)}
|
|
|
|
|
|
onConfirm={confirmPay}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
// ── Customer + coupon controls (self-contained: reads/writes the cart store) ──
|
|
|
|
|
|
function Pos2Extras({ cafeId }: { cafeId: string }) {
|
|
|
|
|
|
const guestName = useCartStore((s) => s.guestName);
|
|
|
|
|
|
const guestPhone = useCartStore((s) => s.guestPhone);
|
|
|
|
|
|
const customerId = useCartStore((s) => s.customerId);
|
|
|
|
|
|
const setGuestName = useCartStore((s) => s.setGuestName);
|
|
|
|
|
|
const setGuestPhone = useCartStore((s) => s.setGuestPhone);
|
|
|
|
|
|
const setCustomer = useCartStore((s) => s.setCustomer);
|
|
|
|
|
|
const clearCustomer = useCartStore((s) => s.clearCustomer);
|
|
|
|
|
|
const couponCode = useCartStore((s) => s.couponCode);
|
|
|
|
|
|
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
|
|
|
|
|
|
const setCouponCode = useCartStore((s) => s.setCouponCode);
|
|
|
|
|
|
const setAppliedCoupon = useCartStore((s) => s.setAppliedCoupon);
|
|
|
|
|
|
const clearCoupon = useCartStore((s) => s.clearCoupon);
|
|
|
|
|
|
const subtotalFn = useCartStore((s) => s.subtotal);
|
|
|
|
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const apply = useMutation({
|
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
|
apiPost<{ couponId: string; code: string; discountAmount: number }>(
|
|
|
|
|
|
`/api/cafes/${cafeId}/coupons/validate`,
|
|
|
|
|
|
{ code: couponCode.trim(), subtotal: subtotalFn() },
|
|
|
|
|
|
),
|
|
|
|
|
|
onSuccess: (d) => {
|
|
|
|
|
|
setAppliedCoupon({ id: d.couponId, code: d.code, discountAmount: d.discountAmount });
|
|
|
|
|
|
setMsg(null);
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (e) => { setAppliedCoupon(null); setMsg(couponErr(e)); },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="shrink-0 space-y-2 border-t border-border px-3 py-2">
|
|
|
|
|
|
<PosCustomerPicker
|
|
|
|
|
|
cafeId={cafeId}
|
|
|
|
|
|
guestName={guestName}
|
|
|
|
|
|
guestPhone={guestPhone}
|
|
|
|
|
|
customerId={customerId}
|
|
|
|
|
|
onGuestNameChange={setGuestName}
|
|
|
|
|
|
onGuestPhoneChange={setGuestPhone}
|
|
|
|
|
|
onCustomerChange={setCustomer}
|
|
|
|
|
|
onClearCustomer={clearCustomer}
|
|
|
|
|
|
compact
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{appliedCoupon ? (
|
|
|
|
|
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-2.5 py-2 text-sm">
|
|
|
|
|
|
<span className="flex items-center gap-1.5 font-medium text-emerald-700">
|
|
|
|
|
|
<BadgePercent className="size-4" /> {appliedCoupon.code}
|
|
|
|
|
|
<span className="text-xs text-emerald-600">(−{fmt(appliedCoupon.discountAmount)})</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button type="button" onClick={() => { clearCoupon(); setMsg(null); }} className="rounded-md p-1 text-emerald-700 hover:bg-emerald-100" aria-label="حذف کوپن">
|
|
|
|
|
|
<X className="size-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
|
<BadgePercent className="pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={couponCode}
|
|
|
|
|
|
onChange={(e) => { setCouponCode(e.target.value); setMsg(null); }}
|
|
|
|
|
|
onKeyDown={(e) => { if (e.key === "Enter" && couponCode.trim() && !apply.isPending) apply.mutate(); }}
|
|
|
|
|
|
placeholder="کد تخفیف"
|
|
|
|
|
|
className="h-10 w-full rounded-lg border border-border bg-background pe-8 ps-3 text-sm outline-none focus:ring-2 focus:ring-primary/40"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!couponCode.trim() || apply.isPending}
|
|
|
|
|
|
onClick={() => apply.mutate()}
|
|
|
|
|
|
className="flex h-10 min-w-[64px] items-center justify-center rounded-lg bg-muted px-3 text-sm font-medium hover:bg-accent disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
{apply.isPending ? <Loader2 className="size-4 animate-spin" /> : "اعمال"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{msg ? <p className="text-xs text-red-500">{msg}</p> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
// ── Order ticket ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
type TicketLine = { menuItem: MenuItem; quantity: number };
|
|
|
|
|
|
function Ticket({
|
2026-06-04 05:16:52 +03:30
|
|
|
|
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit,
|
2026-06-04 00:16:37 +03:30
|
|
|
|
}: {
|
2026-06-04 05:16:52 +03:30
|
|
|
|
cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
|
|
|
|
|
|
count: number; pendingCount: number;
|
2026-06-04 00:16:37 +03:30
|
|
|
|
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>
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<Pos2Extras cafeId={cafeId} />
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<div className="space-y-1 border-t border-border px-4 py-3 text-sm">
|
|
|
|
|
|
<Row label="جمع" value={`${fmt(subtotal)} تومان`} />
|
2026-06-04 05:16:52 +03:30
|
|
|
|
{discount > 0 && <Row label="تخفیف" value={`−${fmt(discount)} تومان`} accent />}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
// ── Payment sheet (cash / card / split + numpad + loyalty) ───────────────────
|
2026-06-04 00:16:37 +03:30
|
|
|
|
function Pos2PaySheet({
|
2026-06-04 05:16:52 +03:30
|
|
|
|
tableName, amountDue, loyaltyPoints, onClose, onConfirm,
|
2026-06-04 00:16:37 +03:30
|
|
|
|
}: {
|
2026-06-04 05:16:52 +03:30
|
|
|
|
tableName: string; amountDue: number; loyaltyPoints: number;
|
2026-06-04 00:16:37 +03:30
|
|
|
|
onClose: () => void;
|
2026-06-04 05:16:52 +03:30
|
|
|
|
onConfirm: (payments: Payment[], loyaltyRedeem: number) => void;
|
2026-06-04 00:16:37 +03:30
|
|
|
|
}) {
|
2026-06-04 05:38:13 +03:30
|
|
|
|
const [method, setMethod] = useState<"cash" | "card" | "split">("card");
|
2026-06-04 00:16:37 +03:30
|
|
|
|
const [recv, setRecv] = useState("");
|
|
|
|
|
|
const [splitN, setSplitN] = useState(2);
|
2026-06-04 05:38:13 +03:30
|
|
|
|
const [splitMethods, setSplitMethods] = useState<Method[]>(["Card", "Card"]);
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const [useLoyalty, setUseLoyalty] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSplitMethods((prev) => Array.from({ length: splitN }, (_, i) => prev[i] ?? "Cash"));
|
|
|
|
|
|
}, [splitN]);
|
|
|
|
|
|
|
|
|
|
|
|
const maxRedeem = Math.min(loyaltyPoints, Math.floor(amountDue / POINT_VALUE));
|
|
|
|
|
|
const redeem = useLoyalty ? maxRedeem : 0;
|
|
|
|
|
|
const due = Math.max(0, amountDue - redeem * POINT_VALUE);
|
2026-06-04 00:16:37 +03:30
|
|
|
|
|
|
|
|
|
|
const received = Number(recv || 0);
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const change = received - due;
|
2026-06-04 00:16:37 +03:30
|
|
|
|
const press = (d: string) => setRecv((r) => (r + d).replace(/^0+(?=\d)/, "").slice(0, 12));
|
|
|
|
|
|
const backspace = () => setRecv((r) => r.slice(0, -1));
|
2026-06-04 05:16:52 +03:30
|
|
|
|
const roundUp = (step: number) => Math.ceil(due / step) * step;
|
|
|
|
|
|
|
|
|
|
|
|
const splitAmounts = useMemo(() => {
|
|
|
|
|
|
const base = Math.floor(due / splitN);
|
|
|
|
|
|
const arr = Array<number>(splitN).fill(base);
|
|
|
|
|
|
arr[splitN - 1] += due - base * splitN;
|
|
|
|
|
|
return arr;
|
|
|
|
|
|
}, [due, splitN]);
|
|
|
|
|
|
|
|
|
|
|
|
const canConfirm = method === "cash" ? received >= due || due === 0 : true;
|
|
|
|
|
|
|
|
|
|
|
|
const confirm = () => {
|
|
|
|
|
|
let payments: Payment[];
|
|
|
|
|
|
if (method === "cash") payments = [{ method: "Cash", amount: due }];
|
|
|
|
|
|
else if (method === "card") payments = [{ method: "Card", amount: due }];
|
|
|
|
|
|
else payments = splitAmounts.map((a, i) => ({ method: splitMethods[i] ?? "Cash", amount: a }));
|
|
|
|
|
|
onConfirm(payments.filter((p) => p.amount > 0), redeem);
|
|
|
|
|
|
};
|
2026-06-04 00:16:37 +03:30
|
|
|
|
|
2026-06-04 05:32:26 +03:30
|
|
|
|
// Card listed first (most common in Iran); Cash stays the pre-selected default.
|
2026-06-04 00:16:37 +03:30
|
|
|
|
const TABS = [
|
|
|
|
|
|
{ id: "card", name: "کارت", icon: CreditCard },
|
2026-06-04 05:32:26 +03:30
|
|
|
|
{ id: "cash", name: "نقدی", icon: Banknote },
|
2026-06-04 00:16:37 +03:30
|
|
|
|
{ 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} />
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<div className="relative flex max-h-svh w-full flex-col bg-card shadow-2xl sm:max-w-md sm:rounded-2xl">
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<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>
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<p className="text-xl font-extrabold text-primary">
|
|
|
|
|
|
{fmt(due)} تومان
|
|
|
|
|
|
{redeem > 0 && <span className="ms-2 text-sm font-medium text-muted-foreground line-through">{fmt(amountDue)}</span>}
|
|
|
|
|
|
</p>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-2">
|
|
|
|
|
|
{loyaltyPoints > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setUseLoyalty((v) => !v)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mb-3 flex w-full items-center justify-between rounded-xl border-2 px-3 py-2.5 text-start transition-colors",
|
|
|
|
|
|
useLoyalty ? "border-primary bg-primary/10" : "border-border hover:bg-accent",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex items-center gap-2 text-sm font-medium">
|
|
|
|
|
|
<Sparkles className={cn("size-4", useLoyalty ? "text-primary" : "text-muted-foreground")} />
|
|
|
|
|
|
امتیاز وفاداری ({fmt(loyaltyPoints)})
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">هر امتیاز ۱۰۰ تومان</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className={cn("text-sm font-bold", useLoyalty ? "text-primary" : "text-muted-foreground")}>
|
|
|
|
|
|
{useLoyalty ? `−${fmt(redeem * POINT_VALUE)}` : "استفاده"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-04 00:16:37 +03:30
|
|
|
|
{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">
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<Chip onClick={() => setRecv(String(due))}>مبلغ دقیق</Chip>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<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>
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<p className="font-medium">مبلغ به دستگاه کارتخوان ارسال میشود</p>
|
|
|
|
|
|
<p className="text-2xl font-extrabold text-primary">{fmt(due)} تومان</p>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<p className="text-sm text-muted-foreground">پس از تأیید تراکنش، دکمهٔ زیر را بزنید</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{method === "split" && (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">تعداد نفرات</span>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<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>
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<ul className="space-y-2">
|
|
|
|
|
|
{splitAmounts.map((amt, i) => (
|
|
|
|
|
|
<li key={i} className="flex items-center justify-between gap-2 rounded-xl border border-border/70 px-3 py-2">
|
|
|
|
|
|
<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(amt)} تومان
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex gap-1 rounded-lg border border-border p-0.5">
|
|
|
|
|
|
{(["Cash", "Card"] as const).map((m) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={m}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSplitMethods((prev) => prev.map((x, j) => (j === i ? m : x)))}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"rounded-md px-2.5 py-1 text-xs font-bold transition-colors",
|
|
|
|
|
|
(splitMethods[i] ?? "Cash") === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{m === "Cash" ? "نقدی" : "کارت"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
</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" />
|
2026-06-04 05:16:52 +03:30
|
|
|
|
{method === "cash" && change > 0 && received > 0
|
2026-06-04 00:16:37 +03:30
|
|
|
|
? `تأیید — بازگشت ${fmt(change)}`
|
2026-06-04 05:16:52 +03:30
|
|
|
|
: `تأیید پرداخت ${fmt(due)} تومان`}
|
2026-06-04 00:16:37 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 05:16:52 +03:30
|
|
|
|
function Row({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
2026-06-04 00:16:37 +03:30
|
|
|
|
return (
|
2026-06-04 05:16:52 +03:30
|
|
|
|
<div className={cn("flex items-center justify-between", accent ? "text-emerald-600" : "text-muted-foreground")}>
|
2026-06-04 00:16:37 +03:30
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
<span>{value}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|