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
+86
View File
@@ -0,0 +1,86 @@
/* AquaFlow service worker: lets the cashier terminal open and run with no network.
*
* - Page loads (navigations): network first, falling back to the cached copy,
* and to the cached /terminal page for anything else, so a cashier who opens
* the app offline still lands on the till.
* - Next.js build assets (/_next/static/*): cache first. They are content-hashed
* and immutable, so a cached copy is always correct.
* - API calls (/api/*) and every non-GET request: never cached. Sales go through
* the IndexedDB queue in the page, not through this worker.
*
* Bump CACHE_VERSION to drop every cached page and asset on the next visit.
*/
const CACHE_VERSION = "aquaflow-v1";
const APP_SHELL = ["/terminal", "/login"];
async function cacheShell() {
const cache = await caches.open(CACHE_VERSION);
for (const path of APP_SHELL) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) continue;
await cache.put(path, res.clone());
// Also cache the scripts and styles the page needs to boot.
const html = await res.text();
const assets = Array.from(html.matchAll(/(?:src|href)="(\/_next\/static\/[^"]+)"/g), (m) => m[1]);
await Promise.all(
Array.from(new Set(assets)).map((url) =>
cache.match(url).then((hit) => hit || fetch(url).then((r) => r.ok && cache.put(url, r)))
)
);
} catch (e) {
// Offline during install: the runtime handlers below fill the cache later.
}
}
}
self.addEventListener("install", (event) => {
event.waitUntil(cacheShell().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE_VERSION).then((c) => c.put(url.pathname, copy));
}
return res;
})
.catch(async () => {
const cache = await caches.open(CACHE_VERSION);
return (await cache.match(url.pathname)) || (await cache.match("/terminal")) || Response.error();
})
);
return;
}
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(
caches.open(CACHE_VERSION).then(async (cache) => {
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
})
);
}
});
+11 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { useTransactionFeed } from "@/lib/pollingFeed";
import { AuthUser, Branch, DashboardSummary, ReconciliationRow } from "@/lib/types";
import { AuthUser, Branch, DashboardSummary, ReconciliationRow, StockLevel } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
@@ -16,6 +16,7 @@ import LiveStatsRow from "@/components/admin/LiveStatsRow";
import BranchRankingList from "@/components/admin/BranchRankingList";
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
import StockSummaryCard from "@/components/admin/StockSummaryCard";
export default function AdminDashboardPage() {
const router = useRouter();
@@ -27,6 +28,7 @@ export default function AdminDashboardPage() {
const [selectedBranchId, setSelectedBranchId] = useState<string>("ALL");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [reconciliation, setReconciliation] = useState<ReconciliationRow[]>([]);
const [stock, setStock] = useState<StockLevel[]>([]);
const [loading, setLoading] = useState(true);
const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50);
@@ -75,12 +77,14 @@ export default function AdminDashboardPage() {
setLoading(true);
try {
const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
const [summaryRes, reconRes] = await Promise.all([
const [summaryRes, reconRes, stockRes] = await Promise.all([
apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam),
apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam),
apiFetch<{ levels: StockLevel[] }>("/api/stock" + branchParam),
]);
setSummary(summaryRes);
setReconciliation(reconRes.rows);
setStock(stockRes.levels);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error");
}
@@ -138,6 +142,9 @@ export default function AdminDashboardPage() {
Platform
</Link>
)}
<Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Stock
</Link>
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Sizes
</Link>
@@ -183,6 +190,8 @@ export default function AdminDashboardPage() {
pendingMpesa={summary.totals.pendingMpesa}
/>
<StockSummaryCard levels={stock} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<PaymentSplitDonut cashTotal={summary.totals.cashTotal} mpesaTotal={summary.totals.mpesaTotal} />
<LiveStatsRow
+37 -6
View File
@@ -25,6 +25,10 @@ export default function ProductsPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [editPrice, setEditPrice] = useState("");
const [editThreshold, setEditThreshold] = useState("");
// Products and prices apply to every branch, so only the owner edits them.
const canEdit = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
@@ -82,14 +86,22 @@ export default function ProductsPage() {
async function handlePriceUpdate(id: string) {
const price = parseFloat(editPrice);
const threshold = parseInt(editThreshold, 10);
if (!Number.isFinite(price) || price < 0) {
setError("Enter a valid price.");
return;
}
if (!Number.isInteger(threshold) || threshold < 0) {
setError("Enter a whole number for the low-stock alert.");
return;
}
setSaving(true);
try {
await apiFetch("/api/products/" + id, { method: "PATCH", body: JSON.stringify({ unitPrice: price }) });
show("Price updated", "success");
await apiFetch("/api/products/" + id, {
method: "PATCH",
body: JSON.stringify({ unitPrice: price, lowStockThreshold: threshold }),
});
show("Product updated", "success");
setEditingId(null);
await loadProducts();
} catch (err) {
@@ -152,6 +164,7 @@ export default function ProductsPage() {
<p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions.
{!canEdit && " Only the business owner can change products and prices."}
</p>
</div>
@@ -170,8 +183,12 @@ export default function ProductsPage() {
<p className="font-medium text-depth-900">
{p.label} <span className="text-depth-500">· {p.sizeLiters}L</span>
</p>
{editingId === p._id ? (
<div className="mt-1 flex items-center gap-2">
{!canEdit ? (
<p className="mt-0.5 font-mono text-sm text-depth-600">
KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10}
</p>
) : editingId === p._id ? (
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-sm text-depth-500">KES</span>
<input
autoFocus
@@ -180,8 +197,19 @@ export default function ProductsPage() {
step="0.01"
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
aria-label="Price"
className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/>
<span className="text-sm text-depth-500">low stock at</span>
<input
type="number"
min="0"
step="1"
value={editThreshold}
onChange={(e) => setEditThreshold(e.target.value)}
aria-label="Low-stock alert level"
className="w-20 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/>
<button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
Save
</button>
@@ -194,10 +222,11 @@ export default function ProductsPage() {
onClick={() => {
setEditingId(p._id);
setEditPrice(String(p.unitPrice));
setEditThreshold(String(p.lowStockThreshold ?? 10));
}}
className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2"
>
KES {p.unitPrice.toLocaleString()} · edit
KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} · edit
</button>
)}
</div>
@@ -205,7 +234,7 @@ export default function ProductsPage() {
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}>
{p.isActive ? "Active" : "Inactive"}
</span>
{p.isActive ? (
{!canEdit ? null : p.isActive ? (
<ConfirmButton
label="Deactivate"
confirmLabel="Deactivate"
@@ -228,6 +257,7 @@ export default function ProductsPage() {
)}
</div>
{canEdit && (
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
@@ -254,6 +284,7 @@ export default function ProductsPage() {
{saving ? "Adding…" : "Add size"}
</button>
</form>
)}
</div>
</main>
);
+42 -6
View File
@@ -109,6 +109,26 @@ export default function StaffPage() {
setSaving(false);
}
function canManage(s: StaffMember): boolean {
if (!me || s._id === me.id) return false;
if (isSuperAdmin) return true;
return s.role === "DELIVERY" && s.branchId === me.branchId;
}
async function toggleActive(s: StaffMember) {
const next = !s.isActive;
if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
return;
}
try {
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ isActive: next }) });
show(`${s.name} ${next ? "reactivated" : "deactivated"}`, "success");
await loadStaff();
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not update this account", "error");
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError(null);
@@ -186,17 +206,33 @@ export default function StaffPage() {
{staff.map((s) => {
const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
return (
<li key={s._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
<div>
<p className="font-medium text-depth-900">{s.name}</p>
<li key={s._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div className={s.isActive ? "" : "opacity-50"}>
<p className="font-medium text-depth-900">
{s.name}
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
</p>
<p className="text-xs text-depth-500">
{s.email}
{branchName ? " · " + branchName : ""}
</p>
</div>
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
{s.role.replace("_", " ")}
</span>
<div className="flex shrink-0 items-center gap-2">
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
{s.role.replace("_", " ")}
</span>
{canManage(s) && (
<button
onClick={() => toggleActive(s)}
className={
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
(s.isActive ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
}
>
{s.isActive ? "Deactivate" : "Reactivate"}
</button>
)}
</div>
</li>
);
})}
+456
View File
@@ -0,0 +1,456 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch, Product, StockLevel, StockMovementRow } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
import GlobalSwitcher from "@/components/admin/GlobalSwitcher";
const statusStyles: Record<StockLevel["status"], string> = {
OK: "bg-confirm-500/15 text-confirm-600",
LOW: "bg-cash-500/15 text-cash-700",
OUT: "bg-alert-500/15 text-alert-600",
};
const statusLabel: Record<StockLevel["status"], string> = { OK: "In stock", LOW: "Low", OUT: "Out" };
const movementLabel: Record<StockMovementRow["type"], string> = {
SALE: "Sale",
PURCHASE: "Purchase",
TRANSFER_OUT: "Transfer out",
TRANSFER_IN: "Transfer in",
ADJUSTMENT: "Adjustment",
};
type StockForm = "count" | "transfer" | "daily";
function newUuid() {
return crypto.randomUUID();
}
export default function StockPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [levels, setLevels] = useState<StockLevel[]>([]);
const [movements, setMovements] = useState<StockMovementRow[]>([]);
const [selectedBranchId, setSelectedBranchId] = useState("ALL");
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<StockForm>("count");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Shared form fields. A fresh clientUuid per submission makes a double
// tap on "Save" record the change once.
const [formBranchId, setFormBranchId] = useState("");
const [toBranchId, setToBranchId] = useState("");
const [productId, setProductId] = useState("");
const [countMode, setCountMode] = useState<"COUNT" | "DELTA">("COUNT");
const [dailyMode, setDailyMode] = useState<"dispatch" | "evening">("dispatch");
const [quantity, setQuantity] = useState("");
const [note, setNote] = useState("");
const [submissionId, setSubmissionId] = useState(newUuid);
const isSuper = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
if (result.user.role !== "SUPER_ADMIN" && result.user.branchId) {
setSelectedBranchId(result.user.branchId);
setFormBranchId(result.user.branchId);
}
const [br, pr] = await Promise.all([
apiFetch<{ branches: Branch[] }>("/api/branches"),
apiFetch<{ products: Product[] }>("/api/products"),
]);
setBranches(br.branches);
setProducts(pr.products);
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!me) return;
loadStock();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me, selectedBranchId]);
async function loadStock() {
const q = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
try {
const [s, m] = await Promise.all([
apiFetch<{ levels: StockLevel[] }>("/api/stock" + q),
apiFetch<{ movements: StockMovementRow[] }>("/api/stock/movements" + q + (q ? "&" : "?") + "limit=30"),
]);
setLevels(s.levels);
setMovements(m.movements);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load stock", "error");
}
}
const byBranch = useMemo(() => {
const groups = new Map<string, { branchName: string; rows: StockLevel[] }>();
for (const l of levels) {
if (!groups.has(l.branchId)) groups.set(l.branchId, { branchName: l.branchName, rows: [] });
groups.get(l.branchId)!.rows.push(l);
}
return Array.from(groups.entries());
}, [levels]);
const alerts = levels.filter((l) => l.status !== "OK").length;
function resetForm() {
setQuantity("");
setNote("");
setSubmissionId(newUuid());
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const qty = parseInt(quantity, 10);
const branchId = isSuper ? formBranchId : me?.branchId ?? "";
if (!productId) return setError("Choose a product.");
if (!branchId) return setError(form === "transfer" ? "Choose the sending branch." : "Choose a branch.");
if (!Number.isInteger(qty)) return setError("Enter a whole number.");
setSaving(true);
try {
if (form === "count") {
if (countMode === "COUNT" && qty < 0) throw new Error("A counted quantity cannot be negative.");
if (countMode === "DELTA" && qty === 0) throw new Error("Enter a non-zero quantity to add or remove.");
const res = await apiFetch<{ quantity: number }>("/api/stock/adjustments", {
method: "POST",
body: JSON.stringify({
clientUuid: submissionId,
branchId,
productId,
mode: countMode,
quantity: qty,
note: note.trim() || undefined,
}),
});
show(`Stock updated - now ${res.quantity}`, "success");
} else if (form === "transfer") {
if (qty <= 0) throw new Error("Enter how many to transfer.");
if (!toBranchId) throw new Error("Choose the receiving branch.");
await apiFetch("/api/stock/transfers", {
method: "POST",
body: JSON.stringify({
clientUuid: submissionId,
fromBranchId: branchId,
toBranchId,
productId,
quantity: qty,
note: note.trim() || undefined,
}),
});
show("Transfer recorded at both branches", "success");
} else {
if (qty < 0) throw new Error("Enter zero or more.");
const path = dailyMode === "dispatch" ? "/api/inventory/dispatch" : "/api/inventory/evening-count";
const field = dailyMode === "dispatch" ? "morningDispatched" : "eveningPhysicalCount";
await apiFetch(path, { method: "POST", body: JSON.stringify({ branchId, productId, [field]: qty }) });
show(dailyMode === "dispatch" ? "Morning dispatch saved" : "Evening count saved", "success");
}
resetForm();
await loadStock();
} catch (err) {
const message = err instanceof Error ? err.message : "Could not save";
setError(message);
show(message, "error");
}
setSaving(false);
}
if (loading || !me) {
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8">
<div className="mx-auto h-14 max-w-4xl" />
</header>
<div className="mx-auto -mt-4 max-w-4xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
const tabs: { id: StockForm; label: string }[] = [
{ id: "count", label: "Count / adjust" },
{ id: "transfer", label: "Transfer" },
{ id: "daily", label: "Daily dispatch" },
];
const inputClass = "tap-target w-full rounded-xl border border-depth-200 px-3";
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
<h1 className="font-display text-xl font-bold">Stock</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<GlobalSwitcher
branches={branches}
selectedBranchId={selectedBranchId}
onChange={setSelectedBranchId}
isSuperAdmin={!!isSuper}
/>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</div>
</header>
<div className="mx-auto -mt-4 max-w-4xl space-y-5 px-5">
{alerts > 0 && (
<div className="rounded-xl2 border border-cash-500/30 bg-cash-500/10 px-4 py-3 text-sm text-cash-700">
{alerts} product line(s) are low or out of stock.
</div>
)}
{byBranch.length === 0 ? (
<div className="rounded-xl2 bg-white shadow-soft">
<EmptyState
icon="📦"
title="No stock to show yet"
description="Add branches and products first, then record opening stock below with Count / adjust."
/>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{byBranch.map(([branchId, group]) => (
<section key={branchId} className="rounded-xl2 bg-white shadow-soft">
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">
{group.branchName}
</h2>
<ul className="divide-y divide-depth-100">
{group.rows.map((l) => (
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-depth-800">{l.productLabel}</span>
<span className="flex shrink-0 items-center gap-2">
<span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
{l.quantity}
</span>
<span className={"rounded-full px-2 py-0.5 text-xs font-medium " + statusStyles[l.status]}>
{statusLabel[l.status]}
</span>
</span>
</li>
))}
</ul>
</section>
))}
</div>
)}
{levels.some((l) => l.quantity < 0) && (
<p className="text-xs text-depth-500">
A negative number means sales were recorded for stock the system never received. Do a physical count
to correct it.
</p>
)}
<form onSubmit={handleSubmit} className="rounded-xl2 bg-white p-4 shadow-soft">
<div role="tablist" className="mb-4 grid grid-cols-3 gap-1 rounded-xl2 bg-sand-100 p-1">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={form === t.id}
onClick={() => {
setForm(t.id);
setError(null);
}}
className={
"rounded-xl px-2 py-2 text-xs font-semibold sm:text-sm " +
(form === t.id ? "bg-white text-depth-900 shadow-soft" : "text-depth-600")
}
>
{t.label}
</button>
))}
</div>
<p className="mb-3 text-xs text-depth-500">
{form === "count" &&
"Record a physical count (the system is corrected to what you counted), or add/remove stock for opening balances, breakage or deliveries not entered as purchases."}
{form === "transfer" && "Moves stock out of one branch and into another. Both branches update immediately."}
{form === "daily" &&
"For delivery crews: how many went out this morning, and how many came back this evening. Feeds the dashboard's reconciliation."}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isSuper && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-branch">
{form === "transfer" ? "From branch" : "Branch"}
</label>
<select id="stock-branch" value={formBranchId} onChange={(e) => setFormBranchId(e.target.value)} className={inputClass}>
<option value="">Select a branch…</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
{form === "transfer" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-to">
To branch
</label>
<select id="stock-to" value={toBranchId} onChange={(e) => setToBranchId(e.target.value)} className={inputClass}>
<option value="">Select a branch…</option>
{branches
.filter((b) => b._id !== (isSuper ? formBranchId : me.branchId))
.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-product">
Product
</label>
<select id="stock-product" value={productId} onChange={(e) => setProductId(e.target.value)} className={inputClass}>
<option value="">Select a product…</option>
{products.map((p) => (
<option key={p._id} value={p._id}>
{p.label}
</option>
))}
</select>
</div>
{form === "count" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-mode">
Type
</label>
<select
id="stock-mode"
value={countMode}
onChange={(e) => setCountMode(e.target.value as "COUNT" | "DELTA")}
className={inputClass}
>
<option value="COUNT">Physical count (set to)</option>
<option value="DELTA">Add or remove (+/-)</option>
</select>
</div>
)}
{form === "daily" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-daily">
Entry
</label>
<select
id="stock-daily"
value={dailyMode}
onChange={(e) => setDailyMode(e.target.value as "dispatch" | "evening")}
className={inputClass}
>
<option value="dispatch">Morning dispatch</option>
<option value="evening">Evening count</option>
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-qty">
{form === "count" && countMode === "DELTA" ? "Quantity (use - to remove)" : "Quantity"}
</label>
<input
id="stock-qty"
type="number"
inputMode="numeric"
step="1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className={inputClass}
/>
</div>
{form !== "daily" && (
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-note">
Note (optional)
</label>
<input
id="stock-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={form === "transfer" ? "e.g. Sent with the Thursday van" : "e.g. Opening stock, 3 cans leaking"}
className={inputClass}
/>
</div>
)}
</div>
{error && <p className="mt-3 text-sm text-alert-600">{error}</p>}
<button
type="submit"
disabled={saving}
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
>
{saving ? "Saving…" : form === "transfer" ? "Record transfer" : "Save"}
</button>
</form>
<div className="rounded-xl2 bg-white shadow-soft">
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">Recent stock movements</h2>
{movements.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-depth-500">No movements yet.</p>
) : (
<ul className="divide-y divide-depth-100">
{movements.map((m) => (
<li key={m._id} className="flex items-start justify-between gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0">
<p className="text-depth-900">
{movementLabel[m.type]} · {m.productId?.label ?? "Product"}
</p>
<p className="truncate text-xs text-depth-500">
{m.branchId?.branchName ?? ""} · {new Date(m.createdAt).toLocaleString("en-KE")}
{m.recordedBy?.name ? " · " + m.recordedBy.name : ""}
{m.note ? " · " + m.note : ""}
</p>
</div>
<span className={"shrink-0 font-mono font-semibold " + (m.quantity < 0 ? "text-alert-600" : "text-confirm-600")}>
{m.quantity > 0 ? "+" : ""}
{m.quantity}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</main>
);
}
+22
View File
@@ -28,3 +28,25 @@ body {
.tap-target {
min-height: 3.25rem;
}
/* Receipts: printing from the terminal prints only the receipt. */
@media print {
body * {
visibility: hidden;
}
.print-receipt,
.print-receipt * {
visibility: visible;
}
.print-receipt {
position: absolute;
left: 0;
top: 0;
width: 72mm;
box-shadow: none !important;
border: none !important;
}
.no-print {
display: none !important;
}
}
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { ToastProvider } from "@/components/ui/Toast";
import ServiceWorkerRegister from "@/components/ServiceWorkerRegister";
// NOTE: We deliberately do NOT use `next/font/google` here. That loader
// downloads the font files from Google at *build time*, which fails in
@@ -34,6 +35,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/>
</head>
<body>
<ServiceWorkerRegister />
<ToastProvider>{children}</ToastProvider>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
// Lets cashiers add the terminal to their home screen and open it full-screen.
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Vessel Branch Terminal",
short_name: "Vessel",
start_url: "/terminal",
display: "standalone",
background_color: "#F5F2EF",
theme_color: "#0C0C0C",
};
}
+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={() => {
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
/**
* Registers public/sw.js so the terminal can open offline. Production only:
* in `next dev` the build assets are not content-hashed, and caching them
* would serve stale code while developing.
*/
export default function ServiceWorkerRegister() {
useEffect(() => {
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js").catch(() => {
// Unsupported or blocked: the app still works online.
});
}, []);
return null;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export default function ActivityFeedMatrix({ transactions, branchNames, connecte
<li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone}</p>
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone || "Walk-in customer"}</p>
{scope === "global" && (
<span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600">
{branchLabel}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { StockLevel } from "@/lib/types";
interface Props {
levels: StockLevel[];
}
/** Current stock per branch at a glance, with anything low or out listed first. */
export default function StockSummaryCard({ levels }: Props) {
const totalsByBranch = new Map<string, { name: string; units: number; alerts: number }>();
for (const l of levels) {
const entry = totalsByBranch.get(l.branchId) ?? { name: l.branchName, units: 0, alerts: 0 };
entry.units += Math.max(l.quantity, 0);
if (l.status !== "OK") entry.alerts += 1;
totalsByBranch.set(l.branchId, entry);
}
const alerts = levels.filter((l) => l.status !== "OK").sort((a, b) => a.quantity - b.quantity);
return (
<section className="rounded-xl2 bg-white p-4 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display font-semibold text-depth-900">Stock on hand</h2>
<Link href="/admin/stock" className="text-sm font-medium text-flow-600 underline underline-offset-2">
Manage stock
</Link>
</div>
{levels.length === 0 ? (
<p className="text-sm text-depth-500">No stock recorded yet.</p>
) : (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{Array.from(totalsByBranch.entries()).map(([id, b]) => (
<div key={id} className="rounded-xl bg-sand-50 px-3 py-2">
<p className="truncate text-xs text-depth-500">{b.name}</p>
<p className="font-display text-lg font-bold text-depth-900">{b.units.toLocaleString()} units</p>
{b.alerts > 0 && <p className="text-xs font-medium text-cash-700">{b.alerts} low/out</p>}
</div>
))}
</div>
{alerts.length > 0 && (
<ul className="mt-3 space-y-1 text-sm">
{alerts.slice(0, 6).map((l) => (
<li key={l.branchId + l.productId} className="flex justify-between gap-3">
<span className="truncate text-depth-700">
{l.productLabel} · {l.branchName}
</span>
<span className={"shrink-0 font-mono " + (l.status === "OUT" ? "text-alert-600" : "text-cash-700")}>
{l.quantity}
</span>
</li>
))}
</ul>
)}
</>
)}
</section>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { Product } from "@/lib/types";
interface Props {
products: Product[];
quantities: Record<string, number>;
onAdd: (productId: string) => void;
}
/** One tap adds one unit of a product to the current sale. */
export default function ProductGrid({ products, quantities, onAdd }: Props) {
if (products.length === 0) {
return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
No products configured yet. Ask the owner to add them.
</div>
);
}
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-depth-600">Tap a product to add it</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{products.map((p) => {
const inCart = quantities[p._id] ?? 0;
return (
<button
key={p._id}
type="button"
onClick={() => onAdd(p._id)}
aria-label={`Add ${p.label}`}
className={
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 px-3 py-2 text-left transition active:scale-[0.97] " +
(inCart > 0 ? "border-flow-500 bg-flow-500/10" : "border-depth-200 bg-white")
}
>
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
<span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
{inCart > 0 && (
<span className="absolute right-2 top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-flow-500 px-1.5 font-mono text-xs font-bold text-white">
{inCart}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -1,54 +0,0 @@
"use client";
import { Product } from "@/lib/types";
interface Props {
products: Product[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function ProductSizeSelector({ products, selectedId, onSelect }: Props) {
if (products.length === 0) {
return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
No jerrycan sizes configured yet.
</div>
);
}
const maxSize = Math.max(...products.map((p) => p.sizeLiters));
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-depth-600">Jerrycan size</span>
<div role="radiogroup" aria-label="Jerrycan size" className="flex flex-wrap gap-2">
{products.map((p) => {
const isSelected = p._id === selectedId;
const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize);
return (
<button
key={p._id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onSelect(p._id)}
className={
"tap-target flex items-center gap-2 rounded-xl2 border-2 px-4 transition " +
(isSelected ? "border-flow-500 bg-flow-500/10 text-depth-900" : "border-depth-200 bg-white text-depth-600")
}
>
<svg width={18 * scale} height={22 * scale} viewBox="0 0 24 28" className={isSelected ? "text-flow-500" : "text-depth-300"}>
<rect x="4" y="6" width="16" height="20" rx="2" fill="currentColor" />
<rect x="9" y="2" width="6" height="5" rx="1" fill="currentColor" />
</svg>
<span className="text-left leading-tight">
<span className="block font-display text-sm font-bold">{p.sizeLiters}{p.unit ?? "L"}</span>
<span className="block font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -1,48 +0,0 @@
"use client";
interface Props {
quantity: number;
onChange: (q: number) => void;
unitPrice: number;
sizeLabel: string;
max?: number;
}
export default function QuantityCounter({ quantity, onChange, unitPrice, sizeLabel, max = 200 }: Props) {
const dec = () => onChange(Math.max(1, quantity - 1));
const inc = () => onChange(Math.min(max, quantity + 1));
return (
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-depth-600">{sizeLabel} jerrycans</span>
<span className="font-mono text-sm text-depth-600">KES {unitPrice.toLocaleString()} each</span>
</div>
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={dec}
aria-label="Decrease quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-sand-100 text-3xl font-bold text-depth-800 active:scale-95"
>
–
</button>
<div className="text-center">
<div className="font-display text-4xl font-extrabold text-depth-900">{quantity}</div>
<div className="font-mono text-sm text-depth-600">KES {(quantity * unitPrice).toLocaleString()}</div>
</div>
<button
type="button"
onClick={inc}
aria-label="Increase quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-flow-500 text-3xl font-bold text-depth-950 active:scale-95"
>
+
</button>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import type { QueuedSale } from "@/lib/offlineQueue";
interface Props {
sale: QueuedSale;
businessName?: string | null;
branchName?: string | null;
cashierName: string;
synced: boolean;
}
/** A printable sale receipt. Printing hides everything but `.print-receipt` (see globals.css). */
export default function Receipt({ sale, businessName, branchName, cashierName, synced }: Props) {
const when = new Date(sale.clientSubmittedAt);
const reference = sale.clientUuid.slice(0, 8).toUpperCase();
return (
<div className="print-receipt rounded-xl2 border border-depth-200/60 bg-white p-5 font-mono text-sm text-depth-900 shadow-soft">
<div className="text-center">
<p className="font-display text-base font-bold">{businessName ?? "Receipt"}</p>
{branchName && <p className="text-xs text-depth-500">{branchName}</p>}
<p className="mt-1 text-xs text-depth-500">
{when.toLocaleDateString("en-KE")} {when.toLocaleTimeString("en-KE", { hour: "2-digit", minute: "2-digit" })}
</p>
<p className="text-xs text-depth-500">Ref {reference}</p>
</div>
<table className="mt-4 w-full">
<tbody>
{sale.lines.map((l) => (
<tr key={l.productId} className="align-top">
<td className="py-0.5 pr-2">
{l.quantity} x {l.label}
<span className="block text-xs text-depth-500">@ KES {l.unitPrice.toLocaleString()}</span>
</td>
<td className="py-0.5 text-right">{(l.quantity * l.unitPrice).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3 flex justify-between border-t border-dashed border-depth-300 pt-2 font-bold">
<span>TOTAL (KES)</span>
<span>{sale.total.toLocaleString()}</span>
</div>
<p className="mt-1 text-xs">Paid: Cash</p>
{sale.customerName && <p className="text-xs">Customer: {sale.customerName}</p>}
<p className="text-xs">Served by: {cashierName}</p>
<p className="no-print mt-3 text-center text-xs text-depth-500">
{synced ? "Synced to head office" : "Saved on this phone - will sync automatically"}
</p>
</div>
);
}
+197 -33
View File
@@ -1,7 +1,49 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import { apiFetch } from "@/lib/api";
import { apiFetch, ApiError } from "@/lib/api";
import { MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
export interface QueuedCashSale {
/**
* The terminal's durable sales queue (IndexedDB).
*
* Every cash sale is written here BEFORE the cashier sees "recorded", and
* stays here until the server has confirmed it. Each row carries the
* cashier and branch that recorded it, and a device only ever sends the
* signed-in cashier's rows — so on a shared phone, sales can never be
* credited to whoever signs in next.
*
* Row lifecycle:
* pending -> sent on the next sync (immediately when online)
* synced -> server answered saved/duplicate; kept 7 days for the receipt history
* rejected -> server can never accept it (reason stored); shown to the cashier,
* never retried, so it can't block the queue
*/
export interface SaleLine {
productId: string;
label: string;
quantity: number;
unitPrice: number; // the price the cashier saw and charged
}
export type SaleState = "pending" | "synced" | "rejected";
export interface QueuedSale {
clientUuid: string;
userId: string;
businessId: string;
branchId: string;
customerName?: string;
customerPhone?: string;
lines: SaleLine[];
total: number;
clientSubmittedAt: string;
state: SaleState;
error?: string;
syncedAt?: string;
}
/** Rows written by the pre-v2 terminal (no owner, one product). */
interface LegacyCashSale {
clientUuid: string;
customerName?: string;
customerPhone: string;
@@ -12,19 +54,30 @@ export interface QueuedCashSale {
}
interface QueueDB extends DBSchema {
cash_sales: { key: string; value: QueuedCashSale };
sales: { key: string; value: QueuedSale; indexes: { byUser: string } };
cash_sales: { key: string; value: LegacyCashSale };
}
export interface QueueOwner {
id: string;
businessId?: string;
branchId: string | null;
}
const DB_NAME = "aquaflow-offline";
const STORE = "cash_sales";
const KEEP_SYNCED_MS = 7 * 24 * 60 * 60 * 1000;
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
function getDb() {
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
if (!dbPromise) {
dbPromise = openDB<QueueDB>(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE, { keyPath: "clientUuid" });
dbPromise = openDB<QueueDB>(DB_NAME, 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) db.createObjectStore("cash_sales", { keyPath: "clientUuid" });
if (oldVersion < 2) {
const sales = db.createObjectStore("sales", { keyPath: "clientUuid" });
sales.createIndex("byUser", "userId");
}
},
});
}
@@ -35,52 +88,163 @@ export function generateClientUuid(): string {
return crypto.randomUUID();
}
export async function queueCashSale(sale: Omit<QueuedCashSale, "synced">): Promise<void> {
export async function queueSale(sale: Omit<QueuedSale, "state">): Promise<QueuedSale> {
const row: QueuedSale = { ...sale, state: "pending" };
const db = await getDb();
await db.put(STORE, { ...sale, synced: false });
await db.put("sales", row);
return row;
}
export async function getPendingSales(): Promise<QueuedCashSale[]> {
export async function salesFor(userId: string): Promise<QueuedSale[]> {
const db = await getDb();
const all = await db.getAll(STORE);
return all.filter((s) => !s.synced);
return db.getAllFromIndex("sales", "byUser", userId);
}
async function markSynced(clientUuids: string[]) {
export async function queueCounts(userId: string): Promise<{ pending: number; rejected: number }> {
const rows = await salesFor(userId);
return {
pending: rows.filter((r) => r.state === "pending").length,
rejected: rows.filter((r) => r.state === "rejected").length,
};
}
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
export async function dismissRejected(clientUuid: string): Promise<void> {
const db = await getDb();
const tx = db.transaction(STORE, "readwrite");
for (const id of clientUuids) {
const row = await tx.store.get(id);
if (row) {
row.synced = true;
await tx.store.put(row);
const row = await db.get("sales", clientUuid);
if (row?.state === "rejected") await db.delete("sales", clientUuid);
}
async function applyResults(results: SaleResult[]) {
const db = await getDb();
const tx = db.transaction("sales", "readwrite");
for (const r of results) {
const row = await tx.store.get(r.clientUuid);
if (!row) continue;
if (r.status === "rejected") {
row.state = "rejected";
row.error = r.error;
} else {
row.state = "synced";
row.syncedAt = new Date().toISOString();
delete row.error;
}
await tx.store.put(row);
}
await tx.done;
}
export async function flushQueue(): Promise<{ synced: number; failed: number }> {
const pending = await getPendingSales();
if (pending.length === 0) return { synced: 0, failed: 0 };
function toPayload(s: QueuedSale) {
return {
clientUuid: s.clientUuid,
staffId: s.userId,
branchId: s.branchId,
customerName: s.customerName || undefined,
customerPhone: s.customerPhone || undefined,
clientSubmittedAt: s.clientSubmittedAt,
lines: s.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
// Adopted legacy rows never recorded a price (0); don't flag those as mismatches.
unitPrice: l.unitPrice > 0 ? l.unitPrice : undefined,
})),
};
}
try {
const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }),
/**
* Pre-v2 rows had no owner. They are adopted by the first cashier who
* syncs on this device — exactly what the old terminal did with them.
*/
async function adoptLegacySales(owner: QueueOwner) {
if (!owner.branchId) return;
const db = await getDb();
const legacy = (await db.getAll("cash_sales")).filter((s) => !s.synced);
for (const s of legacy) {
await db.put("sales", {
clientUuid: s.clientUuid,
userId: owner.id,
businessId: owner.businessId ?? "",
branchId: owner.branchId,
customerName: s.customerName,
customerPhone: s.customerPhone,
lines: [{ productId: s.productId, label: "Sale", quantity: s.quantity, unitPrice: 0 }],
total: 0,
clientSubmittedAt: s.clientSubmittedAt,
state: "pending",
});
const syncedIds = result.inserted.map((r) => r.clientUuid);
await markSynced(syncedIds);
return { synced: syncedIds.length, failed: pending.length - syncedIds.length };
} catch {
return { synced: 0, failed: pending.length };
}
await db.clear("cash_sales");
}
async function pruneSynced(userId: string) {
const db = await getDb();
const cutoff = Date.now() - KEEP_SYNCED_MS;
for (const row of await salesFor(userId)) {
if (row.state === "synced" && row.syncedAt && Date.parse(row.syncedAt) < cutoff) {
await db.delete("sales", row.clientUuid);
}
}
}
export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) {
export interface FlushResult {
synced: number;
rejected: number;
pending: number;
offline: boolean;
}
let flushInFlight: Promise<FlushResult> | null = null;
/**
* Send this cashier's pending sales in chunks the server accepts. Stops at
* the first network/auth failure (everything unsent stays pending).
*/
export function flushQueue(owner: QueueOwner): Promise<FlushResult> {
if (!flushInFlight) {
flushInFlight = doFlush(owner).finally(() => {
flushInFlight = null;
});
}
return flushInFlight;
}
async function doFlush(owner: QueueOwner): Promise<FlushResult> {
await adoptLegacySales(owner);
const pending = (await salesFor(owner.id))
.filter((s) => s.state === "pending")
.sort((a, b) => a.clientSubmittedAt.localeCompare(b.clientSubmittedAt));
let synced = 0;
let rejected = 0;
let offline = false;
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
try {
const { results } = await apiFetch<{ results: SaleResult[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: chunk.map(toPayload) }),
});
await applyResults(results);
synced += results.filter((r) => r.status !== "rejected").length;
rejected += results.filter((r) => r.status === "rejected").length;
} catch (err) {
// 401/403: signed out or blocked — keep everything for later.
// Network error: offline — same. Nothing is ever dropped here.
offline = !(err instanceof ApiError);
break;
}
}
await pruneSynced(owner.id);
const counts = await queueCounts(owner.id);
return { synced, rejected, pending: counts.pending, offline };
}
/** Flush on reconnect and every 30s while online. Returns an unsubscribe. */
export function registerOfflineSync(owner: QueueOwner, onFlush?: (r: FlushResult) => void) {
if (typeof window === "undefined") return () => {};
const handler = async () => {
const result = await flushQueue();
const result = await flushQueue(owner);
onFlush?.(result);
};
+50
View File
@@ -0,0 +1,50 @@
import type { AuthUser, Product } from "@/lib/types";
/**
* The last confirmed session and catalogue, so the terminal can start and
* keep selling with no network. Only a snapshot for display and for
* labelling queued sales: the server still authenticates every sync with
* the httpOnly cookies and re-prices every line.
*/
const KEY = "aquaflow_terminal_session";
export interface SessionSnapshot {
user: AuthUser;
features: { mpesa: boolean };
products: Product[];
savedAt: string;
}
export function loadSession(): SessionSnapshot | null {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as SessionSnapshot) : null;
} catch {
return null;
}
}
export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser }): void {
try {
const prev = loadSession();
const sameUser = prev?.user.id === update.user.id;
const next: SessionSnapshot = {
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
products: update.products ?? (sameUser ? prev!.products : []),
user: update.user,
savedAt: new Date().toISOString(),
};
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
// Storage full or blocked: the terminal still works online.
}
}
export function clearSession(): void {
try {
localStorage.removeItem(KEY);
} catch {
// ignore
}
}
+24 -2
View File
@@ -31,6 +31,7 @@ export interface Product {
unitPrice: number;
sortOrder: number;
isActive: boolean;
lowStockThreshold?: number;
}
export interface Transaction {
@@ -38,7 +39,7 @@ export interface Transaction {
branchId: string | { _id: string; branchName: string };
staffId: string;
customerName: string | null;
customerPhone: string;
customerPhone: string | null;
sizeLiters: number;
quantity: number;
unitPrice: number;
@@ -83,4 +84,25 @@ export interface ReconciliationRow {
totalSold: number;
expectedRemaining: number;
reconciledOk: boolean | null;
}
}
export interface StockLevel {
branchId: string;
branchName: string;
productId: string;
productLabel: string;
unit: string;
quantity: number;
lowStockThreshold: number;
status: "OK" | "LOW" | "OUT";
}
export interface StockMovementRow {
_id: string;
type: "SALE" | "PURCHASE" | "TRANSFER_OUT" | "TRANSFER_IN" | "ADJUSTMENT";
quantity: number;
note: string | null;
createdAt: string;
branchId: { _id: string; branchName: string } | null;
productId: { _id: string; label: string; unit?: string } | null;
recordedBy: { _id: string; name: string } | null;
}