Files
meezi/web/dashboard/src/components/pos/pos-slip-modal.tsx
T
soroush.asadi fc21471131 Native thermal printing via iframe — 80mm, RTL, no blank tail
Problem: window.print() on the main page used A4 height (blank paper
after receipt), no RTL direction, and Tailwind styles leaked into print.

Solution — iframe isolation:
- lib/thermal-print.ts: builds a self-contained HTML document
  (@page { size: 80mm auto; margin: 0 }, html { direction: rtl })
  and fires it through a hidden off-screen <iframe>. The iframe
  document contains only the receipt so height == content height.
- pos-slip-modal.tsx: Print button calls printThermal(buildThermalDocument())
  instead of window.print(). Preview panel is unchanged (screen only).
- pos-receipt-print.css: updated @page + direction as fallback for any
  remaining window.print() callers.

Works with USB driver (Atom A300) as default printer — OS print spooler
receives the job exactly as if it were any other document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 02:52:46 +03:30

219 lines
7.4 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 { 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 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,
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: 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"
>
<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">{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" />
<div className="mt-2 text-center text-xs">{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}
/>
);
}