Merge branch 'feat/financial-combined-chart'

This commit is contained in:
DEls 2026-10-11 00:17:08 +02:00
commit ffe8901d15
6 changed files with 135 additions and 86 deletions

View file

@ -0,0 +1,83 @@
"use client";
import { format, parseISO } from "date-fns";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatNumber } from "@/lib/utils";
import type { MonthlyFinancialPoint } from "@/lib/metrics/monthly-financials";
const PO_COLOR = "#FFA743";
const DELIVERY_COLOR = "#007160";
export function MonthlyFinancialChart({ months, undated }: {
months: MonthlyFinancialPoint[];
undated: { count: number; value: number };
}) {
const maximum = Math.max(1, ...months.flatMap((month) => [month.poValue, month.deliveryValue]));
const poTotal = months.reduce((sum, month) => sum + month.poValue, 0);
const deliveryTotal = months.reduce((sum, month) => sum + month.deliveryValue, 0);
const period = months.length > 0
? `${format(parseISO(`${months[0].month}-01`), "MMM yyyy")} to ${format(parseISO(`${months[months.length - 1].month}-01`), "MMM yyyy")}`
: "No history yet";
return (
<Card aria-label="Purchase orders against deliveries by month" className="min-w-0">
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3 pb-3">
<div>
<CardTitle>Purchase orders vs deliveries, per month</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{period} · Orders raised against value received</p>
</div>
<dl className="flex shrink-0 gap-6 text-right">
<div>
<dt className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<span aria-hidden className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: PO_COLOR }} />Orders
</dt>
<dd className="text-lg font-semibold tabular-nums">{formatCurrency(poTotal)}</dd>
</div>
<div>
<dt className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<span aria-hidden className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: DELIVERY_COLOR }} />Delivered
</dt>
<dd className="text-lg font-semibold tabular-nums">{formatCurrency(deliveryTotal)}</dd>
</div>
</dl>
</CardHeader>
<CardContent>
<div className="h-72 w-full min-w-0">
{months.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Nothing recorded yet.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={months} accessibilityLayer margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickFormatter={(value: string) => format(parseISO(`${value}-01`), "MMM yy")}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} />
<YAxis domain={[0, maximum]} tickFormatter={(value: number) => `R${formatNumber(value, { notation: "compact", maximumFractionDigits: 1 })}`}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} />
<Tooltip cursor={{ fill: "var(--muted)", opacity: 0.6 }}
contentStyle={{ backgroundColor: "var(--popover)", color: "var(--popover-foreground)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 12 }}
labelFormatter={(value) => format(parseISO(`${String(value)}-01`), "MMMM yyyy")}
formatter={(value: number) => formatCurrency(value)} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="poValue" name="Orders raised" fill={PO_COLOR} radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
<Bar dataKey="deliveryValue" name="Delivered" fill={DELIVERY_COLOR} radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<table className="sr-only">
<caption>Purchase orders raised and value delivered, in rand, by month</caption>
<thead><tr><th scope="col">Month</th><th scope="col">Orders</th><th scope="col">Delivered</th></tr></thead>
<tbody>{months.map((month) => <tr key={month.month}><th scope="row">{month.month}</th><td>{month.poValue}</td><td>{month.deliveryValue}</td></tr>)}</tbody>
</table>
<p className="mt-3 text-xs text-muted-foreground">Current month is still in progress.</p>
{undated.count > 0 && (
<p className="mt-2 text-sm text-destructive">
{formatCurrency(undated.value)} across {formatNumber(undated.count)} {undated.count === 1 ? "order has" : "orders have"} no
date, so {undated.count === 1 ? "it is" : "they are"} not on this chart. Deliveries against {undated.count === 1 ? "it" : "them"} still show.
</p>
)}
</CardContent>
</Card>
);
}

View file

@ -1,71 +0,0 @@
"use client";
import { format, parseISO } from "date-fns";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatNumber } from "@/lib/utils";
import type { MonthlyFinancialPoint } from "@/lib/metrics/monthly-financials";
const SERIES = [
{ key: "poValue", title: "Purchase orders per month", note: "Value of orders raised", tooltip: "PO value", color: "#FFA743" },
{ key: "deliveryValue", title: "Delivery notes per month", note: "Value received on delivery notes", tooltip: "Delivery note value", color: "#007160" },
] as const;
export function MonthlyFinancialCharts({ months }: { months: MonthlyFinancialPoint[] }) {
const maximum = Math.max(1, ...months.flatMap((month) => [month.poValue, month.deliveryValue]));
const period = months.length > 0
? `${format(parseISO(`${months[0].month}-01`), "MMM yyyy")} to ${format(parseISO(`${months[months.length - 1].month}-01`), "MMM yyyy")}`
: "No history yet";
return (
<section aria-label="Monthly purchase orders and delivery notes" className="space-y-3">
<div className="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
{SERIES.map(({ key, title, note, tooltip, color }) => {
const total = months.reduce((sum, month) => sum + month[key], 0);
return (
<Card key={key} className="min-w-0">
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-3">
<div>
<CardTitle className="flex items-center gap-2">
<span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
{title}
</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{period} · {note}</p>
</div>
<p className="shrink-0 text-right text-lg font-semibold tabular-nums">{formatCurrency(total)}</p>
</CardHeader>
<CardContent>
<div className="h-64 w-full min-w-0">
{months.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Nothing recorded yet.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={months} accessibilityLayer margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickFormatter={(value: string) => format(parseISO(`${value}-01`), "MMM yy")}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} />
<YAxis domain={[0, maximum]} tickFormatter={(value: number) => `R${formatNumber(value, { notation: "compact", maximumFractionDigits: 1 })}`}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} />
<Tooltip cursor={{ fill: "var(--muted)", opacity: 0.6 }}
contentStyle={{ backgroundColor: "var(--popover)", color: "var(--popover-foreground)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 12 }}
labelFormatter={(value) => format(parseISO(`${String(value)}-01`), "MMMM yyyy")}
formatter={(value: number) => [formatCurrency(value), tooltip]} />
<Bar dataKey={key} name={tooltip} fill={color} radius={[4, 4, 0, 0]} maxBarSize={48} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<table className="sr-only">
<caption>{title}, in rand</caption>
<thead><tr><th scope="col">Month</th><th scope="col">Value</th></tr></thead>
<tbody>{months.map((month) => <tr key={month.month}><th scope="row">{month.month}</th><td>{month[key]}</td></tr>)}</tbody>
</table>
<p className="mt-3 text-xs text-muted-foreground">Current month is still in progress.</p>
</CardContent>
</Card>
);
})}
</div>
</section>
);
}

View file

@ -216,10 +216,19 @@ it("charts this month's orders and deliveries with the same figures as the tiles
notes: [{ id: "dn1", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" }],
});
const html = await renderPage();
expect(html).toContain("Purchase orders per month");
expect(html).toContain("Delivery notes per month");
// sr-only tables mirror the chart data: R1 000 ordered, R400 received.
expect(html).toContain('<th scope="row">2026-10</th><td>1000</td>');
expect(html).toContain('<th scope="row">2026-10</th><td>400</td>');
expect(html).toContain("Purchase orders vs deliveries, per month");
// The sr-only table mirrors the chart data: R1 000 ordered, R400 received.
expect(html).toContain('<th scope="row">2026-10</th><td>1000</td><td>400</td>');
expect(tileValue(html, "Received this month")).toBe("R 400");
});
it("says how much order value has no date and so cannot sit on the chart", async () => {
setup({
pos: [
{ id: "po1", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" },
{ id: "po2", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 5000, date_uploaded: "" },
],
});
const html = (await renderPage()).replace(/<!-- -->/g, "").replace(/\u00a0/g, " ");
expect(html).toContain("R 5 000 across 1 order has no");
});

View file

@ -19,7 +19,7 @@ import type { DocumentKind } from "@/lib/document-kind";
import { deliveryLineValues, type SupplierBalanceInput as In } from "@/lib/metrics/supplier-balance";
import { workDate } from "@/lib/clock-day";
import { monthlyFinancials } from "@/lib/metrics/monthly-financials";
import { MonthlyFinancialCharts } from "./monthly-financial-charts";
import { MonthlyFinancialChart } from "./monthly-financial-chart";
const round2 = (n: number) => Math.round(n * 100) / 100;
@ -240,7 +240,7 @@ export default async function FinancialLanding({
/>
</section>
<MonthlyFinancialCharts months={summary.monthly} />
<MonthlyFinancialChart months={summary.monthly.months} undated={summary.monthly.undated} />
<MissingPoAttention rows={summary.missingPos} />
</div>

View file

@ -4,7 +4,7 @@ import { monthlyFinancials } from "./monthly-financials";
const now = new Date("2026-10-04T10:00:00Z");
it("sums PO value and delivery value per month, zero-filling quiet months", () => {
const months = monthlyFinancials({
const { months } = monthlyFinancials({
pos: [
{ total_value: 1000, date_uploaded: "2026-08-10 08:00:00.000Z" },
{ total_value: 500, date_uploaded: "2026-08-20 08:00:00.000Z" },
@ -24,7 +24,7 @@ it("sums PO value and delivery value per month, zero-filling quiet months", () =
});
it("dates an order by its SAST work date and falls back to created", () => {
const months = monthlyFinancials({
const { months } = monthlyFinancials({
pos: [
// 23:30 UTC on 31 Oct is 01:30 SAST on 1 Nov -- November.
{ total_value: 100, date_uploaded: "2026-09-30 23:30:00.000Z" },
@ -39,7 +39,7 @@ it("dates an order by its SAST work date and falls back to created", () => {
});
it("keeps only the newest months and ignores anything dated in the future", () => {
const months = monthlyFinancials({
const { months } = monthlyFinancials({
pos: [
{ total_value: 1, date_uploaded: "2025-01-10 08:00:00.000Z" },
{ total_value: 9, date_uploaded: "2027-01-10 08:00:00.000Z" },
@ -53,5 +53,19 @@ it("keeps only the newest months and ignores anything dated in the future", () =
});
it("returns nothing when there is no activity", () => {
expect(monthlyFinancials({ pos: [], deliveries: [], now })).toEqual([]);
expect(monthlyFinancials({ pos: [], deliveries: [], now })).toEqual({ months: [], undated: { count: 0, value: 0 } });
});
it("totals orders that have no date instead of dropping them without a trace", () => {
const { months, undated } = monthlyFinancials({
pos: [
{ total_value: 2000 },
{ total_value: 500.5, date_uploaded: "" },
{ total_value: 100, date_uploaded: "2026-10-02 08:00:00.000Z" },
],
deliveries: [],
now,
});
expect(undated).toEqual({ count: 2, value: 2500.5 });
expect(months).toEqual([{ month: "2026-10", poValue: 100, poCount: 1, deliveryValue: 0 }]);
});

View file

@ -19,6 +19,12 @@ interface MonthlyFinancialsInput {
maxMonths?: number;
}
export interface MonthlyFinancials {
months: MonthlyFinancialPoint[];
/** Orders with no usable date: real money that cannot be put on any month. */
undated: { count: number; value: number };
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/**
@ -27,8 +33,10 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
* dated by date_uploaded (else `created`) resolved to the SAST work date,
* deliveries by the delivery note's date_received. Months are contiguous
* from the first month with any activity to the current one, zero-filled.
* Orders with no date at all are not guessed onto a month; they are totalled
* in `undated` so the gap is visible rather than silently missing.
*/
export function monthlyFinancials({ pos, deliveries, now, maxMonths = 12 }: MonthlyFinancialsInput): MonthlyFinancialPoint[] {
export function monthlyFinancials({ pos, deliveries, now, maxMonths = 12 }: MonthlyFinancialsInput): MonthlyFinancials {
const currentMonth = workDate(now.toISOString()).slice(0, 7);
const byMonth = new Map<string, MonthlyFinancialPoint>();
const point = (month: string) => {
@ -37,9 +45,14 @@ export function monthlyFinancials({ pos, deliveries, now, maxMonths = 12 }: Mont
return p;
};
const undated = { count: 0, value: 0 };
for (const po of pos) {
const iso = String(po.date_uploaded || po.created || "").replace(" ", "T");
if (!iso || Number.isNaN(new Date(iso).getTime())) continue;
if (!iso || Number.isNaN(new Date(iso).getTime())) {
undated.count += 1;
undated.value += po.total_value ?? 0;
continue;
}
const month = workDate(iso).slice(0, 7);
if (month > currentMonth) continue;
const p = point(month);
@ -53,7 +66,8 @@ export function monthlyFinancials({ pos, deliveries, now, maxMonths = 12 }: Mont
}
const first = [...byMonth.keys()].sort()[0];
if (!first) return [];
undated.value = round2(undated.value);
if (!first) return { months: [], undated };
const months: MonthlyFinancialPoint[] = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
while (cursor.toISOString().slice(0, 7) <= currentMonth) {
@ -62,5 +76,5 @@ export function monthlyFinancials({ pos, deliveries, now, maxMonths = 12 }: Mont
months.push({ ...p, poValue: round2(p.poValue), deliveryValue: round2(p.deliveryValue) });
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return months.slice(-maxMonths);
return { months: months.slice(-maxMonths), undated };
}