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:
+197
-33
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user