RJBULK-APP/components/dashboard-charts.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

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