8fe50af341
The terminal's badge only looked at navigator.onLine, so it said Online while the server was down. It now also reflects whether the last sync or health check reached the server. The cart badge no longer covers the product name. docs/PILOT_READINESS.md records what changed against the PRD review, the decisions taken (offline sales may take stock negative, transfers may not, server price wins but differences are flagged), rollout notes, and what is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
613 lines
24 KiB
TypeScript
613 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { apiFetch, ApiError } from "@/lib/api";
|
||
import { isValidKenyanPhone } from "@/lib/phone";
|
||
import { AuthUser, PaymentMethod, Product } from "@/lib/types";
|
||
import {
|
||
dismissRejected,
|
||
flushQueue,
|
||
generateClientUuid,
|
||
queueCounts,
|
||
queueSale,
|
||
registerOfflineSync,
|
||
salesFor,
|
||
type QueuedSale,
|
||
type QueueOwner,
|
||
} from "@/lib/offlineQueue";
|
||
import { clearSession, loadSession, saveSession } from "@/lib/sessionCache";
|
||
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
||
import { useToast } from "@/components/ui/Toast";
|
||
import ProductGrid from "@/components/terminal/ProductGrid";
|
||
import Receipt from "@/components/terminal/Receipt";
|
||
import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle";
|
||
import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay";
|
||
|
||
type ScreenState =
|
||
| { kind: "idle" }
|
||
| { kind: "receipt"; sale: QueuedSale }
|
||
| { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null };
|
||
|
||
interface CartLine {
|
||
productId: string;
|
||
quantity: number;
|
||
}
|
||
|
||
export default function TerminalPage() {
|
||
const router = useRouter();
|
||
const { show } = useToast();
|
||
const [me, setMe] = useState<AuthUser | null>(null);
|
||
const [mpesaEnabled, setMpesaEnabled] = useState(false);
|
||
const [bootError, setBootError] = useState<string | null>(null);
|
||
const [online, setOnline] = useState(true);
|
||
// navigator.onLine only knows about the phone's own connection; this
|
||
// also goes false when the server could not be reached.
|
||
const [serverReachable, setServerReachable] = useState(true);
|
||
const [counts, setCounts] = useState({ pending: 0, rejected: 0 });
|
||
const [showQueue, setShowQueue] = useState(false);
|
||
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
|
||
|
||
const [products, setProducts] = useState<Product[]>([]);
|
||
const [productsLoading, setProductsLoading] = useState(true);
|
||
const [cart, setCart] = useState<CartLine[]>([]);
|
||
|
||
const [showCustomer, setShowCustomer] = useState(false);
|
||
const [customerName, setCustomerName] = useState("");
|
||
const [customerPhone, setCustomerPhone] = useState("");
|
||
const [method, setMethod] = useState<PaymentMethod>("CASH");
|
||
const [phoneError, setPhoneError] = useState<string | null>(null);
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
|
||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const owner: QueueOwner | null = me ? { id: me.id, businessId: me.businessId, branchId: me.branchId } : null;
|
||
const productById = new Map(products.map((p) => [p._id, p]));
|
||
const lines = cart
|
||
.map((c) => ({ ...c, product: productById.get(c.productId) }))
|
||
.filter((c): c is CartLine & { product: Product } => !!c.product);
|
||
const total = lines.reduce((sum, l) => sum + l.quantity * l.product.unitPrice, 0);
|
||
|
||
const refreshCounts = useCallback(async (userId: string) => {
|
||
setCounts(await queueCounts(userId));
|
||
}, []);
|
||
|
||
// Boot: confirm the session online, or fall back to the last confirmed
|
||
// session on this device so a cashier can keep selling with no network.
|
||
useEffect(() => {
|
||
(async () => {
|
||
const cached = loadSession();
|
||
let user: AuthUser;
|
||
try {
|
||
const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me");
|
||
if (result.user.role === "SUPER_ADMIN") {
|
||
router.replace("/admin/dashboard");
|
||
return;
|
||
}
|
||
user = result.user;
|
||
setMpesaEnabled(!!result.features?.mpesa);
|
||
saveSession({ user, features: { mpesa: !!result.features?.mpesa } });
|
||
} catch (err) {
|
||
// Signed out or blocked: go to login. Anything else (no network,
|
||
// server unreachable) falls back to the saved session.
|
||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||
router.replace("/login");
|
||
return;
|
||
}
|
||
if (!cached) {
|
||
setBootError("You're offline. Connect to the internet once to sign in on this phone.");
|
||
return;
|
||
}
|
||
user = cached.user;
|
||
setServerReachable(false);
|
||
setMpesaEnabled(false); // M-Pesa needs a connection anyway
|
||
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
|
||
}
|
||
setMe(user);
|
||
await refreshCounts(user.id);
|
||
|
||
try {
|
||
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
|
||
setProducts(productsResult.products);
|
||
saveSession({ user, products: productsResult.products });
|
||
} catch {
|
||
const saved = loadSession();
|
||
if (saved?.user.id === user.id && saved.products.length > 0) {
|
||
setProducts(saved.products);
|
||
} else {
|
||
show("Could not load products - check your connection", "error");
|
||
}
|
||
}
|
||
setProductsLoading(false);
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Connectivity + background sync for this cashier's queue.
|
||
useEffect(() => {
|
||
if (!me) return;
|
||
setOnline(navigator.onLine);
|
||
const goOnline = () => {
|
||
setOnline(true);
|
||
show("Back online - syncing saved sales", "info");
|
||
};
|
||
const goOffline = () => {
|
||
setOnline(false);
|
||
show("You're offline - cash sales keep saving and sync automatically", "info");
|
||
};
|
||
window.addEventListener("online", goOnline);
|
||
window.addEventListener("offline", goOffline);
|
||
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
||
setCounts((c) => ({ ...c, pending: r.pending }));
|
||
setServerReachable(!r.offline);
|
||
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
|
||
if (r.rejected > 0) {
|
||
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
|
||
refreshCounts(me.id);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
window.removeEventListener("online", goOnline);
|
||
window.removeEventListener("offline", goOffline);
|
||
unregister();
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [me?.id]);
|
||
|
||
function addProduct(productId: string) {
|
||
setFormError(null);
|
||
setCart((prev) => {
|
||
const existing = prev.find((l) => l.productId === productId);
|
||
if (existing) {
|
||
return prev.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + 1) } : l));
|
||
}
|
||
if (prev.length >= MAX_LINES_PER_SALE) return prev;
|
||
return [...prev, { productId, quantity: 1 }];
|
||
});
|
||
}
|
||
|
||
function changeQuantity(productId: string, delta: number) {
|
||
setCart((prev) =>
|
||
prev
|
||
.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + delta) } : l))
|
||
.filter((l) => l.quantity > 0)
|
||
);
|
||
}
|
||
|
||
function resetForm() {
|
||
setCart([]);
|
||
setCustomerName("");
|
||
setCustomerPhone("");
|
||
setShowCustomer(false);
|
||
setMethod("CASH");
|
||
setPhoneError(null);
|
||
setFormError(null);
|
||
setScreen({ kind: "idle" });
|
||
}
|
||
|
||
function validate(requirePhone: boolean): boolean {
|
||
if (lines.length === 0) {
|
||
setFormError("Add at least one product.");
|
||
return false;
|
||
}
|
||
if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) {
|
||
setShowCustomer(true);
|
||
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
|
||
return false;
|
||
}
|
||
setPhoneError(null);
|
||
setFormError(null);
|
||
return true;
|
||
}
|
||
|
||
async function handleCashSale() {
|
||
if (!me || !owner || !validate(false)) return;
|
||
if (!me.branchId) {
|
||
setFormError("Your account is not assigned to a branch.");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
|
||
// The IndexedDB write is the only step that must finish before we show
|
||
// success: it is what keeps the sale if the phone dies before syncing.
|
||
const sale = await queueSale({
|
||
clientUuid: generateClientUuid(),
|
||
userId: me.id,
|
||
businessId: me.businessId ?? "",
|
||
branchId: me.branchId,
|
||
customerName: customerName.trim() || undefined,
|
||
customerPhone: customerPhone.trim() || undefined,
|
||
lines: lines.map((l) => ({
|
||
productId: l.productId,
|
||
label: l.product.label,
|
||
quantity: l.quantity,
|
||
unitPrice: l.product.unitPrice,
|
||
})),
|
||
total,
|
||
clientSubmittedAt: new Date().toISOString(),
|
||
});
|
||
setSubmitting(false);
|
||
setScreen({ kind: "receipt", sale });
|
||
setCart([]);
|
||
await refreshCounts(me.id);
|
||
|
||
// Sync in the background; a failure leaves the sale queued for the
|
||
// next automatic retry.
|
||
if (navigator.onLine) {
|
||
flushQueue(owner).then(async (r) => {
|
||
setCounts((c) => ({ ...c, pending: r.pending }));
|
||
setServerReachable(!r.offline);
|
||
const rows = await salesFor(me.id);
|
||
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
|
||
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
|
||
if (r.rejected > 0) refreshCounts(me.id);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function startMpesaPush() {
|
||
if (!validate(true)) return;
|
||
if (lines.length !== 1) {
|
||
setFormError("M-Pesa payments support one product per payment. Use cash for mixed sales.");
|
||
return;
|
||
}
|
||
if (!navigator.onLine) {
|
||
setFormError("M-Pesa requires an active network connection.");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
|
||
try {
|
||
const data = await apiFetch<{ transactionId: string }>("/api/mpesa/stkpush", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
customerName: customerName || undefined,
|
||
customerPhone,
|
||
productId: lines[0].productId,
|
||
quantity: lines[0].quantity,
|
||
}),
|
||
});
|
||
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
|
||
beginPolling(data.transactionId);
|
||
} catch (err) {
|
||
const reason = err instanceof ApiError ? err.message : "Network error while contacting M-Pesa.";
|
||
setScreen({ kind: "mpesa", transactionId: "", ui: "FAILED", reason });
|
||
}
|
||
setSubmitting(false);
|
||
}
|
||
|
||
function beginPolling(transactionId: string) {
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
const start = Date.now();
|
||
|
||
pollRef.current = setInterval(async () => {
|
||
try {
|
||
const data = await apiFetch<{
|
||
paymentStatus: string;
|
||
mpesaDetails?: { receiptNumber?: string | null; resultDesc?: string | null };
|
||
}>("/api/mpesa/status/" + transactionId);
|
||
if (data.paymentStatus === "SUCCESS") {
|
||
clearInterval(pollRef.current!);
|
||
setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber });
|
||
} else if (data.paymentStatus === "FAILED") {
|
||
clearInterval(pollRef.current!);
|
||
setScreen({
|
||
kind: "mpesa",
|
||
transactionId,
|
||
ui: "FAILED",
|
||
reason: data.mpesaDetails?.resultDesc || "Payment was not completed.",
|
||
});
|
||
} else if (Date.now() - start > 60000) {
|
||
clearInterval(pollRef.current!);
|
||
// Not "failed": the customer may still approve it, and the callback
|
||
// would then record it as paid. Taking cash now would count it twice.
|
||
setScreen({
|
||
kind: "mpesa",
|
||
transactionId,
|
||
ui: "FAILED",
|
||
reason:
|
||
"No confirmation yet. Do NOT take cash for this sale until a manager has checked whether the M-Pesa payment arrived.",
|
||
});
|
||
}
|
||
} catch {
|
||
// transient network hiccup while polling; keep trying until timeout above
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
async function openQueue() {
|
||
if (!me) return;
|
||
setQueueRows((await salesFor(me.id)).filter((r) => r.state !== "synced"));
|
||
setShowQueue(true);
|
||
}
|
||
|
||
async function handleDismiss(clientUuid: string) {
|
||
if (!me) return;
|
||
if (!window.confirm("Remove this sale from the phone? Make sure your manager has a record of it first.")) return;
|
||
await dismissRejected(clientUuid);
|
||
setQueueRows((rows) => rows.filter((r) => r.clientUuid !== clientUuid));
|
||
await refreshCounts(me.id);
|
||
}
|
||
|
||
async function handleSignOut() {
|
||
if (counts.pending > 0) {
|
||
const ok = window.confirm(
|
||
`${counts.pending} sale(s) have not synced yet. They stay safely on this phone and sync the next time you sign in here. Sign out anyway?`
|
||
);
|
||
if (!ok) return;
|
||
}
|
||
try {
|
||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||
} catch {
|
||
// Offline: the cookies expire on their own; still clear this device.
|
||
}
|
||
clearSession();
|
||
router.replace("/login");
|
||
}
|
||
|
||
if (bootError) {
|
||
return (
|
||
<main className="flex min-h-screen items-center justify-center bg-depth-900 px-6 text-center">
|
||
<div>
|
||
<p className="text-sand-200">{bootError}</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="tap-target mt-6 rounded-xl2 bg-flow-500 px-6 font-display font-semibold text-depth-950"
|
||
>
|
||
Try again
|
||
</button>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (!me) {
|
||
return (
|
||
<main className="min-h-screen bg-sand-50 pb-10">
|
||
<header className="bg-depth-900 px-5 pb-6 pt-8">
|
||
<div className="h-12 w-40 animate-pulse rounded-lg bg-white/10" />
|
||
</header>
|
||
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
|
||
<div className="h-40 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
||
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
|
||
const connected = online && serverReachable;
|
||
|
||
return (
|
||
<main className="min-h-screen bg-sand-50 pb-10">
|
||
<header className="no-print bg-depth-900 px-5 pb-6 pt-8 text-white">
|
||
<div className="mx-auto flex max-w-md items-center justify-between">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p>
|
||
<h1 className="font-display text-lg font-bold">{me.name}</h1>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-1">
|
||
<span
|
||
className={
|
||
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
||
(connected ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
|
||
}
|
||
>
|
||
{connected ? "● Online" : "● Offline"}
|
||
</span>
|
||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
|
||
{(counts.pending > 0 || counts.rejected > 0) && (
|
||
<button
|
||
onClick={openQueue}
|
||
className={
|
||
"no-print w-full rounded-xl2 px-4 py-3 text-left text-sm font-medium shadow-soft " +
|
||
(counts.rejected > 0 ? "bg-alert-500 text-white" : "bg-cash-400 text-depth-900")
|
||
}
|
||
>
|
||
{counts.rejected > 0
|
||
? `${counts.rejected} sale(s) need attention - tap to see why`
|
||
: `${counts.pending} sale(s) saved on this phone, waiting to sync`}
|
||
</button>
|
||
)}
|
||
|
||
{screen.kind === "receipt" ? (
|
||
<div className="animate-fade-up space-y-4">
|
||
<Receipt
|
||
sale={screen.sale}
|
||
businessName={me.businessName}
|
||
branchName={me.branchName}
|
||
cashierName={me.name}
|
||
synced={screen.sale.state === "synced"}
|
||
/>
|
||
<div className="no-print grid grid-cols-2 gap-3">
|
||
<button
|
||
onClick={() => window.print()}
|
||
className="tap-target rounded-xl2 border border-depth-200 bg-white font-display font-semibold text-depth-800"
|
||
>
|
||
Print receipt
|
||
</button>
|
||
<button
|
||
onClick={resetForm}
|
||
className="tap-target rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]"
|
||
>
|
||
Next sale
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{productsLoading ? (
|
||
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
||
) : (
|
||
<ProductGrid products={products} quantities={quantities} onAdd={addProduct} />
|
||
)}
|
||
|
||
{lines.length > 0 && (
|
||
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||
<p className="mb-2 text-sm font-medium text-depth-600">This sale</p>
|
||
<ul className="divide-y divide-depth-100">
|
||
{lines.map((l) => (
|
||
<li key={l.productId} className="flex items-center justify-between gap-3 py-2">
|
||
<div className="min-w-0">
|
||
<p className="truncate font-display text-sm font-bold text-depth-900">{l.product.label}</p>
|
||
<p className="font-mono text-xs text-depth-500">
|
||
KES {(l.quantity * l.product.unitPrice).toLocaleString()}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => changeQuantity(l.productId, -1)}
|
||
aria-label={`Remove one ${l.product.label}`}
|
||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-sand-100 text-2xl font-bold text-depth-800 active:scale-95"
|
||
>
|
||
–
|
||
</button>
|
||
<span className="w-8 text-center font-display text-lg font-bold">{l.quantity}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => changeQuantity(l.productId, 1)}
|
||
aria-label={`Add one ${l.product.label}`}
|
||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-flow-500 text-2xl font-bold text-depth-950 active:scale-95"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-2 flex justify-between border-t border-depth-100 pt-2 font-display font-bold">
|
||
<span>Total</span>
|
||
<span>KES {total.toLocaleString()}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||
{!showCustomer ? (
|
||
<button onClick={() => setShowCustomer(true)} className="text-sm font-medium text-flow-600 underline underline-offset-2">
|
||
+ Add customer details {method === "MPESA" ? "(phone required)" : "(optional)"}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
|
||
Customer name (optional)
|
||
</label>
|
||
<input
|
||
id="cust-name"
|
||
value={customerName}
|
||
onChange={(e) => setCustomerName(e.target.value)}
|
||
placeholder="e.g. Jane Wambui"
|
||
className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3"
|
||
/>
|
||
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
|
||
Customer phone {method === "MPESA" ? "" : "(optional)"}
|
||
</label>
|
||
<input
|
||
id="cust-phone"
|
||
inputMode="tel"
|
||
value={customerPhone}
|
||
onChange={(e) => {
|
||
setCustomerPhone(e.target.value);
|
||
if (phoneError) setPhoneError(null);
|
||
}}
|
||
placeholder="07XXXXXXXX"
|
||
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
|
||
/>
|
||
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{formError && <p className="text-sm text-alert-500">{formError}</p>}
|
||
|
||
<div className="space-y-3">
|
||
{mpesaEnabled && <PaymentMethodToggle value={method} onChange={setMethod} />}
|
||
|
||
{method === "CASH" || !mpesaEnabled ? (
|
||
<button
|
||
onClick={handleCashSale}
|
||
disabled={submitting || lines.length === 0}
|
||
className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
||
>
|
||
{submitting ? "Saving…" : "Record Cash Sale · KES " + total.toLocaleString()}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={startMpesaPush}
|
||
disabled={submitting || lines.length === 0}
|
||
className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
||
>
|
||
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + total.toLocaleString()}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showQueue && (
|
||
<div role="dialog" aria-modal="true" className="no-print fixed inset-0 z-40 flex items-end justify-center bg-depth-950/60 sm:items-center">
|
||
<div className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-5 sm:rounded-3xl">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="font-display text-lg font-bold">Sales on this phone</h2>
|
||
<button onClick={() => setShowQueue(false)} className="text-sm text-depth-500 underline">
|
||
Close
|
||
</button>
|
||
</div>
|
||
{queueRows.length === 0 && <p className="text-sm text-depth-500">Everything is synced.</p>}
|
||
<ul className="space-y-3">
|
||
{queueRows.map((r) => (
|
||
<li key={r.clientUuid} className="rounded-xl border border-depth-200 p-3 text-sm">
|
||
<div className="flex justify-between font-medium">
|
||
<span>{new Date(r.clientSubmittedAt).toLocaleString("en-KE")}</span>
|
||
<span>KES {r.total.toLocaleString()}</span>
|
||
</div>
|
||
<p className="text-xs text-depth-500">{r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}</p>
|
||
{r.state === "pending" ? (
|
||
<p className="mt-1 text-xs text-cash-600">Waiting to sync</p>
|
||
) : (
|
||
<>
|
||
<p className="mt-1 text-xs text-alert-600">Not accepted: {r.error}</p>
|
||
<p className="text-xs text-depth-500">Tell your manager, then remove it from this phone.</p>
|
||
<button onClick={() => handleDismiss(r.clientUuid)} className="mt-1 text-xs font-medium text-alert-600 underline">
|
||
Remove from phone
|
||
</button>
|
||
</>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{screen.kind === "mpesa" && (
|
||
<PaymentStatusOverlay
|
||
state={screen.ui}
|
||
amount={total}
|
||
receiptNumber={screen.receipt}
|
||
failureReason={screen.reason}
|
||
onRetry={() => {
|
||
setScreen({ kind: "idle" });
|
||
startMpesaPush();
|
||
}}
|
||
onNewSale={resetForm}
|
||
onDismiss={() => setScreen({ kind: "idle" })}
|
||
/>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|