From 472573a1a8496fe20974fe78582e8b46a94f8c6b Mon Sep 17 00:00:00 2001 From: denisireri Date: Sun, 11 Oct 2026 18:46:23 +0300 Subject: [PATCH] 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 --- public/sw.js | 86 +++ src/app/admin/dashboard/page.tsx | 13 +- src/app/admin/products/page.tsx | 43 +- src/app/admin/staff/page.tsx | 48 +- src/app/admin/stock/page.tsx | 456 ++++++++++++++++ src/app/globals.css | 22 + src/app/layout.tsx | 2 + src/app/manifest.ts | 13 + src/app/terminal/page.tsx | 502 +++++++++++++----- src/components/ServiceWorkerRegister.tsx | 18 + src/components/admin/ActivityFeedMatrix.tsx | 2 +- src/components/admin/StockSummaryCard.tsx | 60 +++ src/components/terminal/ProductGrid.tsx | 51 ++ .../terminal/ProductSizeSelector.tsx | 54 -- src/components/terminal/QuantityCounter.tsx | 48 -- src/components/terminal/Receipt.tsx | 56 ++ src/lib/offlineQueue.ts | 230 ++++++-- src/lib/sessionCache.ts | 50 ++ src/lib/types.ts | 26 +- 19 files changed, 1506 insertions(+), 274 deletions(-) create mode 100644 public/sw.js create mode 100644 src/app/admin/stock/page.tsx create mode 100644 src/app/manifest.ts create mode 100644 src/components/ServiceWorkerRegister.tsx create mode 100644 src/components/admin/StockSummaryCard.tsx create mode 100644 src/components/terminal/ProductGrid.tsx delete mode 100644 src/components/terminal/ProductSizeSelector.tsx delete mode 100644 src/components/terminal/QuantityCounter.tsx create mode 100644 src/components/terminal/Receipt.tsx create mode 100644 src/lib/sessionCache.ts diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..0b11b0f --- /dev/null +++ b/public/sw.js @@ -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; + }) + ); + } +}); diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 56ebf93..640b65a 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -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("ALL"); const [summary, setSummary] = useState(null); const [reconciliation, setReconciliation] = useState([]); + const [stock, setStock] = useState([]); 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("/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 )} + + Stock + Sizes @@ -183,6 +190,8 @@ export default function AdminDashboardPage() { pendingMpesa={summary.totals.pendingMpesa} /> + +
(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() {

Sizes shown here appear as options on every branch's terminal. Deactivating a size hides it from new sales without touching past transactions. + {!canEdit && " Only the business owner can change products and prices."}

@@ -170,8 +183,12 @@ export default function ProductsPage() {

{p.label} · {p.sizeLiters}L

- {editingId === p._id ? ( -
+ {!canEdit ? ( +

+ KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} +

+ ) : editingId === p._id ? ( +
KES setEditPrice(e.target.value)} + aria-label="Price" className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm" /> + low stock at + 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" + /> @@ -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 )}
@@ -205,7 +234,7 @@ export default function ProductsPage() { {p.isActive ? "Active" : "Inactive"} - {p.isActive ? ( + {!canEdit ? null : p.isActive ? ( + {canEdit && (

Add a new size

@@ -254,6 +284,7 @@ export default function ProductsPage() { {saving ? "Adding…" : "Add size"} + )}
); diff --git a/src/app/admin/staff/page.tsx b/src/app/admin/staff/page.tsx index 440d802..413d797 100644 --- a/src/app/admin/staff/page.tsx +++ b/src/app/admin/staff/page.tsx @@ -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 ( -
  • -
    -

    {s.name}

    +
  • +
    +

    + {s.name} + {!s.isActive && Deactivated} +

    {s.email} {branchName ? " · " + branchName : ""}

    - - {s.role.replace("_", " ")} - +
    + + {s.role.replace("_", " ")} + + {canManage(s) && ( + + )} +
  • ); })} diff --git a/src/app/admin/stock/page.tsx b/src/app/admin/stock/page.tsx new file mode 100644 index 0000000..d11a4ae --- /dev/null +++ b/src/app/admin/stock/page.tsx @@ -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 = { + 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 = { OK: "In stock", LOW: "Low", OUT: "Out" }; + +const movementLabel: Record = { + 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(null); + const [branches, setBranches] = useState([]); + const [products, setProducts] = useState([]); + const [levels, setLevels] = useState([]); + const [movements, setMovements] = useState([]); + const [selectedBranchId, setSelectedBranchId] = useState("ALL"); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState("count"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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(); + 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 ( +
    +
    +
    +
    +
    + +
    +
    + ); + } + + 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 ( +
    +
    +
    +
    +

    {me.businessName ?? "Vessel"}

    +

    Stock

    +
    +
    + + + ← Dashboard + +
    +
    +
    + +
    + {alerts > 0 && ( +
    + {alerts} product line(s) are low or out of stock. +
    + )} + + {byBranch.length === 0 ? ( +
    + +
    + ) : ( +
    + {byBranch.map(([branchId, group]) => ( +
    +

    + {group.branchName} +

    +
      + {group.rows.map((l) => ( +
    • + {l.productLabel} + + + {l.quantity} + + + {statusLabel[l.status]} + + +
    • + ))} +
    +
    + ))} +
    + )} + {levels.some((l) => l.quantity < 0) && ( +

    + A negative number means sales were recorded for stock the system never received. Do a physical count + to correct it. +

    + )} + +
    +
    + {tabs.map((t) => ( + + ))} +
    + +

    + {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."} +

    + +
    + {isSuper && ( +
    + + +
    + )} + {form === "transfer" && ( +
    + + +
    + )} +
    + + +
    + {form === "count" && ( +
    + + +
    + )} + {form === "daily" && ( +
    + + +
    + )} +
    + + setQuantity(e.target.value)} + className={inputClass} + /> +
    + {form !== "daily" && ( +
    + + setNote(e.target.value)} + placeholder={form === "transfer" ? "e.g. Sent with the Thursday van" : "e.g. Opening stock, 3 cans leaking"} + className={inputClass} + /> +
    + )} +
    + + {error &&

    {error}

    } + +
    + +
    +

    Recent stock movements

    + {movements.length === 0 ? ( +

    No movements yet.

    + ) : ( +
      + {movements.map((m) => ( +
    • +
      +

      + {movementLabel[m.type]} · {m.productId?.label ?? "Product"} +

      +

      + {m.branchId?.branchName ?? ""} · {new Date(m.createdAt).toLocaleString("en-KE")} + {m.recordedBy?.name ? " · " + m.recordedBy.name : ""} + {m.note ? " · " + m.note : ""} +

      +
      + + {m.quantity > 0 ? "+" : ""} + {m.quantity} + +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 85d59f1..28987fc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 975f5a2..73a1721 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 }) /> + {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..91725a0 --- /dev/null +++ b/src/app/manifest.ts @@ -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", + }; +} diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 5ed93e7..8e74308 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -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(null); - const [authChecked, setAuthChecked] = useState(false); + const [mpesaEnabled, setMpesaEnabled] = useState(false); + const [bootError, setBootError] = useState(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([]); const [products, setProducts] = useState([]); const [productsLoading, setProductsLoading] = useState(true); - const [selectedProductId, setSelectedProductId] = useState(null); + const [cart, setCart] = useState([]); + const [showCustomer, setShowCustomer] = useState(false); const [customerName, setCustomerName] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); - const [quantity, setQuantity] = useState(1); const [method, setMethod] = useState("CASH"); const [phoneError, setPhoneError] = useState(null); const [formError, setFormError] = useState(null); @@ -40,49 +60,87 @@ export default function TerminalPage() { const [screen, setScreen] = useState({ kind: "idle" }); const pollRef = useRef | 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("/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 ( +
    +
    +

    {bootError}

    + +
    +
    + ); + } + + if (!me) { return (
    @@ -232,95 +373,176 @@ export default function TerminalPage() { ); } + const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity])); + return (
    -
    -
    +
    +

    {me.branchName || "Branch"}

    {me.name}

    - + {online ? "● Online" : "● Offline"} - {pendingCount > 0 && {pendingCount} cash sale(s) syncing…} +
    - {screen.kind === "cash_saved" ? ( -
    -
    💵
    -

    Cash Sale Recorded

    -

    - KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans -

    - + {(counts.pending > 0 || counts.rejected > 0) && ( + + )} + + {screen.kind === "receipt" ? ( +
    + +
    + + +
    ) : ( <> -
    - - setCustomerName(e.target.value)} - placeholder="e.g. Jane Wambui" - className="tap-target w-full rounded-xl border border-depth-200 px-3 mb-3" - /> - - { - 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 &&

    {phoneError}

    } -
    - {productsLoading ? (
    ) : ( - + )} - {selectedProduct && ( - + {lines.length > 0 && ( +
    +

    This sale

    +
      + {lines.map((l) => ( +
    • +
      +

      {l.product.label}

      +

      + KES {(l.quantity * l.product.unitPrice).toLocaleString()} +

      +
      +
      + + {l.quantity} + +
      +
    • + ))} +
    +
    + Total + KES {total.toLocaleString()} +
    +
    )} +
    + {!showCustomer ? ( + + ) : ( + <> + + setCustomerName(e.target.value)} + placeholder="e.g. Jane Wambui" + className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3" + /> + + { + 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 &&

    {phoneError}

    } + + )} +
    + {formError &&

    {formError}

    }
    - + {mpesaEnabled && } - {method === "CASH" ? ( + {method === "CASH" || !mpesaEnabled ? ( ) : ( )}
    @@ -328,10 +550,46 @@ export default function TerminalPage() { )}
    + {showQueue && ( +
    +
    +
    +

    Sales on this phone

    + +
    + {queueRows.length === 0 &&

    Everything is synced.

    } +
      + {queueRows.map((r) => ( +
    • +
      + {new Date(r.clientSubmittedAt).toLocaleString("en-KE")} + KES {r.total.toLocaleString()} +
      +

      {r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}

      + {r.state === "pending" ? ( +

      Waiting to sync

      + ) : ( + <> +

      Not accepted: {r.error}

      +

      Tell your manager, then remove it from this phone.

      + + + )} +
    • + ))} +
    +
    +
    + )} + {screen.kind === "mpesa" && ( { diff --git a/src/components/ServiceWorkerRegister.tsx b/src/components/ServiceWorkerRegister.tsx new file mode 100644 index 0000000..7beebfb --- /dev/null +++ b/src/components/ServiceWorkerRegister.tsx @@ -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; +} diff --git a/src/components/admin/ActivityFeedMatrix.tsx b/src/components/admin/ActivityFeedMatrix.tsx index 0b6ce58..44e3332 100644 --- a/src/components/admin/ActivityFeedMatrix.tsx +++ b/src/components/admin/ActivityFeedMatrix.tsx @@ -45,7 +45,7 @@ export default function ActivityFeedMatrix({ transactions, branchNames, connecte
  • -

    {t.customerName || t.customerPhone}

    +

    {t.customerName || t.customerPhone || "Walk-in customer"}

    {scope === "global" && ( {branchLabel} diff --git a/src/components/admin/StockSummaryCard.tsx b/src/components/admin/StockSummaryCard.tsx new file mode 100644 index 0000000..3f5af6a --- /dev/null +++ b/src/components/admin/StockSummaryCard.tsx @@ -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(); + 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 ( +
    +
    +

    Stock on hand

    + + Manage stock + +
    + {levels.length === 0 ? ( +

    No stock recorded yet.

    + ) : ( + <> +
    + {Array.from(totalsByBranch.entries()).map(([id, b]) => ( +
    +

    {b.name}

    +

    {b.units.toLocaleString()} units

    + {b.alerts > 0 &&

    {b.alerts} low/out

    } +
    + ))} +
    + {alerts.length > 0 && ( +
      + {alerts.slice(0, 6).map((l) => ( +
    • + + {l.productLabel} · {l.branchName} + + + {l.quantity} + +
    • + ))} +
    + )} + + )} +
    + ); +} diff --git a/src/components/terminal/ProductGrid.tsx b/src/components/terminal/ProductGrid.tsx new file mode 100644 index 0000000..3de8d0b --- /dev/null +++ b/src/components/terminal/ProductGrid.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Product } from "@/lib/types"; + +interface Props { + products: Product[]; + quantities: Record; + 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 ( +
    + No products configured yet. Ask the owner to add them. +
    + ); + } + + return ( +
    + Tap a product to add it +
    + {products.map((p) => { + const inCart = quantities[p._id] ?? 0; + return ( + + ); + })} +
    +
    + ); +} diff --git a/src/components/terminal/ProductSizeSelector.tsx b/src/components/terminal/ProductSizeSelector.tsx deleted file mode 100644 index e3b8f4d..0000000 --- a/src/components/terminal/ProductSizeSelector.tsx +++ /dev/null @@ -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 ( -
    - No jerrycan sizes configured yet. -
    - ); - } - const maxSize = Math.max(...products.map((p) => p.sizeLiters)); - - return ( -
    - Jerrycan size -
    - {products.map((p) => { - const isSelected = p._id === selectedId; - const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize); - return ( - - ); - })} -
    -
    - ); -} diff --git a/src/components/terminal/QuantityCounter.tsx b/src/components/terminal/QuantityCounter.tsx deleted file mode 100644 index 651eb0d..0000000 --- a/src/components/terminal/QuantityCounter.tsx +++ /dev/null @@ -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 ( -
    -
    - {sizeLabel} jerrycans - KES {unitPrice.toLocaleString()} each -
    - -
    - - -
    -
    {quantity}
    -
    KES {(quantity * unitPrice).toLocaleString()}
    -
    - - -
    -
    - ); -} diff --git a/src/components/terminal/Receipt.tsx b/src/components/terminal/Receipt.tsx new file mode 100644 index 0000000..c876408 --- /dev/null +++ b/src/components/terminal/Receipt.tsx @@ -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 ( +
    +
    +

    {businessName ?? "Receipt"}

    + {branchName &&

    {branchName}

    } +

    + {when.toLocaleDateString("en-KE")} {when.toLocaleTimeString("en-KE", { hour: "2-digit", minute: "2-digit" })} +

    +

    Ref {reference}

    +
    + + + + {sale.lines.map((l) => ( + + + + + ))} + +
    + {l.quantity} x {l.label} + @ KES {l.unitPrice.toLocaleString()} + {(l.quantity * l.unitPrice).toLocaleString()}
    + +
    + TOTAL (KES) + {sale.total.toLocaleString()} +
    +

    Paid: Cash

    + {sale.customerName &&

    Customer: {sale.customerName}

    } +

    Served by: {cashierName}

    + +

    + {synced ? "Synced to head office" : "Saved on this phone - will sync automatically"} +

    +
    + ); +} diff --git a/src/lib/offlineQueue.ts b/src/lib/offlineQueue.ts index acd9341..0335090 100644 --- a/src/lib/offlineQueue.ts +++ b/src/lib/offlineQueue.ts @@ -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> | null = null; function getDb() { if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser"); if (!dbPromise) { - dbPromise = openDB(DB_NAME, 1, { - upgrade(db) { - db.createObjectStore(STORE, { keyPath: "clientUuid" }); + dbPromise = openDB(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): Promise { +export async function queueSale(sale: Omit): Promise { + 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 { +export async function salesFor(userId: string): Promise { 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 { 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 | 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 { + if (!flushInFlight) { + flushInFlight = doFlush(owner).finally(() => { + flushInFlight = null; + }); + } + return flushInFlight; +} + +async function doFlush(owner: QueueOwner): Promise { + 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); }; diff --git a/src/lib/sessionCache.ts b/src/lib/sessionCache.ts new file mode 100644 index 0000000..b19cc19 --- /dev/null +++ b/src/lib/sessionCache.ts @@ -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 & { 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 + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7195b24..498616e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; -} \ No newline at end of file +} +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; +}