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

@@ -10,6 +10,12 @@ import {
RefreshCw, RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import { ACCENT_CHIP_BG } from "@/components/overview/OverviewKpiCard";
import {
overviewAccentGradients,
type OverviewAccent,
} from "@/components/overview/overview.styles";
import type { import type {
BookingListSummaryMetrics, BookingListSummaryMetrics,
BookingListSummaryTabs, BookingListSummaryTabs,
@@ -71,14 +77,21 @@ export function BookingRequestsHeader({
</Group> </Group>
<Group grow gap="md" align="stretch" wrap="wrap"> <Group grow gap="md" align="stretch" wrap="wrap">
<HeroStat icon={LayoutList} label="In queue" value={val(metrics?.inQueue)} hint="Matching current filter" /> <HeroStat
icon={LayoutList}
label="In queue"
value={val(metrics?.inQueue)}
hint="Matching current filter"
accent="gold"
variant="area"
/>
<HeroStat <HeroStat
icon={Clock} icon={Clock}
label="Needs action" label="Needs action"
value={val(metrics?.needsAction)} value={val(metrics?.needsAction)}
hint="Submitted or pending" hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0} ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratioColor="#f59e0b" accent="orange"
/> />
<HeroStat <HeroStat
icon={AlertTriangle} icon={AlertTriangle}
@@ -86,110 +99,89 @@ export function BookingRequestsHeader({
value={val(metrics?.urgent)} value={val(metrics?.urgent)}
hint="High priority score" hint="High priority score"
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0} ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
ratioColor="#ef4444" accent="rose"
/>
<HeroStat
icon={CheckCircle2}
label="Completed"
value={val(tabs?.completed)}
hint="Fully executed"
accent="gold"
variant="line"
/> />
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
</Group> </Group>
{tabs ? <PipelineBar tabs={tabs} /> : null} {/* {tabs ? <PipelineBar tabs={tabs} /> : null} */}
</Stack> </Stack>
); );
} }
/** Compact ring gauge with the stat icon at its center. */ /**
function MiniDonut({ * KPI card matching the overview style: icon chip + value + label, with a mini
pct, * graph at the bottom. Ratio cards show a ring; the rest show an area/line trend.
color, */
children,
size = 52,
stroke = 5,
}: {
pct?: number | null;
color: string;
children: ReactNode;
size?: number;
stroke?: number;
}) {
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
const dash = clamped != null ? (clamped / 100) * circumference : 0;
return (
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--mantine-color-gray-2)"
strokeWidth={stroke}
/>
{clamped != null ? (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${circumference}`}
style={{ transition: "stroke-dasharray 400ms ease" }}
/>
) : null}
</svg>
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color,
}}
>
{children}
</Box>
</Box>
);
}
function HeroStat({ function HeroStat({
icon: Icon, icon: Icon,
label, label,
value, value,
hint, hint,
ratio, ratio,
ratioColor = "var(--mantine-color-green-6)", accent = "emerald",
variant = "area",
}: { }: {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
value: ReactNode; value: ReactNode;
hint?: string; hint?: string;
ratio?: number; ratio?: number;
ratioColor?: string; accent?: OverviewAccent;
variant?: "area" | "line";
}) { }) {
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null; const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
return ( return (
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}> <Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
<Group gap="md" wrap="nowrap" align="center"> <Stack gap={8}>
<MiniDonut pct={pct} color={ratioColor}> <Group gap="sm" wrap="nowrap" align="center">
<Icon size={19} /> <Box
</MiniDonut> style={{
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}> display: "flex",
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}> alignItems: "center",
{label} justifyContent: "center",
</Text> width: 40,
<Text fw={700} size="28px" lh={1} style={{ color: "#0f172a" }}> height: 40,
{value} borderRadius: 11,
</Text> background: chipBg,
<Text size="xs" c="dimmed" truncate> color: accentDeep,
{pct != null ? `${pct}% of queue` : hint} flexShrink: 0,
</Text> }}
</Stack> >
</Group> <Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
{hint ? ` · ${pct != null ? `${pct}% of queue` : hint}` : ""}
</Text>
</Stack>
{pct != null ? (
<MiniRing pct={pct} accent={accent} size={44} stroke={5}>
<Text size="10px" fw={800} style={{ color: accentDeep }}>
{pct}%
</Text>
</MiniRing>
) : null}
</Group>
{pct == null ? (
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
) : null}
</Stack>
</Paper> </Paper>
); );
} }

View File

@@ -42,7 +42,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
<Table.Td>{container.quantity}</Table.Td> <Table.Td>{container.quantity}</Table.Td>
<Table.Td>{container.vgmPerUnitTons} t</Table.Td> <Table.Td>{container.vgmPerUnitTons} t</Table.Td>
<Table.Td> <Table.Td>
<Text fw={600} c="green.7" size="sm"> <Text fw={600} size="sm" style={{ color: "#B26C09" }}>
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t {(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
</Text> </Text>
</Table.Td> </Table.Td>

View File

@@ -21,10 +21,10 @@ export function BookingPaymentCard({
Total Amount Total Amount
</Text> </Text>
<Group align="flex-end" gap="xs"> <Group align="flex-end" gap="xs">
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}> <Title order={1} fw={700} style={{ letterSpacing: "-1px", color: "#8A5304" }}>
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} {totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Title> </Title>
<Text fw={600} c="green.7" mb={6}> <Text fw={600} mb={6} style={{ color: "#B26C09" }}>
{currency} {currency}
</Text> </Text>
</Group> </Group>

View File

@@ -83,7 +83,7 @@ export function BookingRequestHero({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg"> <Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}> <Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="green.7" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}> <Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
Booking reference Booking reference
</Text> </Text>
<Group gap="sm" align="center" wrap="wrap"> <Group gap="sm" align="center" wrap="wrap">

View File

@@ -25,7 +25,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
<Stack gap="md"> <Stack gap="md">
{notes.map((note) => ( {notes.map((note) => (
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap"> <Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
<ThemeIcon size={34} radius="xl" variant="light" color="green"> <ThemeIcon size={34} radius="xl" variant="light" color="#F2A516">
<FileText size={16} /> <FileText size={16} />
</ThemeIcon> </ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}> <Stack gap={2} style={{ flex: 1 }}>

View File

@@ -1,9 +1,7 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { FREIGHT_BRAND } from "@/theme/freight-brand"; /** Single brand accent (gold). Used sparingly for icons/highlights, no gradients. */
export const BRAND_GREEN = "#F2A516";
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
export const BRAND_GREEN = FREIGHT_BRAND;
/** Centralised style tokens for the booking detail page + cards. */ /** Centralised style tokens for the booking detail page + cards. */
export const detailStyles = { export const detailStyles = {

View File

@@ -0,0 +1,193 @@
import { Box } from "@mantine/core";
import {
overviewAccentGradients,
type OverviewAccent,
} from "@/components/overview/overview.styles";
/**
* Tiny inline-SVG mini graphs shared by every KPI / stat card across the app.
* No charting dependency — crisp and cheap to render in long card grids.
*
* - `MiniSparkline` (variant "area" | "line") draws a smooth decorative trend.
* - `MiniRing` draws a circular percentage gauge.
*
* The sparkline series is deterministic (seeded from a string) so a given card
* always renders the same shape — purely decorative, not a real time-series.
*/
/** Deterministic, gently-rising series seeded by a string (purely decorative). */
export 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 point up so the series reads as an upward trend.
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
return out;
}
/** Build a smooth (Catmull-Rom → bezier) path string through y-points in [0,1]. */
function smoothPath(values: number[], width: number, height: number, pad = 2): string {
const n = values.length;
if (n === 0) return "";
const innerW = width - pad * 2;
const innerH = height - pad * 2;
const pts = values.map((v, i) => ({
x: pad + (n === 1 ? 0 : (i / (n - 1)) * innerW),
y: pad + (1 - v) * innerH,
}));
if (n === 1) return `M ${pts[0].x} ${pts[0].y}`;
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < n - 1; i += 1) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
}
return d;
}
export type SparklineVariant = "area" | "line";
/**
* Smooth decorative trend. `area` fills under the curve with an accent gradient;
* `line` is a clean stroke with a highlighted dot at the latest point.
*/
export function MiniSparkline({
variant,
accent = "default",
seed,
baseline = 0,
height = 38,
}: {
variant: SparklineVariant;
accent?: OverviewAccent;
seed: string;
baseline?: number;
height?: number;
}) {
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const values = sparkHeights(seed, baseline);
const width = 120;
const pad = 3;
const line = smoothPath(values, width, height, pad);
const last = values[values.length - 1];
const lastX = pad + ((values.length - 1) / (values.length - 1 || 1)) * (width - pad * 2);
const lastY = pad + (1 - last) * (height - pad * 2);
// Unique gradient id per seed+accent so multiple cards don't collide.
const gid = `spark-${variant}-${accent}-${seed.replace(/[^a-zA-Z0-9]/g, "")}`;
return (
<Box style={{ width: "100%" }}>
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ display: "block", overflow: "visible" }}
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={deep} stopOpacity={variant === "area" ? 0.32 : 0} />
<stop offset="100%" stopColor={light} stopOpacity={0} />
</linearGradient>
</defs>
{variant === "area" ? (
<path
d={`${line} L ${width - pad} ${height - pad} L ${pad} ${height - pad} Z`}
fill={`url(#${gid})`}
stroke="none"
/>
) : null}
<path
d={line}
fill="none"
stroke={deep}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{variant === "line" ? (
<circle cx={lastX} cy={lastY} r={3} fill={deep} stroke="white" strokeWidth={1.5} />
) : null}
</svg>
</Box>
);
}
/** Circular percentage gauge with an accent stroke. */
export function MiniRing({
pct,
accent = "default",
size = 52,
stroke = 5,
children,
}: {
pct: number | null | undefined;
accent?: OverviewAccent;
size?: number;
stroke?: number;
children?: React.ReactNode;
}) {
const [, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
const dash = clamped != null ? (clamped / 100) * circumference : 0;
return (
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--mantine-color-gray-2)"
strokeWidth={stroke}
/>
{clamped != null ? (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={deep}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${circumference}`}
style={{ transition: "stroke-dasharray 400ms ease" }}
/>
) : null}
</svg>
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: deep,
}}
>
{children}
</Box>
</Box>
);
}

View File

@@ -78,8 +78,8 @@ const FreightDashboardHeader = ({
<header className="fdh-root"> <header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}> <Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow"> <span className="fdh-eyebrow">
<span className="fdh-eyebrow-dot" /> {/* <span className="fdh-eyebrow-dot" />
Freight Backoffice Freight Backoffice */}
</span> </span>
<Text <Text
fw={700} fw={700}
@@ -88,9 +88,9 @@ const FreightDashboardHeader = ({
> >
{pageMeta.title} {pageMeta.title}
</Text> </Text>
<Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}> {/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle} {pageMeta.subtitle}
</Text> </Text> */}
</Stack> </Stack>
<Group gap={10} wrap="nowrap"> <Group gap={10} wrap="nowrap">

View File

@@ -79,11 +79,11 @@ const FreightDashboardLayout = ({
height: "100dvh", height: "100dvh",
overflow: "hidden", overflow: "hidden",
background: "var(--mantine-color-gray-1)", background: "var(--mantine-color-gray-1)",
padding: "8px", padding: "0px",
fontFamily: "'Outfit', var(--font-sans)", fontFamily: "'Outfit', var(--font-sans)",
}} }}
> >
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "8px" }}> <Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
<FreightSidebar <FreightSidebar
sections={sidebarSections} sections={sidebarSections}
activeHref={activeHref} activeHref={activeHref}

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,25 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card, Group, Stack, Text } from "@mantine/core"; import { Card, Group, Stack, Text } from "@mantine/core";
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import { import {
overviewAccentGradients, overviewAccentGradients,
type OverviewAccent, type OverviewAccent,
} from "./overview.styles"; } 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. */ /** 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", default: "#F1F5F4",
emerald: "#E7F8F2", emerald: "#E7F8F2",
amber: "#FEF9C3", amber: "#FEF9C3",
rose: "#FFE4E6", rose: "#FFE4E6",
sky: "#E0F2FE", sky: "#E0F2FE",
violet: "#EDE9FE", violet: "#EDE9FE",
gold: "#FEF1D5",
orange: "#FEEAD7",
}; };
export interface OverviewKpiItem { export interface OverviewKpiItem {
@@ -22,67 +28,10 @@ export interface OverviewKpiItem {
hint?: string; hint?: string;
icon: LucideIcon; icon: LucideIcon;
accent?: keyof typeof ACCENT_CHIP_BG; 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; progress?: number;
} /** Mini-graph type; defaults to "area" when not provided. */
variant?: KpiGraphVariant;
/** 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>
);
} }
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) { export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
@@ -91,10 +40,11 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
const accentKey = accent as OverviewAccent; const accentKey = accent as OverviewAccent;
const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default; const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default;
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default; const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
const variant = item.variant ?? "area";
return ( return (
<Card <Card
p="lg" p="md"
radius={16} radius={16}
withBorder withBorder
style={{ style={{
@@ -113,15 +63,15 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
e.currentTarget.style.boxShadow = "none"; e.currentTarget.style.boxShadow = "none";
}} }}
> >
<Stack gap={14}> <Stack gap={8}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group gap="sm" wrap="nowrap" align="center">
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
width: 38, width: 40,
height: 38, height: 40,
borderRadius: 11, borderRadius: 11,
background: chipBg, background: chipBg,
color: accentDeep, color: accentDeep,
@@ -130,25 +80,38 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
> >
<Icon size={20} strokeWidth={2} /> <Icon size={20} strokeWidth={2} />
</div> </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> </Group>
<Stack gap={3} style={{ minWidth: 0 }}> {variant !== "ring" ? (
<Text size="30px" fw={800} style={{ lineHeight: 1, letterSpacing: "-0.02em" }} truncate> <MiniSparkline
{item.value} variant={variant}
</Text> accent={accentKey}
<Group gap={6} wrap="nowrap"> baseline={item.progress ?? 0}
<Text size="sm" fw={600} c="dimmed" truncate> seed={item.label}
{item.label} height={22}
</Text> />
{item.hint && ( ) : null}
<Text size="xs" c="dimmed" truncate>
· {item.hint}
</Text>
)}
</Group>
</Stack>
<KpiSparkline accent={accentKey} baseline={item.progress ?? 0} seed={item.label} />
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -1,6 +1,20 @@
import { Group, Paper, Text } from "@mantine/core"; 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 { interface OverviewKpiStripProps {
title?: string; title?: string;
@@ -33,11 +47,13 @@ export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
</Text> </Text>
)} )}
<Group gap="md" align="stretch" wrap="wrap"> <Group gap="md" align="stretch" wrap="wrap">
{items.map((item) => ( {items.map((item, index) => (
<OverviewKpiCard <OverviewKpiCard
key={item.label} key={item.label}
item={{ item={{
...item, ...item,
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
progress: progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0), 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); const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
return ( return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}> <Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="md" h="100%"> <Stack gap="sm" h="100%">
<Text fw={600}>Payment trend</Text> <Text fw={600}>Payment trend</Text>
{!hasData ? ( {!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl"> <Text size="sm" c="dimmed" ta="center" py="xl">
No successful payments in this period No successful payments in this period
</Text> </Text>
) : ( ) : (
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}> <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" /> <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis <XAxis

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,13 @@ import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import type { OverviewAccent } from "@/components/overview/overview.styles";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
/** Mini-graph variants a StatTile can render at its bottom. */
export type StatTileGraph = "area" | "line" | "ring" | "none";
/** /**
* Shared visual building blocks for the Train Scheduling V2 surfaces. * Shared visual building blocks for the Train Scheduling V2 surfaces.
* Everything keys off the freight brand green so the list + detail pages * Everything keys off the freight brand green so the list + detail pages
@@ -95,6 +100,9 @@ export function StatTile({
hint, hint,
onDark = false, onDark = false,
accent = freightBrand.primary, accent = freightBrand.primary,
graph = "none",
graphAccent = "emerald",
graphPct,
}: { }: {
icon?: LucideIcon; icon?: LucideIcon;
label: string; label: string;
@@ -102,7 +110,15 @@ export function StatTile({
hint?: ReactNode; hint?: ReactNode;
onDark?: boolean; onDark?: boolean;
accent?: string; accent?: string;
/** Optional decorative mini-graph at the bottom (ignored on dark tiles). */
graph?: StatTileGraph;
graphAccent?: OverviewAccent;
/** Percentage 0..100 for the ring variant. */
graphPct?: number | null;
}) { }) {
// Mini-graphs only render on light tiles (the gradient reads poorly on dark).
const showGraph = graph !== "none" && !onDark;
return ( return (
<Paper <Paper
p="md" p="md"
@@ -120,50 +136,64 @@ export function StatTile({
} }
} }
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Stack gap={8}>
{Icon ? ( <Group gap="sm" wrap="nowrap" align="center">
<Box {Icon ? (
style={{ <Box
display: "flex", style={{
alignItems: "center", display: "flex",
justifyContent: "center", alignItems: "center",
width: 36, justifyContent: "center",
height: 36, width: 38,
borderRadius: 10, height: 38,
flexShrink: 0, borderRadius: 10,
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`, flexShrink: 0,
color: onDark ? "white" : accent, background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
}} color: onDark ? "white" : accent,
> }}
<Icon size={18} /> >
</Box> <Icon size={18} />
) : null} </Box>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
style={{ letterSpacing: 0.4 }}
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
>
{label}
</Text>
<Text
fw={700}
size="lg"
lh={1.1}
c={onDark ? "white" : undefined}
style={{ whiteSpace: "nowrap" }}
>
{value}
</Text>
{hint ? (
<Text size="xs" c={onDark ? "rgba(255,255,255,0.7)" : "dimmed"}>
{hint}
</Text>
) : null} ) : null}
</Stack> <Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
</Group> <Text
fw={800}
size="lg"
lh={1.05}
c={onDark ? "white" : "dark.5"}
style={{ whiteSpace: "nowrap" }}
>
{value}
</Text>
<Text
size="xs"
fw={600}
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
truncate
>
{label}
{hint ? <Text component="span" size="xs" c="dimmed"> · {hint}</Text> : null}
</Text>
</Stack>
{showGraph && graph === "ring" ? (
<MiniRing pct={graphPct ?? 0} accent={graphAccent} size={42} stroke={5}>
<Text size="9px" fw={800} c="dark.4">
{Math.round(graphPct ?? 0)}%
</Text>
</MiniRing>
) : null}
</Group>
{showGraph && graph !== "ring" ? (
<MiniSparkline
variant={graph}
accent={graphAccent}
baseline={(graphPct ?? 50) / 100}
seed={label}
height={20}
/>
) : null}
</Stack>
</Paper> </Paper>
); );
} }

View File

@@ -58,10 +58,10 @@ const fmtScheduleDate = (iso: string | null) =>
}).format(new Date(iso)) + " EAT" }).format(new Date(iso)) + " EAT"
: "No date"; : "No date";
/** Capacity ring color: gold normally, red once over capacity. */
function ringColor(pct: number) { function ringColor(pct: number) {
if (pct >= 100) return "red"; if (pct >= 100) return "#fa5252";
if (pct >= 85) return "orange"; return "#F2A516";
return "green";
} }
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */ /** Capacity ring that keeps the explicit allocated/max numbers underneath. */
@@ -88,7 +88,7 @@ function CapacityRing({
rootColor="var(--mantine-color-gray-1)" rootColor="var(--mantine-color-gray-1)"
label={ label={
<Stack gap={0} align="center"> <Stack gap={0} align="center">
<Text ta="center" size="lg" fw={800} c={`${color}.7`} lh={1}> <Text ta="center" size="lg" fw={800} lh={1} style={{ color }}>
{Math.round(pct)}% {Math.round(pct)}%
</Text> </Text>
<Text ta="center" size="9px" c="dimmed" fw={600}> <Text ta="center" size="9px" c="dimmed" fw={600}>
@@ -141,19 +141,11 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
} }
> >
{/* thin brand accent line */}
<Box
style={{
height: 4,
background: `linear-gradient(90deg, ${FREIGHT_BRAND}, var(--mantine-color-teal-6))`,
}}
/>
<Stack gap="md" p="lg" style={{ flex: 1 }}> <Stack gap="md" p="lg" style={{ flex: 1 }}>
{/* header */} {/* header */}
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={44} radius="md" variant="light" color="green"> <ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<Train size={22} /> <Train size={22} />
</ThemeIcon> </ThemeIcon>
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0 }}>
@@ -224,7 +216,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
) : null} ) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}> <Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="green"> <ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} /> <Package size={17} />
</ThemeIcon> </ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}> <Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
@@ -289,7 +281,6 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
function CardSkeleton() { function CardSkeleton() {
return ( return (
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}> <Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
<Skeleton height={4} radius={0} />
<Stack gap="md" p="lg"> <Stack gap="md" p="lg">
<Group justify="space-between"> <Group justify="space-between">
<Group gap="sm"> <Group gap="sm">
@@ -339,18 +330,32 @@ export default function BatchBoardPage() {
label="Active schedules" label="Active schedules"
value={isLoading ? "…" : schedules.length} value={isLoading ? "…" : schedules.length}
hint="on the board right now" hint="on the board right now"
accent="#F2A516"
graph="area"
graphAccent="gold"
/> />
<StatTile <StatTile
icon={CalendarDays} icon={CalendarDays}
label="Open windows" label="Open windows"
value={isLoading ? "…" : summary.openWindows} value={isLoading ? "…" : summary.openWindows}
hint="accepting bookings" hint="accepting bookings"
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/> />
<StatTile <StatTile
icon={Package} icon={Package}
label="Bookings in play" label="Bookings in play"
value={isLoading ? "…" : summary.totalBookings} value={isLoading ? "…" : summary.totalBookings}
hint={`${summary.totalWagons} wagons allocated`} hint={`${summary.totalWagons} wagons allocated`}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={
schedules.length
? Math.min(100, Math.round((summary.openWindows / schedules.length) * 100))
: 0
}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -47,7 +47,9 @@ import {
totalBookingCount, totalBookingCount,
WindowStatusPill, WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals"; } from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, scheduleBrand } from "@/components/trainScheduling/scheduleVisuals"; import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import type { OverviewAccent } from "@/components/overview/overview.styles";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { import {
useBatchBoardDetail, useBatchBoardDetail,
useRunAllocation, useRunAllocation,
@@ -215,11 +217,11 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
height: 26, height: 26,
borderRadius: 8, borderRadius: 8,
flexShrink: 0, flexShrink: 0,
background: "var(--mantine-color-green-0)", background: "#FEF1D5",
border: "1px solid var(--mantine-color-green-1)", border: "1px solid #FBD171",
}} }}
> >
<Text size="10px" fw={800} c="green.8"> <Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)} {initials(b.company)}
</Text> </Text>
</Box> </Box>
@@ -336,15 +338,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
height: 34, height: 34,
borderRadius: 10, borderRadius: 10,
flexShrink: 0, flexShrink: 0,
background: total background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
? "var(--mantine-color-green-0)" border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
: "var(--mantine-color-gray-0)", color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
border: total
? "1px solid var(--mantine-color-green-2)"
: "1px solid var(--mantine-color-gray-2)",
color: total
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-5)",
}} }}
> >
<Clock size={16} /> <Clock size={16} />
@@ -380,28 +376,28 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
); );
} }
/** Light stat card with an optional capacity bar — keeps the explicit numbers. */ /**
* Light stat card matching the overview look — keeps the explicit numbers.
* Capacity cards (pct set) show a ring; count cards show an area/line trend.
*/
function StatCard({ function StatCard({
icon: Icon, icon: Icon,
label, label,
value, value,
sub, sub,
pct, pct,
variant = "area",
}: { }: {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
value: ReactNode; value: ReactNode;
sub?: string; sub?: string;
pct?: number | null; pct?: number | null;
variant?: "area" | "line";
}) { }) {
const barColor = const ringAccent: OverviewAccent =
pct == null pct == null ? "gold" : pct >= 100 ? "rose" : pct >= 85 ? "orange" : "gold";
? "green"
: pct >= 100
? "red"
: pct >= 85
? "orange"
: "green";
return ( return (
<Paper <Paper
p="md" p="md"
@@ -409,50 +405,33 @@ function StatCard({
withBorder withBorder
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }} style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Stack gap={8}>
<ThemeIcon size={38} radius="md" variant="light" color="green"> <Group gap="sm" wrap="nowrap" align="center">
<Icon size={19} /> <ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
</ThemeIcon> <Icon size={19} />
<Stack gap={5} style={{ minWidth: 0, flex: 1 }}> </ThemeIcon>
<Text <Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
size="xs" <Text fw={800} size="lg" lh={1.05} c="dark.5" style={{ whiteSpace: "nowrap" }}>
fw={600} {value}
tt="uppercase"
style={{ letterSpacing: 0.4 }}
c="dimmed"
>
{label}
</Text>
<Text fw={800} size="lg" lh={1.1} c="dark.5" style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{pct != null ? (
<Box
style={{
height: 6,
borderRadius: 999,
background: "var(--mantine-color-gray-1)",
overflow: "hidden",
}}
>
<Box
style={{
width: `${Math.min(100, Math.max(0, pct))}%`,
height: "100%",
borderRadius: 999,
background: `var(--mantine-color-${barColor}-5)`,
transition: "width 0.4s ease",
}}
/>
</Box>
) : null}
{sub ? (
<Text size="xs" c="dimmed">
{sub}
</Text> </Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
{sub ? ` · ${sub}` : ""}
</Text>
</Stack>
{pct != null ? (
<MiniRing pct={pct} accent={ringAccent} size={42} stroke={5}>
<Text size="9px" fw={800} c="dark.4">
{Math.round(pct)}%
</Text>
</MiniRing>
) : null} ) : null}
</Stack> </Group>
</Group>
{pct == null ? (
<MiniSparkline variant={variant} accent="gold" baseline={0.5} seed={label} height={20} />
) : null}
</Stack>
</Paper> </Paper>
); );
} }
@@ -655,6 +634,7 @@ export default function BatchScheduleDetailPage() {
label="Bookings" label="Bookings"
value={totalBookings} value={totalBookings}
sub={`${data.counts.allocated} allocated · ${data.counts.expired} expired`} sub={`${data.counts.allocated} allocated · ${data.counts.expired} expired`}
variant="line"
/> />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
@@ -669,7 +649,7 @@ export default function BatchScheduleDetailPage() {
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color="green"> <ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
<Package size={16} /> <Package size={16} />
</ThemeIcon> </ThemeIcon>
<Text fw={700}>Booking pipeline</Text> <Text fw={700}>Booking pipeline</Text>
@@ -716,7 +696,7 @@ export default function BatchScheduleDetailPage() {
width: 38, width: 38,
height: 38, height: 38,
borderRadius: 10, borderRadius: 10,
background: scheduleBrand.heroGradient, background: "linear-gradient(135deg, #FBD171, #F2A516)",
color: "white", color: "white",
flexShrink: 0, flexShrink: 0,
}} }}

View File

@@ -622,7 +622,7 @@ export default function TrainScheduleV2DetailPage() {
}} }}
> >
<Group gap="md" align="flex-start" wrap="nowrap"> <Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green"> <ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<CheckCircle2 size={22} /> <CheckCircle2 size={22} />
</ThemeIcon> </ThemeIcon>
<Stack gap={2}> <Stack gap={2}>
@@ -722,7 +722,7 @@ export default function TrainScheduleV2DetailPage() {
<Stack gap="lg" style={{ position: "relative" }}> <Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap"> <Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap"> <Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="green"> <ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
<Train size={28} /> <Train size={28} />
</ThemeIcon> </ThemeIcon>
<Stack gap={6}> <Stack gap={6}>
@@ -731,7 +731,7 @@ export default function TrainScheduleV2DetailPage() {
{schedule.route?.name ?? "Train schedule"} {schedule.route?.name ?? "Train schedule"}
</Title> </Title>
{schedule.trainNumber ? ( {schedule.trainNumber ? (
<Badge variant="light" color="green" radius="sm" style={{ fontWeight: 600 }}> <Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber} {schedule.trainNumber}
</Badge> </Badge>
) : null} ) : null}
@@ -791,11 +791,17 @@ export default function TrainScheduleV2DetailPage() {
? "Import-ready" ? "Import-ready"
: undefined : undefined
} }
accent="#F2A516"
graph="area"
graphAccent="gold"
/> />
<StatTile <StatTile
icon={Package} icon={Package}
label="Bookings" label="Bookings"
value={schedule.bookings?.length ?? 0} value={schedule.bookings?.length ?? 0}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/> />
<StatTile <StatTile
icon={Weight} icon={Weight}
@@ -803,6 +809,9 @@ export default function TrainScheduleV2DetailPage() {
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${ value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0 schedule.trainSet?.totalWeightTons ?? 0
}T`} }T`}
accent="#F2A516"
graph="area"
graphAccent="gold"
/> />
<StatTile <StatTile
icon={CalendarClock} icon={CalendarClock}
@@ -815,6 +824,9 @@ export default function TrainScheduleV2DetailPage() {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -356,10 +356,40 @@ export default function TrainScheduleV2ListPage() {
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile icon={Train} label="Total trains" value={stats.total} /> <StatTile
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} /> icon={Train}
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} /> label="Total trains"
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} /> value={stats.total}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
graphPct={stats.total ? Math.round((stats.scheduled / stats.total) * 100) : 0}
/>
<StatTile
icon={Send}
label="Dispatched"
value={stats.dispatched}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={stats.total ? Math.round((stats.dispatched / stats.total) * 100) : 0}
/>
<StatTile
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
accent="#FB8C2E"
graph="area"
graphAccent="orange"
/>
</SimpleGrid> </SimpleGrid>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}> <Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
@@ -607,11 +637,11 @@ function ScheduleCard({
}} }}
> >
{/* accent strip */} {/* accent strip */}
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} /> <Box style={{ height: 4, background: "linear-gradient(90deg, #FBD171, #F2A516)" }} />
<Stack gap="sm" p="md"> <Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="green"> <ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
<Train size={18} /> <Train size={18} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>