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