24da1e0522
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
Lets the POS agent and the QR/app customer attach a free-text note to each order line (e.g. "no tomato", "extra hot") that reaches the kitchen/bar. - Backend already supported it (OrderItem.Notes persists; CreateOrderItemRequest and OrderItemDto carry Notes; LiveOrderDto items include it) — this wires the UI. - cart.store: add setNotes(menuItemId, notes); notes already travel in getPendingLines and round-trip via hydrateFromOrder. - POS pos-screen: a note input under each cart line. - QR guest menu: a note input under each cart line (QrCartLine.note). - KDS: render the note prominently under each item so kitchen/bar sees it. - i18n: pos.itemNotePlaceholder + qrMenu.itemNote (fa/ar/en). Note: notes are captured on items being added; editing a note on an already-submitted line is out of scope (no pending delta to re-send). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1569 lines
59 KiB
TypeScript
1569 lines
59 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Minus,
|
|
Package,
|
|
Plus,
|
|
Search,
|
|
ShoppingCart,
|
|
Trash2,
|
|
UtensilsCrossed,
|
|
Users,
|
|
Video,
|
|
X,
|
|
} from "lucide-react";
|
|
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
|
import type {
|
|
MenuCategory,
|
|
MenuItem,
|
|
Order,
|
|
Table,
|
|
TableBoardItem,
|
|
QueueTicket,
|
|
} from "@/lib/api/types";
|
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
|
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
|
|
import { useCartStore, type CartItem } from "@/lib/stores/cart.store";
|
|
import type { OrderType } from "@/lib/stores/cart.store";
|
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
|
import { formatOrderNumber } from "@/lib/order-number";
|
|
import { iranMobileForApi } from "@/lib/phone";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { useIsRtl } from "@/lib/use-is-rtl";
|
|
import { useBranchStore } from "@/lib/stores/branch.store";
|
|
import { getOrCreateTerminalId } from "@/lib/terminal";
|
|
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
|
import { CategoryVisual } from "@/components/menu/category-visual";
|
|
import { getMenuPrimaryName, menuItemMatchesSearch } from "@/lib/menu-display";
|
|
import { Input } from "@/components/ui/input";
|
|
import { LabeledField } from "@/components/ui/labeled-field";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { MenuItemMedia } from "@/components/menu/menu-item-media";
|
|
import {
|
|
buildCategoryNameMap,
|
|
inferMenuItemKind,
|
|
} from "@/lib/menu-item-image";
|
|
import { PosPayPanel } from "@/components/pos/pos-pay-panel";
|
|
import { PosTableBoard } from "@/components/pos/pos-table-board";
|
|
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
|
|
import { PosSlipModal, type KitchenSlipLine } from "@/components/pos/pos-slip-modal";
|
|
import { PosQueueBar } from "@/components/pos/pos-queue-bar";
|
|
import {
|
|
branchMenuItemToMenuItem,
|
|
getBranchMenu,
|
|
} from "@/lib/api/branch-menu";
|
|
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
|
import { orderAmountDue, submitOrderToApi, isLocalOrder } from "@/lib/pos/submit-order";
|
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
|
import { useConfirm } from "@/components/providers/confirm-provider";
|
|
|
|
const TAX_RATE = 0.09;
|
|
|
|
function buildKitchenLines(
|
|
pending: { menuItemId: string; quantity: number; notes?: string }[],
|
|
cartItems: CartItem[]
|
|
): KitchenSlipLine[] {
|
|
return pending.map((p) => {
|
|
const line = cartItems.find((i) => i.menuItem.id === p.menuItemId);
|
|
return {
|
|
name: line?.menuItem.name ?? p.menuItemId,
|
|
quantity: p.quantity,
|
|
notes: p.notes,
|
|
};
|
|
});
|
|
}
|
|
|
|
function cartToKitchenLines(cartItems: CartItem[]): KitchenSlipLine[] {
|
|
return cartItems
|
|
.filter((i) => !i.isVoided && i.quantity > 0)
|
|
.map((i) => ({
|
|
name: i.menuItem.name,
|
|
quantity: i.quantity,
|
|
notes: i.notes,
|
|
}));
|
|
}
|
|
|
|
// ─── Order Type Picker ───────────────────────────────────────────────────────
|
|
|
|
function OrderTypePicker({
|
|
onSelect,
|
|
t,
|
|
}: {
|
|
onSelect: (type: OrderType) => void;
|
|
t: ReturnType<typeof useTranslations<"pos">>;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 py-10">
|
|
<div className="text-center">
|
|
<h2 className="text-2xl font-bold tracking-tight">
|
|
{t("orderTypePicker")}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="grid w-full max-w-xl grid-cols-3 gap-3 sm:gap-4">
|
|
{/* Table */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect("table")}
|
|
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]"
|
|
>
|
|
<div className="rounded-xl bg-primary/10 p-3.5 transition-colors group-hover:bg-primary/20">
|
|
<UtensilsCrossed className="size-8 text-primary" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-semibold sm:text-base">
|
|
{t("orderTypeTable")}
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{t("orderTypeTableDesc")}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Counter */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect("counter")}
|
|
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500 hover:shadow-md active:scale-[0.97]"
|
|
>
|
|
<div className="rounded-xl bg-blue-500/10 p-3.5 transition-colors group-hover:bg-blue-500/20">
|
|
<Users className="size-8 text-blue-600" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-semibold sm:text-base">
|
|
{t("orderTypeCounter")}
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{t("orderTypeCounterDesc")}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Takeaway */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect("takeaway")}
|
|
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-amber-500 hover:shadow-md active:scale-[0.97]"
|
|
>
|
|
<div className="rounded-xl bg-amber-500/10 p-3.5 transition-colors group-hover:bg-amber-500/20">
|
|
<Package className="size-8 text-amber-600" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-semibold sm:text-base">
|
|
{t("orderTypeTakeaway")}
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{t("orderTypeTakeawayDesc")}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Order Type Badge ─────────────────────────────────────────────────────────
|
|
|
|
function OrderTypeBadge({
|
|
orderType,
|
|
tableNumber,
|
|
onClick,
|
|
t,
|
|
}: {
|
|
orderType: OrderType;
|
|
tableNumber?: number | string | null;
|
|
onClick?: () => void;
|
|
t: ReturnType<typeof useTranslations<"pos">>;
|
|
}) {
|
|
const label =
|
|
orderType === "table"
|
|
? `${t("table")} ${tableNumber ?? "—"}`
|
|
: orderType === "counter"
|
|
? t("counterBadge")
|
|
: t("takeawayBadge");
|
|
|
|
const cls =
|
|
orderType === "table"
|
|
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
|
|
: orderType === "counter"
|
|
? "border-blue-400 bg-blue-50 text-blue-700 hover:bg-blue-100"
|
|
: "border-amber-400 bg-amber-50 text-amber-700 hover:bg-amber-100";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
"shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
|
|
cls,
|
|
onClick ? "cursor-pointer" : "cursor-default"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
export function PosScreen() {
|
|
const t = useTranslations("pos");
|
|
const tQueue = useTranslations("queue");
|
|
const tErrors = useTranslations("errors");
|
|
const tCommon = useTranslations("common");
|
|
const tDashboard = useTranslations("dashboard");
|
|
const locale = useLocale();
|
|
const isRtl = useIsRtl();
|
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
|
const { data: cafeSettings } = useCafeSettings(cafeId);
|
|
const cafeName = cafeSettings?.name ?? tDashboard("cafeName");
|
|
const userRole = useAuthStore((s) => s.user?.role);
|
|
const isManager = userRole === "Manager" || userRole === "Owner";
|
|
const branchId = useBranchStore((s) => s.branchId);
|
|
const setBranchId = useBranchStore((s) => s.setBranchId);
|
|
const queryClient = useQueryClient();
|
|
const confirmDialog = useConfirm();
|
|
const isOnline = useSyncQueueStore((s) => s.isOnline);
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const reservationId = searchParams.get("reservationId");
|
|
const reservationGuest = searchParams.get("guestName");
|
|
const urlOrderId = searchParams.get("orderId");
|
|
|
|
const [kitchenSlip, setKitchenSlip] = useState<{
|
|
lines: KitchenSlipLine[];
|
|
orderId?: string;
|
|
tableNumber?: string | number | null;
|
|
guestName?: string | null;
|
|
} | null>(null);
|
|
const [posMode, setPosMode] = useState<"order" | "pay">("order");
|
|
const [showTablePicker, setShowTablePicker] = useState(false);
|
|
const [showTransferPicker, setShowTransferPicker] = useState(false);
|
|
|
|
const {
|
|
items,
|
|
addItem,
|
|
removeItem,
|
|
updateQty,
|
|
setNotes,
|
|
couponCode,
|
|
appliedCoupon,
|
|
setCouponCode,
|
|
setAppliedCoupon,
|
|
clearCoupon,
|
|
tableId,
|
|
setTableId,
|
|
orderType,
|
|
setOrderType,
|
|
activeOrderId,
|
|
activeOrderDisplayNumber,
|
|
setActiveOrderId,
|
|
customerId,
|
|
guestName,
|
|
setGuestName,
|
|
guestPhone,
|
|
setGuestPhone,
|
|
setCustomer,
|
|
clearCustomer,
|
|
hydrateFromOrder,
|
|
getPendingLines,
|
|
clearCart,
|
|
clearSession,
|
|
subtotal,
|
|
} = useCartStore();
|
|
|
|
const syncUrl = useCallback(
|
|
(tid: string | null, oid: string | null) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (tid) params.set("tableId", tid);
|
|
else params.delete("tableId");
|
|
if (oid) params.set("orderId", oid);
|
|
else params.delete("orderId");
|
|
const qs = params.toString();
|
|
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
|
},
|
|
[pathname, router, searchParams]
|
|
);
|
|
|
|
// Restore tableId + infer orderType from URL params
|
|
useEffect(() => {
|
|
const tid = searchParams.get("tableId");
|
|
if (tid) {
|
|
setTableId(tid);
|
|
if (!orderType) setOrderType("table");
|
|
}
|
|
}, [searchParams, setTableId, setOrderType, orderType]);
|
|
|
|
useEffect(() => {
|
|
if (urlOrderId) setActiveOrderId(urlOrderId);
|
|
}, [urlOrderId, setActiveOrderId]);
|
|
|
|
useEffect(() => {
|
|
if (reservationGuest) setGuestName(reservationGuest);
|
|
}, [reservationGuest, setGuestName]);
|
|
|
|
useEffect(() => {
|
|
if (!cafeId) return;
|
|
apiPost(`/api/cafes/${cafeId}/terminals/register`, {
|
|
terminalId: getOrCreateTerminalId(),
|
|
}).catch(() => undefined);
|
|
}, [cafeId]);
|
|
|
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all");
|
|
const [itemSearch, setItemSearch] = useState("");
|
|
const [orderMessage, setOrderMessage] = useState<string | null>(null);
|
|
const [couponMessage, setCouponMessage] = useState<{
|
|
type: "success" | "error";
|
|
text: string;
|
|
} | null>(null);
|
|
|
|
const { data: branches = [] } = useQuery({
|
|
queryKey: ["branches", cafeId],
|
|
queryFn: () =>
|
|
apiGet<{ id: string; name: string }[]>(`/api/cafes/${cafeId}/branches`),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (branches.length === 0) return;
|
|
const valid = branchId && branches.some((b) => b.id === branchId);
|
|
if (!valid) setBranchId(branches[0]!.id);
|
|
}, [branches, branchId, setBranchId]);
|
|
|
|
const orderBranchId = useMemo(() => {
|
|
if (branches.length === 0) return null;
|
|
if (branchId && branches.some((b) => b.id === branchId)) return branchId;
|
|
return branches[0]?.id ?? null;
|
|
}, [branchId, branches]);
|
|
|
|
const { data: categories, isLoading: loadingCategories } = useQuery({
|
|
queryKey: ["menu-categories", cafeId],
|
|
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
const { data: globalMenuItems, isLoading: loadingGlobalItems } = useQuery({
|
|
queryKey: ["menu-items", cafeId, selectedCategory],
|
|
queryFn: () => {
|
|
const qs =
|
|
selectedCategory !== "all" ? `?categoryId=${selectedCategory}` : "";
|
|
return apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items${qs}`);
|
|
},
|
|
enabled: !!cafeId && !orderBranchId,
|
|
});
|
|
|
|
const { data: branchMenuRows, isLoading: loadingBranchMenu } = useQuery({
|
|
queryKey: ["branch-menu", cafeId, orderBranchId, selectedCategory],
|
|
queryFn: () => getBranchMenu(cafeId!, orderBranchId!),
|
|
enabled: !!cafeId && !!orderBranchId,
|
|
});
|
|
|
|
const menuItems = useMemo(() => {
|
|
if (orderBranchId && branchMenuRows) {
|
|
const mapped = branchMenuRows.map(branchMenuItemToMenuItem);
|
|
if (selectedCategory === "all") return mapped;
|
|
return mapped.filter((i) => i.categoryId === selectedCategory);
|
|
}
|
|
return globalMenuItems;
|
|
}, [orderBranchId, branchMenuRows, globalMenuItems, selectedCategory]);
|
|
|
|
const loadingItems = orderBranchId ? loadingBranchMenu : loadingGlobalItems;
|
|
|
|
const { data: allMenuItems, isLoading: loadingAllCatalog } = useQuery({
|
|
queryKey: ["branch-menu-all", cafeId, orderBranchId],
|
|
queryFn: async () => {
|
|
if (orderBranchId && cafeId) {
|
|
const rows = await getBranchMenu(cafeId, orderBranchId);
|
|
return rows.map(branchMenuItemToMenuItem);
|
|
}
|
|
return apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`);
|
|
},
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
const menuById = useMemo(() => {
|
|
const map = new Map<string, MenuItem>();
|
|
for (const m of allMenuItems ?? menuItems ?? []) {
|
|
map.set(m.id, m);
|
|
}
|
|
return map;
|
|
}, [allMenuItems, menuItems]);
|
|
|
|
// Hydrate from URL order
|
|
useEffect(() => {
|
|
if (!cafeId || !urlOrderId || menuById.size === 0) return;
|
|
if (activeOrderId === urlOrderId && items.length > 0) return;
|
|
apiGet<Order>(`/api/cafes/${cafeId}/orders/${urlOrderId}`)
|
|
.then((order) => {
|
|
hydrateFromOrder(order, menuById);
|
|
if (order.tableId) {
|
|
setTableId(order.tableId);
|
|
setOrderType("table");
|
|
} else if (!orderType) {
|
|
setOrderType("counter");
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}, [
|
|
cafeId,
|
|
urlOrderId,
|
|
menuById,
|
|
activeOrderId,
|
|
items.length,
|
|
hydrateFromOrder,
|
|
setTableId,
|
|
setOrderType,
|
|
orderType,
|
|
]);
|
|
|
|
const sessionPatchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (!cafeId || !activeOrderId) return;
|
|
if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current);
|
|
sessionPatchRef.current = setTimeout(() => {
|
|
apiPatch(`/api/cafes/${cafeId}/orders/${activeOrderId}/session`, {
|
|
guestName: guestName.trim() || null,
|
|
guestPhone: iranMobileForApi(guestPhone) ?? null,
|
|
customerId: customerId ?? null,
|
|
}).catch(() => undefined);
|
|
}, 600);
|
|
return () => {
|
|
if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current);
|
|
};
|
|
}, [guestName, guestPhone, customerId, activeOrderId, cafeId]);
|
|
|
|
const handleTableSelect = useCallback(
|
|
(table: TableBoardItem, activeOrder: Order | null) => {
|
|
setShowTablePicker(false);
|
|
|
|
if (activeOrder) {
|
|
setTableId(table.id);
|
|
hydrateFromOrder(activeOrder, menuById);
|
|
syncUrl(table.id, activeOrder.id);
|
|
setOrderMessage(t("sessionActive"));
|
|
return;
|
|
}
|
|
|
|
const hadOpenSession = !!useCartStore.getState().activeOrderId;
|
|
if (hadOpenSession) {
|
|
clearSession();
|
|
} else {
|
|
setActiveOrderId(null);
|
|
}
|
|
|
|
setTableId(table.id);
|
|
syncUrl(table.id, null);
|
|
setOrderMessage(null);
|
|
},
|
|
[
|
|
setTableId,
|
|
hydrateFromOrder,
|
|
menuById,
|
|
syncUrl,
|
|
setActiveOrderId,
|
|
clearSession,
|
|
t,
|
|
]
|
|
);
|
|
|
|
// Handle order type selection from the picker
|
|
const handleOrderTypeSelect = useCallback(
|
|
(type: OrderType) => {
|
|
setOrderType(type);
|
|
if (type === "table") {
|
|
setShowTablePicker(true);
|
|
}
|
|
},
|
|
[setOrderType]
|
|
);
|
|
|
|
// Go back to type picker (with clear)
|
|
const handleBackToTypePicker = useCallback(() => {
|
|
clearSession();
|
|
syncUrl(null, null);
|
|
setOrderMessage(null);
|
|
setCouponMessage(null);
|
|
}, [clearSession, syncUrl]);
|
|
|
|
const tablesQuery = orderBranchId
|
|
? `?branchId=${encodeURIComponent(orderBranchId)}`
|
|
: "";
|
|
|
|
const { data: tables } = useQuery({
|
|
queryKey: ["tables", cafeId, orderBranchId],
|
|
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables${tablesQuery}`),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
const { data: boardTables = [] } = useQuery({
|
|
queryKey: ["tables-board", cafeId, orderBranchId, "transfer"],
|
|
queryFn: () =>
|
|
apiGet<TableBoardItem[]>(
|
|
`/api/cafes/${cafeId}/tables/board${tablesQuery}`
|
|
),
|
|
enabled: !!cafeId && showTransferPicker,
|
|
});
|
|
|
|
const voidItemMutation = useMutation({
|
|
mutationFn: async (orderItemId: string) => {
|
|
if (!cafeId || !activeOrderId) throw new Error("no session");
|
|
return apiPatch<Order>(
|
|
`/api/cafes/${cafeId}/orders/${activeOrderId}/items/${orderItemId}/void`,
|
|
{}
|
|
);
|
|
},
|
|
onSuccess: async (order) => {
|
|
hydrateFromOrder(order, menuById);
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
|
},
|
|
onError: () => setOrderMessage(t("voidError")),
|
|
});
|
|
|
|
const transferTableMutation = useMutation({
|
|
mutationFn: async (targetTableId: string) => {
|
|
if (!cafeId || !activeOrderId) throw new Error("no session");
|
|
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${activeOrderId}/transfer`, {
|
|
targetTableId,
|
|
});
|
|
},
|
|
onSuccess: async (order) => {
|
|
setShowTransferPicker(false);
|
|
hydrateFromOrder(order, menuById);
|
|
if (order.tableId) setTableId(order.tableId);
|
|
syncUrl(order.tableId ?? null, order.id);
|
|
setOrderMessage(t("transferSuccess"));
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
|
},
|
|
onError: (err: Error) => {
|
|
const code = err instanceof ApiClientError ? err.code : "";
|
|
if (code === "TABLE_OCCUPIED") setOrderMessage(t("tableOccupied"));
|
|
else if (code === "TABLE_CLEANING") setOrderMessage(t("tableNotAvailable"));
|
|
else setOrderMessage(t("transferError"));
|
|
},
|
|
});
|
|
|
|
const handleVoidItem = async (orderItemId: string) => {
|
|
const ok = await confirmDialog({
|
|
description: t("confirmVoid"),
|
|
variant: "destructive",
|
|
confirmLabel: tCommon("confirm"),
|
|
});
|
|
if (!ok) return;
|
|
voidItemMutation.mutate(orderItemId);
|
|
};
|
|
|
|
const freeTransferTables = boardTables.filter(
|
|
(tbl) => tbl.status === "Free" && tbl.id !== tableId
|
|
);
|
|
|
|
const itemSearchQuery = itemSearch.trim();
|
|
const isSearchingItems = itemSearchQuery.length > 0;
|
|
|
|
const catalogForSearch = useMemo(
|
|
() => allMenuItems ?? menuItems ?? [],
|
|
[allMenuItems, menuItems]
|
|
);
|
|
|
|
const filteredItems = useMemo(() => {
|
|
const base = isSearchingItems ? catalogForSearch : (menuItems ?? []);
|
|
return base.filter((i) => {
|
|
if (!i.isAvailable) return false;
|
|
if (!isSearchingItems) return true;
|
|
return menuItemMatchesSearch(i, itemSearchQuery, locale);
|
|
});
|
|
}, [catalogForSearch, menuItems, isSearchingItems, itemSearchQuery, locale]);
|
|
|
|
const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems;
|
|
|
|
const categoryNameById = useMemo(
|
|
() => buildCategoryNameMap(categories ?? []),
|
|
[categories]
|
|
);
|
|
|
|
const itemVisualKind = (item: MenuItem) =>
|
|
inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId));
|
|
|
|
const sub = subtotal();
|
|
const discount = appliedCoupon?.discountAmount ?? 0;
|
|
const taxable = Math.max(0, sub - discount);
|
|
const tax = Math.round(taxable * TAX_RATE);
|
|
const total = taxable + tax;
|
|
|
|
const couponErrorKey = (code: string) => {
|
|
const map: Record<string, string> = {
|
|
COUPON_NOT_FOUND: "couponInvalid",
|
|
COUPON_INACTIVE: "couponInvalid",
|
|
COUPON_EXPIRED: "couponExpired",
|
|
COUPON_NOT_STARTED: "couponNotStarted",
|
|
COUPON_LIMIT_REACHED: "couponLimitReached",
|
|
COUPON_MIN_ORDER: "couponMinOrder",
|
|
CART_EMPTY: "couponCartEmpty",
|
|
COUPON_REQUIRED: "couponRequired",
|
|
COUPON_NO_DISCOUNT: "couponInvalid",
|
|
};
|
|
return map[code] ?? "couponInvalid";
|
|
};
|
|
|
|
const validateCoupon = useMutation({
|
|
mutationFn: async () => {
|
|
if (!cafeId) throw new Error("no cafe");
|
|
return apiPost<{
|
|
couponId: string;
|
|
code: string;
|
|
discountAmount: number;
|
|
}>(`/api/cafes/${cafeId}/coupons/validate`, {
|
|
code: couponCode.trim(),
|
|
subtotal: subtotal(),
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
setAppliedCoupon({
|
|
id: data.couponId,
|
|
code: data.code,
|
|
discountAmount: data.discountAmount,
|
|
});
|
|
setCouponMessage({
|
|
type: "success",
|
|
text: t("couponApplied", {
|
|
code: data.code,
|
|
amount: formatCurrency(data.discountAmount, numberLocale),
|
|
}),
|
|
});
|
|
},
|
|
onError: (err: Error) => {
|
|
setAppliedCoupon(null);
|
|
const code = err instanceof ApiClientError ? err.code : "COUPON_NOT_FOUND";
|
|
setCouponMessage({ type: "error", text: t(couponErrorKey(code)) });
|
|
},
|
|
});
|
|
|
|
const openKitchenSlip = useCallback(() => {
|
|
const pending = getPendingLines();
|
|
const lines =
|
|
pending.length > 0
|
|
? buildKitchenLines(pending, items)
|
|
: cartToKitchenLines(items);
|
|
if (lines.length === 0) return;
|
|
setKitchenSlip({
|
|
lines,
|
|
orderId: activeOrderId ?? undefined,
|
|
tableNumber: tables?.find((tbl) => tbl.id === tableId)?.number ?? null,
|
|
guestName: guestName.trim() || null,
|
|
});
|
|
}, [getPendingLines, items, activeOrderId, tables, tableId, guestName]);
|
|
|
|
const submitOrder = useMutation({
|
|
mutationFn: async () => {
|
|
if (!cafeId || items.length === 0) throw new Error("empty");
|
|
const cart = useCartStore.getState();
|
|
const pending = cart.getPendingLines();
|
|
if (pending.length === 0) throw new Error("nothing pending");
|
|
const kitchenLines = buildKitchenLines(pending, cart.items);
|
|
const order = await submitOrderToApi({
|
|
cafeId,
|
|
orderBranchId: orderBranchId ?? undefined,
|
|
cart,
|
|
reservationId,
|
|
cartItems: cart.items,
|
|
});
|
|
return { order, kitchenLines };
|
|
},
|
|
onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }),
|
|
onSuccess: ({ order, kitchenLines }, _, context) => {
|
|
hydrateFromOrder(order, menuById);
|
|
syncUrl(order.tableId ?? tableId, order.id);
|
|
setCouponMessage(null);
|
|
if (kitchenLines.length > 0) {
|
|
setKitchenSlip({
|
|
lines: kitchenLines,
|
|
orderId: order.id,
|
|
tableNumber: order.tableNumber ?? null,
|
|
guestName: order.guestName ?? (guestName.trim() || null),
|
|
});
|
|
}
|
|
const baseMsg = context?.hadSession ? t("addToOrder") : t("orderPlaced");
|
|
setOrderMessage(baseMsg);
|
|
void apiPost<QueueTicket>(`/api/cafes/${cafeId}/queue/next`, {
|
|
branchId: orderBranchId,
|
|
customerLabel:
|
|
order.guestName ?? (guestName.trim() || undefined),
|
|
orderId: order.id,
|
|
})
|
|
.then((ticket) => {
|
|
setOrderMessage(
|
|
`${baseMsg} · ${t("queueNumber", { number: ticket.number })}`
|
|
);
|
|
queryClient.invalidateQueries({ queryKey: ["queue-today"] });
|
|
})
|
|
.catch(() => undefined);
|
|
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
if (reservationId) {
|
|
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
|
}
|
|
},
|
|
onError: (err: Error) => {
|
|
if (err instanceof ApiClientError) {
|
|
if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") {
|
|
setActiveOrderId(null);
|
|
syncUrl(tableId, null);
|
|
}
|
|
const key =
|
|
err.code === "TABLE_NOT_AVAILABLE"
|
|
? "tableNotAvailable"
|
|
: err.code === "TABLE_OCCUPIED"
|
|
? "tableOccupied"
|
|
: err.code === "PLAN_LIMIT_REACHED"
|
|
? "planLimit"
|
|
: err.code === "INVALID_ORDER"
|
|
? "orderInvalid"
|
|
: err.code === "ORDER_NOT_OPEN"
|
|
? "orderNotOpen"
|
|
: err.code === "ORDER_NOT_FOUND"
|
|
? "orderNotOpen"
|
|
: err.code === "VALIDATION_ERROR"
|
|
? "orderValidation"
|
|
: "orderError";
|
|
setOrderMessage(
|
|
err.code === "VALIDATION_ERROR"
|
|
? err.message
|
|
: key === "planLimit"
|
|
? tErrors("planLimit")
|
|
: t(key)
|
|
);
|
|
return;
|
|
}
|
|
if (err.message === "nothing pending") {
|
|
setOrderMessage(t("nothingPending"));
|
|
return;
|
|
}
|
|
setOrderMessage(t("orderError"));
|
|
},
|
|
});
|
|
|
|
const submitOrderAndPay = useMutation({
|
|
mutationFn: async () => {
|
|
if (!cafeId || items.length === 0) throw new Error("empty");
|
|
const cart = useCartStore.getState();
|
|
const pending = cart.getPendingLines();
|
|
if (pending.length === 0) throw new Error("nothing pending");
|
|
const kitchenLines = buildKitchenLines(pending, cart.items);
|
|
const order = await submitOrderToApi({
|
|
cafeId,
|
|
orderBranchId: orderBranchId ?? undefined,
|
|
cart,
|
|
reservationId,
|
|
cartItems: cart.items,
|
|
});
|
|
const due = orderAmountDue(order);
|
|
if (isLocalOrder(order.id)) return { order, kitchenLines };
|
|
const payBranchId = order.branchId ?? orderBranchId;
|
|
if (due > 0) {
|
|
if (!payBranchId) throw new Error("no branch");
|
|
await requestPosPayment(cafeId, payBranchId, order.id, due);
|
|
await apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, {
|
|
payments: [{ method: "Card", amount: due }],
|
|
});
|
|
}
|
|
return { order, kitchenLines };
|
|
},
|
|
onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }),
|
|
onSuccess: ({ order, kitchenLines }, _, context) => {
|
|
hydrateFromOrder(order, menuById);
|
|
syncUrl(order.tableId ?? tableId, order.id);
|
|
setCouponMessage(null);
|
|
if (kitchenLines.length > 0) {
|
|
setKitchenSlip({
|
|
lines: kitchenLines,
|
|
orderId: order.id,
|
|
tableNumber: order.tableNumber ?? null,
|
|
guestName: order.guestName ?? (guestName.trim() || null),
|
|
});
|
|
}
|
|
const baseMsg = context?.hadSession ? t("orderPaidAdd") : t("orderPaidNew");
|
|
setOrderMessage(baseMsg);
|
|
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
if (reservationId) {
|
|
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
|
}
|
|
},
|
|
onError: (err: Error) => {
|
|
if (err instanceof ApiClientError) {
|
|
if (
|
|
err.code === "POS_DEVICE_CONNECTION_FAILED" ||
|
|
err.code === "POS_DEVICE_TIMEOUT" ||
|
|
err.code === "POS_DEVICE_REJECTED" ||
|
|
err.code.startsWith("POS_DEVICE")
|
|
) {
|
|
setOrderMessage(posDeviceErrorMessage(err, t));
|
|
return;
|
|
}
|
|
if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") {
|
|
setActiveOrderId(null);
|
|
syncUrl(tableId, null);
|
|
}
|
|
const key =
|
|
err.code === "TABLE_NOT_AVAILABLE"
|
|
? "tableNotAvailable"
|
|
: err.code === "TABLE_OCCUPIED"
|
|
? "tableOccupied"
|
|
: err.code === "PLAN_LIMIT_REACHED"
|
|
? "planLimit"
|
|
: err.code === "INVALID_ORDER"
|
|
? "orderInvalid"
|
|
: err.code === "ORDER_NOT_OPEN"
|
|
? "orderNotOpen"
|
|
: err.code === "ORDER_NOT_FOUND"
|
|
? "orderNotOpen"
|
|
: err.code === "VALIDATION_ERROR"
|
|
? "orderValidation"
|
|
: "orderError";
|
|
setOrderMessage(
|
|
err.code === "VALIDATION_ERROR"
|
|
? err.message
|
|
: key === "planLimit"
|
|
? tErrors("planLimit")
|
|
: t(key)
|
|
);
|
|
return;
|
|
}
|
|
if (err.message === "nothing pending") {
|
|
setOrderMessage(t("nothingPending"));
|
|
return;
|
|
}
|
|
if (err.message === "no branch") {
|
|
setOrderMessage(t("posDeviceNoBranch"));
|
|
return;
|
|
}
|
|
setOrderMessage(t("payError"));
|
|
},
|
|
});
|
|
|
|
const pendingCount = getPendingLines().length;
|
|
const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending;
|
|
|
|
// Counter/takeaway orders don't require a table
|
|
const canSubmitOrder =
|
|
pendingCount > 0 &&
|
|
(orderType === "counter" ||
|
|
orderType === "takeaway" ||
|
|
!!tableId ||
|
|
!!customerId ||
|
|
guestName.trim().length > 0);
|
|
|
|
// Show order type picker when there's no active session
|
|
const showTypePicker =
|
|
posMode === "order" &&
|
|
orderType === null &&
|
|
items.length === 0 &&
|
|
!urlOrderId &&
|
|
!activeOrderId;
|
|
|
|
// The current table number for display
|
|
const currentTableNumber = tables?.find((tbl) => tbl.id === tableId)?.number;
|
|
|
|
if (!cafeId) return null;
|
|
|
|
return (
|
|
<div
|
|
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
|
|
dir={isRtl ? "rtl" : "ltr"}
|
|
>
|
|
{/* ── Top bar: mode switcher ─────────────────────────────────────────── */}
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-1 pb-2">
|
|
<Button
|
|
size="sm"
|
|
variant={posMode === "order" ? "default" : "outline"}
|
|
onClick={() => setPosMode("order")}
|
|
>
|
|
{t("modeOrder")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={posMode === "pay" ? "default" : "outline"}
|
|
onClick={() => setPosMode("pay")}
|
|
>
|
|
{t("modePay")}
|
|
</Button>
|
|
|
|
<div className="flex-1" />
|
|
</div>
|
|
|
|
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
|
{posMode === "pay" ? (
|
|
<PosPayPanel
|
|
cafeId={cafeId}
|
|
numberLocale={numberLocale}
|
|
branchId={orderBranchId}
|
|
/>
|
|
) : showTypePicker ? (
|
|
/* ── Order type picker ──────────────────────────────────────────── */
|
|
<OrderTypePicker onSelect={handleOrderTypeSelect} t={t} />
|
|
) : (
|
|
/* ── Order screen ───────────────────────────────────────────────── */
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
|
|
{/* Order screen header */}
|
|
<div className="flex shrink-0 items-center gap-2 px-1">
|
|
{/* Back / new order button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleBackToTypePicker}
|
|
aria-label={t("newOrder")}
|
|
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
{isRtl ? (
|
|
<ChevronRight className="size-4" />
|
|
) : (
|
|
<ChevronLeft className="size-4" />
|
|
)}
|
|
{t("newOrder")}
|
|
</button>
|
|
|
|
{/* Order type badge — tappable to change table */}
|
|
{orderType ? (
|
|
<OrderTypeBadge
|
|
orderType={orderType}
|
|
tableNumber={currentTableNumber}
|
|
onClick={
|
|
orderType === "table"
|
|
? () => setShowTablePicker(true)
|
|
: undefined
|
|
}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Active order number */}
|
|
{activeOrderId ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
#
|
|
{activeOrderDisplayNumber
|
|
? String(activeOrderDisplayNumber)
|
|
: formatOrderNumber({ id: activeOrderId })}
|
|
</span>
|
|
) : null}
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Queue bar */}
|
|
{cafeId ? (
|
|
<PosQueueBar cafeId={cafeId} branchId={orderBranchId} />
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Reservation banner */}
|
|
{reservationId && reservationGuest ? (
|
|
<div className="mx-1 shrink-0 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-primary">
|
|
{t("reservationBanner", { name: reservationGuest })}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ── Main split: menu + cart ──────────────────────────────────── */}
|
|
<div className="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
|
{/* ── Menu panel ────────────────────────────────────────────── */}
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
|
|
{/* Search bar */}
|
|
<div className="relative shrink-0">
|
|
<Search
|
|
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
<Input
|
|
type="search"
|
|
value={itemSearch}
|
|
onChange={(e) => setItemSearch(e.target.value)}
|
|
placeholder={t("searchItemsPlaceholder")}
|
|
aria-label={t("searchItems")}
|
|
className="h-10 ps-9 pe-9"
|
|
/>
|
|
{itemSearch ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute end-1 top-1/2 size-7 -translate-y-1/2"
|
|
onClick={() => setItemSearch("")}
|
|
aria-label={tCommon("cancel")}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Horizontal scrolling category tabs — no wrap */}
|
|
<div className="flex shrink-0 gap-1.5 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none]">
|
|
<Button
|
|
size="sm"
|
|
variant={selectedCategory === "all" ? "default" : "outline"}
|
|
className="shrink-0"
|
|
onClick={() => setSelectedCategory("all")}
|
|
>
|
|
{t("allCategories")}
|
|
</Button>
|
|
{loadingCategories
|
|
? Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-9 w-20 shrink-0" />
|
|
))
|
|
: categories?.map((c) => (
|
|
<Button
|
|
key={c.id}
|
|
size="sm"
|
|
variant={
|
|
selectedCategory === c.id ? "default" : "outline"
|
|
}
|
|
className="shrink-0 gap-1.5"
|
|
onClick={() => setSelectedCategory(c.id)}
|
|
>
|
|
<CategoryVisual
|
|
icon={c.icon}
|
|
iconPresetId={c.iconPresetId}
|
|
iconStyle={c.iconStyle}
|
|
imageUrl={c.imageUrl}
|
|
size="xs"
|
|
/>
|
|
{getMenuPrimaryName(c, locale)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Product grid — bigger cards with image area */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
<div className="grid grid-cols-2 gap-2 content-start sm:grid-cols-3 lg:grid-cols-4">
|
|
{showItemsLoading
|
|
? Array.from({ length: 8 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-36 rounded-xl" />
|
|
))
|
|
: filteredItems.length === 0 && isSearchingItems
|
|
? (
|
|
<p className="col-span-full py-10 text-center text-sm text-muted-foreground">
|
|
{t("searchNoResults")}
|
|
</p>
|
|
)
|
|
: filteredItems.map((item) => {
|
|
const qty = items.find(
|
|
(ci) => ci.menuItem.id === item.id
|
|
)?.quantity;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => addItem(item)}
|
|
className={cn(
|
|
"group relative flex cursor-pointer flex-col gap-2 rounded-xl border bg-card p-2.5 text-start shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]",
|
|
qty
|
|
? "border-primary"
|
|
: "border-border"
|
|
)}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div className="relative w-full overflow-hidden rounded-lg border border-border/50 bg-muted aspect-[4/3]">
|
|
<MenuItemMedia
|
|
imageUrl={item.imageUrl}
|
|
kind={itemVisualKind(item)}
|
|
size="sm"
|
|
/>
|
|
{item.videoUrl ? (
|
|
<span
|
|
className="absolute bottom-0 end-0 rounded-ss-sm bg-black/55 p-px"
|
|
aria-hidden
|
|
>
|
|
<Video className="h-2.5 w-2.5 text-white" />
|
|
</span>
|
|
) : null}
|
|
{/* Cart quantity badge */}
|
|
{qty ? (
|
|
<span className="absolute start-1.5 top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
|
|
{qty}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="min-w-0">
|
|
<MenuItemLabels
|
|
item={item}
|
|
lines={2}
|
|
primaryClassName="text-sm font-medium leading-snug"
|
|
secondaryClassName="text-[10px]"
|
|
/>
|
|
<p className="mt-1 text-sm font-semibold text-primary">
|
|
{formatCurrency(item.price, numberLocale)}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Cart sidebar ──────────────────────────────────────────── */}
|
|
<Card className="flex h-full min-h-0 w-[min(100%,21rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
|
|
<CardHeader className="shrink-0 space-y-2 p-3 pb-2">
|
|
{/* Cart header: title + table/type badge */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingCart className="size-4 text-muted-foreground" />
|
|
<CardTitle className="text-sm">{t("takeOrder")}</CardTitle>
|
|
</div>
|
|
|
|
{orderType === "table" ? (
|
|
tableId ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTablePicker(true)}
|
|
className="shrink-0 cursor-pointer rounded-md border-2 border-primary bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary transition-colors hover:bg-primary/20"
|
|
>
|
|
{t("table")}{" "}
|
|
{currentTableNumber ?? "—"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTablePicker(true)}
|
|
className="shrink-0 cursor-pointer rounded-md border border-dashed border-primary px-2 py-0.5 text-xs text-primary transition-colors hover:bg-primary/5"
|
|
>
|
|
{t("selectTableBoard")}
|
|
</button>
|
|
)
|
|
) : orderType ? (
|
|
<span
|
|
className={cn(
|
|
"shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium",
|
|
orderType === "counter"
|
|
? "bg-blue-50 text-blue-700"
|
|
: "bg-amber-50 text-amber-700"
|
|
)}
|
|
>
|
|
{orderType === "counter"
|
|
? t("counterBadge")
|
|
: t("takeawayBadge")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Customer picker */}
|
|
{cafeId ? (
|
|
<PosCustomerPicker
|
|
compact
|
|
cafeId={cafeId}
|
|
guestName={guestName}
|
|
guestPhone={guestPhone}
|
|
customerId={customerId}
|
|
onGuestNameChange={setGuestName}
|
|
onGuestPhoneChange={setGuestPhone}
|
|
onCustomerChange={setCustomer}
|
|
onClearCustomer={clearCustomer}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Transfer table (for table orders with active session) */}
|
|
{activeOrderId && tableId ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-full text-xs"
|
|
onClick={() => setShowTransferPicker(true)}
|
|
>
|
|
{t("transferTable")}
|
|
</Button>
|
|
) : null}
|
|
|
|
{/* Assign table button (for counter orders) */}
|
|
{orderType === "counter" && activeOrderId ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-full text-xs"
|
|
onClick={() => setShowTablePicker(true)}
|
|
>
|
|
{t("assignTable")} →
|
|
</Button>
|
|
) : null}
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-3 pt-0">
|
|
{/* Cart items */}
|
|
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain pe-0.5">
|
|
{items.length === 0 ? (
|
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
|
{t("emptyCart")}
|
|
</p>
|
|
) : (
|
|
items.map((line) => (
|
|
<div
|
|
key={line.orderItemId ?? line.menuItem.id}
|
|
className={cn(
|
|
"flex flex-col gap-1.5 rounded-lg border border-border p-2",
|
|
line.isVoided && "opacity-60"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<MenuItemLabels
|
|
item={line.menuItem}
|
|
lines={1}
|
|
primaryClassName={cn(
|
|
"text-xs font-medium",
|
|
line.isVoided &&
|
|
"line-through text-muted-foreground"
|
|
)}
|
|
secondaryClassName="text-[10px]"
|
|
/>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{line.isVoided ? (
|
|
<span>{t("voided")}</span>
|
|
) : (
|
|
formatCurrency(
|
|
line.menuItem.price * line.quantity,
|
|
numberLocale
|
|
)
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
className="flex shrink-0 items-center gap-0.5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
{isManager &&
|
|
line.orderItemId &&
|
|
!line.isVoided &&
|
|
activeOrderId ? (
|
|
<button
|
|
type="button"
|
|
className="cursor-pointer text-[10px] text-destructive hover:underline"
|
|
onClick={() => handleVoidItem(line.orderItemId!)}
|
|
aria-label={t("voidItem")}
|
|
>
|
|
{t("void")}
|
|
</button>
|
|
) : null}
|
|
{!line.isVoided ? (
|
|
<>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
className="h-8 w-8"
|
|
onClick={() =>
|
|
updateQty(line.menuItem.id, line.quantity - 1)
|
|
}
|
|
>
|
|
<Minus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<span className="w-6 text-center text-xs font-medium">
|
|
{formatNumber(line.quantity, numberLocale)}
|
|
</span>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
className="h-8 w-8"
|
|
onClick={() =>
|
|
updateQty(line.menuItem.id, line.quantity + 1)
|
|
}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => removeItem(line.menuItem.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{!line.isVoided && (
|
|
<input
|
|
type="text"
|
|
value={line.notes ?? ""}
|
|
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
placeholder={t("itemNotePlaceholder")}
|
|
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
|
/>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Totals + actions */}
|
|
<div className="shrink-0 space-y-1.5 border-t border-border bg-card pt-2">
|
|
{/* Coupon row */}
|
|
<div className="flex flex-wrap items-end gap-1.5">
|
|
<LabeledField
|
|
label={t("couponCode")}
|
|
htmlFor="pos-coupon"
|
|
className="min-w-0 flex-1"
|
|
>
|
|
<Input
|
|
id="pos-coupon"
|
|
value={couponCode}
|
|
onChange={(e) => {
|
|
setCouponCode(e.target.value);
|
|
if (couponMessage) setCouponMessage(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === "Enter" &&
|
|
!appliedCoupon &&
|
|
couponCode.trim() &&
|
|
!validateCoupon.isPending
|
|
) {
|
|
e.preventDefault();
|
|
validateCoupon.mutate();
|
|
}
|
|
}}
|
|
disabled={!!appliedCoupon}
|
|
dir="ltr"
|
|
className="h-8 text-end text-sm"
|
|
/>
|
|
</LabeledField>
|
|
{appliedCoupon ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
clearCoupon();
|
|
setCouponMessage({
|
|
type: "success",
|
|
text: t("couponRemoved"),
|
|
});
|
|
}}
|
|
>
|
|
{t("removeCoupon")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
!couponCode.trim() ||
|
|
items.length === 0 ||
|
|
validateCoupon.isPending
|
|
}
|
|
onClick={() => validateCoupon.mutate()}
|
|
>
|
|
{t("applyCoupon")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{couponMessage ? (
|
|
<p
|
|
className={cn(
|
|
"text-center text-sm",
|
|
couponMessage.type === "success"
|
|
? "text-[#0F6E56]"
|
|
: "text-[#A32D2D]"
|
|
)}
|
|
>
|
|
{couponMessage.text}
|
|
</p>
|
|
) : null}
|
|
|
|
{appliedCoupon ? (
|
|
<div className="flex justify-between text-sm text-[#BA7517]">
|
|
<span>{t("couponActive", { code: appliedCoupon.code })}</span>
|
|
<span>-{formatCurrency(discount, numberLocale)}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex justify-between text-sm">
|
|
<span>{t("subtotal")}</span>
|
|
<span>{formatCurrency(sub, numberLocale)}</span>
|
|
</div>
|
|
{discount > 0 ? (
|
|
<div className="flex justify-between text-sm text-[#0F6E56]">
|
|
<span>{t("discount")}</span>
|
|
<span>-{formatCurrency(discount, numberLocale)}</span>
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-between text-sm">
|
|
<span>{t("tax")}</span>
|
|
<span>{formatCurrency(tax, numberLocale)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm font-bold">
|
|
<span>{t("total")}</span>
|
|
<span>{formatCurrency(total, numberLocale)}</span>
|
|
</div>
|
|
|
|
{!isOnline ? (
|
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-center text-[11px] text-amber-700">
|
|
{t("offlineQueueNotice")}
|
|
</p>
|
|
) : null}
|
|
|
|
{orderMessage ? (
|
|
<p className="text-center text-xs text-primary">
|
|
{orderMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{!canSubmitOrder &&
|
|
items.length > 0 &&
|
|
orderType !== "counter" &&
|
|
orderType !== "takeaway" ? (
|
|
<p className="text-center text-[10px] text-amber-700">
|
|
{t("needTableOrName")}
|
|
</p>
|
|
) : null}
|
|
|
|
{items.some((line) => !line.isVoided) ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={openKitchenSlip}
|
|
>
|
|
{t("kitchenSlip")}
|
|
</Button>
|
|
) : null}
|
|
|
|
<div className="flex flex-col gap-2 pt-0.5">
|
|
<Button
|
|
size="sm"
|
|
className="w-full"
|
|
disabled={!canSubmitOrder || isOrderBusy}
|
|
onClick={() => submitOrderAndPay.mutate()}
|
|
>
|
|
{submitOrderAndPay.isPending
|
|
? "..."
|
|
: t("submitOrderAndPay")}
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="flex-1"
|
|
disabled={!canSubmitOrder || isOrderBusy}
|
|
onClick={() => submitOrder.mutate()}
|
|
>
|
|
{submitOrder.isPending ? "..." : t("submitOrder")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleBackToTypePicker}
|
|
>
|
|
{t("clearCart")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Table picker modal (for Table orders & counter assign) ────────── */}
|
|
{showTablePicker ? (
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-16">
|
|
<div className="w-full max-w-3xl rounded-2xl border border-border bg-background p-5 shadow-2xl">
|
|
<div className="mb-4 flex items-center justify-between gap-2">
|
|
<h3 className="text-base font-semibold">{t("selectTableBoard")}</h3>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setShowTablePicker(false);
|
|
// If table type was selected but user dismissed without choosing a table,
|
|
// go back to type picker
|
|
if (orderType === "table" && !tableId) {
|
|
setOrderType(null);
|
|
}
|
|
}}
|
|
>
|
|
<X className="size-5" />
|
|
</Button>
|
|
</div>
|
|
{cafeId ? (
|
|
<PosTableBoard
|
|
cafeId={cafeId}
|
|
numberLocale={numberLocale}
|
|
selectedTableId={tableId}
|
|
branchId={orderBranchId}
|
|
onSelectTable={handleTableSelect}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ── Kitchen slip modal ────────────────────────────────────────────── */}
|
|
{kitchenSlip ? (
|
|
<PosSlipModal
|
|
variant="kitchen"
|
|
cafeName={cafeName}
|
|
kitchenLines={kitchenSlip.lines}
|
|
orderId={kitchenSlip.orderId}
|
|
tableNumber={kitchenSlip.tableNumber}
|
|
guestName={kitchenSlip.guestName}
|
|
onClose={() => setKitchenSlip(null)}
|
|
/>
|
|
) : null}
|
|
|
|
{/* ── Transfer table modal ──────────────────────────────────────────── */}
|
|
{showTransferPicker ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
<div className="w-full max-w-sm rounded-xl border border-border bg-background p-4 shadow-lg">
|
|
<p className="mb-3 text-sm font-medium">{t("selectTargetTable")}</p>
|
|
<div className="mb-4 flex max-h-48 flex-wrap gap-2 overflow-y-auto">
|
|
{freeTransferTables.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("noOrderOnTable")}
|
|
</p>
|
|
) : (
|
|
freeTransferTables.map((tbl) => (
|
|
<Button
|
|
key={tbl.id}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={transferTableMutation.isPending}
|
|
onClick={() => transferTableMutation.mutate(tbl.id)}
|
|
>
|
|
{t("table")} {tbl.number}
|
|
</Button>
|
|
))
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="w-full"
|
|
onClick={() => setShowTransferPicker(false)}
|
|
>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|