0f609d57bb
Branch price overrides were already applied at the till, in cash sales and in M-Pesa, but nothing could set them. - Products -> Branch prices (/admin/prices): pick a branch and set its price for any product; setting it back to the default removes the override, and "Use default" resets it. Branch managers see their own branch's prices read-only. - /api/branch-prices: GET (owner: any branch; manager: own), PUT and DELETE (owner only). Branch and product must belong to the business; prices must be above zero; who changed a price is recorded. - Branch pricing is a plan feature, as the Growth plan already advertised: Growth and Enterprise, plus any business on its free trial. A paid Starter business keeps any prices set during its trial and can reset them, but cannot set new ones. - The catalogue view on the Products page always shows default prices; the till's view shows what the branch charges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
"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 { formatSize } from "@/lib/units";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
|
import EmptyState from "@/components/ui/EmptyState";
|
|
|
|
interface PriceRow {
|
|
productId: string;
|
|
label: string;
|
|
unit: string;
|
|
sizeLiters: number;
|
|
isActive: boolean;
|
|
defaultPrice: number;
|
|
branchPrice: number | null;
|
|
updatedAt: string | null;
|
|
}
|
|
|
|
interface PricesResponse {
|
|
branchId: string;
|
|
branchName: string;
|
|
allowed: boolean;
|
|
prices: PriceRow[];
|
|
}
|
|
|
|
/**
|
|
* Branch prices: what each branch charges for each product. Owners edit;
|
|
* branch managers see their own branch's prices.
|
|
*/
|
|
export default function BranchPricesPage() {
|
|
const router = useRouter();
|
|
const { show } = useToast();
|
|
const [me, setMe] = useState<AuthUser | null>(null);
|
|
const [branches, setBranches] = useState<Branch[]>([]);
|
|
const [branchId, setBranchId] = useState("");
|
|
const [data, setData] = useState<PricesResponse | null>(null);
|
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
const [savingId, setSavingId] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const isOwner = 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);
|
|
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
|
|
setBranches(br.branches);
|
|
const first = result.user.role === "SUPER_ADMIN" ? br.branches[0]?._id : result.user.branchId;
|
|
if (first) setBranchId(first);
|
|
} catch {
|
|
router.replace("/login");
|
|
return;
|
|
}
|
|
setLoading(false);
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!branchId) return;
|
|
loadPrices(branchId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [branchId]);
|
|
|
|
async function loadPrices(id: string) {
|
|
try {
|
|
const res = await apiFetch<PricesResponse>("/api/branch-prices?branchId=" + id);
|
|
setData(res);
|
|
setDrafts(Object.fromEntries(res.prices.map((p) => [p.productId, String(p.branchPrice ?? p.defaultPrice)])));
|
|
} catch (err) {
|
|
show(err instanceof ApiError ? err.message : "Could not load prices", "error");
|
|
}
|
|
}
|
|
|
|
async function save(row: PriceRow) {
|
|
const price = parseFloat(drafts[row.productId]);
|
|
if (!Number.isFinite(price) || price <= 0) {
|
|
show("Enter a price above zero", "error");
|
|
return;
|
|
}
|
|
setSavingId(row.productId);
|
|
try {
|
|
await apiFetch("/api/branch-prices", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ branchId, productId: row.productId, unitPrice: price }),
|
|
});
|
|
show(
|
|
price === row.defaultPrice
|
|
? `${row.label} back to the default price`
|
|
: `${row.label} now KES ${price.toLocaleString()} at ${data?.branchName}`,
|
|
"success"
|
|
);
|
|
await loadPrices(branchId);
|
|
} catch (err) {
|
|
show(err instanceof ApiError ? err.message : "Could not save the price", "error");
|
|
}
|
|
setSavingId(null);
|
|
}
|
|
|
|
async function reset(row: PriceRow) {
|
|
setSavingId(row.productId);
|
|
try {
|
|
await apiFetch(`/api/branch-prices?branchId=${branchId}&productId=${row.productId}`, { method: "DELETE" });
|
|
show(`${row.label} back to the default price`, "success");
|
|
await loadPrices(branchId);
|
|
} catch (err) {
|
|
show(err instanceof ApiError ? err.message : "Could not reset the price", "error");
|
|
}
|
|
setSavingId(null);
|
|
}
|
|
|
|
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 canEdit = isOwner && !!data?.allowed;
|
|
const overrides = data?.prices.filter((p) => p.branchPrice !== null).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-3xl flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
|
|
<h1 className="font-display text-xl font-bold">Branch prices</h1>
|
|
</div>
|
|
<Link
|
|
href="/admin/products"
|
|
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
|
|
>
|
|
← Products
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
|
|
{branches.length === 0 ? (
|
|
<div className="rounded-xl2 bg-white shadow-soft">
|
|
<EmptyState icon="🏢" title="No branches yet" description="Create a branch first, then set its prices here." />
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl2 bg-white shadow-soft">
|
|
<div className="space-y-3 border-b border-depth-100 px-4 py-3">
|
|
{isOwner ? (
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price-branch">
|
|
Branch
|
|
</label>
|
|
<select
|
|
id="price-branch"
|
|
value={branchId}
|
|
onChange={(e) => setBranchId(e.target.value)}
|
|
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3 sm:w-72"
|
|
>
|
|
{branches.map((b) => (
|
|
<option key={b._id} value={b._id}>
|
|
{b.branchName} — {b.locationCity}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<h2 className="font-display font-semibold text-depth-900">{data?.branchName ?? "Your branch"}</h2>
|
|
)}
|
|
<p className="text-xs text-depth-500">
|
|
Each product sells at its default price unless this branch has its own price. Cashiers at the branch
|
|
see the new price the next time their till loads products.
|
|
{!isOwner && " Only the business owner can change prices."}
|
|
</p>
|
|
{isOwner && data && !data.allowed && (
|
|
<p className="rounded-lg bg-cash-500/10 px-3 py-2 text-sm text-cash-700">
|
|
Branch prices are part of the Growth plan. Existing branch prices still apply and can be reset to
|
|
the default.
|
|
</p>
|
|
)}
|
|
{data && <p className="text-xs font-medium text-depth-600">{overrides} product(s) priced differently here</p>}
|
|
</div>
|
|
|
|
{!data ? (
|
|
<SkeletonList rows={3} />
|
|
) : data.prices.length === 0 ? (
|
|
<EmptyState icon="🧴" title="No products yet" description="Add products first on the Products page." />
|
|
) : (
|
|
<ul className="divide-y divide-depth-100">
|
|
{data.prices.map((row) => {
|
|
const custom = row.branchPrice !== null;
|
|
const draft = drafts[row.productId] ?? "";
|
|
const changed = parseFloat(draft) !== (row.branchPrice ?? row.defaultPrice);
|
|
return (
|
|
<li key={row.productId} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-depth-900">
|
|
{row.label}{" "}
|
|
<span className="text-depth-500">· {formatSize(row.sizeLiters, row.unit)}</span>
|
|
{!row.isActive && <span className="ml-2 text-xs text-depth-500">(deactivated)</span>}
|
|
</p>
|
|
<p className="text-xs text-depth-500">
|
|
Default KES {row.defaultPrice.toLocaleString()}
|
|
{custom && (
|
|
<span className="ml-2 rounded-full bg-flow-500/15 px-2 py-0.5 font-medium text-flow-700">
|
|
This branch: KES {row.branchPrice!.toLocaleString()}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{canEdit ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-depth-500">KES</span>
|
|
<input
|
|
type="number"
|
|
min="0.01"
|
|
step="0.01"
|
|
inputMode="decimal"
|
|
value={draft}
|
|
onChange={(e) => setDrafts((d) => ({ ...d, [row.productId]: e.target.value }))}
|
|
aria-label={`Price of ${row.label} at this branch`}
|
|
className="w-24 rounded-lg border border-depth-200 px-2 py-1.5 text-sm"
|
|
/>
|
|
<button
|
|
onClick={() => save(row)}
|
|
disabled={!changed || savingId === row.productId}
|
|
className="rounded-lg bg-flow-500 px-3 py-1.5 text-sm font-semibold text-depth-950 disabled:opacity-40"
|
|
>
|
|
Save
|
|
</button>
|
|
{custom && (
|
|
<button
|
|
onClick={() => reset(row)}
|
|
disabled={savingId === row.productId}
|
|
className="text-xs text-depth-500 underline"
|
|
>
|
|
Use default
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm font-semibold text-depth-900">
|
|
KES {(row.branchPrice ?? row.defaultPrice).toLocaleString()}
|
|
</span>
|
|
{isOwner && custom && (
|
|
<button onClick={() => reset(row)} className="text-xs text-depth-500 underline">
|
|
Use default
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|