From 5596e8dbc5479a2cc82087afbfdc7f2218dbfada Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 19:42:32 +0330 Subject: [PATCH] chore(pos): fully remove the classic POS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POS v2 has been the default at /pos for a while; this deletes the old classic POS entirely: - removed the /pos-classic route and all classic-only components (pos-screen, pos-pay-panel, pos-table-board, pos-queue-bar, pos-receipt-modal, pos-slip-modal, pos-receipt-print.css) - relocated the two modules POS v2 still shared into the pos2 tree (lib/pos/submit-order → lib/pos2, components/pos/pos-customer-picker → pos2), so the components/pos and lib/pos folders are gone - dropped the now-dead "نسخه کلاسیک" (classic version) button + RotateCcw import from the POS v2 header, and updated stale comments POS v2 (/pos) is unchanged and fully self-contained. Typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../(fullscreen)/pos-classic/layout.tsx | 50 - .../(fullscreen)/pos-classic/page.tsx | 12 - .../app/[locale]/(fullscreen)/pos/layout.tsx | 1 - .../app/[locale]/(fullscreen)/pos/page.tsx | 5 +- .../src/components/pos/pos-pay-panel.tsx | 682 ------- .../src/components/pos/pos-queue-bar.tsx | 72 - .../src/components/pos/pos-receipt-modal.tsx | 2 - .../src/components/pos/pos-receipt-print.css | 76 - .../src/components/pos/pos-screen.tsx | 1575 ----------------- .../src/components/pos/pos-slip-modal.tsx | 273 --- .../src/components/pos/pos-table-board.tsx | 290 --- .../{pos => pos2}/pos-customer-picker.tsx | 0 .../src/components/pos2/pos2-screen.tsx | 13 +- .../src/lib/{pos => pos2}/submit-order.ts | 0 14 files changed, 5 insertions(+), 3046 deletions(-) delete mode 100644 web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx delete mode 100644 web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx delete mode 100644 web/dashboard/src/components/pos/pos-pay-panel.tsx delete mode 100644 web/dashboard/src/components/pos/pos-queue-bar.tsx delete mode 100644 web/dashboard/src/components/pos/pos-receipt-modal.tsx delete mode 100644 web/dashboard/src/components/pos/pos-receipt-print.css delete mode 100644 web/dashboard/src/components/pos/pos-screen.tsx delete mode 100644 web/dashboard/src/components/pos/pos-slip-modal.tsx delete mode 100644 web/dashboard/src/components/pos/pos-table-board.tsx rename web/dashboard/src/components/{pos => pos2}/pos-customer-picker.tsx (100%) rename web/dashboard/src/lib/{pos => pos2}/submit-order.ts (100%) diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx deleted file mode 100644 index 0690187..0000000 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/layout.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useLocale } from "next-intl"; -import { Sidebar } from "@/components/layout/sidebar"; -import { Topbar } from "@/components/layout/topbar"; -import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; - -/** - * Classic POS route layout — wraps the terminal in the standard dashboard - * chrome (collapsible sidebar + topbar) but keeps the main content area - * overflow-hidden so PosScreen can manage its own internal scrolling. - */ -export default function PosClassicLayout({ - children, -}: { - children: React.ReactNode; -}) { - const locale = useLocale(); - const isRtl = locale !== "en"; - - const mainColumn = ( -
- -
- {children} -
-
- ); - - return ( - -
- {isRtl ? ( - <> - - {mainColumn} - - ) : ( - <> - - {mainColumn} - - )} -
-
- ); -} diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx deleted file mode 100644 index 3a52fc3..0000000 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos-classic/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Suspense } from "react"; -import { PosScreen } from "@/components/pos/pos-screen"; - -/** Classic POS terminal — chrome (sidebar + topbar) is provided by layout.tsx. - * Kept as a fallback while POS v2 (at /pos) is piloted. */ -export default function PosClassicPage() { - return ( - - - - ); -} diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx index 6b107e1..370b590 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos/layout.tsx @@ -6,7 +6,6 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; * POS v2 layout — the redesigned terminal is full-screen (its own topbar + * order ticket), so no dashboard sidebar/topbar chrome here. Café theming * still applies. Auth guarding comes from the parent (fullscreen) layout. - * The classic POS keeps its chrome under /pos-classic. */ export default function PosLayout({ children }: { children: React.ReactNode }) { return {children}; diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx index 6e1d92d..6926072 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/pos/page.tsx @@ -1,8 +1,7 @@ import { Pos2Screen } from "@/components/pos2/pos2-screen"; -/** Default POS terminal — redesigned v2, wired to live data (menu, tables, - * orders, payments) via the shared cart store + offline submit pipeline. - * The classic POS remains available at /[locale]/pos-classic. */ +/** POS terminal — wired to live data (menu, tables, orders, payments) via the + * shared cart store + offline submit pipeline. */ export default function PosPage() { return ; } diff --git a/web/dashboard/src/components/pos/pos-pay-panel.tsx b/web/dashboard/src/components/pos/pos-pay-panel.tsx deleted file mode 100644 index 50e3420..0000000 --- a/web/dashboard/src/components/pos/pos-pay-panel.tsx +++ /dev/null @@ -1,682 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import * as signalR from "@microsoft/signalr"; -import { apiGet, apiPost, ApiClientError } from "@/lib/api/client"; -import { printErrorMessage, printReceipt } from "@/lib/api/print"; -import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; -import { PosSlipModal } from "@/components/pos/pos-slip-modal"; -import type { Customer, Order, Table, TableBoardItem } from "@/lib/api/types"; -import { formatCurrency, formatNumber } from "@/lib/format"; -import { formatPosOrderLabel } from "@/lib/pos-order-label"; -import { formatOrderNumber } from "@/lib/order-number"; -import { PosTableBoard } from "@/components/pos/pos-table-board"; -import { Can } from "@/components/auth/can"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { LabeledField } from "@/components/ui/labeled-field"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; -import { useCafeSettings } from "@/lib/hooks/use-cafe-settings"; -import { confirmPayLabel } from "@/lib/pos-confirm-pay-label"; -import { useConfirm } from "@/components/providers/confirm-provider"; - -type PaymentRow = { - method: "Cash" | "Card" | "Credit"; - amount: string; -}; - -type BranchPrintSettings = { - receiptHeader?: string | null; - receiptFooter?: string | null; - wifiPassword?: string | null; - paperWidthMm?: number; -}; - -type PosPayPanelProps = { - cafeId: string; - numberLocale: string; - branchId?: string | null; -}; - -export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPanelProps) { - const t = useTranslations("pos"); - const tPrint = useTranslations("print"); - const tDashboard = useTranslations("dashboard"); - const queryClient = useQueryClient(); - const confirmDialog = useConfirm(); - const { data: cafeSettings } = useCafeSettings(cafeId); - const cafeName = cafeSettings?.name ?? tDashboard("cafeName"); - const [selectedId, setSelectedId] = useState(null); - const [selectedTableId, setSelectedTableId] = useState(null); - const [filterTableId, setFilterTableId] = useState(null); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [payMessage, setPayMessage] = useState(null); - const [cancelReason, setCancelReason] = useState(""); - const [receiptOrder, setReceiptOrder] = useState(null); - const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null; - const [lastPaidOrderId, setLastPaidOrderId] = useState(null); - const [paymentRows, setPaymentRows] = useState([ - { method: "Cash", amount: "" }, - ]); - const [loyaltyRedeem, setLoyaltyRedeem] = useState(0); - - const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080"; - - useEffect(() => { - const id = setTimeout(() => setDebouncedSearch(search.trim()), 300); - return () => clearTimeout(id); - }, [search]); - - const { data: openOrders = [], isLoading } = useQuery({ - queryKey: ["orders-open", cafeId, debouncedSearch], - queryFn: () => { - const qs = debouncedSearch - ? `?search=${encodeURIComponent(debouncedSearch)}` - : ""; - return apiGet(`/api/cafes/${cafeId}/orders/open${qs}`); - }, - enabled: !!cafeId, - refetchInterval: 15_000, - }); - - const { data: tables = [] } = useQuery({ - queryKey: ["tables", cafeId], - queryFn: () => apiGet(`/api/cafes/${cafeId}/tables`), - enabled: !!cafeId, - }); - - const { data: printSettings } = useQuery({ - queryKey: ["branch-print-settings", cafeId, printSettingsBranchId], - queryFn: () => - apiGet( - `/api/cafes/${cafeId}/branches/${printSettingsBranchId}/print-settings` - ), - enabled: !!cafeId && !!printSettingsBranchId, - staleTime: 5 * 60 * 1000, - }); - - const displayedOrders = useMemo(() => { - if (!filterTableId) return openOrders; - return openOrders.filter((o) => o.tableId === filterTableId); - }, [openOrders, filterTableId]); - - useEffect(() => { - if (!cafeId) return; - const token = - typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null; - const connection = new signalR.HubConnectionBuilder() - .withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" }) - .withAutomaticReconnect() - .build(); - connection - .start() - .then(() => connection.invoke("JoinCafe", cafeId)) - .catch(() => undefined); - const refresh = () => { - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - }; - connection.on("TableStatusChanged", refresh); - connection.on("OrderStatusChanged", refresh); - return () => { - void connection.stop(); - }; - }, [cafeId, apiBase, queryClient]); - - const selectOrder = (order: Order, tableId?: string | null) => { - setSelectedId(order.id); - setSelectedTableId(tableId ?? order.tableId ?? null); - setPayMessage(null); - }; - - const handleTableSelect = (table: TableBoardItem, activeOrder: Order | null) => { - setFilterTableId(table.id); - setSelectedTableId(table.id); - if (activeOrder) { - selectOrder(activeOrder, table.id); - return; - } - setSelectedId(null); - setPayMessage(t("noOrderOnTable")); - }; - - const selected = openOrders.find((o) => o.id === selectedId) ?? null; - - const remaining = useMemo(() => { - if (!selected) return 0; - return Math.max(0, selected.total - (selected.paidAmount ?? 0)); - }, [selected]); - - const { data: payCustomer } = useQuery({ - queryKey: ["customer", cafeId, selected?.customerId], - queryFn: () => - apiGet(`/api/cafes/${cafeId}/customers/${selected!.customerId}`), - enabled: !!cafeId && !!selected?.customerId, - }); - - const maxLoyaltyRedeem = useMemo(() => { - if (!payCustomer || !selected) return 0; - const byDue = Math.floor(remaining / 100); - return Math.min(payCustomer.loyaltyPoints, byDue); - }, [payCustomer, selected, remaining]); - - const loyaltyDiscount = loyaltyRedeem * 100; - const effectiveRemaining = Math.max(0, remaining - loyaltyDiscount); - - useEffect(() => { - setLoyaltyRedeem(0); - setCancelReason(""); - }, [selected?.id]); - - useEffect(() => { - if (!selected) return; - setPaymentRows([{ method: "Cash", amount: String(effectiveRemaining) }]); - }, [selected?.id, selected?.total, selected?.paidAmount, effectiveRemaining]); - - const payOrder = useMutation({ - mutationFn: async (order: Order) => { - const payments = paymentRows - .map((row) => ({ - method: row.method, - amount: parseFloat(row.amount.replace(/,/g, "")) || 0, - })) - .filter((p) => p.amount > 0); - - if (payments.length === 0) throw new Error("no payments"); - - const cardTotal = payments - .filter((p) => p.method === "Card") - .reduce((s, p) => s + p.amount, 0); - - const payBranchId = order.branchId ?? branchId; - if (cardTotal > 0 && payBranchId) { - await requestPosPayment(cafeId, payBranchId, order.id, cardTotal); - } - - return apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, { - payments, - loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined, - }); - }, - onSuccess: async (_data, order) => { - setPayMessage(t("paySuccess")); - setLastPaidOrderId(order.id); - try { - const paid = await apiGet(`/api/cafes/${cafeId}/orders/${order!.id}`); - setReceiptOrder(paid); - } catch { - setReceiptOrder(order ?? null); - } - setSelectedId(null); - setSelectedTableId(null); - setFilterTableId(null); - setSearch(""); - setPaymentRows([{ method: "Cash", amount: "" }]); - setLoyaltyRedeem(0); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["customer", cafeId] }); - }, - onError: (err) => { - if (err instanceof ApiClientError) { - if (err.code.startsWith("POS_DEVICE")) { - setPayMessage(posDeviceErrorMessage(err, t)); - return; - } - if (err.code === "NO_OPEN_SHIFT") { - setPayMessage(t("payNeedsOpenShift")); - return; - } - if (err.code === "LOYALTY_NO_CUSTOMER") { - setPayMessage(t("loyaltyNoCustomer")); - return; - } - if (err.code === "LOYALTY_INSUFFICIENT_POINTS") { - setPayMessage(t("loyaltyInsufficient")); - return; - } - setPayMessage(err.message || t("payError")); - return; - } - setPayMessage(t("payError")); - }, - }); - - const cancelOrder = useMutation({ - mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) => - apiPost(`/api/cafes/${cafeId}/orders/${orderId}/cancel`, { - reason: reason.trim() || undefined, - }), - onSuccess: () => { - setPayMessage(t("cancelOrderSuccess")); - setCancelReason(""); - setSelectedId(null); - setSelectedTableId(null); - setFilterTableId(null); - setPaymentRows([{ method: "Cash", amount: "" }]); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - }, - onError: (err) => { - if (err instanceof ApiClientError) { - if (err.code === "ORDER_HAS_PAYMENTS") { - setPayMessage(t("cancelOrderHasPayments")); - return; - } - setPayMessage(err.message || t("cancelOrderError")); - return; - } - setPayMessage(t("cancelOrderError")); - }, - }); - - const paymentSum = paymentRows.reduce( - (s, row) => s + (parseFloat(row.amount.replace(/,/g, "")) || 0), - 0 - ); - const canPay = - selected && paymentSum > 0 && paymentSum <= effectiveRemaining + 0.01; - - const payButtonLabel = confirmPayLabel(paymentRows, t); - - const thermalPrint = useMutation({ - mutationFn: (orderId: string) => printReceipt(cafeId, orderId), - onSuccess: () => setPayMessage(tPrint("success")), - onError: (err) => setPayMessage(printErrorMessage(err, tPrint)), - }); - - return ( -
- - - {t("openOrders")} -

{t("payOpenOrdersHint")}

- - - - - - - -
-

- {t("payPickByName")} -

- setSearch(e.target.value)} - placeholder={t("searchOpenOrder")} - className="h-9" - /> -
-
- - {payMessage && !selected ? ( -

{payMessage}

- ) : null} - {isLoading ? ( -

...

- ) : displayedOrders.length === 0 ? ( -

- {filterTableId ? t("noOpenOrdersOnTable") : t("noOpenOrders")} -

- ) : ( -
    - {displayedOrders.map((order) => { - const label = formatPosOrderLabel(order, t("table")); - const isSelected = selectedId === order.id; - const guestLine = - order.guestName?.trim() || - order.customerName?.trim() || - order.guestPhone || - order.customerPhone; - return ( -
  • - -
  • - ); - })} -
- )} -
-
- - - - {t("payOrder")} - {selected ? ( -
-

{t("payFor")}

-

- {formatPosOrderLabel(selected, t("table"))} -

-
- ) : ( -

{t("selectOrderToPay")}

- )} -
- - {selected ? ( - <> -
- {selected.items.map((line) => ( -
- - {line.menuItemName} × {formatNumber(line.quantity, numberLocale)} - - - {formatCurrency(line.unitPrice * line.quantity, numberLocale)} - -
- ))} -
-
-
- {t("subtotal")} - {formatCurrency(selected.subtotal, numberLocale)} -
- {selected.discountAmount > 0 ? ( -
- {t("discount")} - -{formatCurrency(selected.discountAmount, numberLocale)} -
- ) : null} -
- {t("tax")} - {formatCurrency(selected.taxTotal, numberLocale)} -
-
- {t("total")} - {formatCurrency(selected.total, numberLocale)} -
- {(selected.paidAmount ?? 0) > 0 ? ( -
- {t("paidSoFar")} - {formatCurrency(selected.paidAmount, numberLocale)} -
- ) : null} -
- {t("remaining")} - {formatCurrency(effectiveRemaining, numberLocale)} -
- {loyaltyDiscount > 0 ? ( -
- {t("loyaltyRedeemApplied")} - -{formatCurrency(loyaltyDiscount, numberLocale)} -
- ) : null} - - {selected.customerId && payCustomer ? ( -
-

- {t("loyaltyBalance", { - points: formatNumber(payCustomer.loyaltyPoints, numberLocale), - })} -

-
- { - const n = Math.min( - maxLoyaltyRedeem, - Math.max(0, parseInt(e.target.value, 10) || 0) - ); - setLoyaltyRedeem(n); - }} - className="h-8 w-24 tabular-nums" - disabled={maxLoyaltyRedeem === 0} - /> - -
-

{t("loyaltyRedeemHint")}

-
- ) : null} - -
-

- {t("splitPayments")} -

- {paymentRows.map((row, idx) => ( -
- - { - const amount = e.target.value; - setPaymentRows((rows) => - rows.map((r, i) => (i === idx ? { ...r, amount } : r)) - ); - }} - placeholder="0" - /> - {paymentRows.length > 1 ? ( - - ) : null} -
- ))} - -
- - {payMessage ? ( -

{payMessage}

- ) : null} - {lastPaidOrderId ? ( - - ) : null} - - setCancelReason(e.target.value)} - placeholder={t("cancelReasonPlaceholder")} - className="h-9" - maxLength={500} - /> - - - -
{ - e.preventDefault(); - if (canPay && selected && !payOrder.isPending) { - payOrder.mutate(selected); - } - }} - > - - - -
-
- - ) : null} -
-
- - {receiptOrder ? ( - setReceiptOrder(null)} - /> - ) : null} -
- ); -} diff --git a/web/dashboard/src/components/pos/pos-queue-bar.tsx b/web/dashboard/src/components/pos/pos-queue-bar.tsx deleted file mode 100644 index 54fcd50..0000000 --- a/web/dashboard/src/components/pos/pos-queue-bar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslations, useLocale } from "next-intl"; -import { apiGet, apiPost } from "@/lib/api/client"; -import type { QueueBoard } from "@/lib/api/types"; -import { formatNumber } from "@/lib/format"; -import { Button } from "@/components/ui/button"; -import { Link } from "@/i18n/routing"; - -type PosQueueBarProps = { - cafeId: string; - branchId: string | null; -}; - -export function PosQueueBar({ cafeId, branchId }: PosQueueBarProps) { - const t = useTranslations("queue"); - const locale = useLocale(); - const numberLocale = locale === "en" ? "en-US" : "fa-IR"; - const queryClient = useQueryClient(); - const query = branchId ? `?branchId=${encodeURIComponent(branchId)}` : ""; - - const { data: board } = useQuery({ - queryKey: ["queue-today", cafeId, branchId], - queryFn: () => apiGet(`/api/cafes/${cafeId}/queue/today${query}`), - enabled: !!cafeId, - refetchInterval: 15_000, - }); - - const callNext = useMutation({ - mutationFn: () => - apiPost(`/api/cafes/${cafeId}/queue/call-next${query}`, {}), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: ["queue-today"] }), - }); - - return ( -
- {t("title")} - - {t("nowServing")}:{" "} - - {board?.nowServing != null - ? formatNumber(board.nowServing, numberLocale) - : "—"} - - - - {t("lastIssued")}:{" "} - - {formatNumber(board?.lastIssued ?? 0, numberLocale)} - - - - - {t("issueNext")} → - -
- ); -} diff --git a/web/dashboard/src/components/pos/pos-receipt-modal.tsx b/web/dashboard/src/components/pos/pos-receipt-modal.tsx deleted file mode 100644 index ea1a755..0000000 --- a/web/dashboard/src/components/pos/pos-receipt-modal.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { PosReceiptModal, PosSlipModal } from "@/components/pos/pos-slip-modal"; -export type { KitchenSlipLine } from "@/components/pos/pos-slip-modal"; diff --git a/web/dashboard/src/components/pos/pos-receipt-print.css b/web/dashboard/src/components/pos/pos-receipt-print.css deleted file mode 100644 index f71458e..0000000 --- a/web/dashboard/src/components/pos/pos-receipt-print.css +++ /dev/null @@ -1,76 +0,0 @@ -/* - * pos-receipt-print.css - * - * Fallback @media print styles for the slip preview panel. - * The real thermal print job goes through thermal-print.ts (iframe) — - * these rules only fire if window.print() is called on the main page directly. - */ - -@media print { - /* 80 mm roll — height tracks the content, zero blank tail */ - @page { - size: 80mm auto; - margin: 0; - } - - /* Force RTL and thermal width on the document root */ - html { - direction: rtl !important; - width: 80mm !important; - } - - /* Hide everything except the slip */ - body > * { - display: none !important; - } - - /* The modal overlay needs to be block but transparent */ - body > *:has(#pos-slip-print-area) { - display: block !important; - position: static !important; - background: transparent !important; - } - - #pos-slip-print-area, - #pos-slip-print-area * { - visibility: visible !important; - } - - #pos-slip-print-area { - display: block !important; - position: static !important; - width: 80mm !important; - margin: 0 !important; - padding: 3mm 4mm !important; - border: none !important; - border-radius: 0 !important; - box-shadow: none !important; - } -} - -/* ── Screen preview styles ───────────────────────────────────────────────── */ - -#pos-slip-print-area { - width: 100%; - max-width: 76mm; - font-family: "Vazirmatn", "Tahoma", "Arial", sans-serif; - font-size: 12px; - direction: rtl; - text-align: right; -} - -.receipt-divider { - border-top: 1px dashed #000; - margin: 3mm 0; -} - -.receipt-row { - display: flex; - justify-content: space-between; - gap: 0.5rem; -} - -.receipt-total { - font-weight: bold; - font-size: 14px; -} diff --git a/web/dashboard/src/components/pos/pos-screen.tsx b/web/dashboard/src/components/pos/pos-screen.tsx deleted file mode 100644 index a47169b..0000000 --- a/web/dashboard/src/components/pos/pos-screen.tsx +++ /dev/null @@ -1,1575 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslations, useLocale } from "next-intl"; -import { - ChevronLeft, - ChevronRight, - Minus, - Package, - Plus, - Search, - ShoppingCart, - Trash2, - UtensilsCrossed, - Users, - Video, - X, -} from "lucide-react"; -import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client"; -import type { - MenuCategory, - MenuItem, - Order, - Table, - TableBoardItem, - QueueTicket, -} from "@/lib/api/types"; -import { useAuthStore } from "@/lib/stores/auth.store"; -import { useCafeSettings } from "@/lib/hooks/use-cafe-settings"; -import { useCartStore, type CartItem } from "@/lib/stores/cart.store"; -import type { OrderType } from "@/lib/stores/cart.store"; -import { formatCurrency, formatNumber } from "@/lib/format"; -import { formatOrderNumber } from "@/lib/order-number"; -import { iranMobileForApi } from "@/lib/phone"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useIsRtl } from "@/lib/use-is-rtl"; -import { useBranchStore } from "@/lib/stores/branch.store"; -import { getOrCreateTerminalId } from "@/lib/terminal"; -import { MenuItemLabels } from "@/components/menu/menu-item-labels"; -import { CategoryVisual } from "@/components/menu/category-visual"; -import { getMenuPrimaryName, menuItemMatchesSearch } from "@/lib/menu-display"; -import { Input } from "@/components/ui/input"; -import { LabeledField } from "@/components/ui/labeled-field"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { MenuItemMedia } from "@/components/menu/menu-item-media"; -import { - buildCategoryNameMap, - inferMenuItemKind, -} from "@/lib/menu-item-image"; -import { Can } from "@/components/auth/can"; -import { PosPayPanel } from "@/components/pos/pos-pay-panel"; -import { PosTableBoard } from "@/components/pos/pos-table-board"; -import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; -import { PosSlipModal, type KitchenSlipLine } from "@/components/pos/pos-slip-modal"; -import { PosQueueBar } from "@/components/pos/pos-queue-bar"; -import { - branchMenuItemToMenuItem, - getBranchMenu, -} from "@/lib/api/branch-menu"; -import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; -import { orderAmountDue, submitOrderToApi, isLocalOrder } from "@/lib/pos/submit-order"; -import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; -import { useConfirm } from "@/components/providers/confirm-provider"; - -const TAX_RATE = 0.09; - -function buildKitchenLines( - pending: { menuItemId: string; quantity: number; notes?: string }[], - cartItems: CartItem[] -): KitchenSlipLine[] { - return pending.map((p) => { - const line = cartItems.find((i) => i.menuItem.id === p.menuItemId); - return { - name: line?.menuItem.name ?? p.menuItemId, - quantity: p.quantity, - notes: p.notes, - }; - }); -} - -function cartToKitchenLines(cartItems: CartItem[]): KitchenSlipLine[] { - return cartItems - .filter((i) => !i.isVoided && i.quantity > 0) - .map((i) => ({ - name: i.menuItem.name, - quantity: i.quantity, - notes: i.notes, - })); -} - -// ─── Order Type Picker ─────────────────────────────────────────────────────── - -function OrderTypePicker({ - onSelect, - t, -}: { - onSelect: (type: OrderType) => void; - t: ReturnType>; -}) { - return ( -
-
-

- {t("orderTypePicker")} -

-
- -
- {/* Table */} - - - {/* Counter */} - - - {/* Takeaway */} - -
-
- ); -} - -// ─── Order Type Badge ───────────────────────────────────────────────────────── - -function OrderTypeBadge({ - orderType, - tableNumber, - onClick, - t, -}: { - orderType: OrderType; - tableNumber?: number | string | null; - onClick?: () => void; - t: ReturnType>; -}) { - const label = - orderType === "table" - ? `${t("table")} ${tableNumber ?? "—"}` - : orderType === "counter" - ? t("counterBadge") - : t("takeawayBadge"); - - const cls = - orderType === "table" - ? "border-primary bg-primary/10 text-primary hover:bg-primary/20" - : orderType === "counter" - ? "border-blue-400 bg-blue-50 text-blue-700 hover:bg-blue-100" - : "border-amber-400 bg-amber-50 text-amber-700 hover:bg-amber-100"; - - return ( - - ); -} - -// ─── Main Component ─────────────────────────────────────────────────────────── - -export function PosScreen() { - const t = useTranslations("pos"); - const tQueue = useTranslations("queue"); - const tErrors = useTranslations("errors"); - const tCommon = useTranslations("common"); - const tDashboard = useTranslations("dashboard"); - const locale = useLocale(); - const isRtl = useIsRtl(); - const numberLocale = locale === "en" ? "en-US" : "fa-IR"; - const cafeId = useAuthStore((s) => s.user?.cafeId); - const { data: cafeSettings } = useCafeSettings(cafeId); - const cafeName = cafeSettings?.name ?? tDashboard("cafeName"); - const userRole = useAuthStore((s) => s.user?.role); - const isManager = userRole === "Manager" || userRole === "Owner"; - const branchId = useBranchStore((s) => s.branchId); - const setBranchId = useBranchStore((s) => s.setBranchId); - const queryClient = useQueryClient(); - const confirmDialog = useConfirm(); - const isOnline = useSyncQueueStore((s) => s.isOnline); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const reservationId = searchParams.get("reservationId"); - const reservationGuest = searchParams.get("guestName"); - const urlOrderId = searchParams.get("orderId"); - - const [kitchenSlip, setKitchenSlip] = useState<{ - lines: KitchenSlipLine[]; - orderId?: string; - tableNumber?: string | number | null; - guestName?: string | null; - } | null>(null); - const [posMode, setPosMode] = useState<"order" | "pay">("order"); - const [showTablePicker, setShowTablePicker] = useState(false); - const [showTransferPicker, setShowTransferPicker] = useState(false); - - const { - items, - addItem, - removeItem, - updateQty, - setNotes, - couponCode, - appliedCoupon, - setCouponCode, - setAppliedCoupon, - clearCoupon, - tableId, - setTableId, - orderType, - setOrderType, - activeOrderId, - activeOrderDisplayNumber, - setActiveOrderId, - customerId, - guestName, - setGuestName, - guestPhone, - setGuestPhone, - setCustomer, - clearCustomer, - hydrateFromOrder, - getPendingLines, - clearCart, - clearSession, - subtotal, - } = useCartStore(); - - const syncUrl = useCallback( - (tid: string | null, oid: string | null) => { - const params = new URLSearchParams(searchParams.toString()); - if (tid) params.set("tableId", tid); - else params.delete("tableId"); - if (oid) params.set("orderId", oid); - else params.delete("orderId"); - const qs = params.toString(); - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); - }, - [pathname, router, searchParams] - ); - - // Restore tableId + infer orderType from URL params - useEffect(() => { - const tid = searchParams.get("tableId"); - if (tid) { - setTableId(tid); - if (!orderType) setOrderType("table"); - } - }, [searchParams, setTableId, setOrderType, orderType]); - - useEffect(() => { - if (urlOrderId) setActiveOrderId(urlOrderId); - }, [urlOrderId, setActiveOrderId]); - - useEffect(() => { - if (reservationGuest) setGuestName(reservationGuest); - }, [reservationGuest, setGuestName]); - - useEffect(() => { - if (!cafeId) return; - apiPost(`/api/cafes/${cafeId}/terminals/register`, { - terminalId: getOrCreateTerminalId(), - }).catch(() => undefined); - }, [cafeId]); - - const [selectedCategory, setSelectedCategory] = useState("all"); - const [itemSearch, setItemSearch] = useState(""); - const [orderMessage, setOrderMessage] = useState(null); - const [couponMessage, setCouponMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - const { data: branches = [] } = useQuery({ - queryKey: ["branches", cafeId], - queryFn: () => - apiGet<{ id: string; name: string }[]>(`/api/cafes/${cafeId}/branches`), - enabled: !!cafeId, - }); - - useEffect(() => { - if (branches.length === 0) return; - const valid = branchId && branches.some((b) => b.id === branchId); - if (!valid) setBranchId(branches[0]!.id); - }, [branches, branchId, setBranchId]); - - const orderBranchId = useMemo(() => { - if (branches.length === 0) return null; - if (branchId && branches.some((b) => b.id === branchId)) return branchId; - return branches[0]?.id ?? null; - }, [branchId, branches]); - - const { data: categories, isLoading: loadingCategories } = useQuery({ - queryKey: ["menu-categories", cafeId], - queryFn: () => apiGet(`/api/cafes/${cafeId}/menu/categories`), - enabled: !!cafeId, - }); - - const { data: globalMenuItems, isLoading: loadingGlobalItems } = useQuery({ - queryKey: ["menu-items", cafeId, selectedCategory], - queryFn: () => { - const qs = - selectedCategory !== "all" ? `?categoryId=${selectedCategory}` : ""; - return apiGet(`/api/cafes/${cafeId}/menu/items${qs}`); - }, - enabled: !!cafeId && !orderBranchId, - }); - - const { data: branchMenuRows, isLoading: loadingBranchMenu } = useQuery({ - queryKey: ["branch-menu", cafeId, orderBranchId, selectedCategory], - queryFn: () => getBranchMenu(cafeId!, orderBranchId!), - enabled: !!cafeId && !!orderBranchId, - }); - - const menuItems = useMemo(() => { - if (orderBranchId && branchMenuRows) { - const mapped = branchMenuRows.map(branchMenuItemToMenuItem); - if (selectedCategory === "all") return mapped; - return mapped.filter((i) => i.categoryId === selectedCategory); - } - return globalMenuItems; - }, [orderBranchId, branchMenuRows, globalMenuItems, selectedCategory]); - - const loadingItems = orderBranchId ? loadingBranchMenu : loadingGlobalItems; - - const { data: allMenuItems, isLoading: loadingAllCatalog } = useQuery({ - queryKey: ["branch-menu-all", cafeId, orderBranchId], - queryFn: async () => { - if (orderBranchId && cafeId) { - const rows = await getBranchMenu(cafeId, orderBranchId); - return rows.map(branchMenuItemToMenuItem); - } - return apiGet(`/api/cafes/${cafeId}/menu/items`); - }, - enabled: !!cafeId, - }); - - const menuById = useMemo(() => { - const map = new Map(); - for (const m of allMenuItems ?? menuItems ?? []) { - map.set(m.id, m); - } - return map; - }, [allMenuItems, menuItems]); - - // Hydrate from URL order - useEffect(() => { - if (!cafeId || !urlOrderId || menuById.size === 0) return; - if (activeOrderId === urlOrderId && items.length > 0) return; - apiGet(`/api/cafes/${cafeId}/orders/${urlOrderId}`) - .then((order) => { - hydrateFromOrder(order, menuById); - if (order.tableId) { - setTableId(order.tableId); - setOrderType("table"); - } else if (!orderType) { - setOrderType("counter"); - } - }) - .catch(() => undefined); - }, [ - cafeId, - urlOrderId, - menuById, - activeOrderId, - items.length, - hydrateFromOrder, - setTableId, - setOrderType, - orderType, - ]); - - const sessionPatchRef = useRef | null>(null); - useEffect(() => { - if (!cafeId || !activeOrderId) return; - if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current); - sessionPatchRef.current = setTimeout(() => { - apiPatch(`/api/cafes/${cafeId}/orders/${activeOrderId}/session`, { - guestName: guestName.trim() || null, - guestPhone: iranMobileForApi(guestPhone) ?? null, - customerId: customerId ?? null, - }).catch(() => undefined); - }, 600); - return () => { - if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current); - }; - }, [guestName, guestPhone, customerId, activeOrderId, cafeId]); - - const handleTableSelect = useCallback( - (table: TableBoardItem, activeOrder: Order | null) => { - setShowTablePicker(false); - - if (activeOrder) { - setTableId(table.id); - hydrateFromOrder(activeOrder, menuById); - syncUrl(table.id, activeOrder.id); - setOrderMessage(t("sessionActive")); - return; - } - - const hadOpenSession = !!useCartStore.getState().activeOrderId; - if (hadOpenSession) { - clearSession(); - } else { - setActiveOrderId(null); - } - - setTableId(table.id); - syncUrl(table.id, null); - setOrderMessage(null); - }, - [ - setTableId, - hydrateFromOrder, - menuById, - syncUrl, - setActiveOrderId, - clearSession, - t, - ] - ); - - // Handle order type selection from the picker - const handleOrderTypeSelect = useCallback( - (type: OrderType) => { - setOrderType(type); - if (type === "table") { - setShowTablePicker(true); - } - }, - [setOrderType] - ); - - // Go back to type picker (with clear) - const handleBackToTypePicker = useCallback(() => { - clearSession(); - syncUrl(null, null); - setOrderMessage(null); - setCouponMessage(null); - }, [clearSession, syncUrl]); - - const tablesQuery = orderBranchId - ? `?branchId=${encodeURIComponent(orderBranchId)}` - : ""; - - const { data: tables } = useQuery({ - queryKey: ["tables", cafeId, orderBranchId], - queryFn: () => apiGet(`/api/cafes/${cafeId}/tables${tablesQuery}`), - enabled: !!cafeId, - }); - - const { data: boardTables = [] } = useQuery({ - queryKey: ["tables-board", cafeId, orderBranchId, "transfer"], - queryFn: () => - apiGet( - `/api/cafes/${cafeId}/tables/board${tablesQuery}` - ), - enabled: !!cafeId && showTransferPicker, - }); - - const voidItemMutation = useMutation({ - mutationFn: async (orderItemId: string) => { - if (!cafeId || !activeOrderId) throw new Error("no session"); - return apiPatch( - `/api/cafes/${cafeId}/orders/${activeOrderId}/items/${orderItemId}/void`, - {} - ); - }, - onSuccess: async (order) => { - hydrateFromOrder(order, menuById); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - }, - onError: () => setOrderMessage(t("voidError")), - }); - - const transferTableMutation = useMutation({ - mutationFn: async (targetTableId: string) => { - if (!cafeId || !activeOrderId) throw new Error("no session"); - return apiPost(`/api/cafes/${cafeId}/orders/${activeOrderId}/transfer`, { - targetTableId, - }); - }, - onSuccess: async (order) => { - setShowTransferPicker(false); - hydrateFromOrder(order, menuById); - if (order.tableId) setTableId(order.tableId); - syncUrl(order.tableId ?? null, order.id); - setOrderMessage(t("transferSuccess")); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - }, - onError: (err: Error) => { - const code = err instanceof ApiClientError ? err.code : ""; - if (code === "TABLE_OCCUPIED") setOrderMessage(t("tableOccupied")); - else if (code === "TABLE_CLEANING") setOrderMessage(t("tableNotAvailable")); - else setOrderMessage(t("transferError")); - }, - }); - - const handleVoidItem = async (orderItemId: string) => { - const ok = await confirmDialog({ - description: t("confirmVoid"), - variant: "destructive", - confirmLabel: tCommon("confirm"), - }); - if (!ok) return; - voidItemMutation.mutate(orderItemId); - }; - - const freeTransferTables = boardTables.filter( - (tbl) => tbl.status === "Free" && tbl.id !== tableId - ); - - const itemSearchQuery = itemSearch.trim(); - const isSearchingItems = itemSearchQuery.length > 0; - - const catalogForSearch = useMemo( - () => allMenuItems ?? menuItems ?? [], - [allMenuItems, menuItems] - ); - - const filteredItems = useMemo(() => { - const base = isSearchingItems ? catalogForSearch : (menuItems ?? []); - return base.filter((i) => { - if (!i.isAvailable) return false; - if (!isSearchingItems) return true; - return menuItemMatchesSearch(i, itemSearchQuery, locale); - }); - }, [catalogForSearch, menuItems, isSearchingItems, itemSearchQuery, locale]); - - const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems; - - const categoryNameById = useMemo( - () => buildCategoryNameMap(categories ?? []), - [categories] - ); - - const itemVisualKind = (item: MenuItem) => - inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId)); - - const sub = subtotal(); - const discount = appliedCoupon?.discountAmount ?? 0; - const taxable = Math.max(0, sub - discount); - const tax = Math.round(taxable * TAX_RATE); - const total = taxable + tax; - - const couponErrorKey = (code: string) => { - const map: Record = { - COUPON_NOT_FOUND: "couponInvalid", - COUPON_INACTIVE: "couponInvalid", - COUPON_EXPIRED: "couponExpired", - COUPON_NOT_STARTED: "couponNotStarted", - COUPON_LIMIT_REACHED: "couponLimitReached", - COUPON_MIN_ORDER: "couponMinOrder", - CART_EMPTY: "couponCartEmpty", - COUPON_REQUIRED: "couponRequired", - COUPON_NO_DISCOUNT: "couponInvalid", - }; - return map[code] ?? "couponInvalid"; - }; - - const validateCoupon = useMutation({ - mutationFn: async () => { - if (!cafeId) throw new Error("no cafe"); - return apiPost<{ - couponId: string; - code: string; - discountAmount: number; - }>(`/api/cafes/${cafeId}/coupons/validate`, { - code: couponCode.trim(), - subtotal: subtotal(), - }); - }, - onSuccess: (data) => { - setAppliedCoupon({ - id: data.couponId, - code: data.code, - discountAmount: data.discountAmount, - }); - setCouponMessage({ - type: "success", - text: t("couponApplied", { - code: data.code, - amount: formatCurrency(data.discountAmount, numberLocale), - }), - }); - }, - onError: (err: Error) => { - setAppliedCoupon(null); - const code = err instanceof ApiClientError ? err.code : "COUPON_NOT_FOUND"; - setCouponMessage({ type: "error", text: t(couponErrorKey(code)) }); - }, - }); - - const openKitchenSlip = useCallback(() => { - const pending = getPendingLines(); - const lines = - pending.length > 0 - ? buildKitchenLines(pending, items) - : cartToKitchenLines(items); - if (lines.length === 0) return; - setKitchenSlip({ - lines, - orderId: activeOrderId ?? undefined, - tableNumber: tables?.find((tbl) => tbl.id === tableId)?.number ?? null, - guestName: guestName.trim() || null, - }); - }, [getPendingLines, items, activeOrderId, tables, tableId, guestName]); - - const submitOrder = useMutation({ - mutationFn: async () => { - if (!cafeId || items.length === 0) throw new Error("empty"); - const cart = useCartStore.getState(); - const pending = cart.getPendingLines(); - if (pending.length === 0) throw new Error("nothing pending"); - const kitchenLines = buildKitchenLines(pending, cart.items); - const order = await submitOrderToApi({ - cafeId, - orderBranchId: orderBranchId ?? undefined, - cart, - reservationId, - cartItems: cart.items, - }); - return { order, kitchenLines }; - }, - onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }), - onSuccess: ({ order, kitchenLines }, _, context) => { - hydrateFromOrder(order, menuById); - syncUrl(order.tableId ?? tableId, order.id); - setCouponMessage(null); - if (kitchenLines.length > 0) { - setKitchenSlip({ - lines: kitchenLines, - orderId: order.id, - tableNumber: order.tableNumber ?? null, - guestName: order.guestName ?? (guestName.trim() || null), - }); - } - const baseMsg = context?.hadSession ? t("addToOrder") : t("orderPlaced"); - setOrderMessage(baseMsg); - void apiPost(`/api/cafes/${cafeId}/queue/next`, { - branchId: orderBranchId, - customerLabel: - order.guestName ?? (guestName.trim() || undefined), - orderId: order.id, - }) - .then((ticket) => { - setOrderMessage( - `${baseMsg} · ${t("queueNumber", { number: ticket.number })}` - ); - queryClient.invalidateQueries({ queryKey: ["queue-today"] }); - }) - .catch(() => undefined); - queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - if (reservationId) { - queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }); - } - }, - onError: (err: Error) => { - if (err instanceof ApiClientError) { - if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") { - setActiveOrderId(null); - syncUrl(tableId, null); - } - const key = - err.code === "TABLE_NOT_AVAILABLE" - ? "tableNotAvailable" - : err.code === "TABLE_OCCUPIED" - ? "tableOccupied" - : err.code === "PLAN_LIMIT_REACHED" - ? "planLimit" - : err.code === "INVALID_ORDER" - ? "orderInvalid" - : err.code === "ORDER_NOT_OPEN" - ? "orderNotOpen" - : err.code === "ORDER_NOT_FOUND" - ? "orderNotOpen" - : err.code === "VALIDATION_ERROR" - ? "orderValidation" - : "orderError"; - setOrderMessage( - err.code === "VALIDATION_ERROR" - ? err.message - : key === "planLimit" - ? tErrors("planLimit") - : t(key) - ); - return; - } - if (err.message === "nothing pending") { - setOrderMessage(t("nothingPending")); - return; - } - setOrderMessage(t("orderError")); - }, - }); - - const submitOrderAndPay = useMutation({ - mutationFn: async () => { - if (!cafeId || items.length === 0) throw new Error("empty"); - const cart = useCartStore.getState(); - const pending = cart.getPendingLines(); - if (pending.length === 0) throw new Error("nothing pending"); - const kitchenLines = buildKitchenLines(pending, cart.items); - const order = await submitOrderToApi({ - cafeId, - orderBranchId: orderBranchId ?? undefined, - cart, - reservationId, - cartItems: cart.items, - }); - const due = orderAmountDue(order); - if (isLocalOrder(order.id)) return { order, kitchenLines }; - const payBranchId = order.branchId ?? orderBranchId; - if (due > 0) { - if (!payBranchId) throw new Error("no branch"); - await requestPosPayment(cafeId, payBranchId, order.id, due); - await apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, { - payments: [{ method: "Card", amount: due }], - }); - } - return { order, kitchenLines }; - }, - onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }), - onSuccess: ({ order, kitchenLines }, _, context) => { - hydrateFromOrder(order, menuById); - syncUrl(order.tableId ?? tableId, order.id); - setCouponMessage(null); - if (kitchenLines.length > 0) { - setKitchenSlip({ - lines: kitchenLines, - orderId: order.id, - tableNumber: order.tableNumber ?? null, - guestName: order.guestName ?? (guestName.trim() || null), - }); - } - const baseMsg = context?.hadSession ? t("orderPaidAdd") : t("orderPaidNew"); - setOrderMessage(baseMsg); - queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - if (reservationId) { - queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }); - } - }, - onError: (err: Error) => { - if (err instanceof ApiClientError) { - if ( - err.code === "POS_DEVICE_CONNECTION_FAILED" || - err.code === "POS_DEVICE_TIMEOUT" || - err.code === "POS_DEVICE_REJECTED" || - err.code.startsWith("POS_DEVICE") - ) { - setOrderMessage(posDeviceErrorMessage(err, t)); - return; - } - if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") { - setActiveOrderId(null); - syncUrl(tableId, null); - } - const key = - err.code === "TABLE_NOT_AVAILABLE" - ? "tableNotAvailable" - : err.code === "TABLE_OCCUPIED" - ? "tableOccupied" - : err.code === "PLAN_LIMIT_REACHED" - ? "planLimit" - : err.code === "INVALID_ORDER" - ? "orderInvalid" - : err.code === "ORDER_NOT_OPEN" - ? "orderNotOpen" - : err.code === "ORDER_NOT_FOUND" - ? "orderNotOpen" - : err.code === "VALIDATION_ERROR" - ? "orderValidation" - : "orderError"; - setOrderMessage( - err.code === "VALIDATION_ERROR" - ? err.message - : key === "planLimit" - ? tErrors("planLimit") - : t(key) - ); - return; - } - if (err.message === "nothing pending") { - setOrderMessage(t("nothingPending")); - return; - } - if (err.message === "no branch") { - setOrderMessage(t("posDeviceNoBranch")); - return; - } - setOrderMessage(t("payError")); - }, - }); - - const pendingCount = getPendingLines().length; - const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending; - - // Counter/takeaway orders don't require a table - const canSubmitOrder = - pendingCount > 0 && - (orderType === "counter" || - orderType === "takeaway" || - !!tableId || - !!customerId || - guestName.trim().length > 0); - - // Show order type picker when there's no active session - const showTypePicker = - posMode === "order" && - orderType === null && - items.length === 0 && - !urlOrderId && - !activeOrderId; - - // The current table number for display - const currentTableNumber = tables?.find((tbl) => tbl.id === tableId)?.number; - - if (!cafeId) return null; - - return ( -
- {/* ── Top bar: mode switcher ─────────────────────────────────────────── */} -
- - - -
-
- - {/* ── Pay mode ──────────────────────────────────────────────────────── */} - {posMode === "pay" ? ( - - ) : showTypePicker ? ( - /* ── Order type picker ──────────────────────────────────────────── */ - - ) : ( - /* ── Order screen ───────────────────────────────────────────────── */ -
- {/* Order screen header */} -
- {/* Back / new order button */} - - - {/* Order type badge — tappable to change table */} - {orderType ? ( - setShowTablePicker(true) - : undefined - } - t={t} - /> - ) : null} - - {/* Active order number */} - {activeOrderId ? ( - - # - {activeOrderDisplayNumber - ? String(activeOrderDisplayNumber) - : formatOrderNumber({ id: activeOrderId })} - - ) : null} - -
- - {/* Queue bar */} - {cafeId ? ( - - ) : null} -
- - {/* Reservation banner */} - {reservationId && reservationGuest ? ( -
- {t("reservationBanner", { name: reservationGuest })} -
- ) : null} - - {/* ── Main split: menu + cart ──────────────────────────────────── */} -
- {/* ── Menu panel ────────────────────────────────────────────── */} -
- {/* Search bar */} -
- - setItemSearch(e.target.value)} - placeholder={t("searchItemsPlaceholder")} - aria-label={t("searchItems")} - className="h-10 ps-9 pe-9" - /> - {itemSearch ? ( - - ) : null} -
- - {/* Horizontal scrolling category tabs — no wrap */} -
- - {loadingCategories - ? Array.from({ length: 4 }).map((_, i) => ( - - )) - : categories?.map((c) => ( - - ))} -
- - {/* Product grid — bigger cards with image area */} -
-
- {showItemsLoading - ? Array.from({ length: 8 }).map((_, i) => ( - - )) - : filteredItems.length === 0 && isSearchingItems - ? ( -

- {t("searchNoResults")} -

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

- {t("emptyCart")} -

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

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

-
- -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {isManager && - line.orderItemId && - !line.isVoided && - activeOrderId ? ( - - - - ) : null} - {!line.isVoided ? ( - <> - - - {formatNumber(line.quantity, numberLocale)} - - - - - ) : null} -
-
- {!line.isVoided && ( - setNotes(line.menuItem.id, e.target.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - placeholder={t("itemNotePlaceholder")} - className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40" - /> - )} -
- )) - )} -
- - {/* Totals + actions */} -
- {/* Coupon row */} -
- - { - setCouponCode(e.target.value); - if (couponMessage) setCouponMessage(null); - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !appliedCoupon && - couponCode.trim() && - !validateCoupon.isPending - ) { - e.preventDefault(); - validateCoupon.mutate(); - } - }} - disabled={!!appliedCoupon} - dir="ltr" - className="h-8 text-end text-sm" - /> - - {appliedCoupon ? ( - - ) : ( - - )} -
- - {couponMessage ? ( -

- {couponMessage.text} -

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

- {t("offlineQueueNotice")} -

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

- {orderMessage} -

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

- {t("needTableOrName")} -

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

{t("selectTableBoard")}

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

{t("selectTargetTable")}

-
- {freeTransferTables.length === 0 ? ( -

- {t("noOrderOnTable")} -

- ) : ( - freeTransferTables.map((tbl) => ( - - )) - )} -
- -
-
- ) : null} -
- ); -} diff --git a/web/dashboard/src/components/pos/pos-slip-modal.tsx b/web/dashboard/src/components/pos/pos-slip-modal.tsx deleted file mode 100644 index ea7f987..0000000 --- a/web/dashboard/src/components/pos/pos-slip-modal.tsx +++ /dev/null @@ -1,273 +0,0 @@ -"use client"; - -import { useTranslations, useLocale } from "next-intl"; -import { Printer } from "lucide-react"; -import type { Order } from "@/lib/api/types"; -import { formatCurrency } from "@/lib/format"; -import { formatOrderNumber } from "@/lib/order-number"; -import { buildThermalDocument, printThermal } from "@/lib/thermal-print"; -import { resolveMediaUrl } from "@/lib/api/client"; -import { Button } from "@/components/ui/button"; -import "./pos-receipt-print.css"; - -export type KitchenSlipLine = { - name: string; - quantity: number; - notes?: string; -}; - -type PosSlipModalProps = { - variant: "kitchen" | "bill"; - cafeName: string; - /** Café logo for receipt branding. */ - logoUrl?: string; - /** Address / phone line shown under the café name on the bill. */ - tagline?: string; - /** Custom header note from branch print settings (bill only). */ - receiptHeader?: string | null; - /** Custom footer note from branch print settings (bill only). */ - receiptFooter?: string | null; - /** WiFi password printed near the bill footer. */ - wifiPassword?: string | null; - /** Paper width in mm — 58 or 80 (default 80). */ - paperWidthMm?: number; - onClose: () => void; - /** Full order for customer bill */ - order?: Order; - /** Kitchen ticket lines (new items or full order) */ - kitchenLines?: KitchenSlipLine[]; - tableNumber?: string | number | null; - orderId?: string; - guestName?: string | null; - createdAt?: string; -}; - -export function PosSlipModal({ - variant, - cafeName, - logoUrl, - tagline, - receiptHeader, - receiptFooter, - wifiPassword, - paperWidthMm, - onClose, - order, - kitchenLines = [], - tableNumber, - orderId, - guestName, - createdAt, -}: PosSlipModalProps) { - const t = useTranslations("receipt"); - const locale = useLocale(); - const numberLocale = locale === "en" ? "en-US" : "fa-IR"; - - const dateSource = order?.createdAt ?? createdAt ?? new Date().toISOString(); - const formattedDate = new Intl.DateTimeFormat( - locale === "en" ? "en-US" : "fa-IR", - { dateStyle: "short", timeStyle: "short" } - ).format(new Date(dateSource)); - - const table = order?.tableNumber ?? tableNumber ?? "—"; - const orderNo = order - ? formatOrderNumber(order) - : orderId - ? formatOrderNumber({ id: orderId }) - : null; - const guest = order?.guestName ?? guestName; - - const activeBillItems = order?.items.filter((i) => !i.isVoided) ?? []; - - const paymentKey = (method: string) => { - const m = method.toLowerCase(); - if (m === "cash") return t("payment.cash"); - if (m === "card") return t("payment.card"); - if (m === "credit") return t("payment.credit"); - return method; - }; - - // ── Build meta row ───────────────────────────────────────────────────────── - const metaParts: string[] = []; - metaParts.push(`${t("table")}: ${table}`); - if (orderNo) metaParts.push(`${t("order")}: #${orderNo}`); - if (guest) metaParts.push(`${t("guest")}: ${guest}`); - const metaRow = metaParts.join(" | "); - - // ── Print handler ───────────────────────────────────────────────────────── - const handlePrint = () => { - const slipData = - variant === "kitchen" - ? { - cafeName, - title: t("kitchenTitle"), - date: formattedDate, - metaRow, - lines: kitchenLines.map((l) => ({ - name: l.name, - quantity: l.quantity, - notes: l.notes, - })), - footer: t("kitchenFooter"), - locale, - } - : { - cafeName, - logoUrl: resolveMediaUrl(logoUrl), - tagline, - header: receiptHeader?.trim() || undefined, - wifi: wifiPassword?.trim() || undefined, - paperWidthMm, - title: t("billTitle"), - date: formattedDate, - metaRow, - lines: activeBillItems.map((item) => ({ - name: item.menuItemName, - quantity: item.quantity, - price: formatCurrency(item.unitPrice * item.quantity, numberLocale), - notes: item.notes, - })), - totals: { - total: formatCurrency(order!.total, numberLocale), - payments: order!.payments?.map((p) => ({ - method: paymentKey(p.method), - amount: formatCurrency(p.amount, numberLocale), - })), - }, - footer: receiptFooter?.trim() || t("thankYou"), - locale, - }; - - printThermal(buildThermalDocument(slipData)); - }; - - // ── Render ───────────────────────────────────────────────────────────────── - return ( -
-
- - {/* ── Print preview ──────────────────────────────────────────────── */} -
- {variant === "bill" && logoUrl && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
{cafeName}
- {variant === "bill" && tagline && ( -
{tagline}
- )} - {variant === "bill" && receiptHeader?.trim() && ( -
- {receiptHeader.trim()} -
- )} -
- {variant === "kitchen" ? t("kitchenTitle") : t("billTitle")} -
-
- {formattedDate} -
-
{metaRow}
- -
- - {variant === "kitchen" - ? kitchenLines.map((line, idx) => ( -
- - {line.name} × {line.quantity} - {line.notes ? ` (${line.notes})` : ""} - -
- )) - : activeBillItems.map((item) => ( -
-
- - {item.menuItemName} × {item.quantity} - - - {formatCurrency(item.unitPrice * item.quantity, numberLocale)} - -
- {item.notes && ( -
- {item.notes} -
- )} -
- ))} - - {variant === "bill" && ( - <> -
-
- {t("total")} - {formatCurrency(order!.total, numberLocale)} -
- {order!.payments?.map((p) => ( -
- {paymentKey(p.method)} - {formatCurrency(p.amount, numberLocale)} -
- ))} -
- {wifiPassword?.trim() && ( -
- WiFi: {wifiPassword.trim()} -
- )} -
- {receiptFooter?.trim() || t("thankYou")} -
- - )} - - {variant === "kitchen" && ( -
- {t("kitchenFooter")} -
- )} -
- - {/* ── Actions ────────────────────────────────────────────────────── */} -
- - -
-
-
- ); -} - -/** @deprecated Use PosSlipModal variant="bill" */ -export function PosReceiptModal({ - order, - cafeName, - onClose, -}: { - order: Order; - cafeName: string; - onClose: () => void; -}) { - return ( - - ); -} diff --git a/web/dashboard/src/components/pos/pos-table-board.tsx b/web/dashboard/src/components/pos/pos-table-board.tsx deleted file mode 100644 index f0c3672..0000000 --- a/web/dashboard/src/components/pos/pos-table-board.tsx +++ /dev/null @@ -1,290 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import * as signalR from "@microsoft/signalr"; -import { apiGet, apiPatch } from "@/lib/api/client"; -import { - branchTablesPath, - fetchCafeTableBoard, - setTableCleaning, - type TableSectionDto, -} from "@/lib/api/branch-tables"; -import type { Order, TableBoardItem } from "@/lib/api/types"; -import { formatCurrency } from "@/lib/format"; -import { Link } from "@/i18n/routing"; -import { cn } from "@/lib/utils"; - -const statusStyles: Record = { - Free: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/40 hover:border-[#0F6E56]", - Busy: "bg-blue-50 text-[#0C447C] border-blue-300 hover:border-blue-500", - Reserved: "bg-amber-50 text-[#BA7517] border-amber-300 hover:border-amber-500", - Cleaning: "bg-slate-100 text-slate-600 border-slate-300 hover:border-slate-500", -}; - -const selectedTableStyles = - "border-primary bg-primary/10 text-primary shadow-[0_0_0_2px_hsl(var(--primary)/0.35)] z-[1]"; - -type PosTableBoardProps = { - cafeId: string; - numberLocale: string; - selectedTableId: string | null; - selectedOrderId?: string | null; - branchId: string | null; - mode?: "order" | "pay"; - onSelectTable: (table: TableBoardItem, activeOrder: Order | null) => void; -}; - -function groupPosTables( - tables: TableBoardItem[], - sections: TableSectionDto[], - noSectionLabel: string -): { key: string; label: string | null; tables: TableBoardItem[] }[] { - const groups: { key: string; label: string | null; tables: TableBoardItem[] }[] = []; - for (const sec of sections) { - const items = tables.filter((t) => t.sectionId === sec.id); - if (items.length > 0) { - groups.push({ key: sec.id, label: sec.name, tables: items }); - } - } - const unassigned = tables.filter((t) => !t.sectionId); - if (unassigned.length > 0) { - groups.push({ key: "_none", label: noSectionLabel, tables: unassigned }); - } - if (groups.length === 0 && tables.length > 0) { - groups.push({ key: "_all", label: null, tables }); - } - return groups; -} - -export function PosTableBoard({ - cafeId, - numberLocale, - selectedTableId, - selectedOrderId = null, - branchId, - mode = "order", - onSelectTable, -}: PosTableBoardProps) { - const t = useTranslations("pos"); - const tQr = useTranslations("qrMenu"); - const tTables = useTranslations("tables"); - const queryClient = useQueryClient(); - const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080"; - - const { - data: tables = [], - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: ["tables-board", cafeId, branchId, "pos"], - queryFn: () => fetchCafeTableBoard(cafeId, branchId), - enabled: !!cafeId, - }); - - const { data: sections = [] } = useQuery({ - queryKey: ["table-sections", cafeId, branchId], - queryFn: () => - apiGet( - `${branchTablesPath(cafeId, branchId!)}/sections` - ), - enabled: !!cafeId && !!branchId, - retry: false, - }); - - const grouped = useMemo( - () => groupPosTables(tables, sections, tTables("noSection")), - [tables, sections, tTables] - ); - - const refresh = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); - queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); - }, [queryClient, cafeId]); - - useEffect(() => { - if (!cafeId) return; - const token = - typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null; - const connection = new signalR.HubConnectionBuilder() - .withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" }) - .withAutomaticReconnect() - .build(); - connection - .start() - .then(() => connection.invoke("JoinCafe", cafeId)) - .catch(() => undefined); - connection.on("TableStatusChanged", refresh); - connection.on("OrderCreated", refresh); - connection.on("OrderStatusChanged", refresh); - return () => { - void connection.stop(); - }; - }, [cafeId, apiBase, refresh]); - - const setCleaning = useMutation({ - mutationFn: ({ - tableId, - isCleaning, - tableBranchId, - }: { - tableId: string; - isCleaning: boolean; - tableBranchId: string; - }) => setTableCleaning(cafeId, tableId, isCleaning, branchId ?? tableBranchId), - onSuccess: () => refresh(), - }); - - const handleClick = async (table: TableBoardItem) => { - if (table.isCleaning ?? table.status === "Cleaning") return; - if (mode === "pay" && table.status !== "Busy") return; - - let activeOrder: Order | null = null; - if (table.status === "Busy" && table.currentOrder?.orderId) { - try { - activeOrder = await apiGet( - `/api/cafes/${cafeId}/orders/${table.currentOrder.orderId}` - ); - } catch { - try { - activeOrder = await apiGet( - `/api/cafes/${cafeId}/tables/${table.id}/active-order` - ); - } catch { - activeOrder = null; - } - } - } - onSelectTable(table, activeOrder); - }; - - const statusLabel = (status: TableBoardItem["status"]) => { - switch (status) { - case "Free": - return tTables("status.free"); - case "Busy": - return tTables("status.occupied"); - case "Reserved": - return tTables("status.reserved"); - case "Cleaning": - return tTables("status.cleaning"); - } - }; - - const title = - mode === "pay" ? t("paySelectTable") : t("selectTableBoard"); - - const renderTableButton = (table: TableBoardItem) => { - const cleaning = table.isCleaning ?? table.status === "Cleaning"; - const isSelected = - selectedTableId === table.id || - (selectedOrderId != null && - table.currentOrder?.orderId === selectedOrderId); - const payDisabled = - mode === "pay" && - (table.status === "Cleaning" || table.status !== "Busy"); - return ( -
- - {mode === "order" ? ( - - ) : null} -
- ); - }; - - return ( -
-

{title}

- - {isLoading ? ( -

{t("loadingTables")}

- ) : null} - - {isError ? ( -
-

{t("tablesLoadError")}

- -
- ) : null} - - {!isLoading && !isError && tables.length === 0 ? ( -
-

{t("noTablesOnBoard")}

- - {t("manageTablesLink")} - -
- ) : null} - - {!isLoading && !isError && tables.length > 0 - ? grouped.map((group) => ( -
- {group.label ? ( -

- {group.label} -

- ) : null} -
- {group.tables.map(renderTableButton)} -
-
- )) - : null} -
- ); -} diff --git a/web/dashboard/src/components/pos/pos-customer-picker.tsx b/web/dashboard/src/components/pos2/pos-customer-picker.tsx similarity index 100% rename from web/dashboard/src/components/pos/pos-customer-picker.tsx rename to web/dashboard/src/components/pos2/pos-customer-picker.tsx diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx index 06397dd..43f40f1 100644 --- a/web/dashboard/src/components/pos2/pos2-screen.tsx +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -12,7 +12,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal, X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair, - Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw, + Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, BadgePercent, Sparkles, Home, StickyNote, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -22,10 +22,10 @@ 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 { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submit-order"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { printReceipt } from "@/lib/api/print"; -import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; +import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker"; import { Can } from "@/components/auth/can"; import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types"; import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; @@ -387,13 +387,6 @@ export function Pos2Screen() {
{offlineBadge} -