Initial commit: NetsKey ERP (copper & cream redesign)
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 & 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user