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
505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
ResponsiveContainer,
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
CartesianGrid,
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
Legend,
|
|
ComposedChart,
|
|
Line,
|
|
ReferenceLine,
|
|
} from "recharts";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { formatCurrency, formatNumber } from "@/lib/utils";
|
|
|
|
const COLORS = {
|
|
primary: "#E89B5C",
|
|
primaryLight: "#F2C49A",
|
|
primaryVeryLight: "#FBEAD9",
|
|
secondary: "#5B8C8A",
|
|
warning: "#E5B85C",
|
|
success: "#6FAE6F",
|
|
destructive: "#D85A5A",
|
|
muted: "#D9DBDD",
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
Pending: COLORS.warning,
|
|
"EFT": COLORS.primary,
|
|
"CASH": COLORS.success,
|
|
"CARD": COLORS.secondary,
|
|
};
|
|
|
|
interface DailyPoint {
|
|
date: string;
|
|
label: string;
|
|
litres: number;
|
|
value: number;
|
|
}
|
|
|
|
interface CustomerPoint {
|
|
name: string;
|
|
litres: number;
|
|
}
|
|
|
|
interface StatusPoint {
|
|
name: string;
|
|
value: number;
|
|
color: string;
|
|
}
|
|
|
|
interface MonthPoint {
|
|
label: string;
|
|
purchased: number;
|
|
delivered: number;
|
|
margin: number;
|
|
}
|
|
|
|
/**
|
|
* Chart card header — small primary-color dot + title, no description.
|
|
* Keeps the cards visually tight and gives the dashboard a clear rhythm
|
|
* (the "metric tile" pattern handles the prose, the "chart" pattern is denser).
|
|
*/
|
|
function ChartHeader({ title }: { title: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2 pb-1">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
<CardTitle className="text-sm font-semibold tracking-tight">{title}</CardTitle>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DailyDeliveriesChart({ data }: { data: DailyPoint[] }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<ChartHeader title="Daily deliveries" />
|
|
</CardHeader>
|
|
<CardContent className="h-[260px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="litresGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={COLORS.primary} stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor={COLORS.primary} stopOpacity={0.05} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis
|
|
dataKey="label"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
tickFormatter={(v) => `${v}L`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value) => [formatNumber(Number(value)) + " L", "Litres"]}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="litres"
|
|
stroke={COLORS.primary}
|
|
strokeWidth={2}
|
|
fill="url(#litresGradient)"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface MonthlyPerCustomerPoint {
|
|
name: string;
|
|
litres: number;
|
|
value: number;
|
|
id: string;
|
|
}
|
|
export type { MonthlyPerCustomerPoint, CustomerPoint, StatusPoint, MonthPoint };
|
|
|
|
export function MonthlyDeliveryPerCustomerChart({ data }: { data: MonthlyPerCustomerPoint[] }) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<ChartHeader title="Monthly delivery per customer" />
|
|
</CardHeader>
|
|
<CardContent className="h-[260px] flex items-center justify-center text-sm text-muted-foreground">
|
|
No customer deliveries yet
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Monthly delivery per customer" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 30 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
angle={-20}
|
|
textAnchor="end"
|
|
height={60}
|
|
interval={0}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
tickFormatter={(v) => `${v}L`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value, name) => {
|
|
const v = Number(value);
|
|
if (name === "litres") return [formatNumber(v) + " L", "Delivered"];
|
|
return [formatCurrency(v), "Value"];
|
|
}}
|
|
/>
|
|
<Bar dataKey="litres" fill={COLORS.primary} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function TopCustomersChart({ data }: { data: CustomerPoint[] }) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Top customers" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px] flex items-center justify-center text-sm text-muted-foreground">
|
|
No customer data yet
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Top customers" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={data} layout="vertical" margin={{ top: 5, right: 20, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis
|
|
type="number"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
tickFormatter={(v) => "R" + (v / 1000).toFixed(0) + "k"}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="name"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
width={120}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value) => [formatCurrency(Number(value)), "Value"]}
|
|
/>
|
|
<Bar dataKey="value" fill={COLORS.primary} radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface MonthComparison {
|
|
thisMonth: number;
|
|
lastMonth: number;
|
|
thisMonthValue: number;
|
|
lastMonthValue: number;
|
|
daysIntoMonth: number;
|
|
daysInMonth: number;
|
|
}
|
|
export type { MonthComparison };
|
|
|
|
export function MonthVsLastChart({ data }: { data: MonthComparison }) {
|
|
const { thisMonth, lastMonth, daysIntoMonth, daysInMonth } = data;
|
|
const pct = lastMonth > 0 ? Math.min(100, (thisMonth / lastMonth) * 100) : 0;
|
|
const remaining = Math.max(0, lastMonth - thisMonth);
|
|
const beating = thisMonth > lastMonth;
|
|
const projected = daysIntoMonth > 0
|
|
? Math.round((thisMonth / daysIntoMonth) * daysInMonth)
|
|
: 0;
|
|
|
|
// Combined data for the chart: both rows share the same shape.
|
|
const chartData = [
|
|
{ label: "Last month", total: Math.round(lastMonth), delivered: 0, remaining: 0 },
|
|
{
|
|
label: "This month",
|
|
total: 0,
|
|
delivered: Math.round(thisMonth),
|
|
remaining: Math.round(remaining),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<ChartHeader title="This month vs last month" />
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
Day {daysIntoMonth} of {daysInMonth}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div>
|
|
<div className="text-2xl font-semibold">{formatNumber(thisMonth)} L</div>
|
|
<div className="text-xs text-muted-foreground">This month</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-semibold text-muted-foreground">{formatNumber(lastMonth)} L</div>
|
|
<div className="text-xs text-muted-foreground">Last month</div>
|
|
</div>
|
|
<div>
|
|
<div className={`text-2xl font-semibold ${beating ? "text-emerald-600" : "text-primary"}`}>
|
|
{beating ? "+" : ""}{formatNumber(thisMonth - lastMonth)} L
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{beating ? "ahead" : "behind"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-muted-foreground">Progress to match</span>
|
|
<span className="font-medium">{Math.round(pct)}%</span>
|
|
</div>
|
|
<div className="h-3 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full transition-all"
|
|
style={{
|
|
width: `${pct}%`,
|
|
background: beating
|
|
? `linear-gradient(90deg, ${COLORS.success}, ${COLORS.primary})`
|
|
: COLORS.primary,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{beating ? (
|
|
<span className="text-emerald-600 font-medium">
|
|
🎉 Already beating last month by {formatNumber(thisMonth - lastMonth)} L
|
|
</span>
|
|
) : remaining > 0 ? (
|
|
<>
|
|
<span className="font-medium text-foreground">{formatNumber(remaining)} L to go</span>
|
|
{" "}to match last month
|
|
{projected > 0 && (
|
|
<> · on pace for ~{formatNumber(projected)} L by month-end</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span>Last month had no deliveries</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-[120px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart
|
|
layout="vertical"
|
|
data={chartData}
|
|
margin={{ top: 5, right: 30, left: 70, bottom: 0 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis
|
|
type="number"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
tickFormatter={(v) => `${v}L`}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="label"
|
|
tick={{ fontSize: 11 }}
|
|
className="fill-muted-foreground"
|
|
width={110}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value) => formatNumber(Number(value)) + " L"}
|
|
/>
|
|
<Bar dataKey="total" name="Last month" fill={COLORS.muted} radius={[0, 4, 4, 0]} />
|
|
<Bar dataKey="delivered" name="Delivered" fill={COLORS.primary} radius={[0, 4, 0, 0]} stackId="this" />
|
|
<Bar dataKey="remaining" name="To match" fill={COLORS.primaryVeryLight} radius={[0, 0, 4, 0]} stackId="this" />
|
|
<ReferenceLine x={lastMonth} stroke={COLORS.secondary} strokeDasharray="3 3" />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function StatusBreakdownChart({ data }: { data: StatusPoint[] }) {
|
|
const total = data.reduce((a, d) => a + d.value, 0);
|
|
if (total === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Note status" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px] flex items-center justify-center text-sm text-muted-foreground">
|
|
No notes yet
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
<ChartHeader title="Note status" />
|
|
|
|
<span className="text-xs text-muted-foreground">{total} open delivery note{total !== 1 ? "s" : ""}</span>
|
|
|
|
</div>
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={data.filter((d) => d.value > 0)}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={50}
|
|
outerRadius={85}
|
|
paddingAngle={2}
|
|
label={(entry) => `${entry.name}: ${entry.value}`}
|
|
labelLine={false}
|
|
>
|
|
{data.map((entry, i) => (
|
|
<Cell key={i} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function PurchasesVsDeliveriesChart({ data }: { data: MonthPoint[] }) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Purchases vs deliveries" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px] flex items-center justify-center text-sm text-muted-foreground">
|
|
No data yet
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
|
|
<ChartHeader title="Purchases vs deliveries" />
|
|
|
|
</CardHeader>
|
|
<CardContent className="h-[260px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
|
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" tickFormatter={(v) => `${v}L`} />
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "hsl(var(--popover))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value, name) => {
|
|
const v = Number(value);
|
|
if (name === "margin") return [formatCurrency(v), "Margin"];
|
|
return [formatNumber(v) + " L", name === "purchased" ? "Purchased" : "Delivered"];
|
|
}}
|
|
/>
|
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
<Bar dataKey="purchased" name="Purchased" fill={COLORS.secondary} radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey="delivered" name="Delivered" fill={COLORS.primary} radius={[4, 4, 0, 0]} />
|
|
<Line type="monotone" dataKey="margin" name="Margin" stroke={COLORS.success} strokeWidth={2} dot={{ r: 3 }} />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|