Files
meezi/web/dashboard/src/components/pos/pos-slip-modal.tsx
T
2026-05-30 09:42:32 +03:30

266 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useTranslations, useLocale } from "next-intl";
import { Printer } from "lucide-react";
import type { Order } from "@/lib/api/types";
import { formatCurrency } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number";
import { buildThermalDocument, printThermal } from "@/lib/thermal-print";
import { resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import "./pos-receipt-print.css";
export type KitchenSlipLine = {
name: string;
quantity: number;
notes?: string;
};
type PosSlipModalProps = {
variant: "kitchen" | "bill";
cafeName: string;
/** Café logo for receipt branding. */
logoUrl?: string;
/** Address / phone line shown under the café name on the bill. */
tagline?: string;
/** Custom header note from branch print settings (bill only). */
receiptHeader?: string | null;
/** Custom footer note from branch print settings (bill only). */
receiptFooter?: string | null;
/** WiFi password printed near the bill footer. */
wifiPassword?: string | null;
/** Paper width in mm — 58 or 80 (default 80). */
paperWidthMm?: number;
onClose: () => void;
/** Full order for customer bill */
order?: Order;
/** Kitchen ticket lines (new items or full order) */
kitchenLines?: KitchenSlipLine[];
tableNumber?: string | number | null;
orderId?: string;
guestName?: string | null;
createdAt?: string;
};
export function PosSlipModal({
variant,
cafeName,
logoUrl,
tagline,
receiptHeader,
receiptFooter,
wifiPassword,
paperWidthMm,
onClose,
order,
kitchenLines = [],
tableNumber,
orderId,
guestName,
createdAt,
}: PosSlipModalProps) {
const t = useTranslations("receipt");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const dateSource = order?.createdAt ?? createdAt ?? new Date().toISOString();
const formattedDate = new Intl.DateTimeFormat(
locale === "en" ? "en-US" : "fa-IR",
{ dateStyle: "short", timeStyle: "short" }
).format(new Date(dateSource));
const table = order?.tableNumber ?? tableNumber ?? "—";
const orderNo = order
? formatOrderNumber(order)
: orderId
? formatOrderNumber({ id: orderId })
: null;
const guest = order?.guestName ?? guestName;
const activeBillItems = order?.items.filter((i) => !i.isVoided) ?? [];
const paymentKey = (method: string) => {
const m = method.toLowerCase();
if (m === "cash") return t("payment.cash");
if (m === "card") return t("payment.card");
if (m === "credit") return t("payment.credit");
return method;
};
// ── Build meta row ─────────────────────────────────────────────────────────
const metaParts: string[] = [];
metaParts.push(`${t("table")}: ${table}`);
if (orderNo) metaParts.push(`${t("order")}: #${orderNo}`);
if (guest) metaParts.push(`${t("guest")}: ${guest}`);
const metaRow = metaParts.join(" | ");
// ── Print handler ─────────────────────────────────────────────────────────
const handlePrint = () => {
const slipData =
variant === "kitchen"
? {
cafeName,
title: t("kitchenTitle"),
date: formattedDate,
metaRow,
lines: kitchenLines.map((l) => ({
name: l.name,
quantity: l.quantity,
notes: l.notes,
})),
footer: t("kitchenFooter"),
locale,
}
: {
cafeName,
logoUrl: resolveMediaUrl(logoUrl),
tagline,
header: receiptHeader?.trim() || undefined,
wifi: wifiPassword?.trim() || undefined,
paperWidthMm,
title: t("billTitle"),
date: formattedDate,
metaRow,
lines: activeBillItems.map((item) => ({
name: item.menuItemName,
quantity: item.quantity,
price: formatCurrency(item.unitPrice * item.quantity, numberLocale),
})),
totals: {
total: formatCurrency(order!.total, numberLocale),
payments: order!.payments?.map((p) => ({
method: paymentKey(p.method),
amount: formatCurrency(p.amount, numberLocale),
})),
},
footer: receiptFooter?.trim() || t("thankYou"),
locale,
};
printThermal(buildThermalDocument(slipData));
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-[340px] rounded-xl border border-border bg-background p-4 shadow-xl">
{/* ── Print preview ──────────────────────────────────────────────── */}
<div
id="pos-slip-print-area"
className="mb-4 rounded-md border border-dashed border-border p-3"
>
{variant === "bill" && logoUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(logoUrl)}
alt=""
className="mx-auto mb-1.5 max-h-12 w-auto object-contain"
/>
)}
<div className="text-center text-lg font-extrabold leading-tight">{cafeName}</div>
{variant === "bill" && tagline && (
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
)}
{variant === "bill" && receiptHeader?.trim() && (
<div className="whitespace-pre-line text-center text-[11px] font-medium text-foreground/80">
{receiptHeader.trim()}
</div>
)}
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div>
<div className="mb-2 text-center text-xs text-muted-foreground">
{formattedDate}
</div>
<div className="text-xs">{metaRow}</div>
<div className="receipt-divider" />
{variant === "kitchen"
? kitchenLines.map((line, idx) => (
<div key={`${line.name}-${idx}`} className="receipt-row mb-1 text-xs">
<span>
{line.name} × {line.quantity}
{line.notes ? ` (${line.notes})` : ""}
</span>
</div>
))
: activeBillItems.map((item) => (
<div key={item.id} className="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" />
{wifiPassword?.trim() && (
<div className="text-center text-[11px]" dir="ltr">
WiFi: {wifiPassword.trim()}
</div>
)}
<div className="mt-2 text-center text-xs">
{receiptFooter?.trim() || t("thankYou")}
</div>
</>
)}
{variant === "kitchen" && (
<div className="mt-2 text-center text-[10px] text-muted-foreground">
{t("kitchenFooter")}
</div>
)}
</div>
{/* ── Actions ────────────────────────────────────────────────────── */}
<div className="flex gap-2">
<Button type="button" className="flex-1 gap-1.5" onClick={handlePrint}>
<Printer className="h-4 w-4" />
{t("print")}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={onClose}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
/** @deprecated Use PosSlipModal variant="bill" */
export function PosReceiptModal({
order,
cafeName,
onClose,
}: {
order: Order;
cafeName: string;
onClose: () => void;
}) {
return (
<PosSlipModal
variant="bill"
order={order}
cafeName={cafeName}
onClose={onClose}
/>
);
}