Initial commit: NetsKey ERP (copper & cream redesign)

This commit is contained in:
EOH Developer
2026-10-02 23:48:11 -07:00
commit 3dc6eef18d
86 changed files with 9176 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
export default function BranchesPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [branchName, setBranchName] = useState("");
const [locationCity, setLocationCity] = useState("");
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
if (result.user.role !== "SUPER_ADMIN") {
router.replace("/admin/dashboard");
return;
}
setMe(result.user);
} catch {
router.replace("/login");
return;
}
await loadBranches();
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadBranches() {
const result = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(result.branches);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!branchName.trim() || !locationCity.trim()) {
setError("Enter both a branch name and a city.");
return;
}
setSaving(true);
try {
await apiFetch("/api/branches", {
method: "POST",
body: JSON.stringify({ branchName: branchName.trim(), locationCity: locationCity.trim() }),
});
show(branchName.trim() + " created", "success");
setBranchName("");
setLocationCity("");
await loadBranches();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not create branch";
setError(message);
show(message, "error");
}
setSaving(false);
}
if (!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-3xl" />
</header>
<div className="mx-auto -mt-4 max-w-3xl px-5">
<SkeletonList rows={3} />
</div>
</main>
);
}
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-3xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Branches</h1>
</div>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
{error && (
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">
{error}
</div>
)}
{loading ? (
<SkeletonList rows={3} />
) : (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">All branches</h2>
<p className="text-xs text-depth-500">
Create a branch here first, then head to{" "}
<Link href="/admin/staff" className="underline underline-offset-2">
Staff
</Link>{" "}
to assign it a Branch Manager. That manager can then add their own delivery staff —
you won&apos;t need to touch this page again for day-to-day hiring.
</p>
</div>
{branches.length === 0 ? (
<EmptyState
icon="🏢"
title="No branches yet"
description="Every staff account needs a branch to belong to. Create your first one using the form below."
action={{ label: "Jump to the form", onClick: () => document.getElementById("branch-name")?.focus() }}
/>
) : (
<ul className="divide-y divide-depth-100">
{branches.map((b) => (
<li key={b._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
<div>
<p className="font-medium text-depth-900">{b.branchName}</p>
<p className="text-xs text-depth-500">{b.locationCity}</p>
</div>
<span
className={
"rounded-full px-2.5 py-1 text-xs font-medium " +
(b.managerId ? "bg-confirm-500/15 text-confirm-600" : "bg-cash-500/15 text-cash-600")
}
>
{b.managerId ? "Has manager" : "No manager yet"}
</span>
</li>
))}
</ul>
)}
</div>
)}
<form onSubmit={handleCreate} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new branch</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="branch-name">
Branch name
</label>
<input
id="branch-name"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
placeholder="e.g. CBD Hub"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="city">
City
</label>
<input
id="city"
value={locationCity}
onChange={(e) => setLocationCity(e.target.value)}
placeholder="e.g. Nairobi"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
</div>
<button
type="submit"
disabled={saving}
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
>
{saving ? "Creating…" : "Create branch"}
</button>
</form>
</div>
</main>
);
}
+203
View File
@@ -0,0 +1,203 @@
"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 } 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";
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 [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] = await Promise.all([
apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam),
apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam),
]);
setSummary(summaryRes);
setReconciliation(reconRes.rows);
} 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">Vessel</p>
<h1 className="font-display text-xl font-bold">
{me.role === "SUPER_ADMIN" ? "Global Control Center" : "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.role === "SUPER_ADMIN" && (
<Link href="/admin/branches" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Branches
</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">
Sizes
</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}
/>
<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>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
const CATEGORIES = [
"Transport",
"Electricity",
"Rent",
"Salaries",
"Repairs",
"Packaging",
"Other",
] as const;
interface ExpenseRow {
_id: string;
branchId: { _id: string; branchName: string } | string;
category: string;
amount: number;
description: string;
reference: string | null;
notes: string | null;
recordedBy: { _id: string; name: string } | string | null;
createdAt: string;
}
function branchLabel(b: ExpenseRow["branchId"]): string {
return typeof b === "object" && b ? b.branchName : "—";
}
function recorderLabel(r: ExpenseRow["recordedBy"]): string {
return typeof r === "object" && r ? r.name : "—";
}
export default function ExpensesPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [expenses, setExpenses] = useState<ExpenseRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// form state
const [branchId, setBranchId] = useState("");
const [category, setCategory] = useState<string>("Transport");
const [amount, setAmount] = useState("");
const [description, setDescription] = useState("");
const [reference, setReference] = useState("");
const [notes, setNotes] = useState("");
const isSuper = me?.role === "SUPER_ADMIN";
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) {
setBranchId(result.user.branchId);
}
try {
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(br.branches);
} catch {
/* best-effort */
}
await loadExpenses();
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadExpenses() {
const result = await apiFetch<{ expenses: ExpenseRow[] }>("/api/expenses");
setExpenses(result.expenses);
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError(null);
const amt = parseFloat(amount);
if (!Number.isFinite(amt) || amt <= 0) {
setError("Enter a valid amount.");
return;
}
if (!description.trim()) {
setError("A short description is required.");
return;
}
if (isSuper && !branchId) {
setError("Select the branch this expense belongs to.");
return;
}
setSaving(true);
try {
await apiFetch("/api/expenses", {
method: "POST",
body: JSON.stringify({
branchId: branchId || undefined,
category,
amount: amt,
description: description.trim(),
reference: reference.trim() || undefined,
notes: notes.trim() || undefined,
}),
});
show("Expense recorded", "success");
setAmount("");
setDescription("");
setReference("");
setNotes("");
await loadExpenses();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not record expense";
setError(message);
show(message, "error");
}
setSaving(false);
}
const total = expenses.reduce((s, e) => s + e.amount, 0);
if (loading || !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-4xl" />
</header>
<div className="mx-auto -mt-4 max-w-4xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
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-4xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Expenses</h1>
</div>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-4xl space-y-5 px-5">
{error && (
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">
{error}
</div>
)}
{/* Record form */}
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Record an expense</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isSuper && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Branch</label>
<select
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
>
<option value="">Select branch…</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Amount (KES)</label>
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Reference (optional)</label>
<input
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder="Receipt / ref no."
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-depth-600">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Fuel for the delivery run"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-depth-600">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl border border-depth-200 px-3 py-2"
/>
</div>
</div>
<button
type="submit"
disabled={saving}
className="tap-target mt-4 w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60 sm:w-auto sm:px-8"
>
{saving ? "Saving…" : "Record expense"}
</button>
</form>
{/* List */}
<div className="rounded-xl2 bg-white shadow-soft">
<div className="flex items-center justify-between border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">Recent expenses</h2>
<span className="font-mono text-sm text-depth-600">
Total: KES {total.toLocaleString()}
</span>
</div>
{expenses.length === 0 ? (
<EmptyState
icon="🧾"
title="No expenses yet"
description="Record your first expense above — rent, transport, salaries and the like."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-depth-100 text-left text-xs uppercase tracking-wide text-depth-500">
<th className="px-4 py-2.5">Date</th>
{isSuper && <th className="px-4 py-2.5">Branch</th>}
<th className="px-4 py-2.5">Category</th>
<th className="px-4 py-2.5">Description</th>
<th className="px-4 py-2.5">By</th>
<th className="px-4 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-depth-100">
{expenses.map((e) => (
<tr key={e._id} className="animate-fade-up">
<td className="px-4 py-2.5 text-depth-600">
{new Date(e.createdAt).toLocaleDateString()}
</td>
{isSuper && <td className="px-4 py-2.5 text-depth-700">{branchLabel(e.branchId)}</td>}
<td className="px-4 py-2.5">
<span className="rounded-full bg-flow-500/10 px-2.5 py-1 text-xs font-medium text-flow-700">
{e.category}
</span>
</td>
<td className="px-4 py-2.5 text-depth-800">{e.description}</td>
<td className="px-4 py-2.5 text-depth-600">{recorderLabel(e.recordedBy)}</td>
<td className="px-4 py-2.5 text-right font-mono text-depth-900">
{e.amount.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
/**
* /admin/login no longer exists as its own page — login is unified at
* /login for every role, and the server decides where each account lands
* after authenticating. This stub just catches anyone with an old
* bookmark or link and forwards them, rather than a bare 404.
*/
export default function LegacyAdminLoginRedirect() {
redirect("/login");
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Product } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
import ConfirmButton from "@/components/ui/ConfirmButton";
export default function ProductsPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [label, setLabel] = useState("");
const [sizeLiters, setSizeLiters] = useState("");
const [unitPrice, setUnitPrice] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editPrice, setEditPrice] = useState("");
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
} catch {
router.replace("/login");
return;
}
await loadProducts();
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadProducts() {
const result = await apiFetch<{ products: Product[] }>("/api/products");
setProducts(result.products);
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError(null);
const size = parseFloat(sizeLiters);
const price = parseFloat(unitPrice);
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price < 0) {
setError("Enter a valid label, size, and price.");
return;
}
setSaving(true);
try {
await apiFetch("/api/products", {
method: "POST",
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unitPrice: price, sortOrder: products.length }),
});
show(label.trim() + " added to the catalog", "success");
setLabel("");
setSizeLiters("");
setUnitPrice("");
await loadProducts();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not add product";
setError(message);
show(message, "error");
}
setSaving(false);
}
async function handlePriceUpdate(id: string) {
const price = parseFloat(editPrice);
if (!Number.isFinite(price) || price < 0) {
setError("Enter a valid price.");
return;
}
setSaving(true);
try {
await apiFetch("/api/products/" + id, { method: "PATCH", body: JSON.stringify({ unitPrice: price }) });
show("Price updated", "success");
setEditingId(null);
await loadProducts();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not update price";
setError(message);
show(message, "error");
}
setSaving(false);
}
async function handleToggleActive(product: Product) {
try {
await apiFetch("/api/products/" + product._id, {
method: "PATCH",
body: JSON.stringify({ isActive: !product.isActive }),
});
show(product.label + (product.isActive ? " deactivated" : " reactivated"), "success");
await loadProducts();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not update product";
show(message, "error");
}
}
if (loading || !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-3xl" />
</header>
<div className="mx-auto -mt-4 max-w-3xl px-5">
<SkeletonList rows={3} />
</div>
</main>
);
}
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-3xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Jerrycan sizes &amp; pricing</h1>
</div>
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
{error && (
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
)}
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">Catalog</h2>
<p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions.
</p>
</div>
{products.length === 0 ? (
<EmptyState
icon="🧴"
title="No jerrycan sizes yet"
description="Add at least one size below (e.g. 20L) before staff can record any sales."
action={{ label: "Jump to the form", onClick: () => document.getElementById("label")?.focus() }}
/>
) : (
<ul className="divide-y divide-depth-100">
{products.map((p) => (
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div>
<p className="font-medium text-depth-900">
{p.label} <span className="text-depth-500">· {p.sizeLiters}L</span>
</p>
{editingId === p._id ? (
<div className="mt-1 flex items-center gap-2">
<span className="text-sm text-depth-500">KES</span>
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/>
<button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
Save
</button>
<button onClick={() => setEditingId(null)} className="text-sm text-depth-500 underline">
Cancel
</button>
</div>
) : (
<button
onClick={() => {
setEditingId(p._id);
setEditPrice(String(p.unitPrice));
}}
className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2"
>
KES {p.unitPrice.toLocaleString()} · edit
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}>
{p.isActive ? "Active" : "Inactive"}
</span>
{p.isActive ? (
<ConfirmButton
label="Deactivate"
confirmLabel="Deactivate"
variant="danger"
onConfirm={() => handleToggleActive(p)}
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-700"
/>
) : (
<button
onClick={() => handleToggleActive(p)}
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-700"
>
Reactivate
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
Label
</label>
<input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. 10L Jerrycan" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
Size (litres)
</label>
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
Price (KES)
</label>
<input id="price" type="number" min="0" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
</div>
<button type="submit" disabled={saving} className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8">
{saving ? "Adding…" : "Add size"}
</button>
</form>
</div>
</main>
);
}
+343
View File
@@ -0,0 +1,343 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch, Product } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
interface PurchaseRow {
_id: string;
branchId: { _id: string; branchName: string } | string;
supplierName: string;
productLabel: string;
quantity: number;
unitCost: number;
totalCost: number;
reference: string | null;
recordedBy: { _id: string; name: string } | string | null;
createdAt: string;
}
function branchLabel(b: PurchaseRow["branchId"]): string {
return typeof b === "object" && b ? b.branchName : "—";
}
function recorderLabel(r: PurchaseRow["recordedBy"]): string {
return typeof r === "object" && r ? r.name : "—";
}
export default function PurchasesPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [purchases, setPurchases] = useState<PurchaseRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// form state
const [branchId, setBranchId] = useState("");
const [supplierName, setSupplierName] = useState("");
const [productId, setProductId] = useState("");
const [quantity, setQuantity] = useState("");
const [unitCost, setUnitCost] = useState("");
const [reference, setReference] = useState("");
const [notes, setNotes] = useState("");
const isSuper = me?.role === "SUPER_ADMIN";
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) {
setBranchId(result.user.branchId);
}
try {
const [br, pr] = await Promise.all([
apiFetch<{ branches: Branch[] }>("/api/branches"),
apiFetch<{ products: Product[] }>("/api/products"),
]);
setBranches(br.branches);
setProducts(pr.products);
} catch {
/* best-effort */
}
await loadPurchases();
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadPurchases() {
const result = await apiFetch<{ purchases: PurchaseRow[] }>("/api/purchases");
setPurchases(result.purchases);
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError(null);
const qty = parseInt(quantity, 10);
const cost = parseFloat(unitCost);
if (!supplierName.trim()) {
setError("Supplier name is required.");
return;
}
if (!productId) {
setError("Choose the product purchased.");
return;
}
if (!Number.isInteger(qty) || qty <= 0) {
setError("Enter a valid quantity.");
return;
}
if (!Number.isFinite(cost) || cost < 0) {
setError("Enter a valid buying price.");
return;
}
if (isSuper && !branchId) {
setError("Select the branch this purchase belongs to.");
return;
}
setSaving(true);
try {
await apiFetch("/api/purchases", {
method: "POST",
body: JSON.stringify({
branchId: branchId || undefined,
supplierName: supplierName.trim(),
productId,
quantity: qty,
unitCost: cost,
reference: reference.trim() || undefined,
notes: notes.trim() || undefined,
}),
});
show("Purchase recorded · stock increased", "success");
setQuantity("");
setUnitCost("");
setReference("");
setNotes("");
await loadPurchases();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not record purchase";
setError(message);
show(message, "error");
}
setSaving(false);
}
const total = purchases.reduce((s, p) => s + p.totalCost, 0);
const previewTotal =
quantity && unitCost ? (parseInt(quantity, 10) || 0) * (parseFloat(unitCost) || 0) : 0;
if (loading || !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-4xl" />
</header>
<div className="mx-auto -mt-4 max-w-4xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
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-4xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Purchases</h1>
</div>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-4xl space-y-5 px-5">
{error && (
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">
{error}
</div>
)}
{/* Record form */}
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Record a purchase</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isSuper && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Branch</label>
<select
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
>
<option value="">Select branch…</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Supplier</label>
<input
value={supplierName}
onChange={(e) => setSupplierName(e.target.value)}
placeholder="e.g. Dasani Distributors"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Product</label>
<select
value={productId}
onChange={(e) => setProductId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
>
<option value="">Select product…</option>
{products.map((p) => (
<option key={p._id} value={p._id}>
{p.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Quantity</label>
<input
type="number"
min="1"
step="1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
placeholder="0"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Buying price / unit (KES)</label>
<input
type="number"
min="0"
step="0.01"
value={unitCost}
onChange={(e) => setUnitCost(e.target.value)}
placeholder="0"
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Reference (optional)</label>
<input
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder="Invoice / ref no."
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-depth-600">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl border border-depth-200 px-3 py-2"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4">
<button
type="submit"
disabled={saving}
className="tap-target rounded-full bg-gradient-to-r from-flow-500 to-flow-700 px-8 font-display font-semibold text-white disabled:opacity-60"
>
{saving ? "Saving…" : "Record purchase"}
</button>
{previewTotal > 0 && (
<span className="font-mono text-sm text-depth-600">
Total cost: KES {previewTotal.toLocaleString()}
</span>
)}
</div>
</form>
{/* List */}
<div className="rounded-xl2 bg-white shadow-soft">
<div className="flex items-center justify-between border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">Recent purchases</h2>
<span className="font-mono text-sm text-depth-600">Total: KES {total.toLocaleString()}</span>
</div>
{purchases.length === 0 ? (
<EmptyState
icon="📦"
title="No purchases yet"
description="Record stock bought from suppliers above — it feeds the profit overview and stock levels."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-depth-100 text-left text-xs uppercase tracking-wide text-depth-500">
<th className="px-4 py-2.5">Date</th>
{isSuper && <th className="px-4 py-2.5">Branch</th>}
<th className="px-4 py-2.5">Supplier</th>
<th className="px-4 py-2.5">Product</th>
<th className="px-4 py-2.5 text-right">Qty</th>
<th className="px-4 py-2.5 text-right">Unit</th>
<th className="px-4 py-2.5 text-right">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-depth-100">
{purchases.map((p) => (
<tr key={p._id} className="animate-fade-up">
<td className="px-4 py-2.5 text-depth-600">
{new Date(p.createdAt).toLocaleDateString()}
</td>
{isSuper && <td className="px-4 py-2.5 text-depth-700">{branchLabel(p.branchId)}</td>}
<td className="px-4 py-2.5 text-depth-800">{p.supplierName}</td>
<td className="px-4 py-2.5 text-depth-800">{p.productLabel}</td>
<td className="px-4 py-2.5 text-right text-depth-700">{p.quantity}</td>
<td className="px-4 py-2.5 text-right font-mono text-depth-700">
{p.unitCost.toLocaleString()}
</td>
<td className="px-4 py-2.5 text-right font-mono text-depth-900">
{p.totalCost.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</main>
);
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonCardGrid } from "@/components/ui/Skeleton";
interface ProfitReport {
scope: "global" | "branch";
range: { from: string; to: string };
revenue: number;
cashTotal: number;
mpesaTotal: number;
salesCount: number;
costOfGoods: number;
purchaseCount: number;
grossProfit: number;
expenses: number;
expenseCount: number;
netProfit: number;
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
export default function ReportsPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [report, setReport] = useState<ProfitReport | null>(null);
const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
const monthStart = new Date();
monthStart.setDate(1);
const [branchId, setBranchId] = useState("ALL");
const [from, setFrom] = useState(isoDay(monthStart));
const [to, setTo] = useState(isoDay(new Date()));
const isSuper = me?.role === "SUPER_ADMIN";
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) {
setBranchId(result.user.branchId);
}
if (result.user.role === "SUPER_ADMIN") {
try {
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(br.branches);
} catch {
/* best-effort */
}
}
await runReport(result.user.role === "SUPER_ADMIN" ? "ALL" : result.user.branchId ?? "ALL");
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function runReport(forcedBranch?: string) {
setFetching(true);
try {
const b = forcedBranch ?? branchId;
const params = new URLSearchParams();
if (b && b !== "ALL") params.set("branchId", b);
if (from) params.set("from", new Date(from).toISOString());
// include the whole "to" day
if (to) {
const end = new Date(to);
end.setHours(23, 59, 59, 999);
params.set("to", end.toISOString());
}
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
setReport(result);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load report", "error");
}
setFetching(false);
}
function exportCsv() {
if (!report) return;
const rows: [string, string | number][] = [
["Metric", "Value (KES)"],
["Range from", report.range.from.slice(0, 10)],
["Range to", report.range.to.slice(0, 10)],
["Revenue", report.revenue],
[" Cash", report.cashTotal],
[" M-Pesa", report.mpesaTotal],
["Sales count", report.salesCount],
["Cost of goods", report.costOfGoods],
["Gross profit", report.grossProfit],
["Expenses", report.expenses],
["Estimated net profit", report.netProfit],
];
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `vessel-profit-${report.range.from.slice(0, 10)}_${report.range.to.slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (loading || !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-5xl" />
</header>
<div className="mx-auto -mt-4 max-w-5xl px-5">
<SkeletonCardGrid count={5} />
</div>
</main>
);
}
const money = (n: number) => "KES " + n.toLocaleString();
const netPositive = (report?.netProfit ?? 0) >= 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-5xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Reports &amp; profit overview</h1>
</div>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-5xl space-y-5 px-5">
{/* Filters */}
<div className="rounded-xl2 bg-white p-4 shadow-soft">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
{isSuper && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Branch</label>
<select
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
>
<option value="ALL">All branches</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">From</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">To</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 px-3"
/>
</div>
<div className="flex items-end">
<button
onClick={() => runReport()}
disabled={fetching}
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
>
{fetching ? "Loading…" : "Run report"}
</button>
</div>
</div>
</div>
{report && (
<>
{/* Profit cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Revenue (sales)</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.revenue)}</p>
<p className="mt-1 text-xs text-depth-500">
{report.salesCount} sales · cash {money(report.cashTotal)} · M-Pesa {money(report.mpesaTotal)}
</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoods)}</p>
<p className="mt-1 text-xs text-depth-500">{report.purchaseCount} purchases in range</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.expenses)}</p>
<p className="mt-1 text-xs text-depth-500">{report.expenseCount} expenses in range</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft sm:col-span-2 lg:col-span-2">
<p className="text-xs uppercase tracking-wide text-depth-500">Estimated net profit</p>
<p
className={
"mt-1 font-display text-3xl font-bold " +
(netPositive ? "text-confirm-600" : "text-alert-600")
}
>
{money(report.netProfit)}
</p>
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses</p>
</div>
</div>
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
<p className="text-xs text-depth-500">
Management / profit overview — a decision aid, not full accounting software.
</p>
<button
onClick={exportCsv}
className="tap-target shrink-0 rounded-full border border-depth-300 bg-white px-5 text-sm font-medium text-depth-800 hover:bg-sand-50"
>
Export CSV
</button>
</div>
</>
)}
</div>
</main>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch, UserRole } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
interface StaffMember {
_id: string;
name: string;
role: UserRole;
branchId: string | null;
phoneNumber: string;
email: string;
isActive: boolean;
}
const roleBadgeStyles: Record<UserRole, string> = {
DELIVERY: "bg-depth-100 text-depth-700",
BRANCH_MANAGER: "bg-flow-500/15 text-flow-700",
SUPER_ADMIN: "bg-alert-500/15 text-alert-700",
};
export default function StaffPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [staff, setStaff] = useState<StaffMember[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmSuperAdmin, setConfirmSuperAdmin] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState<UserRole>("DELIVERY");
const [branchId, setBranchId] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const isSuperAdmin = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
} catch {
router.replace("/login");
return;
}
const branchesResult = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(branchesResult.branches);
await loadStaff();
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadStaff() {
const result = await apiFetch<{ users: StaffMember[] }>("/api/users");
setStaff(result.users);
}
function validate(): string | null {
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
if (!email.trim()) return "Email is required — every account signs in with email and password.";
if (password.length < 8) return "Password must be at least 8 characters.";
return null;
}
async function submitCreate() {
setSaving(true);
try {
await apiFetch("/api/users", {
method: "POST",
body: JSON.stringify({
name: name.trim(),
role,
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
phoneNumber,
email,
password,
}),
});
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
setName("");
setBranchId("");
setPhoneNumber("");
setEmail("");
setPassword("");
setConfirmSuperAdmin(false);
await loadStaff();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not create staff account";
setError(message);
show(message, "error");
}
setSaving(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError(null);
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
if (role === "SUPER_ADMIN" && !confirmSuperAdmin) {
setConfirmSuperAdmin(true);
return;
}
await submitCreate();
}
if (loading || !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-3xl" />
</header>
<div className="mx-auto -mt-4 max-w-3xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
const canCreateManagerOrAdmin = isSuperAdmin;
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-3xl items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Staff</h1>
</div>
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
← Dashboard
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
{isSuperAdmin && branches.length === 0 && (
<div className="rounded-xl2 border border-cash-500/30 bg-cash-500/10 px-4 py-3 text-sm text-cash-700">
No branches exist yet.{" "}
<Link href="/admin/branches" className="font-semibold underline underline-offset-2">
Create a branch first
</Link>{" "}
before adding staff.
</div>
)}
{error && (
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
)}
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">Team</h2>
</div>
{staff.length === 0 ? (
<EmptyState
icon="🧑‍🤝‍🧑"
title="No staff yet"
description="Add your first team member below — a branch manager to run day-to-day operations, or a delivery worker to sell at the door. Everyone signs in the same way, with email and password."
/>
) : (
<ul className="divide-y divide-depth-100">
{staff.map((s) => {
const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
return (
<li key={s._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
<div>
<p className="font-medium text-depth-900">{s.name}</p>
<p className="text-xs text-depth-500">
{s.email}
{branchName ? " · " + branchName : ""}
</p>
</div>
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
{s.role.replace("_", " ")}
</span>
</li>
);
})}
</ul>
)}
</div>
<form onSubmit={handleCreate} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Add staff member</h2>
<p className="mb-3 text-xs text-depth-500">
Every role signs in the same way, at the same page, with email and password — the account
decides where they land after signing in.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Full name</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Role</label>
<select
value={role}
onChange={(e) => {
setRole(e.target.value as UserRole);
setConfirmSuperAdmin(false);
}}
className="tap-target w-full rounded-xl border border-depth-200 px-3"
>
<option value="DELIVERY">Delivery</option>
{canCreateManagerOrAdmin && <option value="BRANCH_MANAGER">Branch Manager</option>}
{canCreateManagerOrAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
</select>
</div>
{isSuperAdmin && role !== "SUPER_ADMIN" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Branch</label>
<select value={branchId} onChange={(e) => setBranchId(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3">
<option value="">Select a branch…</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName} — {b.locationCity}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Phone number</label>
<input value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="07XXXXXXXX" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
</div>
{confirmSuperAdmin ? (
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
<p className="mb-2 text-sm text-alert-700">
<strong>{name.trim() || "This account"}</strong> will get full access to every branch, all
financial data, and the ability to create other admins. Confirm this is intended.
</p>
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-alert-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{saving ? "Creating…" : "Yes, make Super Admin"}
</button>
<button
type="button"
onClick={() => setConfirmSuperAdmin(false)}
className="rounded-lg border border-depth-200 px-4 py-2 text-sm font-medium text-depth-600"
>
Cancel
</button>
</div>
</div>
) : (
<button
type="submit"
disabled={saving}
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
>
{saving ? "Adding…" : "Add staff member"}
</button>
)}
</form>
</div>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { Branch } from "@/lib/server/models/Branch";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { getEnv } from "@/lib/server/env";
export const runtime = "nodejs";
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(),
});
async function enforceDeviceBinding(userId: string, deviceId: string) {
const env = getEnv();
const existing = await UserSession.find({ userId }).sort({ lastUsedAt: -1 }).lean();
const alreadyBound = existing.some((s) => s.deviceId === deviceId);
if (alreadyBound || existing.length < env.DELIVERY_MAX_DEVICES) {
return { allowed: true as const };
}
return {
allowed: false as const,
activeDevices: existing.map((s) => ({ deviceId: s.deviceId, label: s.deviceLabel, lastUsedAt: s.lastUsedAt })),
};
}
export async function POST(req: NextRequest) {
await connectDb();
const json = await req.json().catch(() => null);
const parsed = loginSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { email, password, deviceId, deviceLabel } = parsed.data;
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
if (!user || !user.isActive) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
}
const validPassword = await user.comparePassword(password);
if (!validPassword) {
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
user.failedLoginAttempts = 0;
}
await user.save();
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await user.save();
if (user.role === "DELIVERY") {
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
if (!binding.allowed) {
return NextResponse.json(
{
error: `This account is already active on ${getEnv().DELIVERY_MAX_DEVICES} device(s). Log out one to continue.`,
activeDevices: binding.activeDevices,
},
{ status: 409 }
);
}
}
const refreshToken = generateRefreshToken();
const refreshTokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + refreshTtlMs());
await UserSession.findOneAndUpdate(
{ userId: user._id, deviceId },
{ refreshTokenHash, expiresAt, deviceLabel: deviceLabel ?? null, lastUsedAt: new Date() },
{ upsert: true, new: true }
);
const accessToken = signAccessToken({
sub: user._id.toString(),
role: user.role,
branchId: user.branchId ? user.branchId.toString() : null,
deviceId,
});
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const res = NextResponse.json({
user: {
id: user._id,
name: user.name,
role: user.role,
branchId: user.branchId,
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
},
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
});
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth, ACCESS_COOKIE, REFRESH_COOKIE } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
const res = NextResponse.json({ ok: true });
if (auth) {
await UserSession.deleteOne({ userId: auth.sub, deviceId: auth.deviceId });
}
res.cookies.set(ACCESS_COOKIE, "", { path: "/", maxAge: 0 });
res.cookies.set(REFRESH_COOKIE, "", { path: "/api/auth", maxAge: 0 });
return res;
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const user = await User.findById(auth.sub).lean();
if (!user || !user.isActive) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
return NextResponse.json({
user: {
id: user._id,
name: user.name,
role: user.role,
branchId: user.branchId,
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
await connectDb();
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const body = await req.json().catch(() => ({}));
const deviceId = body?.deviceId as string | undefined;
if (!refreshToken || !deviceId) {
return NextResponse.json({ error: "No active session" }, { status: 401 });
}
const tokenHash = hashRefreshToken(refreshToken);
const session = await UserSession.findOne({ deviceId, refreshTokenHash: tokenHash });
if (!session || session.expiresAt.getTime() < Date.now()) {
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
}
const user = await User.findById(session.userId);
if (!user || !user.isActive) {
await UserSession.deleteOne({ _id: session._id });
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
}
const newRefreshToken = generateRefreshToken();
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
session.lastUsedAt = new Date();
session.expiresAt = new Date(Date.now() + refreshTtlMs());
await session.save();
const accessToken = signAccessToken({
sub: user._id.toString(),
role: user.role,
branchId: user.branchId ? user.branchId.toString() : null,
deviceId,
});
const res = NextResponse.json({ ok: true });
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, newRefreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function DELETE(req: NextRequest, { params }: { params: { deviceId: string } }) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
await UserSession.deleteOne({ userId: auth.sub, deviceId: params.deviceId });
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const sessions = await UserSession.find({ userId: auth.sub }).sort({ lastUsedAt: -1 }).lean();
return NextResponse.json({
sessions: sessions.map((s) => ({
deviceId: s.deviceId,
label: s.deviceLabel,
lastUsedAt: s.lastUsedAt,
isCurrent: s.deviceId === auth.deviceId,
})),
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
if (auth.role === "SUPER_ADMIN") {
const branches = await Branch.find().sort({ branchName: 1 }).lean();
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
const branch = await Branch.findById(auth.branchId).lean();
return NextResponse.json({ branches: branch ? [branch] : [] });
}
const createSchema = z.object({
branchName: z.string().trim().min(1).max(120),
locationCity: z.string().trim().min(1).max(120),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.create(parsed.data);
return NextResponse.json({ branch }, { status: 201 });
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
const invMatch: Record<string, unknown> = { date };
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
const inventoryRows = await DailyInventory.aggregate([
{ $match: invMatch },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } },
{ $unwind: "$product" },
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } },
{ $unwind: "$branch" },
{
$project: {
_id: 0,
branchId: 1,
branchName: "$branch.branchName",
productId: 1,
productLabel: "$product.label",
sizeLiters: "$product.sizeLiters",
morningDispatched: 1,
eveningPhysicalCount: 1,
},
},
]);
const dayStart = new Date(date + "T00:00:00.000Z");
const dayEnd = new Date(dayStart);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
const soldMatch: Record<string, unknown> = {
createdAt: { $gte: dayStart, $lt: dayEnd },
paymentStatus: "SUCCESS",
};
if (branchId) soldMatch.branchId = new mongoose.Types.ObjectId(branchId);
const soldRows = await Transaction.aggregate([
{ $match: soldMatch },
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, totalSold: { $sum: "$quantity" } } },
]);
const soldMap = new Map(
soldRows.map((r) => [r._id.branchId.toString() + ":" + r._id.productId.toString(), r.totalSold as number])
);
const rows = inventoryRows.map((row: any) => {
const key = row.branchId.toString() + ":" + row.productId.toString();
const totalSold = soldMap.get(key) ?? 0;
const expectedRemaining = row.morningDispatched - totalSold;
const reconciledOk =
row.eveningPhysicalCount != null ? row.eveningPhysicalCount === expectedRemaining : null;
return { ...row, totalSold, expectedRemaining, reconciledOk };
});
return NextResponse.json({ date, rows });
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function todayRange() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const { start, end } = todayRange();
const matchStage: Record<string, unknown> = { createdAt: { $gte: start, $lt: end } };
if (branchId) matchStage.branchId = new mongoose.Types.ObjectId(branchId);
const [totals] = await Transaction.aggregate([
{ $match: matchStage },
{
$group: {
_id: null,
cashTotal: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "CASH"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
"$amountTotal",
0,
],
},
},
mpesaTotal: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
"$amountTotal",
0,
],
},
},
totalSales: { $sum: { $cond: [{ $eq: ["$paymentStatus", "SUCCESS"] }, 1, 0] } },
pendingMpesa: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "PENDING"] }] },
1,
0,
],
},
},
},
},
]);
let branchRanking: any[] = [];
if (!branchId) {
branchRanking = await Transaction.aggregate([
{ $match: { createdAt: { $gte: start, $lt: end }, paymentStatus: "SUCCESS" } },
{
$group: {
_id: "$branchId",
revenue: { $sum: "$amountTotal" },
salesCount: { $sum: 1 },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
},
},
{ $sort: { revenue: -1 } },
{ $lookup: { from: "branches", localField: "_id", foreignField: "_id", as: "branch" } },
{ $unwind: "$branch" },
{
$project: {
_id: 0,
branchId: "$_id",
branchName: "$branch.branchName",
locationCity: "$branch.locationCity",
revenue: 1,
salesCount: 1,
cashTotal: 1,
mpesaTotal: 1,
},
},
]);
}
return NextResponse.json({
scope: branchId ? "branch" : "global",
totals: totals ?? { cashTotal: 0, mpesaTotal: 0, totalSales: 0, pendingMpesa: 0 },
branchRanking,
});
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/expenses?branchId=&from=&to=
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is
// hard-scoped to their own branch regardless of query params.
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const filter: Record<string, unknown> = {};
if (scopeBranch) filter.branchId = scopeBranch;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (from || to) {
const createdAt: Record<string, Date> = {};
if (from) createdAt.$gte = new Date(from);
if (to) createdAt.$lte = new Date(to);
filter.createdAt = createdAt;
}
const expenses = await Expense.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.sort({ createdAt: -1 })
.limit(500)
.lean();
return NextResponse.json({ expenses });
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
category: z.enum(EXPENSE_CATEGORIES),
amount: z.number().positive().max(100_000_000),
description: z.string().trim().min(1).max(300),
reference: z.string().trim().max(80).optional(),
notes: z.string().trim().max(500).optional(),
});
// POST /api/expenses — any authenticated staff can record an expense, but
// only against a branch they are allowed to touch.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 }
);
}
// Resolve the branch this expense is booked against, then enforce scope.
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = parsed.data.branchId ?? null;
if (!branchId) {
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
}
} else {
branchId = auth.branchId;
// A scoped user may not book an expense onto another branch.
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
}
}
const expense = await Expense.create({
branchId,
category: parsed.data.category,
amount: parsed.data.amount,
description: parsed.data.description,
reference: parsed.data.reference ?? null,
notes: parsed.data.notes ?? null,
recordedBy: auth.sub,
});
return NextResponse.json({ expense }, { status: 201 });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await connectDb();
return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });
} catch (err: any) {
return NextResponse.json({ status: "error", detail: err.message }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const dispatchSchema = z.object({
productId: z.string().min(1),
morningDispatched: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = dispatchSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
const date = parsed.data.date ?? todayLocal();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub },
{ upsert: true, new: true }
);
return NextResponse.json({ inventory: row });
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const countSchema = z.object({
productId: z.string().min(1),
eveningPhysicalCount: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = countSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
const date = parsed.data.date ?? todayLocal();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub },
{ upsert: true, new: true }
);
return NextResponse.json({ inventory: row });
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs";
function getSourceIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return "unknown";
}
function isIpAllowed(ip: string): boolean {
const allowlist = process.env.DARAJA_CALLBACK_IP_ALLOWLIST;
if (!allowlist) return true;
return allowlist.split(",").map((s) => s.trim()).includes(ip);
}
export async function POST(req: NextRequest) {
await connectDb();
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
const sourceIp = getSourceIp(req);
let payload: any;
try {
payload = await req.json();
} catch {
await MpesaCallbackLog.create({
sourceIp,
rawPayload: { unparsable: true },
processingNote: "Malformed JSON body",
});
return ack();
}
const callback = payload?.Body?.stkCallback;
const checkoutRequestId: string | undefined = callback?.CheckoutRequestID;
const resultCode: number | undefined = callback?.ResultCode;
const resultDesc: string = callback?.ResultDesc ?? "";
if (!isIpAllowed(sourceIp)) {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId: checkoutRequestId ?? null,
resultCode: resultCode ?? null,
rawPayload: payload,
processingNote: "Rejected: source IP not in DARAJA_CALLBACK_IP_ALLOWLIST",
});
return ack();
}
if (!checkoutRequestId) {
await MpesaCallbackLog.create({
sourceIp,
rawPayload: payload ?? {},
processingNote: "No CheckoutRequestID in payload",
});
return ack();
}
let mpesaReceiptNumber: string | null = null;
if (resultCode === 0 && callback.CallbackMetadata?.Item) {
const items: { Name: string; Value: string | number }[] = callback.CallbackMetadata.Item;
const receiptItem = items.find((i: any) => i.Name === "MpesaReceiptNumber");
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
}
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
if (!txn) {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
rawPayload: payload,
processingNote: "No matching transaction for CheckoutRequestID",
});
return ack();
}
if (txn.paymentStatus !== "PENDING") {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
matchedTransactionId: txn._id,
rawPayload: payload,
processingNote: "Ignored: transaction already " + txn.paymentStatus,
});
return ack();
}
const newStatus = resultCode === 0 ? "SUCCESS" : "FAILED";
txn.paymentStatus = newStatus;
txn.mpesaDetails.resultCode = resultCode ?? null;
txn.mpesaDetails.resultDesc = resultDesc;
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
await txn.save();
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
matchedTransactionId: txn._id,
rawPayload: payload,
processingNote: "Applied: transaction set to " + newStatus,
});
return ack();
}
export async function GET() {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth } from "@/lib/server/auth";
import { queryStkStatus } from "@/lib/server/mpesa";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const txn = await Transaction.findById(params.transactionId);
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
const ageMs = Date.now() - txn.createdAt.getTime();
if (txn.paymentStatus === "PENDING" && txn.mpesaDetails.checkoutRequestId && ageMs > 15000) {
try {
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
const code = Number(result.ResultCode);
if (!Number.isNaN(code) && code !== 1032) {
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
if (fresh) {
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
fresh.mpesaDetails.resultCode = code;
fresh.mpesaDetails.resultDesc = result.ResultDesc;
await fresh.save();
return NextResponse.json(fresh);
}
}
} catch {
// Daraja query failed/still pending — fall through and return current row.
}
}
return NextResponse.json(txn);
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { initiateStkPush } from "@/lib/server/mpesa";
export const runtime = "nodejs";
const stkSchema = z.object({
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().min(9).max(15),
productId: z.string().min(1),
quantity: z.number().int().min(1).max(500),
});
const hits = new Map<string, { count: number; resetAt: number }>();
function rateLimited(key: string): boolean {
const now = Date.now();
const entry = hits.get(key);
if (!entry || now > entry.resetAt) {
hits.set(key, { count: 1, resetAt: now + 60_000 });
return false;
}
if (entry.count >= 5) return true;
entry.count += 1;
return false;
}
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
if (rateLimited(auth!.sub)) {
return NextResponse.json(
{ error: "Too many payment prompts sent recently. Wait a moment and try again." },
{ status: 429 }
);
}
const json = await req.json().catch(() => null);
const parsed = stkSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!);
if (!branchId) {
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
}
const { customerName, customerPhone, productId, quantity } = parsed.data;
let phone: string;
try {
phone = normalizeKenyanPhone(customerPhone);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
const product = await Product.findById(productId).lean();
if (!product || !product.isActive) {
return NextResponse.json({ error: "Selected product is not available" }, { status: 400 });
}
const override = await BranchPricing.findOne({ branchId, productId }).lean();
const unitPrice = override?.unitPrice ?? product.unitPrice;
const amount = Math.round(quantity * unitPrice * 100) / 100;
if (amount <= 0) {
return NextResponse.json({ error: "Invalid sale amount" }, { status: 400 });
}
const branch = await Branch.findById(branchId).lean();
const txn = await Transaction.create({
branchId,
staffId: auth!.sub,
customerName: customerName ?? null,
customerPhone: phone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity,
unitPrice,
amountTotal: amount,
paymentMethod: "MPESA",
paymentStatus: "PENDING",
clientUuid: `mpesa-${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
try {
const stk = await initiateStkPush({
phone,
amount,
accountReference: branch?.branchName ?? "Vessel",
transactionDesc: `${quantity}x${product.sizeLiters}L`,
});
if (stk.ResponseCode !== "0") {
txn.paymentStatus = "FAILED";
txn.mpesaDetails.resultDesc = stk.ResponseDescription;
await txn.save();
return NextResponse.json({ error: stk.ResponseDescription || "Payment prompt was rejected" }, { status: 502 });
}
txn.mpesaDetails.checkoutRequestId = stk.CheckoutRequestID;
txn.mpesaDetails.merchantRequestId = stk.MerchantRequestID;
await txn.save();
return NextResponse.json({
transactionId: txn._id,
checkoutRequestId: stk.CheckoutRequestID,
customerMessage: stk.CustomerMessage,
});
} catch (err: any) {
txn.paymentStatus = "FAILED";
txn.mpesaDetails.resultDesc = err?.response?.data?.errorMessage ?? err.message;
await txn.save();
return NextResponse.json({ error: "Could not reach M-Pesa. Please retry." }, { status: 502 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth";
export const runtime = "nodejs";
const updateSchema = z.object({
label: z.string().trim().min(1).max(60).optional(),
sizeLiters: z.number().positive().max(1000).optional(),
unitPrice: z.number().nonnegative().optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
});
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
if (Object.keys(parsed.data).length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
}
const product = await Product.findByIdAndUpdate(params.id, parsed.data, { new: true });
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
return NextResponse.json({ product });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const products = await Product.find({ isActive: true }).sort({ sortOrder: 1 }).lean();
return NextResponse.json({ products });
}
const createSchema = z.object({
label: z.string().trim().min(1).max(60),
sizeLiters: z.number().positive().max(1000),
unit: z.string().trim().min(1).max(12).optional(),
category: z.string().trim().min(1).max(40).optional(),
unitPrice: z.number().nonnegative(),
sortOrder: z.number().int().optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const product = await Product.create(parsed.data);
return NextResponse.json({ product }, { status: 201 });
}
+116
View File
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Purchase } from "@/lib/server/models/Purchase";
import { Product } from "@/lib/server/models/Product";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/purchases?branchId=&from=&to=
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const filter: Record<string, unknown> = {};
if (scopeBranch) filter.branchId = scopeBranch;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (from || to) {
const createdAt: Record<string, Date> = {};
if (from) createdAt.$gte = new Date(from);
if (to) createdAt.$lte = new Date(to);
filter.createdAt = createdAt;
}
const purchases = await Purchase.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.sort({ createdAt: -1 })
.limit(500)
.lean();
return NextResponse.json({ purchases });
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
supplierName: z.string().trim().min(1).max(120),
productId: z.string().trim().min(1).optional(),
productLabel: z.string().trim().min(1).max(120).optional(),
quantity: z.number().int().positive().max(1_000_000),
unitCost: z.number().nonnegative().max(100_000_000),
reference: z.string().trim().max(80).optional(),
notes: z.string().trim().max(500).optional(),
});
// POST /api/purchases — records a supplier purchase. The buying price is
// snapshotted (unitCost/totalCost) so historical records never shift when
// product prices change later. A completed purchase conceptually increases
// branch stock.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 }
);
}
// Resolve branch + enforce scope.
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = parsed.data.branchId ?? null;
if (!branchId) {
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
}
} else {
branchId = auth.branchId;
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
}
}
// Resolve a human-readable product label snapshot. Prefer an explicit
// productId lookup; fall back to a free-text label for ad-hoc items.
let productLabel = parsed.data.productLabel ?? null;
let productId: string | null = parsed.data.productId ?? null;
if (productId) {
const product = await Product.findById(productId).lean();
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 400 });
}
productLabel = (product as { label: string }).label;
}
if (!productLabel) {
return NextResponse.json({ error: "productId or productLabel is required" }, { status: 400 });
}
const totalCost = parsed.data.quantity * parsed.data.unitCost;
const purchase = await Purchase.create({
branchId,
supplierName: parsed.data.supplierName,
productId,
productLabel,
quantity: parsed.data.quantity,
unitCost: parsed.data.unitCost,
totalCost,
reference: parsed.data.reference ?? null,
notes: parsed.data.notes ?? null,
recordedBy: auth.sub,
});
return NextResponse.json({ purchase }, { status: 201 });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase";
import { Expense } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Management / profit overview — NOT full accounting.
// Revenue = sum of SUCCESS sales in range
// Cost of Goods= sum of purchase cost in range (stock bought in)
// Gross Profit = Revenue - Cost of Goods
// Expenses = sum of operating expenses in range
// Net Profit = Gross Profit - Expenses
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const url = new URL(req.url);
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId"));
// Default range: start of the current month -> now.
const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const dateMatch = { createdAt: { $gte: from, $lte: to } };
const salesMatch: Record<string, unknown> = { ...dateMatch, paymentStatus: "SUCCESS" };
if (branchObjId) salesMatch.branchId = branchObjId;
const costMatch: Record<string, unknown> = { ...dateMatch };
if (branchObjId) costMatch.branchId = branchObjId;
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
Transaction.aggregate([
{ $match: salesMatch },
{
$group: {
_id: null,
revenue: { $sum: "$amountTotal" },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
salesCount: { $sum: 1 },
},
},
]),
Purchase.aggregate([
{ $match: costMatch },
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
]),
Expense.aggregate([
{ $match: costMatch },
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
]),
]);
const revenue = salesAgg[0]?.revenue ?? 0;
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
const expenses = expenseAgg[0]?.expenses ?? 0;
const grossProfit = revenue - costOfGoods;
const netProfit = grossProfit - expenses;
return NextResponse.json({
scope: branchId ? "branch" : "global",
range: { from: from.toISOString(), to: to.toISOString() },
revenue,
cashTotal: salesAgg[0]?.cashTotal ?? 0,
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
salesCount: salesAgg[0]?.salesCount ?? 0,
costOfGoods,
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
grossProfit,
expenses,
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
netProfit,
});
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
export const runtime = "nodejs";
const saleSchema = z.object({
clientUuid: z.string().uuid(),
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().min(9).max(15),
productId: z.string().min(1),
quantity: z.number().int().min(1).max(500),
clientSubmittedAt: z.string().datetime().optional(),
});
const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) });
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
const parsed = bodySchema.safeParse(normalized);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!);
if (!branchId) {
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
}
const { sales } = parsed.data;
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
const products = await Product.find({ _id: { $in: productIds } }).lean();
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds } }).lean();
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
const rejected: { clientUuid: string; error: string }[] = [];
const inserted: any[] = [];
for (const sale of sales) {
let phone: string;
try {
phone = normalizeKenyanPhone(sale.customerPhone);
} catch (e: any) {
rejected.push({ clientUuid: sale.clientUuid, error: e.message });
continue;
}
const product = productMap.get(sale.productId);
if (!product || !product.isActive) {
rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" });
continue;
}
const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice;
try {
const created = await Transaction.create({
branchId,
staffId: auth!.sub,
customerName: sale.customerName ?? null,
customerPhone: phone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity: sale.quantity,
unitPrice,
amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100,
paymentMethod: "CASH",
paymentStatus: "SUCCESS",
clientUuid: sale.clientUuid,
syncedFromOffline: !!sale.clientSubmittedAt,
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
});
inserted.push(created);
} catch (err: any) {
if (err?.code === 11000) continue;
rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" });
}
}
return NextResponse.json({ inserted, rejected });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
const filter: Record<string, unknown> = {};
if (branchId) filter.branchId = branchId;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
filter.createdAt = { $gte: startOfDay };
const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.lean();
return NextResponse.json({ transactions });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
const filter: Record<string, unknown> = branchId ? { branchId } : {};
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
return NextResponse.json({ users });
}
const createSchema = z.object({
name: z.string().trim().min(1).max(120),
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
branchId: z.string().min(1).optional(),
phoneNumber: z.string().min(9).max(15),
email: z.string().email(),
password: z.string().min(8),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const data = parsed.data;
if (auth!.role === "BRANCH_MANAGER") {
if (data.role !== "DELIVERY") {
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
}
if (data.branchId && data.branchId !== auth!.branchId) {
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
}
}
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined;
if (data.role !== "SUPER_ADMIN" && !branchId) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
}
let phone: string;
try {
phone = normalizeKenyanPhone(data.phoneNumber);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
const passwordHash = await bcrypt.hash(data.password, 12);
try {
const user = await User.create({
name: data.name,
role: data.role,
branchId: branchId ?? null,
phoneNumber: phone,
email: data.email.toLowerCase(),
passwordHash,
});
if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findByIdAndUpdate(branchId, { managerId: user._id });
}
const obj: any = user.toObject();
delete obj.passwordHash;
return NextResponse.json({ user: obj }, { status: 201 });
} catch (err: any) {
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
}
throw err;
}
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-display: "Space Grotesk", ui-sans-serif, sans-serif;
--font-body: "Inter", ui-sans-serif, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
}
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-sand-50 text-depth-900 font-body antialiased;
}
:focus-visible {
outline: 2px solid theme("colors.flow.500");
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
.tap-target {
min-height: 3.25rem;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata, Viewport } from "next";
import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
import { ToastProvider } from "@/components/ui/Toast";
const display = Space_Grotesk({ subsets: ["latin"], variable: "--font-display", weight: ["500", "700"] });
const body = Inter({ subsets: ["latin"], variable: "--font-body", weight: ["400", "500", "600"] });
const mono = IBM_Plex_Mono({ subsets: ["latin"], variable: "--font-mono", weight: ["400", "500"] });
export const metadata: Metadata = {
title: "Vessel — Branch Delivery Console",
description: "Multi-branch 20L jerrycan delivery & payment operations",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#0C0C0C",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={display.variable + " " + body.variable + " " + mono.variable}>
<body>
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api";
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
import VesselLogo from "@/components/ui/VesselLogo";
interface ActiveDevice {
deviceId: string;
label: string | null;
lastUsedAt: string;
}
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [remember, setRemember] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setDeviceConflict(null);
setLoading(true);
try {
const result = await apiFetch<{ redirectTo: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({
email,
password,
deviceId: getDeviceId(),
deviceLabel: getDeviceLabel(),
}),
});
router.replace(result.redirectTo);
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
setError(err.message);
} else if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Something went wrong. Please try again.");
}
}
setLoading(false);
}
return (
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-4 py-10">
<div className="w-full max-w-sm animate-fade-up">
{/* Brand lockup */}
<div className="mb-8 flex items-center justify-center gap-2.5">
<VesselLogo size={34} />
<span className="font-display text-lg font-bold tracking-tight text-depth-900">
vessel<span className="text-flow-500">.</span>
<span className="ml-1 font-body text-sm font-normal text-depth-500">erp</span>
</span>
</div>
<div className="rounded-3xl border border-depth-200 bg-white p-8 shadow-soft sm:p-10">
<h1 className="font-display text-3xl font-bold tracking-tight text-depth-900">Login</h1>
<p className="mt-2 text-sm leading-relaxed text-depth-500">
Run every branch with confidence — sales, stock, M-Pesa and cash.
</p>
{deviceConflict ? (
<div className="mt-8 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-4">
<p className="text-sm font-medium text-alert-600">{error}</p>
<ul className="mt-3 space-y-2">
{deviceConflict.map((d) => (
<li
key={d.deviceId}
className="rounded-lg bg-sand-50 px-3 py-2 text-sm text-depth-700"
>
{d.label ?? "Unknown device"} · last used{" "}
{new Date(d.lastUsedAt).toLocaleString()}
</li>
))}
</ul>
<button
onClick={() => {
setDeviceConflict(null);
setError(null);
}}
className="tap-target mt-4 w-full rounded-full bg-depth-900 text-sm font-medium text-sand-50 transition hover:bg-depth-800"
>
Back
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
{/* Email */}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-depth-700">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 placeholder:text-depth-400 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25"
/>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-depth-700">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 pr-11 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3.5 flex items-center text-depth-400 hover:text-depth-700"
>
{showPassword ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.5 5.2A10.8 10.8 0 0 1 12 5c5 0 9 4 10 7-.4 1.1-1.1 2.3-2.1 3.4M6.6 6.6C4.6 8 3.2 9.9 2 12c1 3 5 7 10 7 1.3 0 2.5-.2 3.6-.7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" />
</svg>
)}
</button>
</div>
</div>
{/* Remember / forgot */}
<div className="flex items-center justify-between text-sm">
<label className="flex cursor-pointer select-none items-center gap-2 text-depth-600">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-depth-300 text-flow-500 focus:ring-flow-500/30"
/>
Remember me
</label>
<span className="font-medium text-flow-600">Forgot password?</span>
</div>
{error && (
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
{error}
</p>
)}
{/* Primary CTA — copper gradient pill */}
<button
type="submit"
disabled={loading}
className="tap-target group relative w-full overflow-hidden rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white shadow-glow transition active:scale-[0.98] disabled:opacity-60"
>
<span className="relative z-10 flex items-center justify-center gap-2">
{loading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
)}
{loading ? "Signing in…" : "Login"}
</span>
</button>
</form>
)}
</div>
<p className="mt-6 text-center text-xs text-depth-400">
Water & eggs · multi-branch · one system.
</p>
</div>
</main>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { AuthUser } from "@/lib/types";
export default function RootPage() {
const router = useRouter();
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
} else {
router.replace("/admin/dashboard");
}
} catch {
router.replace("/login");
}
})();
}, [router]);
return (
<main className="flex min-h-screen items-center justify-center bg-depth-900">
<p className="text-sand-200/60">Loading…</p>
</main>
);
}
+347
View File
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api";
import { isValidKenyanPhone } from "@/lib/phone";
import { AuthUser, PaymentMethod, Product } from "@/lib/types";
import { generateClientUuid, queueCashSale, registerOfflineSync } from "@/lib/offlineQueue";
import { useToast } from "@/components/ui/Toast";
import ProductSizeSelector from "@/components/terminal/ProductSizeSelector";
import QuantityCounter from "@/components/terminal/QuantityCounter";
import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle";
import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay";
type ScreenState =
| { kind: "idle" }
| { kind: "cash_saved" }
| { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null };
export default function TerminalPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [online, setOnline] = useState(true);
const [pendingCount, setPendingCount] = useState(0);
const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [customerName, setCustomerName] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [quantity, setQuantity] = useState(1);
const [method, setMethod] = useState<PaymentMethod>("CASH");
const [phoneError, setPhoneError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const selectedProduct = products.find((p) => p._id === selectedProductId) ?? null;
const amount = selectedProduct ? quantity * selectedProduct.unitPrice : 0;
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "SUPER_ADMIN") {
router.replace("/admin/dashboard");
return;
}
setMe(result.user);
} catch {
router.replace("/login");
return;
}
setAuthChecked(true);
try {
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
setProducts(productsResult.products);
if (productsResult.products.length > 0) setSelectedProductId(productsResult.products[0]._id);
} catch {
show("Could not load jerrycan sizes — check your connection", "error");
}
setProductsLoading(false);
})();
setOnline(navigator.onLine);
const goOnline = () => {
setOnline(true);
show("Back online — syncing queued sales", "info");
};
const goOffline = () => {
setOnline(false);
show("You're offline — cash sales will keep saving and sync automatically", "info");
};
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
const unregister = registerOfflineSync((r) => {
if (r.synced > 0) {
setPendingCount((c) => Math.max(0, c - r.synced));
show(r.synced + " queued sale(s) synced", "success");
}
});
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
unregister();
if (pollRef.current) clearInterval(pollRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function resetForm() {
setCustomerName("");
setCustomerPhone("");
setQuantity(1);
setMethod("CASH");
setPhoneError(null);
setFormError(null);
setScreen({ kind: "idle" });
}
function validate(): boolean {
if (!selectedProduct) {
setFormError("Choose a jerrycan size first.");
return false;
}
if (!isValidKenyanPhone(customerPhone)) {
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
return false;
}
setPhoneError(null);
setFormError(null);
return true;
}
async function handleCashSale() {
if (!validate() || !selectedProduct) return;
setSubmitting(true);
const sale = {
clientUuid: generateClientUuid(),
customerName: customerName || undefined,
customerPhone,
productId: selectedProduct._id,
quantity,
clientSubmittedAt: new Date().toISOString(),
};
// IndexedDB write is the only step that must complete before we can
// safely show success — it's what guarantees the sale survives even if
// the app crashes or the device loses power before the network sync
// below finishes. It's also fast (a few ms), so this doesn't add
// perceptible delay.
await queueCashSale(sale);
setPendingCount((c) => c + 1);
setSubmitting(false);
// OPTIMISTIC UI: show the confirmation screen immediately rather than
// waiting on the network round trip. The worker handed over the
// jerrycan and took the cash — from their perspective the sale is
// already done, and the app should agree instantly. The actual sync
// to the server happens in the background; if it fails here, the sale
// is NOT lost — it stays in the offline queue and the 'online'
// listener / interval in offlineQueue.ts retries it automatically.
setScreen({ kind: "cash_saved" });
if (navigator.onLine) {
apiFetch("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: [sale] }),
})
.then(() => setPendingCount((c) => Math.max(0, c - 1)))
.catch(() => {
// stays queued; offline sync interval will retry — no toast here,
// since this is a routine background retry, not a user-facing error
});
}
}
async function startMpesaPush() {
if (!validate() || !selectedProduct) return;
if (!navigator.onLine) {
setFormError("M-Pesa requires an active network connection.");
return;
}
setSubmitting(true);
try {
const data = await apiFetch<{ transactionId: string }>("/api/mpesa/stkpush", {
method: "POST",
body: JSON.stringify({
customerName: customerName || undefined,
customerPhone,
productId: selectedProduct._id,
quantity,
}),
});
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
beginPolling(data.transactionId);
} catch (err) {
const reason = err instanceof ApiError ? err.message : "Network error while contacting M-Pesa.";
setScreen({ kind: "mpesa", transactionId: "", ui: "FAILED", reason });
}
setSubmitting(false);
}
function beginPolling(transactionId: string) {
if (pollRef.current) clearInterval(pollRef.current);
const start = Date.now();
pollRef.current = setInterval(async () => {
try {
const data = await apiFetch<any>("/api/mpesa/status/" + transactionId);
if (data.paymentStatus === "SUCCESS") {
clearInterval(pollRef.current!);
setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber });
} else if (data.paymentStatus === "FAILED") {
clearInterval(pollRef.current!);
setScreen({
kind: "mpesa",
transactionId,
ui: "FAILED",
reason: data.mpesaDetails?.resultDesc || "Payment was not completed.",
});
} else if (Date.now() - start > 60000) {
clearInterval(pollRef.current!);
setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No response from customer in time." });
}
} catch {
// transient network hiccup while polling; keep trying until timeout above
}
}, 3000);
}
if (!authChecked || !me) {
return (
<main className="min-h-screen bg-sand-50 pb-10">
<header className="bg-depth-900 px-5 pb-6 pt-8">
<div className="h-12 w-40 animate-pulse rounded-lg bg-white/10" />
</header>
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
<div className="h-40 animate-pulse rounded-xl2 bg-white shadow-soft" />
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
</div>
</main>
);
}
return (
<main className="min-h-screen bg-sand-50 pb-10">
<header className="bg-depth-900 px-5 pb-6 pt-8 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p>
<h1 className="font-display text-lg font-bold">{me.name}</h1>
</div>
<div className="flex flex-col items-end gap-1">
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")}>
{online ? "● Online" : "● Offline"}
</span>
{pendingCount > 0 && <span className="text-xs text-cash-400">{pendingCount} cash sale(s) syncing…</span>}
</div>
</div>
</header>
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
{screen.kind === "cash_saved" ? (
<div className="animate-fade-up rounded-xl2 border border-cash-500/30 bg-white p-6 text-center shadow-soft">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-cash-500/15 text-2xl">💵</div>
<h2 className="font-display text-lg font-bold text-depth-900">Cash Sale Recorded</h2>
<p className="mt-1 text-sm text-depth-600">
KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans
</p>
<button onClick={resetForm} className="tap-target mt-6 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]">
Start next sale
</button>
</div>
) : (
<>
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
Customer name (optional)
</label>
<input
id="cust-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="e.g. Jane Wambui"
className="tap-target w-full rounded-xl border border-depth-200 px-3 mb-3"
/>
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
Customer phone
</label>
<input
id="cust-phone"
inputMode="tel"
value={customerPhone}
onChange={(e) => {
setCustomerPhone(e.target.value);
if (phoneError) setPhoneError(null);
}}
placeholder="07XXXXXXXX"
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
/>
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
</div>
{productsLoading ? (
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
) : (
<ProductSizeSelector products={products} selectedId={selectedProductId} onSelect={setSelectedProductId} />
)}
{selectedProduct && (
<QuantityCounter quantity={quantity} onChange={setQuantity} unitPrice={selectedProduct.unitPrice} sizeLabel={selectedProduct.sizeLiters + "L"} />
)}
{formError && <p className="text-sm text-alert-500">{formError}</p>}
<div className="space-y-3">
<PaymentMethodToggle value={method} onChange={setMethod} />
{method === "CASH" ? (
<button
onClick={handleCashSale}
disabled={submitting || !selectedProduct}
className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
>
{submitting ? "Saving…" : "Record Cash Sale · KES " + amount.toLocaleString()}
</button>
) : (
<button
onClick={startMpesaPush}
disabled={submitting || !selectedProduct}
className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
>
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + amount.toLocaleString()}
</button>
)}
</div>
</>
)}
</div>
{screen.kind === "mpesa" && (
<PaymentStatusOverlay
state={screen.ui}
amount={amount}
receiptNumber={screen.receipt}
failureReason={screen.reason}
onRetry={() => {
setScreen({ kind: "idle" });
startMpesaPush();
}}
onNewSale={resetForm}
onDismiss={() => setScreen({ kind: "idle" })}
/>
)}
</main>
);
}
@@ -0,0 +1,72 @@
"use client";
import { Transaction } from "@/lib/types";
import { formatDistanceToNow } from "date-fns";
import EmptyState from "@/components/ui/EmptyState";
interface Props {
transactions: Transaction[];
branchNames: Record<string, string>;
connected: boolean;
scope: "global" | "branch";
}
const statusStyles: Record<string, string> = {
SUCCESS: "bg-confirm-500/15 text-confirm-600",
PENDING: "bg-cash-500/15 text-cash-600",
FAILED: "bg-alert-500/15 text-alert-600",
};
export default function ActivityFeedMatrix({ transactions, branchNames, connected, scope }: Props) {
return (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="flex items-center justify-between border-b border-depth-100 px-4 py-3">
<h3 className="font-display font-semibold text-depth-900">Live activity feed</h3>
<span className={"flex items-center gap-1.5 text-xs " + (connected ? "text-confirm-600" : "text-alert-600")}>
<span className={"h-1.5 w-1.5 rounded-full " + (connected ? "animate-pulse bg-confirm-500" : "bg-alert-500")} />
{connected ? "Live" : "Reconnecting…"}
</span>
</div>
<ul className="max-h-[32rem] divide-y divide-depth-100 overflow-y-auto">
{transactions.length === 0 && (
<li>
<EmptyState
icon="📡"
title="No sales yet"
description="As soon as a delivery worker records a sale anywhere in your network, it'll appear here in real time."
/>
</li>
)}
{transactions.map((t) => {
const branchId = typeof t.branchId === "string" ? t.branchId : t.branchId._id;
const branchLabel = typeof t.branchId === "object" ? t.branchId.branchName : branchNames[branchId] ?? "—";
return (
<li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone}</p>
{scope === "global" && (
<span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600">
{branchLabel}
</span>
)}
</div>
<p className="text-xs text-depth-500">
{t.quantity} × {t.sizeLiters}L · {t.paymentMethod === "CASH" ? "Cash" : "M-Pesa"} ·{" "}
{formatDistanceToNow(new Date(t.createdAt), { addSuffix: true })}
</p>
</div>
<div className="flex flex-shrink-0 flex-col items-end gap-1">
<span className="font-mono text-sm font-semibold text-depth-900">KES {t.amountTotal.toLocaleString()}</span>
<span className={"rounded-full px-2 py-0.5 text-[11px] font-medium " + statusStyles[t.paymentStatus]}>
{t.paymentStatus}
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { DashboardSummary } from "@/lib/types";
interface Props {
ranking: DashboardSummary["branchRanking"];
}
export default function BranchRankingList({ ranking }: Props) {
if (ranking.length === 0) {
return (
<div className="rounded-xl2 bg-white p-6 text-center text-sm text-depth-500 shadow-soft">
No branch activity yet today.
</div>
);
}
const maxRevenue = Math.max(...ranking.map((r) => r.revenue), 1);
return (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<h3 className="font-display font-semibold text-depth-900">Branch performance ranking</h3>
<p className="text-xs text-depth-500">Revenue today, highest first</p>
</div>
<ul className="divide-y divide-depth-100">
{ranking.map((r, i) => (
<li key={r.branchId} className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-depth-100 text-xs font-bold text-depth-700">
{i + 1}
</span>
<span className="text-sm font-medium text-depth-900">{r.branchName}</span>
<span className="text-xs text-depth-500">{r.locationCity}</span>
</div>
<span className="font-mono text-sm font-semibold text-depth-900">
KES {r.revenue.toLocaleString()}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-depth-100">
<div className="h-full rounded-full bg-flow-500" style={{ width: (r.revenue / maxRevenue) * 100 + "%" }} />
</div>
<div className="mt-1 flex gap-3 text-xs text-depth-500">
<span>{r.salesCount} sales</span>
<span className="text-cash-600">Cash KES {r.cashTotal.toLocaleString()}</span>
<span className="text-confirm-600">M-Pesa KES {r.mpesaTotal.toLocaleString()}</span>
</div>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
interface Props {
cashTotal: number;
mpesaTotal: number;
totalSales: number;
pendingMpesa: number;
}
function Card({ label, value, accent }: { label: string; value: string; accent: string }) {
return (
<div className="rounded-xl2 bg-white p-4 shadow-soft">
<p className="text-xs font-medium uppercase tracking-wide text-depth-500">{label}</p>
<p className={"mt-1 font-display text-2xl font-bold " + accent}>{value}</p>
</div>
);
}
export default function FinancialOverviewCards({ cashTotal, mpesaTotal, totalSales, pendingMpesa }: Props) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card label="Cash Collected" value={"KES " + cashTotal.toLocaleString()} accent="text-cash-600" />
<Card label="M-Pesa Revenue" value={"KES " + mpesaTotal.toLocaleString()} accent="text-confirm-600" />
<Card label="Sales Today" value={String(totalSales)} accent="text-depth-900" />
<Card label="Pending M-Pesa" value={String(pendingMpesa)} accent="text-alert-600" />
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Branch } from "@/lib/types";
interface Props {
branches: Branch[];
selectedBranchId: string;
onChange: (id: string) => void;
isSuperAdmin: boolean;
}
export default function GlobalSwitcher({ branches, selectedBranchId, onChange, isSuperAdmin }: Props) {
if (!isSuperAdmin) {
return (
<div className="flex items-center gap-2 rounded-xl2 bg-white px-4 py-2.5 shadow-soft">
<span className="h-2 w-2 rounded-full bg-flow-500" />
<span className="font-display text-sm font-semibold text-depth-900">
{branches[0]?.branchName ?? "Your branch"}
</span>
</div>
);
}
return (
<select
aria-label="Global Switcher — filter by branch"
value={selectedBranchId}
onChange={(e) => onChange(e.target.value)}
className="tap-target sticky top-2 z-20 rounded-xl2 border border-depth-200 bg-white px-4 font-display text-sm font-semibold text-depth-900 shadow-soft"
>
<option value="ALL">Global — All Branches</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName} — {b.locationCity}
</option>
))}
</select>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import RadialGauge from "@/components/admin/RadialGauge";
import { ReconciliationRow } from "@/lib/types";
interface Props {
reconciliation: ReconciliationRow[];
totalSales: number;
pendingMpesa: number;
}
/**
* Three at-a-glance radial stats, recomputed on every poll cycle (the
* dashboard re-fetches reconciliation + summary every few seconds), so
* these rings genuinely animate toward new values as the day progresses
* rather than being a static snapshot.
*/
export default function LiveStatsRow({ reconciliation, totalSales, pendingMpesa }: Props) {
const countedRows = reconciliation.filter((r) => r.eveningPhysicalCount != null);
const reconciledCount = countedRows.filter((r) => r.reconciledOk).length;
const reconciliationRate = countedRows.length > 0 ? (reconciledCount / countedRows.length) * 100 : 0;
const totalDispatched = reconciliation.reduce((s, r) => s + r.morningDispatched, 0);
const totalSold = reconciliation.reduce((s, r) => s + r.totalSold, 0);
const stockSoldRate = totalDispatched > 0 ? (totalSold / totalDispatched) * 100 : 0;
const mpesaSuccessRate =
totalSales + pendingMpesa > 0 ? (totalSales / (totalSales + pendingMpesa)) * 100 : 100;
return (
<div className="grid grid-cols-3 gap-3 rounded-xl2 bg-white p-4 shadow-soft">
<RadialGauge
value={reconciliationRate}
label="Reconciled"
sublabel={reconciledCount + "/" + countedRows.length + " branches"}
color="stroke-confirm-500"
size={104}
strokeWidth={9}
/>
<RadialGauge
value={stockSoldRate}
label="Stock sold"
sublabel={totalSold + " of " + totalDispatched}
color="stroke-flow-500"
size={104}
strokeWidth={9}
/>
<RadialGauge
value={mpesaSuccessRate}
label="Payments cleared"
sublabel={pendingMpesa + " pending"}
color="stroke-cash-500"
size={104}
strokeWidth={9}
/>
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
interface Props {
cashTotal: number;
mpesaTotal: number;
}
/**
* Two-segment donut: cash and M-Pesa as arcs of a single ring, total
* revenue in the center. Standard SVG multi-segment donut technique —
* each segment is its own circle, same circumference, offset by the
* previous segment's length via strokeDashoffset.
*/
export default function PaymentSplitDonut({ cashTotal, mpesaTotal }: Props) {
const total = cashTotal + mpesaTotal;
const size = 168;
const strokeWidth = 18;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const cashFraction = total > 0 ? cashTotal / total : 0;
const mpesaFraction = total > 0 ? mpesaTotal / total : 0;
const cashLength = cashFraction * circumference;
const mpesaLength = mpesaFraction * circumference;
return (
<div className="rounded-xl2 bg-white p-4 shadow-soft">
<h3 className="mb-3 font-display text-sm font-semibold text-depth-900">Payment split today</h3>
<div className="flex items-center gap-5">
<div className="relative flex-shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={center} cy={center} r={radius} strokeWidth={strokeWidth} className="fill-none stroke-depth-100" />
{total > 0 && (
<>
<circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className="fill-none stroke-cash-500 transition-all duration-700 ease-out"
strokeDasharray={`${cashLength} ${circumference - cashLength}`}
strokeDashoffset={0}
/>
<circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className="fill-none stroke-confirm-500 transition-all duration-700 ease-out"
strokeDasharray={`${mpesaLength} ${circumference - mpesaLength}`}
strokeDashoffset={-cashLength}
/>
</>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-mono text-[11px] text-depth-500">Total</span>
<span className="font-display text-base font-bold text-depth-900">
{total >= 1000 ? (total / 1000).toFixed(1) + "k" : total.toLocaleString()}
</span>
</div>
</div>
<div className="flex-1 space-y-3">
<div>
<div className="flex items-center gap-1.5 text-sm font-medium text-depth-700">
<span className="h-2.5 w-2.5 rounded-full bg-cash-500" />
Cash
</div>
<p className="font-mono text-sm text-depth-900">KES {cashTotal.toLocaleString()}</p>
<p className="text-xs text-depth-400">{Math.round(cashFraction * 100)}%</p>
</div>
<div>
<div className="flex items-center gap-1.5 text-sm font-medium text-depth-700">
<span className="h-2.5 w-2.5 rounded-full bg-confirm-500" />
M-Pesa
</div>
<p className="font-mono text-sm text-depth-900">KES {mpesaTotal.toLocaleString()}</p>
<p className="text-xs text-depth-400">{Math.round(mpesaFraction * 100)}%</p>
</div>
</div>
</div>
{total === 0 && <p className="mt-2 text-center text-xs text-depth-400">No sales recorded yet today.</p>}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
interface Props {
value: number; // 0-100
label: string;
sublabel?: string;
color?: string; // tailwind stroke class, e.g. "stroke-flow-500"
size?: number;
strokeWidth?: number;
}
/**
* A single circular progress ring with a value in the center. Pure SVG —
* no charting library needed for this. The stroke-dashoffset transition
* is what gives it the "live" animated feel as polled data updates the
* value prop every few seconds.
*/
export default function RadialGauge({
value,
label,
sublabel,
color = "stroke-flow-500",
size = 128,
strokeWidth = 10,
}: Props) {
const clamped = Math.max(0, Math.min(100, value));
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (clamped / 100) * circumference;
const center = size / 2;
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className="fill-none stroke-depth-100"
/>
<circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
strokeLinecap="round"
className={"fill-none transition-all duration-700 ease-out " + color}
style={{
strokeDasharray: circumference,
strokeDashoffset: offset,
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-display text-xl font-bold text-depth-900">{Math.round(clamped)}%</span>
</div>
</div>
<p className="mt-2 text-center text-xs font-medium text-depth-600">{label}</p>
{sublabel && <p className="text-center text-[11px] text-depth-400">{sublabel}</p>}
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { ReconciliationRow } from "@/lib/types";
interface Props {
rows: ReconciliationRow[];
showBranchColumn: boolean;
}
function VarianceBadge({ row }: { row: ReconciliationRow }) {
if (row.eveningPhysicalCount == null) {
return (
<span className="rounded-full bg-depth-100 px-2.5 py-1 text-xs font-medium text-depth-600">
Awaiting count
</span>
);
}
if (row.reconciledOk) {
return (
<span className="rounded-full bg-confirm-500/15 px-2.5 py-1 text-xs font-medium text-confirm-600">
✓ Reconciled
</span>
);
}
const diff = row.eveningPhysicalCount - row.expectedRemaining;
return (
<span className="flex items-center gap-1 rounded-full bg-red-600 px-2.5 py-1 text-xs font-bold text-white shadow-sm">
⚠ {diff > 0 ? "+" + diff + " surplus" : diff + " SHORT"}
</span>
);
}
export default function ReconciliationGrid({ rows, showBranchColumn }: Props) {
const hasVariance = rows.some((r) => r.eveningPhysicalCount != null && !r.reconciledOk);
return (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="flex items-center justify-between border-b border-depth-100 px-4 py-3">
<div>
<h3 className="font-display font-semibold text-depth-900">Automated reconciliation</h3>
<p className="text-xs text-depth-500">Dispatched − sold = expected, vs. physical evening count</p>
</div>
{hasVariance && (
<span className="rounded-full bg-red-600 px-3 py-1 text-xs font-bold text-white">Variance detected</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-depth-100 text-left text-xs uppercase tracking-wide text-depth-500">
{showBranchColumn && <th className="px-4 py-2.5">Branch</th>}
<th className="px-4 py-2.5">Size</th>
<th className="px-4 py-2.5 text-right">Dispatched</th>
<th className="px-4 py-2.5 text-right">Sold</th>
<th className="px-4 py-2.5 text-right">Expected</th>
<th className="px-4 py-2.5 text-right">Physical</th>
<th className="px-4 py-2.5">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-depth-100">
{rows.map((r) => {
const flagged = r.eveningPhysicalCount != null && !r.reconciledOk;
return (
<tr key={r.branchId + "-" + r.productId} className={flagged ? "bg-red-50" : undefined}>
{showBranchColumn && <td className="px-4 py-3 font-medium text-depth-900">{r.branchName}</td>}
<td className="px-4 py-3 font-medium text-depth-900">{r.sizeLiters}L</td>
<td className="px-4 py-3 text-right font-mono">{r.morningDispatched}</td>
<td className="px-4 py-3 text-right font-mono">{r.totalSold}</td>
<td className="px-4 py-3 text-right font-mono">{r.expectedRemaining}</td>
<td className="px-4 py-3 text-right font-mono">{r.eveningPhysicalCount ?? "—"}</td>
<td className="px-4 py-3">
<VarianceBadge row={r} />
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={showBranchColumn ? 7 : 6} className="px-4 py-8 text-center text-depth-500">
No stock data for the selected filter. Enter this morning&apos;s dispatched counts to begin tracking.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { PaymentMethod } from "@/lib/types";
interface Props {
value: PaymentMethod;
onChange: (m: PaymentMethod) => void;
}
export default function PaymentMethodToggle({ value, onChange }: Props) {
return (
<div role="radiogroup" aria-label="Payment method" className="grid grid-cols-2 gap-2 rounded-xl2 bg-sand-100 p-1.5">
<button
type="button"
role="radio"
aria-checked={value === "CASH"}
onClick={() => onChange("CASH")}
className={
"tap-target rounded-xl font-display font-semibold transition " +
(value === "CASH" ? "bg-cash-500 text-white shadow-soft" : "text-depth-600")
}
>
💵 Cash
</button>
<button
type="button"
role="radio"
aria-checked={value === "MPESA"}
onClick={() => onChange("MPESA")}
className={
"tap-target rounded-xl font-display font-semibold transition " +
(value === "MPESA" ? "bg-confirm-500 text-white shadow-soft" : "text-depth-600")
}
>
📱 M-Pesa
</button>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
export type MpesaUiState = "AWAITING_PIN" | "SUCCESS" | "FAILED";
interface Props {
state: MpesaUiState;
amount: number;
receiptNumber?: string | null;
failureReason?: string | null;
onRetry: () => void;
onNewSale: () => void;
onDismiss: () => void;
}
export default function PaymentStatusOverlay({ state, amount, receiptNumber, failureReason, onRetry, onNewSale, onDismiss }: Props) {
return (
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-depth-950/95 px-6 text-center animate-fade-up">
{state === "AWAITING_PIN" && (
<>
<div className="relative mb-8 flex h-24 w-24 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-flow-500/20" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-flow-500" />
<span className="text-3xl">📲</span>
</div>
<h2 className="font-display text-xl font-bold text-white">Awaiting Customer PIN entry…</h2>
<p className="mt-2 text-sand-200/70">KES {amount.toLocaleString()} · Ask the customer to check their phone</p>
<button onClick={onDismiss} className="mt-10 text-sm text-sand-200/50 underline underline-offset-4">
Cancel and keep waiting in background
</button>
</>
)}
{state === "SUCCESS" && (
<>
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-confirm-500/15">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path d="M20 6 9 17l-5-5" stroke="#22B36B" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2 className="font-display text-2xl font-bold text-white">Payment Received</h2>
<p className="mt-2 text-sand-200/70">KES {amount.toLocaleString()}</p>
{receiptNumber && <p className="mt-1 font-mono text-sm text-flow-400">Receipt: {receiptNumber}</p>}
<button
onClick={onNewSale}
className="tap-target mt-10 w-full max-w-xs rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]"
>
Start next sale
</button>
</>
)}
{state === "FAILED" && (
<>
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-alert-500/15">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<path d="M6 6l12 12M18 6 6 18" stroke="#D9603B" strokeWidth="3" strokeLinecap="round" />
</svg>
</div>
<h2 className="font-display text-xl font-bold text-white">Payment Failed / Timed Out</h2>
<p className="mt-2 text-sand-200/70">{failureReason || "The customer may have cancelled or entered the wrong PIN."}</p>
<div className="mt-10 flex w-full max-w-xs flex-col gap-3">
<button onClick={onRetry} className="tap-target rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]">
Retry payment
</button>
<button onClick={onDismiss} className="tap-target rounded-xl2 border border-white/15 font-display font-semibold text-white">
Switch to cash instead
</button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Product } from "@/lib/types";
interface Props {
products: Product[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function ProductSizeSelector({ products, selectedId, onSelect }: Props) {
if (products.length === 0) {
return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
No jerrycan sizes configured yet.
</div>
);
}
const maxSize = Math.max(...products.map((p) => p.sizeLiters));
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-depth-600">Jerrycan size</span>
<div role="radiogroup" aria-label="Jerrycan size" className="flex flex-wrap gap-2">
{products.map((p) => {
const isSelected = p._id === selectedId;
const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize);
return (
<button
key={p._id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onSelect(p._id)}
className={
"tap-target flex items-center gap-2 rounded-xl2 border-2 px-4 transition " +
(isSelected ? "border-flow-500 bg-flow-500/10 text-depth-900" : "border-depth-200 bg-white text-depth-600")
}
>
<svg width={18 * scale} height={22 * scale} viewBox="0 0 24 28" className={isSelected ? "text-flow-500" : "text-depth-300"}>
<rect x="4" y="6" width="16" height="20" rx="2" fill="currentColor" />
<rect x="9" y="2" width="6" height="5" rx="1" fill="currentColor" />
</svg>
<span className="text-left leading-tight">
<span className="block font-display text-sm font-bold">{p.sizeLiters}{p.unit ?? "L"}</span>
<span className="block font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
interface Props {
quantity: number;
onChange: (q: number) => void;
unitPrice: number;
sizeLabel: string;
max?: number;
}
export default function QuantityCounter({ quantity, onChange, unitPrice, sizeLabel, max = 200 }: Props) {
const dec = () => onChange(Math.max(1, quantity - 1));
const inc = () => onChange(Math.min(max, quantity + 1));
return (
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-depth-600">{sizeLabel} jerrycans</span>
<span className="font-mono text-sm text-depth-600">KES {unitPrice.toLocaleString()} each</span>
</div>
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={dec}
aria-label="Decrease quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-sand-100 text-3xl font-bold text-depth-800 active:scale-95"
>
–
</button>
<div className="text-center">
<div className="font-display text-4xl font-extrabold text-depth-900">{quantity}</div>
<div className="font-mono text-sm text-depth-600">KES {(quantity * unitPrice).toLocaleString()}</div>
</div>
<button
type="button"
onClick={inc}
aria-label="Increase quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-flow-500 text-3xl font-bold text-depth-950 active:scale-95"
>
+
</button>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
interface Props {
label: string;
confirmLabel?: string;
onConfirm: () => void | Promise<void>;
className?: string;
variant?: "default" | "danger";
}
export default function ConfirmButton({
label,
confirmLabel = "Confirm",
onConfirm,
className = "",
variant = "default",
}: Props) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
if (confirming) {
return (
<span className="inline-flex items-center gap-1.5">
<button
type="button"
disabled={busy}
onClick={async () => {
setBusy(true);
await onConfirm();
setBusy(false);
setConfirming(false);
}}
className={
"rounded-lg px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60 " +
(variant === "danger" ? "bg-alert-500" : "bg-flow-600")
}
>
{busy ? "…" : confirmLabel}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
disabled={busy}
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-600"
>
Cancel
</button>
</span>
);
}
return (
<button type="button" onClick={() => setConfirming(true)} className={className}>
{label}
</button>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
interface Props {
icon: React.ReactNode;
title: string;
description: string;
action?: { label: string; onClick?: () => void; href?: string };
}
/**
* A blank "No X yet" text string is a dead end, especially on a brand-new
* Super Admin's first visit. This turns that moment into a nudge toward
* the next action instead — every list page's zero-state should use this
* rather than a plain sentence.
*/
export default function EmptyState({ icon, title, description, action }: Props) {
return (
<div className="flex flex-col items-center px-6 py-12 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-flow-500/10 text-2xl text-flow-600">
{icon}
</div>
<h3 className="font-display text-base font-semibold text-depth-900">{title}</h3>
<p className="mt-1.5 max-w-xs text-sm text-depth-500">{description}</p>
{action &&
(action.href ? (
<Link
href={action.href}
className="tap-target mt-5 inline-flex items-center rounded-xl2 bg-flow-500 px-5 font-display text-sm font-semibold text-depth-950 active:scale-[0.98]"
>
{action.label}
</Link>
) : (
<button
onClick={action.onClick}
className="tap-target mt-5 inline-flex items-center rounded-xl2 bg-flow-500 px-5 font-display text-sm font-semibold text-depth-950 active:scale-[0.98]"
>
{action.label}
</button>
))}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
function base(extra: string) {
return "animate-pulse rounded-lg bg-depth-100 " + extra;
}
export function SkeletonLine({ width = "100%" }: { width?: string }) {
return <div className={base("h-3.5")} style={{ width }} />;
}
export function SkeletonCard() {
return (
<div className="rounded-xl2 bg-white p-4 shadow-soft">
<div className={base("h-3 w-2/3 mb-3")} />
<div className={base("h-6 w-1/2")} />
</div>
);
}
export function SkeletonCardGrid({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
);
}
export function SkeletonList({ rows = 4 }: { rows?: number }) {
return (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<div className={base("h-4 w-32")} />
</div>
<ul className="divide-y divide-depth-100">
{Array.from({ length: rows }).map((_, i) => (
<li key={i} className="flex items-center justify-between px-4 py-3">
<div className="space-y-2">
<div className={base("h-3.5 w-40")} />
<div className={base("h-3 w-24")} />
</div>
<div className={base("h-6 w-16")} />
</li>
))}
</ul>
</div>
);
}
export function SkeletonTable({ rows = 4, cols = 5 }: { rows?: number; cols?: number }) {
return (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3">
<div className={base("h-4 w-40")} />
</div>
<div className="p-4 space-y-3">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex gap-4">
{Array.from({ length: cols }).map((_, c) => (
<div key={c} className={base("h-3.5 flex-1")} />
))}
</div>
))}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { createContext, useCallback, useContext, useRef, useState } from "react";
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastContextValue {
show: (message: string, kind?: ToastKind) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const kindStyles: Record<ToastKind, string> = {
success: "border-confirm-500/30 bg-depth-900 text-confirm-400",
error: "border-alert-500/30 bg-depth-900 text-alert-400",
info: "border-flow-500/30 bg-depth-900 text-flow-400",
};
const kindIcon: Record<ToastKind, string> = {
success: "✓",
error: "✕",
info: "ℹ",
};
/**
* Every mutating action in the app (create staff, save a price, record a
* sale, etc.) previously gave the person no explicit confirmation beyond
* the page silently re-rendering. This provider gives every one of those
* actions a brief, consistent "yes, that worked" (or "no, here's why not")
* moment. Wrap the app root with <ToastProvider>, then call
* `const { show } = useToast()` anywhere and `show("Saved", "success")`.
*/
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const show = useCallback((message: string, kind: ToastKind = "success") => {
const id = idRef.current++;
setToasts((prev) => [...prev, { id, kind, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<div
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-center gap-2 px-4 sm:bottom-6 sm:items-end sm:right-6 sm:left-auto"
>
{toasts.map((t) => (
<div
key={t.id}
role="status"
className={
"pointer-events-auto flex w-full max-w-sm items-center gap-2.5 rounded-xl2 border px-4 py-3 shadow-soft animate-fade-up " +
kindStyles[t.kind]
}
>
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-xs font-bold">
{kindIcon[t.kind]}
</span>
<span className="text-sm font-medium text-white">{t.message}</span>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
return ctx;
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
interface Props {
size?: number;
glow?: boolean;
className?: string;
}
/**
* The Vessel mark: a droplet held inside a circular ring. The ring is the
* deliberate link between the brand and the dashboard's radial/circular
* charts — same geometric language throughout the product.
*/
export default function VesselLogo({ size = 40, glow = false, className = "" }: Props) {
return (
<div
className={
"relative flex items-center justify-center rounded-2xl " +
(glow ? "shadow-glow " : "") +
className
}
style={{ width: size, height: size }}
>
<svg width={size} height={size} viewBox="0 0 40 40" fill="none">
<defs>
<linearGradient id="vessel-grad" x1="4" y1="4" x2="36" y2="36" gradientUnits="userSpaceOnUse">
<stop stopColor="#E7A684" />
<stop offset="1" stopColor="#A3644F" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="17.5" stroke="url(#vessel-grad)" strokeWidth="2" opacity="0.55" />
<path d="M20 9c0 0-8 10.5-8 15.5a8 8 0 0 0 16 0C28 19.5 20 9 20 9Z" fill="url(#vessel-grad)" />
</svg>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { getDeviceId } from "@/lib/deviceId";
export class ApiError extends Error {
status: number;
body: any;
constructor(message: string, status: number, body?: any) {
super(message);
this.status = status;
this.body = body;
}
}
let refreshInFlight: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: getDeviceId() }),
})
.then((r) => r.ok)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
export async function apiFetch<T = any>(
path: string,
options: RequestInit = {},
_retried = false
): Promise<T> {
const res = await fetch(path, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
if (res.status === 401 && !_retried && !path.startsWith("/api/auth/")) {
const refreshed = await tryRefresh();
if (refreshed) {
return apiFetch<T>(path, options, true);
}
}
const isJson = res.headers.get("content-type")?.includes("application/json");
const body = isJson ? await res.json().catch(() => ({})) : null;
if (!res.ok) {
throw new ApiError(body?.error ?? "Request failed", res.status, body);
}
return body as T;
}
+29
View File
@@ -0,0 +1,29 @@
const STORAGE_KEY = "aquaflow_device_id";
/**
* A stable per-device identifier the server uses for device binding (max
* simultaneous DELIVERY sessions) and refresh-token session tracking.
* Persisted in localStorage — NOT sessionStorage or an in-memory variable
* — precisely because it must survive the browser/PWA being closed and
* reopened; that persistence is exactly what makes it "this device"
* rather than "this tab".
*/
export function getDeviceId(): string {
if (typeof window === "undefined") return "";
let id = localStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, id);
}
return id;
}
export function getDeviceLabel(): string {
if (typeof navigator === "undefined") return "Unknown device";
const ua = navigator.userAgent;
const isAndroid = /Android/i.test(ua);
const isIOS = /iPhone|iPad/i.test(ua);
const browser = /Chrome/i.test(ua) ? "Chrome" : /Firefox/i.test(ua) ? "Firefox" : /Safari/i.test(ua) ? "Safari" : "Browser";
const platform = isAndroid ? "Android" : isIOS ? "iOS" : "Desktop";
return browser + " on " + platform;
}
+98
View File
@@ -0,0 +1,98 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import { apiFetch } from "@/lib/api";
export interface QueuedCashSale {
clientUuid: string;
customerName?: string;
customerPhone: string;
productId: string;
quantity: number;
clientSubmittedAt: string;
synced: boolean;
}
interface QueueDB extends DBSchema {
cash_sales: { key: string; value: QueuedCashSale };
}
const DB_NAME = "aquaflow-offline";
const STORE = "cash_sales";
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" });
},
});
}
return dbPromise;
}
export function generateClientUuid(): string {
return crypto.randomUUID();
}
export async function queueCashSale(sale: Omit<QueuedCashSale, "synced">): Promise<void> {
const db = await getDb();
await db.put(STORE, { ...sale, synced: false });
}
export async function getPendingSales(): Promise<QueuedCashSale[]> {
const db = await getDb();
const all = await db.getAll(STORE);
return all.filter((s) => !s.synced);
}
async function markSynced(clientUuids: string[]) {
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);
}
}
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 };
try {
const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }),
});
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 };
}
}
export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) {
if (typeof window === "undefined") return () => {};
const handler = async () => {
const result = await flushQueue();
onFlush?.(result);
};
window.addEventListener("online", handler);
if (navigator.onLine) handler();
const interval = setInterval(() => {
if (navigator.onLine) handler();
}, 30000);
return () => {
window.removeEventListener("online", handler);
clearInterval(interval);
};
}
+4
View File
@@ -0,0 +1,4 @@
export function isValidKenyanPhone(raw: string): boolean {
const digits = raw.replace(/\s|-/g, "").replace(/^\+/, "");
return /^0(7|1)\d{8}$/.test(digits) || /^254(7|1)\d{8}$/.test(digits) || /^(7|1)\d{8}$/.test(digits);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { Transaction } from "@/lib/types";
export function useTransactionFeed(branchId: string, maxItems = 50, intervalMs = 4000) {
const [events, setEvents] = useState<Transaction[]>([]);
const [connected, setConnected] = useState(true);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const branchParam = branchId === "ALL" ? "" : "?branchId=" + branchId;
const result = await apiFetch<{ transactions: Transaction[] }>(
"/api/transactions" + (branchParam ? branchParam + "&limit=" + maxItems : "?limit=" + maxItems)
);
if (!cancelled) {
setEvents(result.transactions);
setConnected(true);
}
} catch {
if (!cancelled) setConnected(false);
}
}
poll();
timerRef.current = setInterval(poll, intervalMs);
return () => {
cancelled = true;
if (timerRef.current) clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [branchId, intervalMs]);
return { events, connected };
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
import { UserRole } from "@/lib/server/models/User";
export const ACCESS_COOKIE = "access_token";
export const REFRESH_COOKIE = "refresh_token";
export function getAuth(req: NextRequest): AccessTokenPayload | null {
const token = req.cookies.get(ACCESS_COOKIE)?.value;
if (!token) return null;
try {
return verifyAccessToken(token);
} catch {
return null;
}
}
export function requireRole(auth: AccessTokenPayload | null, ...roles: UserRole[]): boolean {
if (!auth) return false;
return roles.includes(auth.role);
}
export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?: string | null): string | null {
if (auth.role === "SUPER_ADMIN") {
return requestedBranchId && requestedBranchId !== "ALL" ? requestedBranchId : null;
}
return auth.branchId;
}
export function isProd(): boolean {
return process.env.NODE_ENV === "production";
}
/**
* Cookie options for both session cookies. Since the frontend and API now
* live in the SAME Next.js app on the SAME Vercel domain, these are
* always same-site — no SameSite=None/cross-site complexity needed here,
* unlike the earlier split Vercel+Render deployment.
*/
export function accessCookieOptions() {
return {
httpOnly: true,
secure: isProd(),
sameSite: "lax" as const,
maxAge: 15 * 60,
path: "/",
};
}
export function refreshCookieOptions(maxAgeSeconds: number) {
return {
httpOnly: true,
secure: isProd(),
sameSite: "lax" as const,
maxAge: maxAgeSeconds,
path: "/api/auth",
};
}
+37
View File
@@ -0,0 +1,37 @@
import mongoose from "mongoose";
/**
* Vercel serverless functions can be invoked many times against the same
* warm container, and Next.js hot-reloads modules in dev — without
* caching, each invocation would open a brand new MongoDB connection and
* quickly exhaust Atlas's connection limit. This caches the connection
* (and the in-flight connect promise, to avoid a race when multiple
* requests hit a cold container simultaneously) on the Node.js global
* object, which survives across invocations within the same warm
* container/module scope.
*/
declare global {
// eslint-disable-next-line no-var
var __mongooseCache: { conn: typeof mongoose | null; promise: Promise<typeof mongoose> | null } | undefined;
}
const cache = global.__mongooseCache ?? { conn: null, promise: null };
global.__mongooseCache = cache;
export async function connectDb(): Promise<typeof mongoose> {
if (cache.conn) return cache.conn;
if (!cache.promise) {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
mongoose.set("strictQuery", true);
cache.promise = mongoose.connect(uri, {
maxPoolSize: 5,
});
}
cache.conn = await cache.promise;
return cache.conn;
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
MONGODB_URI: z.string().min(1),
JWT_ACCESS_SECRET: z.string().min(16),
JWT_REFRESH_SECRET: z.string().min(16),
JWT_ACCESS_TTL: z.string().default("15m"),
JWT_REFRESH_TTL: z.string().default("30d"),
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
DARAJA_CONSUMER_KEY: z.string().min(1),
DARAJA_CONSUMER_SECRET: z.string().min(1),
DARAJA_SHORTCODE: z.string().min(1),
DARAJA_PASSKEY: z.string().min(1),
DARAJA_TRANSACTION_TYPE: z
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
.default("CustomerPayBillOnline"),
DARAJA_CALLBACK_URL: z.string().url(),
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
});
type ServerEnv = z.infer<typeof schema>;
let cached: ServerEnv | null = null;
export function getEnv(): ServerEnv {
if (cached) return cached;
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
cached = parsed.data;
return cached;
}
+39
View File
@@ -0,0 +1,39 @@
import jwt, { SignOptions } from "jsonwebtoken";
import crypto from "crypto";
import { getEnv } from "@/lib/server/env";
import { UserRole } from "@/lib/server/models/User";
export interface AccessTokenPayload {
sub: string;
role: UserRole;
branchId: string | null;
deviceId: string;
}
export function signAccessToken(payload: AccessTokenPayload): string {
const env = getEnv();
const options: SignOptions = { expiresIn: env.JWT_ACCESS_TTL as SignOptions["expiresIn"] };
return jwt.sign(payload, env.JWT_ACCESS_SECRET, options);
}
export function verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, getEnv().JWT_ACCESS_SECRET) as AccessTokenPayload;
}
export function generateRefreshToken(): string {
return crypto.randomBytes(48).toString("hex");
}
export function hashRefreshToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function refreshTtlMs(): number {
const ttl = getEnv().JWT_REFRESH_TTL;
const match = ttl.match(/^(\d+)([smhd])$/);
if (!match) return 30 * 24 * 60 * 60 * 1000;
const value = Number(match[1]);
const unit = match[2];
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return value * multipliers[unit];
}
+22
View File
@@ -0,0 +1,22 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IBranch extends Document {
_id: Types.ObjectId;
branchName: string;
locationCity: string;
managerId: Types.ObjectId | null;
createdAt: Date;
updatedAt: Date;
}
const branchSchema = new Schema<IBranch>(
{
branchName: { type: String, required: true, trim: true },
locationCity: { type: String, required: true, trim: true },
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: true }
);
export const Branch =
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
+43
View File
@@ -0,0 +1,43 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IDailyInventory extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
productId: Types.ObjectId;
date: string; // YYYY-MM-DD, branch-local calendar day — see note below
morningDispatched: number;
eveningPhysicalCount: number | null;
recordedBy: Types.ObjectId | null;
createdAt: Date;
updatedAt: Date;
}
/**
* One document per branch, per product (size), per day — a 5L and a 20L
* are physically different stock and must reconcile independently.
* `date` is stored as a plain YYYY-MM-DD string rather than a Date so
* "today" comparisons are unambiguous and don't depend on server timezone
* math; the app writes it using the branch's local calendar day.
*/
const dailyInventorySchema = new Schema<IDailyInventory>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
date: { type: String, required: true },
morningDispatched: { type: Number, default: 0, min: 0 },
eveningPhysicalCount: { type: Number, default: null, min: 0 },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: true }
);
// Required compound index: {branch_id: 1, date: 1} — this is the exact
// access pattern for both "today's dispatch entry for this branch" writes
// and the reconciliation aggregation's $match stage.
dailyInventorySchema.index({ branchId: 1, date: 1 });
// One ledger row per branch+product+day.
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
export const DailyInventory =
(models.DailyInventory as Model<IDailyInventory>) ||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
+48
View File
@@ -0,0 +1,48 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
// Business expense categories. Kept as a constrained string set so the UI
// can offer a dropdown while the owner can still extend later via code.
export const EXPENSE_CATEGORIES = [
"Transport",
"Electricity",
"Rent",
"Salaries",
"Repairs",
"Packaging",
"Other",
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
export interface IExpense extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
category: ExpenseCategory;
amount: number;
description: string;
reference: string | null;
notes: string | null;
recordedBy: Types.ObjectId;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const expenseSchema = new Schema<IExpense>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
category: { type: String, enum: EXPENSE_CATEGORIES, required: true },
amount: { type: Number, required: true, min: 0 },
description: { type: String, required: true, trim: true },
reference: { type: String, default: null, trim: true },
notes: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true } // createdAt/updatedAt from the server clock, never client input
);
expenseSchema.index({ branchId: 1, createdAt: -1 });
expenseSchema.index({ category: 1 });
// `models.Expense` guard avoids OverwriteModelError on serverless warm
// re-invocations where the module is evaluated more than once.
export const Expense =
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
+31
View File
@@ -0,0 +1,31 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IMpesaCallbackLog extends Document {
_id: Types.ObjectId;
sourceIp: string;
checkoutRequestId: string | null;
resultCode: number | null;
matchedTransactionId: Types.ObjectId | null;
rawPayload: unknown;
processingNote: string;
receivedAt: Date;
}
const mpesaCallbackLogSchema = new Schema<IMpesaCallbackLog>(
{
sourceIp: { type: String, required: true },
checkoutRequestId: { type: String, default: null, index: true },
resultCode: { type: Number, default: null },
matchedTransactionId: { type: Schema.Types.ObjectId, ref: "Transaction", default: null },
rawPayload: { type: Schema.Types.Mixed, required: true },
processingNote: { type: String, required: true },
receivedAt: { type: Date, default: () => new Date() },
},
{ timestamps: false }
);
mpesaCallbackLogSchema.index({ receivedAt: -1 });
export const MpesaCallbackLog =
(models.MpesaCallbackLog as Model<IMpesaCallbackLog>) ||
model<IMpesaCallbackLog>("MpesaCallbackLog", mpesaCallbackLogSchema);
+52
View File
@@ -0,0 +1,52 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IProduct extends Document {
_id: Types.ObjectId;
label: string;
sizeLiters: number;
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
category: string; // e.g. "Water", "Eggs"
unitPrice: number;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
const productSchema = new Schema<IProduct>(
{
label: { type: String, required: true, trim: true },
sizeLiters: { type: Number, required: true, min: 0.1 },
unit: { type: String, default: "L", trim: true },
category: { type: String, default: "Water", trim: true },
unitPrice: { type: Number, required: true, min: 0 },
sortOrder: { type: Number, default: 0 },
isActive: { type: Boolean, default: true, index: true },
},
{ timestamps: true }
);
export const Product =
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
// Branch-specific price override — optional, falls back to Product.unitPrice.
export interface IBranchPricing extends Document {
branchId: Types.ObjectId;
productId: Types.ObjectId;
unitPrice: number;
updatedAt: Date;
}
const branchPricingSchema = new Schema<IBranchPricing>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
unitPrice: { type: Number, required: true, min: 0 },
},
{ timestamps: { createdAt: false, updatedAt: true } }
);
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
export const BranchPricing =
(models.BranchPricing as Model<IBranchPricing>) ||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
+42
View File
@@ -0,0 +1,42 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
// A recorded stock purchase from a supplier. Completing a purchase is what
// increases branch stock. Price fields are snapshotted onto the record so
// historical purchases never change when a product's price changes later.
export interface IPurchase extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
supplierName: string;
productId: Types.ObjectId | null;
productLabel: string; // snapshot of the product name at purchase time
quantity: number;
unitCost: number; // buying price, snapshotted
totalCost: number; // quantity * unitCost, snapshotted
reference: string | null;
notes: string | null;
recordedBy: Types.ObjectId;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const purchaseSchema = new Schema<IPurchase>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
supplierName: { type: String, required: true, trim: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
productLabel: { type: String, required: true, trim: true },
quantity: { type: Number, required: true, min: 1 },
unitCost: { type: Number, required: true, min: 0 },
totalCost: { type: Number, required: true, min: 0 },
reference: { type: String, default: null, trim: true },
notes: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true }
);
purchaseSchema.index({ branchId: 1, createdAt: -1 });
purchaseSchema.index({ productId: 1 });
export const Purchase =
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
+95
View File
@@ -0,0 +1,95 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export type PaymentMethod = "CASH" | "MPESA";
export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface ITransaction extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
staffId: Types.ObjectId;
customerName: string | null;
customerPhone: string;
productId: Types.ObjectId;
sizeLiters: number; // snapshotted at sale time
quantity: number;
unitPrice: number; // snapshotted at sale time
amountTotal: number;
paymentMethod: PaymentMethod;
paymentStatus: PaymentStatus;
mpesaDetails: {
checkoutRequestId: string | null;
merchantRequestId: string | null;
receiptNumber: string | null;
resultCode: number | null;
resultDesc: string | null;
};
clientUuid: string; // idempotency key generated on-device
syncedFromOffline: boolean;
// TAMPER PREVENTION: this is what the device *claimed* the time was when
// the sale happened — informational only, e.g. for offline-queue
// ordering in the UI. It is NEVER used for reconciliation, reporting, or
// as the authoritative sale time, precisely because a delivery worker
// could set their phone's clock backward/forward to forge history. The
// authoritative time is `createdAt`, which Mongoose/MongoDB stamps from
// the SERVER's clock at insert time and which the API never accepts as
// client input (see transactions.routes.ts / mpesa.routes.ts — neither
// route reads a timestamp field off the request body).
clientSubmittedAt: Date | null;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const transactionSchema = new Schema<ITransaction>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
customerName: { type: String, default: null, trim: true },
customerPhone: { type: String, required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
sizeLiters: { type: Number, required: true },
quantity: { type: Number, required: true, min: 1 },
unitPrice: { type: Number, required: true, min: 0 },
amountTotal: { type: Number, required: true, min: 0 },
paymentMethod: { type: String, enum: ["CASH", "MPESA"], required: true },
paymentStatus: {
type: String,
enum: ["PENDING", "SUCCESS", "FAILED"],
required: true,
default: "PENDING",
},
mpesaDetails: {
checkoutRequestId: { type: String, default: null },
merchantRequestId: { type: String, default: null },
receiptNumber: { type: String, default: null },
resultCode: { type: Number, default: null },
resultDesc: { type: String, default: null },
},
clientUuid: { type: String, required: true },
syncedFromOffline: { type: Boolean, default: false },
clientSubmittedAt: { type: Date, default: null },
},
{ timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input
);
// Required indexes: branch_id, created_at, mpesa checkout id.
transactionSchema.index({ branchId: 1 });
transactionSchema.index({ createdAt: -1 });
transactionSchema.index({ "mpesaDetails.checkoutRequestId": 1 });
// Idempotency: an offline-queued sale replayed twice must never duplicate.
transactionSchema.index({ clientUuid: 1 }, { unique: true });
// Practical addition: the actual hot query pattern for both the live feed
// and reconciliation is "this branch, most recent first" / "this branch,
// today" — a compound index serves that far better than the two single
// indexes above combined.
transactionSchema.index({ branchId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 });
export const Transaction =
(models.Transaction as Model<ITransaction>) ||
model<ITransaction>("Transaction", transactionSchema);
+59
View File
@@ -0,0 +1,59 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
import bcrypt from "bcryptjs";
export type UserRole = "DELIVERY" | "BRANCH_MANAGER" | "SUPER_ADMIN";
export const USER_ROLES: UserRole[] = ["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"];
export interface IUser extends Document {
_id: Types.ObjectId;
name: string;
role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
email: string; // login identifier for every role, via the single /login page
passwordHash: string;
isActive: boolean;
failedLoginAttempts: number;
lockedUntil: Date | null;
createdAt: Date;
updatedAt: Date;
comparePassword(password: string): Promise<boolean>;
}
const userSchema = new Schema<IUser>(
{
name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 },
lockedUntil: { type: Date, default: null },
},
{ timestamps: true }
);
// Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no
// per-role credential branching left here — just the branch scoping rule.
userSchema.pre("validate", function (next) {
if (this.role !== "SUPER_ADMIN" && !this.branchId) {
return next(new Error("branchId is required for all roles except SUPER_ADMIN"));
}
if (this.role === "SUPER_ADMIN" && this.branchId) {
return next(new Error("SUPER_ADMIN accounts must not be scoped to a single branchId"));
}
next();
});
userSchema.methods.comparePassword = async function (password: string): Promise<boolean> {
if (!this.passwordHash) return false;
return bcrypt.compare(password, this.passwordHash);
};
export const User =
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
+44
View File
@@ -0,0 +1,44 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
/**
* One document per (user, device). This is what makes "persistent sessions
* that survive poor network coverage" possible — a device holds a
* long-lived refresh token (30d default) here, and uses it to mint fresh
* short-lived access tokens (15m) without re-entering a PIN. It is also
* how device binding is enforced: a DELIVERY account is capped at
* DELIVERY_MAX_DEVICES active sessions (see auth.routes.ts), counted by
* distinct `deviceId`, not by token — logging in again on the same device
* simply rotates that device's row rather than consuming a new slot.
*/
export interface IUserSession extends Document {
_id: Types.ObjectId;
userId: Types.ObjectId;
deviceId: string; // client-generated UUID, persisted in device storage
deviceLabel: string | null; // e.g. "Chrome on Android" — for the "active devices" UI
refreshTokenHash: string; // the raw refresh token is never stored, only its hash
lastUsedAt: Date;
expiresAt: Date;
createdAt: Date;
}
const userSessionSchema = new Schema<IUserSession>(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
deviceId: { type: String, required: true },
deviceLabel: { type: String, default: null },
refreshTokenHash: { type: String, required: true },
lastUsedAt: { type: Date, default: () => new Date() },
expiresAt: { type: Date, required: true },
},
{ timestamps: { createdAt: true, updatedAt: false } }
);
userSessionSchema.index({ userId: 1, deviceId: 1 }, { unique: true });
// TTL index — MongoDB automatically deletes expired sessions, so a stale
// refresh token can never be replayed after its expiry even if somehow
// leaked.
userSessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
export const UserSession =
(models.UserSession as Model<IUserSession>) ||
model<IUserSession>("UserSession", userSessionSchema);
+122
View File
@@ -0,0 +1,122 @@
import axios from "axios";
import { getEnv } from "@/lib/server/env";
function baseUrl(): string {
return getEnv().DARAJA_ENV === "production"
? "https://api.safaricom.co.ke"
: "https://sandbox.safaricom.co.ke";
}
let cachedToken: { token: string; expiresAt: number } | null = null;
export async function getDarajaToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt) {
return cachedToken.token;
}
const env = getEnv();
const credentials = Buffer.from(`${env.DARAJA_CONSUMER_KEY}:${env.DARAJA_CONSUMER_SECRET}`).toString(
"base64"
);
const res = await axios.get(`${baseUrl()}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${credentials}` },
timeout: 10_000,
});
const token = res.data.access_token as string;
const expiresIn = Number(res.data.expires_in ?? 3599);
cachedToken = { token, expiresAt: Date.now() + (expiresIn - 60) * 1000 };
return token;
}
function darajaTimestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getFullYear().toString() +
pad(d.getMonth() + 1) +
pad(d.getDate()) +
pad(d.getHours()) +
pad(d.getMinutes()) +
pad(d.getSeconds())
);
}
function darajaPassword(shortcode: string, passkey: string, timestamp: string) {
return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");
}
export interface StkPushParams {
phone: string;
amount: number;
accountReference: string;
transactionDesc: string;
}
export interface StkPushResult {
MerchantRequestID: string;
CheckoutRequestID: string;
ResponseCode: string;
ResponseDescription: string;
CustomerMessage: string;
}
export async function initiateStkPush(params: StkPushParams): Promise<StkPushResult> {
const env = getEnv();
const token = await getDarajaToken();
const timestamp = darajaTimestamp();
const password = darajaPassword(env.DARAJA_SHORTCODE, env.DARAJA_PASSKEY, timestamp);
const body = {
BusinessShortCode: env.DARAJA_SHORTCODE,
Password: password,
Timestamp: timestamp,
TransactionType: env.DARAJA_TRANSACTION_TYPE,
Amount: Math.round(params.amount),
PartyA: params.phone,
PartyB: env.DARAJA_SHORTCODE,
PhoneNumber: params.phone,
CallBackURL: env.DARAJA_CALLBACK_URL,
AccountReference: params.accountReference.slice(0, 12),
TransactionDesc: params.transactionDesc.slice(0, 13),
};
const res = await axios.post(`${baseUrl()}/mpesa/stkpush/v1/processrequest`, body, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 15_000,
});
return res.data as StkPushResult;
}
export interface StkQueryResult {
ResponseCode: string;
ResponseDescription: string;
MerchantRequestID: string;
CheckoutRequestID: string;
ResultCode: string;
ResultDesc: string;
}
export async function queryStkStatus(checkoutRequestId: string): Promise<StkQueryResult> {
const env = getEnv();
const token = await getDarajaToken();
const timestamp = darajaTimestamp();
const password = darajaPassword(env.DARAJA_SHORTCODE, env.DARAJA_PASSKEY, timestamp);
const res = await axios.post(
`${baseUrl()}/mpesa/stkpushquery/v1/query`,
{
BusinessShortCode: env.DARAJA_SHORTCODE,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestId,
},
{
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 10_000,
}
);
return res.data as StkQueryResult;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Normalizes Kenyan phone numbers to 2547XXXXXXXX / 2541XXXXXXXX — the
* format Daraja requires and what we use as the canonical stored form.
* Accepts 07..., 01..., +2547..., 2547...
*/
export function normalizeKenyanPhone(raw: string): string {
const digits = raw.replace(/\s|-/g, "").replace(/^\+/, "");
let normalized: string | null = null;
if (/^0(7|1)\d{8}$/.test(digits)) {
normalized = "254" + digits.slice(1);
} else if (/^254(7|1)\d{8}$/.test(digits)) {
normalized = digits;
} else if (/^(7|1)\d{8}$/.test(digits)) {
normalized = "254" + digits;
}
if (!normalized) {
throw new Error("Invalid phone number. Use 07XXXXXXXX, 01XXXXXXXX, or +254XXXXXXXXX.");
}
return normalized;
}
export function isValidKenyanPhone(raw: string): boolean {
try {
normalizeKenyanPhone(raw);
return true;
} catch {
return false;
}
}
+83
View File
@@ -0,0 +1,83 @@
export type UserRole = "DELIVERY" | "BRANCH_MANAGER" | "SUPER_ADMIN";
export type PaymentMethod = "CASH" | "MPESA";
export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface Branch {
_id: string;
branchName: string;
locationCity: string;
managerId: string | null;
}
export interface AuthUser {
id: string;
name: string;
role: UserRole;
branchId: string | null;
branchName?: string | null;
phoneNumber?: string;
email?: string | null;
}
export interface Product {
_id: string;
label: string;
sizeLiters: number;
unit?: string;
category?: string;
unitPrice: number;
sortOrder: number;
isActive: boolean;
}
export interface Transaction {
_id: string;
branchId: string | { _id: string; branchName: string };
staffId: string;
customerName: string | null;
customerPhone: string;
sizeLiters: number;
quantity: number;
unitPrice: number;
amountTotal: number;
paymentMethod: PaymentMethod;
paymentStatus: PaymentStatus;
mpesaDetails?: {
checkoutRequestId: string | null;
receiptNumber: string | null;
resultDesc: string | null;
};
createdAt: string;
}
export interface DashboardSummary {
scope: "global" | "branch";
totals: {
cashTotal: number;
mpesaTotal: number;
totalSales: number;
pendingMpesa: number;
};
branchRanking: {
branchId: string;
branchName: string;
locationCity: string;
revenue: number;
salesCount: number;
cashTotal: number;
mpesaTotal: number;
}[];
}
export interface ReconciliationRow {
branchId: string;
branchName: string;
productId: string;
productLabel: string;
sizeLiters: number;
morningDispatched: number;
eveningPhysicalCount: number | null;
totalSold: number;
expectedRemaining: number;
reconciledOk: boolean | null;
}