Files
ERP/src/app/terminal/page.tsx
T
denis_ireri 8fe50af341 Show Offline when the server is unreachable, and document the pilot changes
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>
2026-10-11 18:54:24 +03:00

613 lines
24 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 { 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>
);
}