Make the terminal work offline end to end, and add stock screens

Terminal (PRD 5.3/5.5):
- Opens with no network: a service worker caches the app shell and build
  assets, and the last confirmed session and product list are kept on the
  phone. Only a definitive 401/403 sends the cashier to the login page.
- The offline queue is keyed by cashier and branch, sends only the
  signed-in cashier's sales, chunks to the server's batch limit, and acts
  on the per-sale result: synced, or rejected with the reason shown and
  never retried. Rows from the old queue are adopted on first sync.
- Several products per sale (tap to add), optional customer details, and
  a printable receipt. The banner shows sales waiting to sync and any
  that need attention; sign-out warns about unsynced sales.
- M-Pesa only appears when FEATURE_MPESA is on. A timed-out prompt now
  tells the cashier not to take cash until the payment is checked,
  instead of reporting it as failed.

Admin:
- New Stock page: stock per branch with low/out flags, physical counts
  and adjustments, inter-branch transfers, morning dispatch and evening
  count entry, and recent movements.
- Dashboard shows stock on hand per branch and links to Stock.
- Staff page can deactivate and reactivate accounts.
- Products page is read-only for branch managers and edits the low-stock
  alert level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:46:23 +03:00
parent 8817c0f52f
commit 472573a1a8
19 changed files with 1506 additions and 274 deletions
+380 -122
View File
@@ -1,37 +1,57 @@
"use client";
import { useEffect, useRef, useState } from "react";
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 { generateClientUuid, queueCashSale, registerOfflineSync } from "@/lib/offlineQueue";
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 ProductSizeSelector from "@/components/terminal/ProductSizeSelector";
import QuantityCounter from "@/components/terminal/QuantityCounter";
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: "cash_saved" }
| { 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 [authChecked, setAuthChecked] = useState(false);
const [mpesaEnabled, setMpesaEnabled] = useState(false);
const [bootError, setBootError] = useState<string | null>(null);
const [online, setOnline] = useState(true);
const [pendingCount, setPendingCount] = useState(0);
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 [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [cart, setCart] = useState<CartLine[]>([]);
const [showCustomer, setShowCustomer] = useState(false);
const [customerName, setCustomerName] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [quantity, setQuantity] = useState(1);
const [method, setMethod] = useState<PaymentMethod>("CASH");
const [phoneError, setPhoneError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
@@ -40,49 +60,87 @@ export default function TerminalPage() {
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const selectedProduct = products.find((p) => p._id === selectedProductId) ?? null;
const amount = selectedProduct ? quantity * selectedProduct.unitPrice : 0;
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 }>("/api/auth/me");
const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me");
if (result.user.role === "SUPER_ADMIN") {
router.replace("/admin/dashboard");
return;
}
setMe(result.user);
} catch {
router.replace("/login");
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;
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");
}
setAuthChecked(true);
setMe(user);
await refreshCounts(user.id);
try {
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
setProducts(productsResult.products);
if (productsResult.products.length > 0) setSelectedProductId(productsResult.products[0]._id);
saveSession({ user, products: productsResult.products });
} catch {
show("Could not load jerrycan sizes — check your connection", "error");
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 queued sales", "info");
show("Back online - syncing saved sales", "info");
};
const goOffline = () => {
setOnline(false);
show("You're offline — cash sales will keep saving and sync automatically", "info");
show("You're offline - cash sales keep saving and sync automatically", "info");
};
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
const unregister = registerOfflineSync((r) => {
if (r.synced > 0) {
setPendingCount((c) => Math.max(0, c - r.synced));
show(r.synced + " queued sale(s) synced", "success");
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
setCounts((c) => ({ ...c, pending: r.pending }));
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);
}
});
@@ -93,24 +151,46 @@ export default function TerminalPage() {
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("");
setQuantity(1);
setShowCustomer(false);
setMethod("CASH");
setPhoneError(null);
setFormError(null);
setScreen({ kind: "idle" });
}
function validate(): boolean {
if (!selectedProduct) {
setFormError("Choose a jerrycan size first.");
function validate(requirePhone: boolean): boolean {
if (lines.length === 0) {
setFormError("Add at least one product.");
return false;
}
if (!isValidKenyanPhone(customerPhone)) {
if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) {
setShowCustomer(true);
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
return false;
}
@@ -120,51 +200,55 @@ export default function TerminalPage() {
}
async function handleCashSale() {
if (!validate() || !selectedProduct) return;
if (!me || !owner || !validate(false)) return;
if (!me.branchId) {
setFormError("Your account is not assigned to a branch.");
return;
}
setSubmitting(true);
const sale = {
// 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(),
customerName: customerName || undefined,
customerPhone,
productId: selectedProduct._id,
quantity,
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(),
};
// IndexedDB write is the only step that must complete before we can
// safely show success — it's what guarantees the sale survives even if
// the app crashes or the device loses power before the network sync
// below finishes. It's also fast (a few ms), so this doesn't add
// perceptible delay.
await queueCashSale(sale);
setPendingCount((c) => c + 1);
});
setSubmitting(false);
setScreen({ kind: "receipt", sale });
setCart([]);
await refreshCounts(me.id);
// OPTIMISTIC UI: show the confirmation screen immediately rather than
// waiting on the network round trip. The worker handed over the
// jerrycan and took the cash — from their perspective the sale is
// already done, and the app should agree instantly. The actual sync
// to the server happens in the background; if it fails here, the sale
// is NOT lost — it stays in the offline queue and the 'online'
// listener / interval in offlineQueue.ts retries it automatically.
setScreen({ kind: "cash_saved" });
// Sync in the background; a failure leaves the sale queued for the
// next automatic retry.
if (navigator.onLine) {
apiFetch("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: [sale] }),
})
.then(() => setPendingCount((c) => Math.max(0, c - 1)))
.catch(() => {
// stays queued; offline sync interval will retry — no toast here,
// since this is a routine background retry, not a user-facing error
});
flushQueue(owner).then(async (r) => {
setCounts((c) => ({ ...c, pending: r.pending }));
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() || !selectedProduct) return;
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;
@@ -177,8 +261,8 @@ export default function TerminalPage() {
body: JSON.stringify({
customerName: customerName || undefined,
customerPhone,
productId: selectedProduct._id,
quantity,
productId: lines[0].productId,
quantity: lines[0].quantity,
}),
});
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
@@ -196,7 +280,10 @@ export default function TerminalPage() {
pollRef.current = setInterval(async () => {
try {
const data = await apiFetch<any>("/api/mpesa/status/" + transactionId);
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 });
@@ -210,7 +297,15 @@ export default function TerminalPage() {
});
} else if (Date.now() - start > 60000) {
clearInterval(pollRef.current!);
setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No response from customer in time." });
// 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
@@ -218,7 +313,53 @@ export default function TerminalPage() {
}, 3000);
}
if (!authChecked || !me) {
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">
@@ -232,95 +373,176 @@ export default function TerminalPage() {
);
}
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
return (
<main className="min-h-screen bg-sand-50 pb-10">
<header className="bg-depth-900 px-5 pb-6 pt-8 text-white">
<div className="flex items-center justify-between">
<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 " + (online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")}>
<span
className={
"rounded-full px-2.5 py-1 text-xs font-medium " +
(online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
}
>
{online ? "● Online" : "● Offline"}
</span>
{pendingCount > 0 && <span className="text-xs text-cash-400">{pendingCount} cash sale(s) syncing…</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">
{screen.kind === "cash_saved" ? (
<div className="animate-fade-up rounded-xl2 border border-cash-500/30 bg-white p-6 text-center shadow-soft">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-cash-500/15 text-2xl">💵</div>
<h2 className="font-display text-lg font-bold text-depth-900">Cash Sale Recorded</h2>
<p className="mt-1 text-sm text-depth-600">
KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans
</p>
<button onClick={resetForm} className="tap-target mt-6 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]">
Start next sale
</button>
{(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>
) : (
<>
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<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 w-full rounded-xl border border-depth-200 px-3 mb-3"
/>
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
Customer phone
</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>
{productsLoading ? (
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
) : (
<ProductSizeSelector products={products} selectedId={selectedProductId} onSelect={setSelectedProductId} />
<ProductGrid products={products} quantities={quantities} onAdd={addProduct} />
)}
{selectedProduct && (
<QuantityCounter quantity={quantity} onChange={setQuantity} unitPrice={selectedProduct.unitPrice} sizeLabel={selectedProduct.sizeLiters + "L"} />
{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">
<PaymentMethodToggle value={method} onChange={setMethod} />
{mpesaEnabled && <PaymentMethodToggle value={method} onChange={setMethod} />}
{method === "CASH" ? (
{method === "CASH" || !mpesaEnabled ? (
<button
onClick={handleCashSale}
disabled={submitting || !selectedProduct}
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 " + amount.toLocaleString()}
{submitting ? "Saving…" : "Record Cash Sale · KES " + total.toLocaleString()}
</button>
) : (
<button
onClick={startMpesaPush}
disabled={submitting || !selectedProduct}
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 " + amount.toLocaleString()}
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + total.toLocaleString()}
</button>
)}
</div>
@@ -328,10 +550,46 @@ export default function TerminalPage() {
)}
</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={amount}
amount={total}
receiptNumber={screen.receipt}
failureReason={screen.reason}
onRetry={() => {