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
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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user