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