mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
ui fix
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user