Merge branch 'feat/financial-combined-chart'
This commit is contained in:
commit
ffe8901d15
6 changed files with 135 additions and 86 deletions
83
src/app/dashboard/financial/monthly-financial-chart.tsx
Normal file
83
src/app/dashboard/financial/monthly-financial-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue