feat(dashboard): Next.js 16 merchant panel with offline POS and PWA

Complete merchant dashboard upgrade:

Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors

Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect

PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,314 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { cn } from "@/lib/utils";
type CustomerMode = "existing" | "new";
type PosCustomerPickerProps = {
cafeId: string;
guestName: string;
guestPhone: string;
customerId: string | null;
onGuestNameChange: (value: string) => void;
onGuestPhoneChange: (value: string) => void;
onCustomerChange: (customer: Customer | null) => void;
onClearCustomer: () => void;
/** Collapsed header in POS sidebar to leave room for cart lines */
compact?: boolean;
};
export function PosCustomerPicker({
cafeId,
guestName,
guestPhone,
customerId,
onGuestNameChange,
onGuestPhoneChange,
onCustomerChange,
onClearCustomer,
compact = false,
}: PosCustomerPickerProps) {
const t = useTranslations("pos");
const tCrm = useTranslations("crm");
const tCommon = useTranslations("common");
const [expanded, setExpanded] = useState(!compact);
const [mode, setMode] = useState<CustomerMode>(customerId ? "existing" : "new");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [highlightIndex, setHighlightIndex] = useState(-1);
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const pickCustomer = (c: Customer) => {
onCustomerChange(c);
setSearch("");
setMessage(null);
setHighlightIndex(-1);
};
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
} else if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
pickCustomer(results[highlightIndex]!);
}
};
useEffect(() => {
if (customerId) setMode("existing");
}, [customerId]);
const { data: results = [], isFetching } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch],
queryFn: () =>
apiGet<Customer[]>(
`/api/cafes/${cafeId}/customers?q=${encodeURIComponent(debouncedSearch)}`
),
enabled: !!cafeId && mode === "existing" && debouncedSearch.length >= 2,
});
useEffect(() => {
setHighlightIndex(-1);
}, [debouncedSearch, results.length]);
const createCustomer = useMutation({
mutationFn: () =>
apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
name: guestName.trim(),
phone: guestPhone.trim(),
group: "New",
}),
onSuccess: (customer) => {
onCustomerChange(customer);
setMode("existing");
setMessage(t("customerSaved"));
},
onError: (err: Error) => {
if (err instanceof ApiClientError && err.code === "DUPLICATE_PHONE") {
setMessage(t("customerPhoneExists"));
return;
}
setMessage(t("customerSaveError"));
},
});
const switchMode = (next: CustomerMode) => {
setMode(next);
setMessage(null);
if (next === "new") {
onClearCustomer();
}
};
const canSaveNew =
guestName.trim().length > 0 &&
/^09\d{9}$/.test(guestPhone.trim()) &&
!customerId;
const summaryLabel =
guestName.trim() || guestPhone.trim()
? `${guestName.trim() || "—"}${guestPhone.trim() ? ` · ${guestPhone.trim()}` : ""}`
: t("customerSection");
return (
<div className={cn("space-y-2", compact && !expanded && "space-y-1")}>
{compact ? (
<button
type="button"
className="flex w-full items-center justify-between gap-2 rounded-md border border-border/80 bg-muted/30 px-2 py-1.5 text-start"
onClick={() => setExpanded((v) => !v)}
>
<span className="min-w-0 truncate text-xs font-medium">{summaryLabel}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180"
)}
/>
</button>
) : (
<p className="text-xs font-medium text-muted-foreground">{t("customerSection")}</p>
)}
{compact && !expanded ? null : (
<>
<div className="flex gap-1 rounded-lg border border-border p-0.5">
<button
type="button"
className={cn(
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
mode === "existing"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
)}
onClick={() => switchMode("existing")}
>
{t("existingCustomer")}
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
mode === "new"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
)}
onClick={() => switchMode("new")}
>
{t("newCustomer")}
</button>
</div>
{customerId ? (
<div className="flex items-center justify-between gap-2 rounded-md border border-[#0F6E56]/30 bg-[#E1F5EE] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[#0F6E56]">{guestName}</p>
<p className="truncate text-xs text-muted-foreground" dir="ltr">
{guestPhone}
</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => {
onClearCustomer();
onGuestNameChange("");
onGuestPhoneChange("");
setMode("new");
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
{mode === "existing" && !customerId ? (
<div className="space-y-2">
<LabeledField label={tCommon("search")} htmlFor="pos-customer-search">
<div className="relative">
<Search className="absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="pos-customer-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t("customerSearchPlaceholder")}
className={cn("ps-8", compact && "h-8 text-sm")}
role="combobox"
aria-expanded={results.length > 0 && debouncedSearch.length >= 2}
aria-activedescendant={
highlightIndex >= 0 ? `pos-customer-opt-${highlightIndex}` : undefined
}
/>
</div>
</LabeledField>
{debouncedSearch.length < 2 ? (
<p className="text-[11px] text-muted-foreground">{t("customerSearchHint")}</p>
) : isFetching ? (
<p className="text-[11px] text-muted-foreground">{tCommon("loading")}</p>
) : results.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t("customerNotFound")}</p>
) : (
<ul
className={cn(
"space-y-1 overflow-y-auto overscroll-contain",
compact ? "max-h-24" : "max-h-32"
)}
>
{results.map((c, idx) => (
<li key={c.id} id={`pos-customer-opt-${idx}`}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-start text-sm transition",
idx === highlightIndex
? "border-primary bg-primary/10"
: "border-border/80 hover:border-primary hover:bg-muted/40"
)}
onClick={() => pickCustomer(c)}
>
<span className="min-w-0 truncate font-medium">{c.name}</span>
<span className="shrink-0 text-xs text-muted-foreground" dir="ltr">
{c.phone}
</span>
</button>
</li>
))}
</ul>
)}
</div>
) : null}
{mode === "new" && !customerId ? (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (canSaveNew && !createCustomer.isPending) createCustomer.mutate();
}}
>
<LabeledField label={t("guestName")} htmlFor="pos-guest">
<Input
id="pos-guest"
value={guestName}
onChange={(e) => onGuestNameChange(e.target.value)}
placeholder={t("guestNamePlaceholder")}
className={compact ? "h-8 text-sm" : undefined}
/>
</LabeledField>
<LabeledField label={t("guestPhone")} htmlFor="pos-phone">
<Input
id="pos-phone"
value={guestPhone}
onChange={(e) => onGuestPhoneChange(e.target.value)}
placeholder={t("guestPhonePlaceholder")}
dir="ltr"
className={cn("text-end", compact && "h-8 text-sm")}
/>
</LabeledField>
<Button
type="submit"
variant="outline"
size="sm"
className="w-full"
disabled={!canSaveNew || createCustomer.isPending}
>
<UserPlus className="me-1.5 h-3.5 w-3.5" />
{createCustomer.isPending ? "..." : tCrm("addCustomer")}
</Button>
{!compact ? (
<p className="text-[10px] text-muted-foreground">{t("newCustomerHint")}</p>
) : null}
</form>
) : null}
{message ? (
<p className="text-center text-xs text-primary">{message}</p>
) : null}
</>
)}
</div>
);
}
@@ -0,0 +1,632 @@
"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, apiPatch, 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 { 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 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 [receiptOrder, setReceiptOrder] = useState<Order | null>(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 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);
}, [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: string) =>
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
status: "Cancelled",
}),
onSuccess: () => {
setPayMessage(t("cancelOrderSuccess"));
setSelectedId(null);
setSelectedTableId(null);
setFilterTableId(null);
setPaymentRows([{ method: "Cash", amount: "" }]);
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
},
onError: (err) => {
setPayMessage(
err instanceof ApiClientError ? err.message : 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>
<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(selected.id);
}}
>
{cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button>
<form
className="w-full"
onSubmit={(e) => {
e.preventDefault();
if (canPay && selected && !payOrder.isPending) {
payOrder.mutate(selected);
}
}}
>
<Button
type="submit"
className="w-full"
disabled={!canPay || payOrder.isPending}
>
{payOrder.isPending ? "..." : payButtonLabel}
</Button>
</form>
</div>
</>
) : null}
</CardContent>
</Card>
{receiptOrder ? (
<PosSlipModal
variant="bill"
order={receiptOrder}
cafeName={cafeName}
onClose={() => setReceiptOrder(null)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,72 @@
"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>
);
}
@@ -0,0 +1,2 @@
export { PosReceiptModal, PosSlipModal } from "@/components/pos/pos-slip-modal";
export type { KitchenSlipLine } from "@/components/pos/pos-slip-modal";
@@ -0,0 +1,44 @@
@media print {
body * {
visibility: hidden;
}
#pos-slip-print-area,
#pos-slip-print-area *,
#receipt-print-area,
#receipt-print-area * {
visibility: visible;
}
#pos-slip-print-area,
#receipt-print-area {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
}
}
#pos-slip-print-area,
#receipt-print-area {
width: 80mm;
font-family: "Courier New", monospace;
font-size: 12px;
direction: rtl;
text-align: right;
padding: 4mm;
}
.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
@@ -0,0 +1,167 @@
"use client";
import { useTranslations, useLocale } from "next-intl";
import type { Order } from "@/lib/api/types";
import { formatCurrency } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number";
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;
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,
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 printId = "pos-slip-print-area";
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;
};
const activeBillItems = order?.items.filter((i) => !i.isVoided) ?? [];
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">
<div
id={printId}
className="mb-4 rounded-md border border-dashed border-border p-3"
>
<div className="text-center text-base font-bold">{cafeName}</div>
<div className="mb-1 text-center text-xs font-semibold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div>
<div className="mb-2 text-center text-xs text-muted-foreground">
{formattedDate}
</div>
<div className="text-xs">
{t("table")}: {table}
{orderNo ? (
<>
{" "}
| {t("order")}: #{orderNo}
</>
) : null}
</div>
{guest ? (
<div className="text-xs">
{t("guest")}: {guest}
</div>
) : null}
<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="receipt-row mb-1 text-xs">
<span>
{item.menuItemName} × {item.quantity}
</span>
<span>
{formatCurrency(item.unitPrice * item.quantity, numberLocale)}
</span>
</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" />
<div className="mt-2 text-center text-xs">{t("thankYou")}</div>
</>
) : (
<div className="mt-2 text-center text-[10px] text-muted-foreground">
{t("kitchenFooter")}
</div>
)}
</div>
<div className="flex gap-2">
<Button type="button" className="flex-1" onClick={() => window.print()}>
{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} />
);
}
@@ -0,0 +1,290 @@
"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>
);
}