From f26d79a89cbe4081a9db389194728b6376ece1d3 Mon Sep 17 00:00:00 2001 From: DEls Date: Sun, 11 Oct 2026 00:13:38 +0200 Subject: [PATCH] feat(financial): one chart of PO value against deliveries per month Orders and deliveries now share a grouped bar chart. Orders with no date (most of The Hills' value: date_uploaded is empty and the `created` fallback does not exist on purchase_orders) cannot be placed on a month, so the chart now totals them in a note instead of dropping them silently. Co-Authored-By: Claude Sonnet 5.5 --- .../financial/monthly-financial-chart.tsx | 83 +++++++++++++++++++ .../financial/monthly-financial-charts.tsx | 71 ---------------- src/app/dashboard/financial/page.test.tsx | 19 +++-- src/app/dashboard/financial/page.tsx | 4 +- src/lib/metrics/monthly-financials.test.ts | 22 ++++- src/lib/metrics/monthly-financials.ts | 22 ++++- 6 files changed, 135 insertions(+), 86 deletions(-) create mode 100644 src/app/dashboard/financial/monthly-financial-chart.tsx delete mode 100644 src/app/dashboard/financial/monthly-financial-charts.tsx diff --git a/src/app/dashboard/financial/monthly-financial-chart.tsx b/src/app/dashboard/financial/monthly-financial-chart.tsx new file mode 100644 index 0000000..60f98e8 --- /dev/null +++ b/src/app/dashboard/financial/monthly-financial-chart.tsx @@ -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 ( + + +
+ Purchase orders vs deliveries, per month +

{period} · Orders raised against value received

+
+
+
+
+ Orders +
+
{formatCurrency(poTotal)}
+
+
+
+ Delivered +
+
{formatCurrency(deliveryTotal)}
+
+
+
+ +
+ {months.length === 0 ? ( +
Nothing recorded yet.
+ ) : ( + + + + format(parseISO(`${value}-01`), "MMM yy")} + stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} /> + `R${formatNumber(value, { notation: "compact", maximumFractionDigits: 1 })}`} + stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} /> + format(parseISO(`${String(value)}-01`), "MMMM yyyy")} + formatter={(value: number) => formatCurrency(value)} /> + + + + + + )} +
+ + + + {months.map((month) => )} +
Purchase orders raised and value delivered, in rand, by month
MonthOrdersDelivered
{month.month}{month.poValue}{month.deliveryValue}
+

Current month is still in progress.

+ {undated.count > 0 && ( +

+ {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. +

+ )} +
+
+ ); +} diff --git a/src/app/dashboard/financial/monthly-financial-charts.tsx b/src/app/dashboard/financial/monthly-financial-charts.tsx deleted file mode 100644 index 2cf2fc3..0000000 --- a/src/app/dashboard/financial/monthly-financial-charts.tsx +++ /dev/null @@ -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 ( -
-
- {SERIES.map(({ key, title, note, tooltip, color }) => { - const total = months.reduce((sum, month) => sum + month[key], 0); - return ( - - -
- - - {title} - -

{period} · {note}

-
-

{formatCurrency(total)}

-
- -
- {months.length === 0 ? ( -
Nothing recorded yet.
- ) : ( - - - - format(parseISO(`${value}-01`), "MMM yy")} - stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} /> - `R${formatNumber(value, { notation: "compact", maximumFractionDigits: 1 })}`} - stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} /> - format(parseISO(`${String(value)}-01`), "MMMM yyyy")} - formatter={(value: number) => [formatCurrency(value), tooltip]} /> - - - - )} -
- - - - {months.map((month) => )} -
{title}, in rand
MonthValue
{month.month}{month[key]}
-

Current month is still in progress.

-
-
- ); - })} -
-
- ); -} diff --git a/src/app/dashboard/financial/page.test.tsx b/src/app/dashboard/financial/page.test.tsx index 03c89bb..b189288 100644 --- a/src/app/dashboard/financial/page.test.tsx +++ b/src/app/dashboard/financial/page.test.tsx @@ -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('2026-101000'); - expect(html).toContain('2026-10400'); + 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('2026-101000400'); 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"); +}); diff --git a/src/app/dashboard/financial/page.tsx b/src/app/dashboard/financial/page.tsx index c1ac551..bce097d 100644 --- a/src/app/dashboard/financial/page.tsx +++ b/src/app/dashboard/financial/page.tsx @@ -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({ /> - + diff --git a/src/lib/metrics/monthly-financials.test.ts b/src/lib/metrics/monthly-financials.test.ts index 2a7ae63..73d8e52 100644 --- a/src/lib/metrics/monthly-financials.test.ts +++ b/src/lib/metrics/monthly-financials.test.ts @@ -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 }]); }); diff --git a/src/lib/metrics/monthly-financials.ts b/src/lib/metrics/monthly-financials.ts index 36e14b8..fc35045 100644 --- a/src/lib/metrics/monthly-financials.ts +++ b/src/lib/metrics/monthly-financials.ts @@ -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(); 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 }; }