chore(pos): fully remove the classic POS
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m52s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 19:42:32 +03:30
parent 46f962eb75
commit 5596e8dbc5
14 changed files with 5 additions and 3046 deletions
@@ -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 = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar />
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
{children}
</main>
</div>
);
return (
<CafeThemeProvider>
<div
className="flex h-screen min-h-0 overflow-hidden bg-background"
dir={isRtl ? "rtl" : "ltr"}
>
{isRtl ? (
<>
<Sidebar side="right" />
{mainColumn}
</>
) : (
<>
<Sidebar side="left" />
{mainColumn}
</>
)}
</div>
</CafeThemeProvider>
);
}
@@ -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 (
<Suspense fallback={null}>
<PosScreen />
</Suspense>
);
}
@@ -6,7 +6,6 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
* POS v2 layout — the redesigned terminal is full-screen (its own topbar + * POS v2 layout — the redesigned terminal is full-screen (its own topbar +
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming * order ticket), so no dashboard sidebar/topbar chrome here. Café theming
* still applies. Auth guarding comes from the parent (fullscreen) layout. * 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 }) { export default function PosLayout({ children }: { children: React.ReactNode }) {
return <CafeThemeProvider>{children}</CafeThemeProvider>; return <CafeThemeProvider>{children}</CafeThemeProvider>;
@@ -1,8 +1,7 @@
import { Pos2Screen } from "@/components/pos2/pos2-screen"; import { Pos2Screen } from "@/components/pos2/pos2-screen";
/** Default POS terminal — redesigned v2, wired to live data (menu, tables, /** POS terminal — wired to live data (menu, tables, orders, payments) via the
* orders, payments) via the shared cart store + offline submit pipeline. * shared cart store + offline submit pipeline. */
* The classic POS remains available at /[locale]/pos-classic. */
export default function PosPage() { export default function PosPage() {
return <Pos2Screen />; return <Pos2Screen />;
} }
@@ -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<string | null>(null);
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
const [filterTableId, setFilterTableId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [payMessage, setPayMessage] = useState<string | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null;
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
{ 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<Order[]>(`/api/cafes/${cafeId}/orders/open${qs}`);
},
enabled: !!cafeId,
refetchInterval: 15_000,
});
const { data: tables = [] } = useQuery({
queryKey: ["tables", cafeId],
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables`),
enabled: !!cafeId,
});
const { data: printSettings } = useQuery({
queryKey: ["branch-print-settings", cafeId, printSettingsBranchId],
queryFn: () =>
apiGet<BranchPrintSettings>(
`/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<Customer>(`/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<Order>(`/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 (
<div className="flex h-full min-h-0 w-full gap-4 overflow-hidden">
<Card className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<CardHeader className="shrink-0 space-y-3 pb-2">
<CardTitle className="text-base">{t("openOrders")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("payOpenOrdersHint")}</p>
<PosTableBoard
cafeId={cafeId}
numberLocale={numberLocale}
branchId={branchId}
mode="pay"
selectedTableId={selectedTableId}
selectedOrderId={selectedId}
onSelectTable={handleTableSelect}
/>
<LabeledField label={t("selectTable")} htmlFor="pay-table-filter">
<select
id="pay-table-filter"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={filterTableId ?? ""}
onChange={(e) => {
const id = e.target.value || null;
setFilterTableId(id);
setSelectedTableId(id);
if (!id) {
setSelectedId(null);
setPayMessage(null);
return;
}
void (async () => {
try {
const order = await apiGet<Order>(
`/api/cafes/${cafeId}/tables/${id}/active-order`
);
selectOrder(order, id);
} catch {
const match = openOrders.find((o) => o.tableId === id);
if (match) selectOrder(match, id);
else {
setSelectedId(null);
setPayMessage(t("noOrderOnTable"));
}
}
})();
}}
>
<option value="">{t("allTables")}</option>
{tables?.map((tbl) => (
<option key={tbl.id} value={tbl.id}>
{t("table")} {tbl.number}
</option>
))}
</select>
</LabeledField>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{t("payPickByName")}
</p>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchOpenOrder")}
className="h-9"
/>
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pt-0">
{payMessage && !selected ? (
<p className="mb-2 text-center text-sm text-amber-700">{payMessage}</p>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">...</p>
) : displayedOrders.length === 0 ? (
<p className="text-sm text-muted-foreground">
{filterTableId ? t("noOpenOrdersOnTable") : t("noOpenOrders")}
</p>
) : (
<ul className="space-y-2">
{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 (
<li key={order.id}>
<button
type="button"
onClick={() => selectOrder(order)}
className={cn(
"flex w-full flex-col gap-1 rounded-lg border border-border bg-card px-4 py-3 text-start shadow-sm transition hover:border-primary",
isSelected && "border-primary ring-1 ring-primary/30"
)}
>
<div className="flex w-full items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{label}</p>
{guestLine ? (
<p className="truncate text-xs text-[#0C447C]">
{guestLine}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{formatNumber(order.items.length, numberLocale)}{" "}
{t("itemsCount")} · {formatOrderNumber(order)}
</p>
</div>
<span className="shrink-0 text-sm font-bold text-primary">
{formatCurrency(order.total, numberLocale)}
</span>
</div>
</button>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<Card className="flex h-full min-h-0 w-[min(100%,20rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
<CardHeader className="shrink-0 space-y-2 pb-2">
<CardTitle className="text-lg">{t("payOrder")}</CardTitle>
{selected ? (
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2">
<p className="text-xs text-muted-foreground">{t("payFor")}</p>
<p className="text-base font-semibold text-[#0F6E56]">
{formatPosOrderLabel(selected, t("table"))}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("selectOrderToPay")}</p>
)}
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
{selected ? (
<>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain">
{selected.items.map((line) => (
<div
key={line.id}
className="flex justify-between gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm"
>
<span className="min-w-0 truncate">
{line.menuItemName} × {formatNumber(line.quantity, numberLocale)}
</span>
<span className="shrink-0 tabular-nums">
{formatCurrency(line.unitPrice * line.quantity, numberLocale)}
</span>
</div>
))}
</div>
<div className="shrink-0 space-y-2 border-t border-border pt-2">
<div className="flex justify-between text-sm">
<span>{t("subtotal")}</span>
<span>{formatCurrency(selected.subtotal, numberLocale)}</span>
</div>
{selected.discountAmount > 0 ? (
<div className="flex justify-between text-sm text-[#0F6E56]">
<span>{t("discount")}</span>
<span>-{formatCurrency(selected.discountAmount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm">
<span>{t("tax")}</span>
<span>{formatCurrency(selected.taxTotal, numberLocale)}</span>
</div>
<div className="flex justify-between text-base font-bold">
<span>{t("total")}</span>
<span>{formatCurrency(selected.total, numberLocale)}</span>
</div>
{(selected.paidAmount ?? 0) > 0 ? (
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t("paidSoFar")}</span>
<span>{formatCurrency(selected.paidAmount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm font-semibold text-primary">
<span>{t("remaining")}</span>
<span>{formatCurrency(effectiveRemaining, numberLocale)}</span>
</div>
{loyaltyDiscount > 0 ? (
<div className="flex justify-between text-sm text-[#0F6E56]">
<span>{t("loyaltyRedeemApplied")}</span>
<span>-{formatCurrency(loyaltyDiscount, numberLocale)}</span>
</div>
) : null}
{selected.customerId && payCustomer ? (
<div className="space-y-2 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/50 p-2">
<p className="text-xs font-medium text-[#0F6E56]">
{t("loyaltyBalance", {
points: formatNumber(payCustomer.loyaltyPoints, numberLocale),
})}
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
min={0}
max={maxLoyaltyRedeem}
value={loyaltyRedeem || ""}
onChange={(e) => {
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}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={maxLoyaltyRedeem === 0}
onClick={() => setLoyaltyRedeem(maxLoyaltyRedeem)}
>
{t("loyaltyUseMax")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t("loyaltyRedeemHint")}</p>
</div>
) : null}
<div className="space-y-2 pt-1">
<p className="text-xs font-medium text-muted-foreground">
{t("splitPayments")}
</p>
{paymentRows.map((row, idx) => (
<div key={idx} className="flex gap-2">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={row.method}
onChange={(e) => {
const method = e.target.value as PaymentRow["method"];
setPaymentRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, method } : r))
);
}}
>
<option value="Cash">{t("cash")}</option>
<option value="Card">{t("card")}</option>
<option value="Credit">{t("credit")}</option>
</select>
<Input
dir="ltr"
className="h-9 flex-1 text-end tabular-nums"
value={row.amount}
onChange={(e) => {
const amount = e.target.value;
setPaymentRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, amount } : r))
);
}}
placeholder="0"
/>
{paymentRows.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setPaymentRows((rows) => rows.filter((_, i) => i !== idx))
}
>
×
</Button>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() =>
setPaymentRows((rows) => [
...rows,
{ method: "Card", amount: "" },
])
}
>
{t("addPaymentRow")}
</Button>
</div>
{payMessage ? (
<p className="text-center text-sm text-primary">{payMessage}</p>
) : null}
{lastPaidOrderId ? (
<Button
type="button"
variant="outline"
className="w-full"
disabled={thermalPrint.isPending}
onClick={() => thermalPrint.mutate(lastPaidOrderId)}
>
{thermalPrint.isPending ? "..." : tPrint("printReceipt")}
</Button>
) : null}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setReceiptOrder(selected)}
>
{t("previewBill")}
</Button>
<Input
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder={t("cancelReasonPlaceholder")}
className="h-9"
maxLength={500}
/>
<Can permission="VoidOrder">
<Button
type="button"
variant="outline"
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
disabled={cancelOrder.isPending}
onClick={async () => {
if (!selected) return;
const ok = await confirmDialog({
description: t("cancelOrderConfirm"),
variant: "destructive",
confirmLabel: t("cancelOrder"),
});
if (!ok) return;
cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
}}
>
{cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button>
</Can>
<form
className="w-full"
onSubmit={(e) => {
e.preventDefault();
if (canPay && selected && !payOrder.isPending) {
payOrder.mutate(selected);
}
}}
>
<Can permission="HandlePayments">
<Button
type="submit"
className="w-full"
disabled={!canPay || payOrder.isPending}
>
{payOrder.isPending ? "..." : payButtonLabel}
</Button>
</Can>
</form>
</div>
</>
) : null}
</CardContent>
</Card>
{receiptOrder ? (
<PosSlipModal
variant="bill"
order={receiptOrder}
cafeName={cafeName}
logoUrl={cafeSettings?.logoUrl}
tagline={
[cafeSettings?.address, cafeSettings?.phone]
.filter(Boolean)
.join(" • ") || undefined
}
receiptHeader={printSettings?.receiptHeader}
receiptFooter={printSettings?.receiptFooter}
wifiPassword={printSettings?.wifiPassword}
paperWidthMm={printSettings?.paperWidthMm}
onClose={() => setReceiptOrder(null)}
/>
) : null}
</div>
);
}
@@ -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<QueueBoard>(`/api/cafes/${cafeId}/queue/today${query}`),
enabled: !!cafeId,
refetchInterval: 15_000,
});
const callNext = useMutation({
mutationFn: () =>
apiPost<QueueBoard>(`/api/cafes/${cafeId}/queue/call-next${query}`, {}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["queue-today"] }),
});
return (
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium text-primary">{t("title")}</span>
<span className="text-muted-foreground">
{t("nowServing")}:{" "}
<strong className="text-foreground tabular-nums">
{board?.nowServing != null
? formatNumber(board.nowServing, numberLocale)
: "—"}
</strong>
</span>
<span className="text-muted-foreground">
{t("lastIssued")}:{" "}
<strong className="tabular-nums">
{formatNumber(board?.lastIssued ?? 0, numberLocale)}
</strong>
</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={callNext.isPending || (board?.waitingCount ?? 0) === 0}
onClick={() => callNext.mutate()}
>
{t("callNext")}
</Button>
<Link
href="/queue"
className="text-xs text-primary underline-offset-2 hover:underline"
>
{t("issueNext")}
</Link>
</div>
);
}
@@ -1,2 +0,0 @@
export { PosReceiptModal, PosSlipModal } from "@/components/pos/pos-slip-modal";
export type { KitchenSlipLine } from "@/components/pos/pos-slip-modal";
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-[340px] rounded-xl border border-border bg-background p-4 shadow-xl">
{/* ── Print preview ──────────────────────────────────────────────── */}
<div
id="pos-slip-print-area"
className="mb-4 rounded-md border border-dashed border-border p-3"
>
{variant === "bill" && logoUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(logoUrl)}
alt=""
className="mx-auto mb-1.5 max-h-12 w-auto object-contain"
/>
)}
<div className="text-center text-lg font-extrabold leading-tight">{cafeName}</div>
{variant === "bill" && tagline && (
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
)}
{variant === "bill" && receiptHeader?.trim() && (
<div className="whitespace-pre-line text-center text-[11px] font-medium text-foreground/80">
{receiptHeader.trim()}
</div>
)}
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div>
<div className="mb-2 text-center text-xs text-muted-foreground">
{formattedDate}
</div>
<div className="text-xs">{metaRow}</div>
<div className="receipt-divider" />
{variant === "kitchen"
? kitchenLines.map((line, idx) => (
<div key={`${line.name}-${idx}`} className="receipt-row mb-1 text-xs">
<span>
{line.name} × {line.quantity}
{line.notes ? ` (${line.notes})` : ""}
</span>
</div>
))
: activeBillItems.map((item) => (
<div key={item.id} className="mb-1 text-xs">
<div className="receipt-row">
<span>
{item.menuItemName} × {item.quantity}
</span>
<span>
{formatCurrency(item.unitPrice * item.quantity, numberLocale)}
</span>
</div>
{item.notes && (
<div className="ps-2 text-[10px] text-muted-foreground">
{item.notes}
</div>
)}
</div>
))}
{variant === "bill" && (
<>
<div className="receipt-divider" />
<div className="receipt-row receipt-total">
<span>{t("total")}</span>
<span>{formatCurrency(order!.total, numberLocale)}</span>
</div>
{order!.payments?.map((p) => (
<div key={p.id} className="receipt-row mt-1 text-xs">
<span>{paymentKey(p.method)}</span>
<span>{formatCurrency(p.amount, numberLocale)}</span>
</div>
))}
<div className="receipt-divider" />
{wifiPassword?.trim() && (
<div className="text-center text-[11px]" dir="ltr">
WiFi: {wifiPassword.trim()}
</div>
)}
<div className="mt-2 text-center text-xs">
{receiptFooter?.trim() || t("thankYou")}
</div>
</>
)}
{variant === "kitchen" && (
<div className="mt-2 text-center text-[10px] text-muted-foreground">
{t("kitchenFooter")}
</div>
)}
</div>
{/* ── Actions ────────────────────────────────────────────────────── */}
<div className="flex gap-2">
<Button type="button" className="flex-1 gap-1.5" onClick={handlePrint}>
<Printer className="h-4 w-4" />
{t("print")}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={onClose}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
/** @deprecated Use PosSlipModal variant="bill" */
export function PosReceiptModal({
order,
cafeName,
onClose,
}: {
order: Order;
cafeName: string;
onClose: () => void;
}) {
return (
<PosSlipModal
variant="bill"
order={order}
cafeName={cafeName}
onClose={onClose}
/>
);
}
@@ -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<TableBoardItem["status"], string> = {
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<TableSectionDto[]>(
`${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<Order>(
`/api/cafes/${cafeId}/orders/${table.currentOrder.orderId}`
);
} catch {
try {
activeOrder = await apiGet<Order>(
`/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 (
<div key={table.id} className="flex shrink-0 flex-col gap-1">
<button
type="button"
disabled={cleaning || payDisabled}
onClick={() => void handleClick(table)}
className={cn(
"flex min-w-[4.5rem] flex-col items-center rounded-lg border-2 px-3 py-2 text-center transition",
isSelected ? selectedTableStyles : statusStyles[table.status],
(cleaning || payDisabled) &&
"cursor-not-allowed opacity-60"
)}
>
<span className="text-lg font-bold">{table.number}</span>
<span className="text-[10px]">{statusLabel(table.status)}</span>
{table.currentOrder && table.status === "Busy" ? (
<span className="mt-0.5 max-w-[4rem] truncate text-[10px] tabular-nums">
{formatCurrency(table.currentOrder.total, numberLocale)}
</span>
) : null}
{table.currentOrder?.guestLabel && table.status === "Busy" ? (
<span className="mt-0.5 max-w-[4.5rem] truncate text-[9px] opacity-90">
{table.currentOrder.guestLabel}
</span>
) : null}
{table.currentOrder?.source === "GuestQr" && table.status === "Busy" ? (
<span className="mt-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-900">
{tQr("guestQrBadge")}
</span>
) : null}
</button>
{mode === "order" ? (
<button
type="button"
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
onClick={(e) => {
e.stopPropagation();
setCleaning.mutate({
tableId: table.id,
tableBranchId: table.branchId,
isCleaning: !cleaning,
});
}}
>
{cleaning
? tTables("markReady")
: tTables("markCleaning")}
</button>
) : null}
</div>
);
};
return (
<div className="shrink-0 space-y-2 rounded-lg border border-border/80 bg-muted/20 p-3">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loadingTables")}</p>
) : null}
{isError ? (
<div className="space-y-2">
<p className="text-sm text-[#A32D2D]">{t("tablesLoadError")}</p>
<button
type="button"
className="text-xs text-[#0F6E56] underline-offset-2 hover:underline"
onClick={() => void refetch()}
>
{t("retryTables")}
</button>
</div>
) : null}
{!isLoading && !isError && tables.length === 0 ? (
<div className="space-y-2 rounded-md border border-dashed border-[#BA7517]/50 bg-amber-50/50 px-3 py-3">
<p className="text-sm text-[#BA7517]">{t("noTablesOnBoard")}</p>
<Link
href="/tables"
className="text-xs font-medium text-[#0F6E56] underline-offset-2 hover:underline"
>
{t("manageTablesLink")}
</Link>
</div>
) : null}
{!isLoading && !isError && tables.length > 0
? grouped.map((group) => (
<div key={group.key} className="space-y-1.5">
{group.label ? (
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{group.label}
</p>
) : null}
<div className="-mx-0.5 flex gap-2 overflow-x-auto px-1 py-1">
{group.tables.map(renderTableButton)}
</div>
</div>
))
: null}
</div>
);
}
@@ -12,7 +12,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal, Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair, 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, BadgePercent, Sparkles, Home, StickyNote,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -22,10 +22,10 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
import { useCartStore } from "@/lib/stores/cart.store"; import { useCartStore } from "@/lib/stores/cart.store";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client"; 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 { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print"; 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 { Can } from "@/components/auth/can";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types"; import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
@@ -387,13 +387,6 @@ export function Pos2Screen() {
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
{offlineBadge} {offlineBadge}
<button
type="button"
onClick={() => router.push("/pos-classic")}
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 <button
type="button" type="button"
onClick={openTakeaway} onClick={openTakeaway}