Files
ERP/src/app/admin/dashboard/page.tsx
T
denis_ireri f865837b27 Address the 11 October test report findings
The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

- F-01: a business on its free trial may run 2 active branches whatever
  its tier, so an owner can try transfers and per-branch stock. Only
  active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
  the Branches page. A branch can only be deactivated once it holds no
  stock and no active staff; its history stays in every report, and it
  leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
  Reactivate. Deactivating warns about stock still on hand, which stays
  on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
  everything being litres.
- CAT-12: product prices must be above zero and names are unique per
  business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
  the page labels which period the cards show. Report dates are Nairobi
  business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
  bought in the period is reported separately, with a cash surplus line,
  so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
  7-day link; the person sets their own password. Managers can issue a
  new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
  sale immediately, online or offline; it warns before selling more than
  the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
  into stock, so existing tenants (and the report's ZERRO fixtures) start
  from what they bought.

tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:21 +03:00

219 lines
8.9 KiB
TypeScript

"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 { useTransactionFeed } from "@/lib/pollingFeed";
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";
import GlobalSwitcher from "@/components/admin/GlobalSwitcher";
import FinancialOverviewCards from "@/components/admin/FinancialOverviewCards";
import PaymentSplitDonut from "@/components/admin/PaymentSplitDonut";
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();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [branches, setBranches] = useState<Branch[]>([]);
const [branchesLoaded, setBranchesLoaded] = useState(false);
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);
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);
}
} catch {
router.replace("/login");
return;
}
setAuthChecked(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!authChecked) return;
(async () => {
try {
const result = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(result.branches);
} catch {
// handled by the empty-branches nudge below on a best-effort basis
}
setBranchesLoaded(true);
})();
}, [authChecked]);
useEffect(() => {
if (!authChecked) return;
loadDashboardData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authChecked, selectedBranchId]);
async function loadDashboardData() {
setLoading(true);
try {
const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
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");
}
setLoading(false);
}
const branchNames = useMemo(() => {
const map: Record<string, string> = {};
branches.forEach((b) => (map[b._id] = b.branchName));
return map;
}, [branches]);
if (!authChecked || !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-6xl" />
</header>
<div className="mx-auto -mt-4 max-w-6xl space-y-5 px-5">
<SkeletonCardGrid count={4} />
<SkeletonTable rows={4} cols={5} />
</div>
</main>
);
}
const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch";
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && branchesLoaded && branches.length === 0;
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-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">
{me.businessName ?? "Vessel"}
</p>
<h1 className="font-display text-xl font-bold">
{me.role === "SUPER_ADMIN"
? "Global Control Center"
: me.branchName
? `${me.branchName} Dashboard`
: "Branch Dashboard"}
</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<GlobalSwitcher
branches={branches}
selectedBranchId={selectedBranchId}
onChange={setSelectedBranchId}
isSuperAdmin={me.role === "SUPER_ADMIN"}
/>
{me.isPlatformAdmin && (
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
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">
Products
</Link>
<Link href="/admin/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Staff
</Link>
<Link href="/admin/purchases" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Purchases
</Link>
<Link href="/admin/expenses" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Expenses
</Link>
<Link href="/admin/reports" className="tap-target flex items-center rounded-full bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-white hover:opacity-90">
Reports
</Link>
</div>
</div>
</header>
<div className="mx-auto -mt-4 max-w-6xl space-y-5 px-5">
{showNoBranchesNudge && (
<div className="rounded-xl2 bg-white shadow-soft">
<EmptyState
icon="👋"
title="Welcome to Vessel"
description="Nothing to show yet — start by creating your first branch, then add a manager and delivery staff to it."
action={{ label: "Create your first branch", href: "/admin/branches" }}
/>
</div>
)}
{loading || !summary ? (
<>
<SkeletonCardGrid count={4} />
<SkeletonTable rows={4} cols={5} />
</>
) : (
<>
<FinancialOverviewCards
cashTotal={summary.totals.cashTotal}
mpesaTotal={summary.totals.mpesaTotal}
totalSales={summary.totals.totalSales}
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
reconciliation={reconciliation}
totalSales={summary.totals.totalSales}
pendingMpesa={summary.totals.pendingMpesa}
/>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
<div className="space-y-5 lg:col-span-3">
{scope === "global" && <BranchRankingList ranking={summary.branchRanking} />}
<ReconciliationGrid rows={reconciliation} showBranchColumn={scope === "global"} />
</div>
<div className="lg:col-span-2">
<ActivityFeedMatrix transactions={feedTransactions} branchNames={branchNames} connected={connected} scope={scope} />
</div>
</div>
</>
)}
</div>
</main>
);
}