f865837b27
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>
219 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|