This commit is contained in:
Marshal
2026-06-12 14:21:07 +00:00
parent 6b79348074
commit d3a479c142
25 changed files with 656 additions and 373 deletions

View File

@@ -21,15 +21,15 @@ export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[
const hasData = data.some((point) => point.count > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>Booking trend</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No bookings in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="bookingTrendFill" x1="0" y1="0" x2="0" y2="1">

View File

@@ -5,7 +5,7 @@ import {
ResponsiveContainer,
Tooltip,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { overviewChartColors } from "./overview.styles";
@@ -27,42 +27,100 @@ export function OverviewDonutChart({
}: OverviewDonutChartProps) {
const filtered = data.filter((item) => item.value > 0);
const hasData = filtered.length > 0;
const total = filtered.reduce((sum, item) => sum + item.value, 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md" h="100%">
<Text fw={600}>{title}</Text>
<Paper p="md" radius="lg" withBorder h="100%">
<Stack gap="sm" h="100%">
<Text fw={600} size="sm">
{title}
</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
<Text size="sm" c="dimmed" ta="center" py="lg">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={filtered}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
<Group gap="md" wrap="nowrap" align="center" style={{ flex: 1 }}>
{/* Compact donut with the total in its center */}
<Box style={{ position: "relative", width: 132, height: 132, flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filtered}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={42}
outerRadius={62}
paddingAngle={2}
stroke="none"
>
{filtered.map((entry, index) => (
<Cell
key={entry.name}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Pie>
<Tooltip formatter={(value) => [value, "Count"]} />
</PieChart>
</ResponsiveContainer>
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{filtered.map((entry, index) => (
<Cell
key={entry.name}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Pie>
<Tooltip formatter={(value) => [value, "Count"]} />
</PieChart>
</ResponsiveContainer>
<Text fw={800} size="xl" lh={1} style={{ color: "#0f172a" }}>
{total}
</Text>
<Text size="10px" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: 0.4 }}>
Total
</Text>
</Box>
</Box>
{/* Legend fills the space — color, name, count and share */}
<Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
{filtered.map((entry, index) => {
const color =
overviewChartColors.pipeline[index % overviewChartColors.pipeline.length];
const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
return (
<Group key={entry.name} gap={8} wrap="nowrap" justify="space-between">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={9}
h={9}
style={{ borderRadius: 3, background: color, flexShrink: 0 }}
/>
<Text size="xs" c="dimmed" truncate>
{entry.name}
</Text>
</Group>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Text size="xs" fw={700} c="dark.5">
{entry.value}
</Text>
<Text size="xs" c="dimmed" style={{ width: 34, textAlign: "right" }}>
{pct}%
</Text>
</Group>
</Group>
);
})}
</Stack>
</Group>
)}
</Stack>
</Paper>

View File

@@ -36,8 +36,8 @@ export function OverviewHorizontalBarChart({
const hasData = chartData.length > 0;
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md" h="100%">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 240 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">

View File

@@ -1,19 +1,25 @@
import type { LucideIcon } from "lucide-react";
import { Card, Group, Stack, Text } from "@mantine/core";
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import {
overviewAccentGradients,
type OverviewAccent,
} from "./overview.styles";
/** Mini-graph type rendered at the bottom of a KPI card. */
export type KpiGraphVariant = "area" | "line" | "ring";
/** Pale chip background per accent — matches the Pencil "tinted icon chip" look. */
const ACCENT_CHIP_BG: Record<string, string> = {
export const ACCENT_CHIP_BG: Record<string, string> = {
default: "#F1F5F4",
emerald: "#E7F8F2",
amber: "#FEF9C3",
rose: "#FFE4E6",
sky: "#E0F2FE",
violet: "#EDE9FE",
gold: "#FEF1D5",
orange: "#FEEAD7",
};
export interface OverviewKpiItem {
@@ -22,67 +28,10 @@ export interface OverviewKpiItem {
hint?: string;
icon: LucideIcon;
accent?: keyof typeof ACCENT_CHIP_BG;
/** Share 0..1 used as a baseline for the decorative trend sparkline. */
/** Share 0..1 used as a baseline for the decorative trend + ring gauge. */
progress?: number;
}
/** Deterministic, gently-rising series seeded by the KPI label (purely decorative). */
function sparkHeights(seed: string, baseline: number, count = 9): number[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i += 1) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
const out: number[] = [];
let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15);
for (let i = 0; i < count; i += 1) {
h = (Math.imul(h, 1103515245) + 12345) >>> 0;
const delta = ((h % 1000) / 1000 - 0.42) * 0.28;
v = Math.min(1, Math.max(0.22, v + delta));
out.push(v);
}
// Nudge the final bar up so the series reads as an upward trend.
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
return out;
}
/** Compact bar sparkline; last bar highlighted in the accent colour. */
function KpiSparkline({
accent,
baseline,
seed,
}: {
accent: OverviewAccent;
baseline: number;
seed: string;
}) {
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const bars = sparkHeights(seed, baseline);
return (
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 4,
height: 38,
width: "100%",
}}
>
{bars.map((value, index) => (
<div
key={index}
style={{
flex: 1,
height: Math.round(8 + value * 30),
borderRadius: 3,
background: index === bars.length - 1 ? deep : light,
opacity: index === bars.length - 1 ? 1 : 0.55,
}}
/>
))}
</div>
);
/** Mini-graph type; defaults to "area" when not provided. */
variant?: KpiGraphVariant;
}
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
@@ -91,10 +40,11 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
const accentKey = accent as OverviewAccent;
const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default;
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
const variant = item.variant ?? "area";
return (
<Card
p="lg"
p="md"
radius={16}
withBorder
style={{
@@ -113,15 +63,15 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
e.currentTarget.style.boxShadow = "none";
}}
>
<Stack gap={14}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={8}>
<Group gap="sm" wrap="nowrap" align="center">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
width: 40,
height: 40,
borderRadius: 11,
background: chipBg,
color: accentDeep,
@@ -130,25 +80,38 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
>
<Icon size={20} strokeWidth={2} />
</div>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text size="24px" fw={800} style={{ lineHeight: 1.05, letterSpacing: "-0.02em" }} truncate>
{item.value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</Stack>
{variant === "ring" ? (
<MiniRing
pct={Math.round((item.progress ?? 0) * 100)}
accent={accentKey}
size={44}
stroke={5}
>
<Text size="10px" fw={800} style={{ color: accentDeep }}>
{Math.round((item.progress ?? 0) * 100)}%
</Text>
</MiniRing>
) : null}
</Group>
<Stack gap={3} style={{ minWidth: 0 }}>
<Text size="30px" fw={800} style={{ lineHeight: 1, letterSpacing: "-0.02em" }} truncate>
{item.value}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} c="dimmed" truncate>
{item.label}
</Text>
{item.hint && (
<Text size="xs" c="dimmed" truncate>
· {item.hint}
</Text>
)}
</Group>
</Stack>
<KpiSparkline accent={accentKey} baseline={item.progress ?? 0} seed={item.label} />
{variant !== "ring" ? (
<MiniSparkline
variant={variant}
accent={accentKey}
baseline={item.progress ?? 0}
seed={item.label}
height={22}
/>
) : null}
</Stack>
</Card>
);

View File

@@ -1,6 +1,20 @@
import { Group, Paper, Text } from "@mantine/core";
import { OverviewKpiCard, type OverviewKpiItem } from "./OverviewKpiCard";
import {
OverviewKpiCard,
type KpiGraphVariant,
type OverviewKpiItem,
} from "./OverviewKpiCard";
/** Rotate mini-graph types per card so each strip reads as a lively mix. */
const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"];
/** Cohesive accent rotation — gold-forward with an orange and neutral break. */
const ACCENT_CYCLE: NonNullable<OverviewKpiItem["accent"]>[] = [
"gold",
"orange",
"default",
];
interface OverviewKpiStripProps {
title?: string;
@@ -33,11 +47,13 @@ export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
</Text>
)}
<Group gap="md" align="stretch" wrap="wrap">
{items.map((item) => (
{items.map((item, index) => (
<OverviewKpiCard
key={item.label}
item={{
...item,
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
}}

View File

@@ -30,15 +30,15 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>Payment trend</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No successful payments in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis

View File

@@ -26,15 +26,15 @@ export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }
const hasData = chartData.some((item) => item.count > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>Pipeline by stage</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No bookings in pipeline
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis

View File

@@ -1,18 +1,18 @@
import { freightBrand } from "@/theme/freight-brand";
export const overviewChartColors = {
primary: freightBrand.primary,
primaryLight: freightBrand.primaryLight,
primaryDark: freightBrand.primaryDark,
primary: "#F2A516",
primaryLight: "#FBD171",
primaryDark: "#D98A0B",
muted: freightBrand.mutedBg,
etb: freightBrand.primary,
etb: "#F2A516",
usd: "#0369a1",
/** Vibrant, well-separated categorical palette for charts. */
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
pipeline: [
"#1B9E7A", // brand green
"#F2A516", // gold (brand accent)
"#0ea5e9", // sky
"#8b5cf6", // violet
"#f59e0b", // amber
"#FB8C2E", // orange
"#14b8a6", // teal
"#ec4899", // pink
"#f43f5e", // rose
@@ -22,6 +22,10 @@ export const overviewChartColors = {
],
} as const;
/** Brand gold + complementary orange used as the primary KPI accents. */
export const ACCENT_GOLD = "#F2A516";
export const ACCENT_ORANGE = "#FB8C2E";
/** Two-stop gradients keyed by KPI accent — used for radial gauges + accent bars. */
export const overviewAccentGradients = {
default: ["#94a3b8", "#475569"],
@@ -30,6 +34,8 @@ export const overviewAccentGradients = {
rose: ["#fb7185", "#e11d48"],
sky: ["#38bdf8", "#0284c7"],
violet: ["#a78bfa", "#7c3aed"],
gold: ["#FBD171", ACCENT_GOLD],
orange: ["#FDBA74", ACCENT_ORANGE],
} as const;
export type OverviewAccent = keyof typeof overviewAccentGradients;

View File

@@ -98,15 +98,15 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps)
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm">
<Text fw={600}>Payments by method</Text>
{methodChartData.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No payment methods recorded
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<BarChart data={methodChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" />

View File

@@ -48,15 +48,15 @@ export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelPro
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm">
<Text fw={600}>Customer growth</Text>
{!hasGrowth ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No new customers in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<AreaChart data={data.customerGrowthTrend}>
<defs>
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">

View File

@@ -47,15 +47,15 @@ export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md">
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm">
<Text fw={600}>Employee onboarding trend</Text>
{!hasGrowth ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No new employees in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={210}>
<AreaChart data={data.employeeGrowthTrend}>
<defs>
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">