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 +
|
||||
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming
|
||||
* still applies. Auth guarding comes from the parent (fullscreen) layout.
|
||||
* The classic POS keeps its chrome under /pos-classic.
|
||||
*/
|
||||
export default function PosLayout({ children }: { children: React.ReactNode }) {
|
||||
return <CafeThemeProvider>{children}</CafeThemeProvider>;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Pos2Screen } from "@/components/pos2/pos2-screen";
|
||||
|
||||
/** Default POS terminal — redesigned v2, wired to live data (menu, tables,
|
||||
* orders, payments) via the shared cart store + offline submit pipeline.
|
||||
* The classic POS remains available at /[locale]/pos-classic. */
|
||||
/** POS terminal — wired to live data (menu, tables, orders, payments) via the
|
||||
* shared cart store + offline submit pipeline. */
|
||||
export default function PosPage() {
|
||||
return <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 {
|
||||
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
|
||||
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
|
||||
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw,
|
||||
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
|
||||
BadgePercent, Sparkles, Home, StickyNote,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -22,10 +22,10 @@ import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { useCartStore } from "@/lib/stores/cart.store";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order";
|
||||
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submit-order";
|
||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||
import { printReceipt } from "@/lib/api/print";
|
||||
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
|
||||
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
|
||||
import { Can } from "@/components/auth/can";
|
||||
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
|
||||
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
|
||||
@@ -387,13 +387,6 @@ export function Pos2Screen() {
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{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
|
||||
type="button"
|
||||
onClick={openTakeaway}
|
||||
|
||||
Reference in New Issue
Block a user