An interactive demonstration of invoicing and accounts receivable dashboard components. Explore aging donut charts, trend charts, and a detailed ledger view.
Revenue Snapshot Laboratory
Track invoicing aging, accounts receivable, and trend analysis with interactive meters, cards, and ledgers.
MTD Revenue vs Target
$45,200
of $65,000
$19,800 remaining
Daily target rate
$2,955/day
Running avg (MTD)
$1,674/day
AR Aging Breakdown
0–30 days
$14,200
(10)
50%
31–60 days
$8,300
(5)
29%
61–90 days
$4,100
(2)
14%
90+ days
$1,800
(1)
6%
Total Outstanding
$28,400
Top Overdue Accounts
Westside Industrial Park
62 days overdue
$8,900
Send reminderCascade Auto Group
45 days overdue
$3,900
Send reminderPinecrest Medical Ctr
35 days overdue
$2,400
Send reminderVariant1_RevenueMeterDonut.tsx (Widget Implementation)
import {
Paper,
Group,
Stack,
Text,
Badge,
Progress,
Box,
Title,
Anchor,
Divider,
SimpleGrid,
} from "@mantine/core";
import { DonutChart } from "@mantine/charts";
import { HiCurrencyDollar, HiBellAlert } from "react-icons/hi2";
import type { RevenueData } from "./types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmt(n: number): string {
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
function pct(value: number, total: number): number {
return Math.round((value / total) * 100);
}
// ---------------------------------------------------------------------------
// Aging bucket color map
// ---------------------------------------------------------------------------
const AGING_COLORS: Record<string, string> = {
"0-30": "#228be6",
"31-60": "#fab005",
"61-90": "#fd7e14",
"90+": "#fa5252",
};
const AGING_LABELS: Record<string, string> = {
"0-30": "0–30 days",
"31-60": "31–60 days",
"61-90": "61–90 days",
"90+": "90+ days",
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function RevenueMeter({ mtdRevenue, mtdTarget }: { mtdRevenue: number; mtdTarget: number }) {
const achieved = pct(mtdRevenue, mtdTarget);
const remaining = mtdTarget - mtdRevenue;
// Determine color based on achievement
const barColor = achieved >= 85 ? "green" : achieved >= 60 ? "blue" : "orange";
return (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
MTD Revenue vs Target
</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={800} c="dark">
{fmt(mtdRevenue)}
</Text>
<Text size="sm" c="dimmed">
of {fmt(mtdTarget)}
</Text>
</Group>
<Progress value={achieved} color={barColor} size="xl" radius="sm" style={{ marginTop: 2 }} />
<Group justify="space-between">
<Badge color={barColor} variant="light" size="sm">
{achieved}% achieved
</Badge>
<Text size="xs" c="dimmed">
{fmt(remaining)} remaining
</Text>
</Group>
{/* Daily pace indicator */}
<Box
style={{
backgroundColor: "var(--mantine-color-gray-0)",
borderRadius: 6,
padding: "8px 10px",
marginTop: 2,
}}
>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Daily target rate
</Text>
<Text size="xs" fw={600}>
{fmt(Math.round(mtdTarget / 22))}/day
</Text>
</Group>
<Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed">
Running avg (MTD)
</Text>
<Text size="xs" fw={600}>
{fmt(Math.round(mtdRevenue / 27))}/day
</Text>
</Group>
</Box>
</Stack>
);
}
function AgingDonut({ agingBuckets }: { agingBuckets: RevenueData["agingBuckets"] }) {
const donutData = agingBuckets.map((b) => ({
name: AGING_LABELS[b.bucket],
value: b.amount,
color: AGING_COLORS[b.bucket],
}));
const total = agingBuckets.reduce((sum, b) => sum + b.amount, 0);
return (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
AR Aging Breakdown
</Text>
<Group justify="center">
<DonutChart
data={donutData}
size={150}
thickness={28}
withTooltip
tooltipDataSource="segment"
valueFormatter={(val) => fmt(val)}
/>
</Group>
{/* Legend */}
<Stack gap={4}>
{agingBuckets.map((b) => (
<Group key={b.bucket} justify="space-between" gap="xs">
<Group gap={6}>
<Box
style={{
width: 10,
height: 10,
borderRadius: 2,
backgroundColor: AGING_COLORS[b.bucket],
flexShrink: 0,
}}
/>
<Text size="xs">{AGING_LABELS[b.bucket]}</Text>
</Group>
<Group gap="xs">
<Text size="xs" fw={600}>
{fmt(b.amount)}
</Text>
<Text size="xs" c="dimmed">
({b.count})
</Text>
<Text size="xs" c="dimmed" style={{ minWidth: 30, textAlign: "right" }}>
{pct(b.amount, total)}%
</Text>
</Group>
</Group>
))}
</Stack>
<Group
justify="space-between"
pt={4}
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="xs" fw={700}>
Total Outstanding
</Text>
<Text size="xs" fw={700}>
{fmt(total)}
</Text>
</Group>
</Stack>
);
}
function OverdueList({ topOverdue }: { topOverdue: RevenueData["topOverdue"] }) {
const top3 = topOverdue.slice(0, 3);
return (
<Stack gap={6}>
<Group justify="space-between">
<Group gap={6}>
<HiBellAlert size={14} color="#fa5252" />
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
Top Overdue Accounts
</Text>
</Group>
<Anchor size="xs" c="blue" style={{ cursor: "pointer" }}>
View all {topOverdue.length}
</Anchor>
</Group>
{top3.map((account, idx) => (
<Group key={idx} justify="space-between" gap="xs" style={{ alignItems: "flex-start" }}>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
fw={600}
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{account.customerName}
</Text>
<Text size="xs" c="red.6">
{account.daysOverdue} days overdue
</Text>
</Stack>
<Group gap="xs" style={{ flexShrink: 0 }}>
<Text size="xs" fw={700} c="dark">
{fmt(account.amount)}
</Text>
<Anchor size="xs" c="blue" style={{ cursor: "pointer", whiteSpace: "nowrap" }}>
Send reminder
</Anchor>
</Group>
</Group>
))}
</Stack>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface Props {
data: RevenueData;
}
export function Variant1_RevenueMeterDonut({ data }: Props) {
return (
<Paper withBorder shadow="sm" radius="md" p="md" style={{ minWidth: 360 }}>
{/* Header */}
<Group justify="space-between" mb="md">
<Group gap="xs">
<HiCurrencyDollar size={18} color="#40c057" />
<Title order={4}>Revenue & Invoicing</Title>
</Group>
<Badge color="gray" variant="light" size="sm">
March 2026
</Badge>
</Group>
{/* Two-column body */}
<SimpleGrid cols={2} spacing="lg">
{/* Left: Revenue Meter */}
<RevenueMeter mtdRevenue={data.mtdRevenue} mtdTarget={data.mtdTarget} />
{/* Right: Aging Donut */}
<AgingDonut agingBuckets={data.agingBuckets} />
</SimpleGrid>
<Divider my="md" />
{/* Overdue accounts */}
<OverdueList topOverdue={data.topOverdue} />
</Paper>
);
}