Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice: - Authentication pages and protected route layouts - Customer, supplier, fuel purchase, and delivery note management - Custom reporting and customer statement sending tools - Full UI component library and Tailwind styling - Server actions, API routes, and PocketBase integration - Development scripts and project configuration updates - Updated README and package metadata
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select } from "@/components/ui/select";
|
|
import { Combobox } from "@/components/ui/combobox";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Pagination } from "@/components/ui/pagination";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { applyMonthlyIncrease, createPricingRule, deletePricingRule, updateDefaultPrice } from "@/actions/pricing";
|
|
import { toast } from "sonner";
|
|
import { Plus, Search, Trash2, X } from "lucide-react";
|
|
import type { Customer, CustomerPricing } from "@/lib/types";
|
|
import { formatCurrency } from "@/lib/utils";
|
|
|
|
interface Props {
|
|
rules: CustomerPricing[];
|
|
customers: Customer[];
|
|
customerMap: [string, Customer][];
|
|
defaultPrice: number;
|
|
searchQuery: string;
|
|
page: number;
|
|
totalPages: number;
|
|
totalItems: number;
|
|
}
|
|
|
|
const MONTHS = [
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
];
|
|
|
|
export function PricingManagement({ rules, customers, customerMap, defaultPrice, searchQuery, page, totalPages, totalItems }: Props) {
|
|
const router = useRouter();
|
|
const customerLookup = new Map(customerMap);
|
|
const [defaultPriceDraft, setDefaultPriceDraft] = useState(defaultPrice);
|
|
const [open, setOpen] = useState(false);
|
|
const [year, setYear] = useState(new Date().getFullYear());
|
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
|
const [delta, setDelta] = useState(5);
|
|
const [, startTransition] = useTransition();
|
|
|
|
function onSaveDefault() {
|
|
startTransition(async () => {
|
|
const res = await updateDefaultPrice(defaultPriceDraft);
|
|
if (res.ok) {
|
|
toast.success("Default price updated");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function onApplyIncrease() {
|
|
startTransition(async () => {
|
|
const res = await applyMonthlyIncrease(year, month, delta);
|
|
if (res.ok) {
|
|
toast.success(`Applied ${delta}¢ increase to all customers for ${MONTHS[month - 1]} ${year}`);
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function onDeleteRule(id: string) {
|
|
if (!confirm("Delete this pricing rule?")) return;
|
|
startTransition(async () => {
|
|
const res = await deletePricingRule(id);
|
|
if (res.ok) {
|
|
toast.success("Rule deleted");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Pricing</h1>
|
|
<p className="text-sm text-muted-foreground">Default price, monthly increases, and per-customer rules</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Default fuel price</CardTitle>
|
|
<CardDescription>Used when no per-customer rule exists for the month</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-end gap-2">
|
|
<div className="space-y-2 flex-1">
|
|
<Label htmlFor="default-price">Default price (R / L)</Label>
|
|
<Input
|
|
id="default-price"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={defaultPriceDraft}
|
|
onChange={(e) => setDefaultPriceDraft(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<Button onClick={onSaveDefault}>Save</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Monthly bulk increase</CardTitle>
|
|
<CardDescription>Add cents per litre to all active customers for a month</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="year">Year</Label>
|
|
<Input id="year" type="number" value={year} onChange={(e) => setYear(Number(e.target.value))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="month">Month</Label>
|
|
<Select
|
|
id="month"
|
|
value={String(month)}
|
|
onChange={(e) => setMonth(Number(e.target.value))}
|
|
options={MONTHS.map((m, i) => ({ value: String(i + 1), label: m }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="delta">Δ cents</Label>
|
|
<Input id="delta" type="number" value={delta} onChange={(e) => setDelta(Number(e.target.value))} />
|
|
</div>
|
|
</div>
|
|
<Button onClick={onApplyIncrease} className="mt-3 w-full">
|
|
Apply to all customers
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>Per-customer pricing rules</CardTitle>
|
|
<CardDescription>
|
|
{searchQuery
|
|
? `${totalItems} match "${searchQuery}"`
|
|
: `${totalItems} rule${totalItems !== 1 ? "s" : ""}`}
|
|
</CardDescription>
|
|
</div>
|
|
<Button onClick={() => setOpen(true)} size="sm">
|
|
<Plus className="h-4 w-4" /> Add rule
|
|
</Button>
|
|
</CardHeader>
|
|
<form action="/pricing" className="flex flex-wrap items-center gap-2 px-6 pb-4">
|
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
name="q"
|
|
type="search"
|
|
defaultValue={searchQuery}
|
|
placeholder="Search by customer name…"
|
|
className="h-9 w-full rounded-md border border-input bg-transparent pl-9 pr-3 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring"
|
|
/>
|
|
</div>
|
|
<Button type="submit" size="sm" variant="secondary">
|
|
Search
|
|
</Button>
|
|
{searchQuery && (
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link href="/pricing">
|
|
<X className="h-4 w-4" /> Clear
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</form>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Customer</TableHead>
|
|
<TableHead>Year</TableHead>
|
|
<TableHead>Month</TableHead>
|
|
<TableHead className="text-right">Price / L</TableHead>
|
|
<TableHead className="text-right">Special</TableHead>
|
|
<TableHead className="w-12"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rules.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
|
{searchQuery
|
|
? `No rules match "${searchQuery}".`
|
|
: "No pricing rules yet."}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{rules.map((r) => {
|
|
const customer = customerLookup.get(r.customer);
|
|
return (
|
|
<TableRow key={r.id}>
|
|
<TableCell className="font-medium">{customer?.name || r.customer}</TableCell>
|
|
<TableCell>{r.year}</TableCell>
|
|
<TableCell>{MONTHS[r.month - 1]}</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(r.price_per_litre)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{r.special_price != null ? <Badge variant="success">{formatCurrency(r.special_price)}</Badge> : "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button variant="ghost" size="icon" onClick={() => onDeleteRule(r.id)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
perPage={25}
|
|
basePath="/pricing"
|
|
query={searchQuery ? { q: searchQuery } : undefined}
|
|
/>
|
|
</Card>
|
|
|
|
<NewRuleDialog open={open} onOpenChange={setOpen} customers={customers} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewRuleDialog({ open, onOpenChange, customers }: { open: boolean; onOpenChange: (v: boolean) => void; customers: Customer[] }) {
|
|
const router = useRouter();
|
|
const [pending, startTransition] = useTransition();
|
|
const [customer, setCustomer] = useState("");
|
|
const [year, setYear] = useState(new Date().getFullYear());
|
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
|
const [price, setPrice] = useState(0);
|
|
const [special, setSpecial] = useState<number | "">("");
|
|
|
|
function onSubmit() {
|
|
if (!customer || !price) {
|
|
toast.error("Customer and price are required");
|
|
return;
|
|
}
|
|
const fd = new FormData();
|
|
fd.set("customer", customer);
|
|
fd.set("year", String(year));
|
|
fd.set("month", String(month));
|
|
fd.set("price_per_litre", String(price));
|
|
if (special) fd.set("special_price", String(special));
|
|
startTransition(async () => {
|
|
const res = await createPricingRule(null, fd);
|
|
if (res.ok) {
|
|
toast.success("Rule created");
|
|
onOpenChange(false);
|
|
setCustomer("");
|
|
setPrice(0);
|
|
setSpecial("");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add pricing rule</DialogTitle>
|
|
<DialogDescription>Set a price for a specific customer and month.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="r-customer">Customer *</Label>
|
|
<Combobox
|
|
id="r-customer"
|
|
value={customer}
|
|
onChange={setCustomer}
|
|
items={customers.map((c) => ({ value: c.id, label: c.name }))}
|
|
placeholder="Search customers…"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="r-year">Year *</Label>
|
|
<Input id="r-year" type="number" value={year} onChange={(e) => setYear(Number(e.target.value))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="r-month">Month *</Label>
|
|
<Select
|
|
id="r-month"
|
|
value={String(month)}
|
|
onChange={(e) => setMonth(Number(e.target.value))}
|
|
options={MONTHS.map((m, i) => ({ value: String(i + 1), label: m }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="r-price">Price / L *</Label>
|
|
<Input id="r-price" type="number" step="0.01" min="0.01" value={price || ""} onChange={(e) => setPrice(Number(e.target.value))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="r-special">Special price</Label>
|
|
<Input id="r-special" type="number" step="0.01" min="0" value={special} onChange={(e) => setSpecial(e.target.value === "" ? "" : Number(e.target.value))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>Cancel</Button>
|
|
<Button onClick={onSubmit} disabled={pending}>
|
|
{pending ? "Creating…" : "Create rule"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|