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
+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;
}