RJBULK-APP/components/forms/pricing-management.tsx
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
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
2026-07-19 20:38:16 +02:00

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>
);
}