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)} />
+
+
+
+
+
+ )}
+
+
+ Purchase orders raised and value delivered, in rand, by month
+ | Month | Orders | Delivered |
+ {months.map((month) => | {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]} />
-
-
-
- )}
-
-
- {title}, in rand
- | Month | Value |
- {months.map((month) => | {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-10 | 1000 | ');
- expect(html).toContain('2026-10 | 400 | ');
+ 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-10 | 1000 | 400 | ');
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 };
}