This commit is contained in:
Marshal
2026-06-11 08:19:16 +00:00
parent 088295d81f
commit 20289621f3
7 changed files with 799 additions and 462 deletions

View File

@@ -11,14 +11,11 @@ import {
RefreshCw,
} from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type {
BookingListSummaryMetrics,
BookingListSummaryTabs,
} from "@/services/bookings.service";
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
const PIPELINE_STAGES: Array<{
key: keyof BookingListSummaryTabs;
@@ -26,13 +23,18 @@ const PIPELINE_STAGES: Array<{
color: string;
}> = [
{ key: "intake", label: "Intake", color: "#38bdf8" },
{ key: "in_approval", label: "Approval", color: "#fbbf24" },
{ key: "approved_contract", label: "Contract", color: "#a78bfa" },
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
{ key: "payment", label: "Payment", color: "#fb923c" },
{ key: "operations", label: "Operations", color: "#2dd4bf" },
{ key: "completed", label: "Completed", color: "#86efac" },
{ key: "operations", label: "Operations", color: "#14b8a6" },
{ key: "completed", label: "Completed", color: "#22c55e" },
];
const CARD_STYLE = {
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
} as const;
export interface BookingRequestsHeaderProps {
metrics?: BookingListSummaryMetrics;
tabs?: BookingListSummaryTabs;
@@ -59,74 +61,40 @@ export function BookingRequestsHeader({
style={{
position: "relative",
overflow: "hidden",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
{/* decorative glows */}
<Box
style={{
position: "absolute",
top: -100,
right: -60,
width: 300,
height: 300,
borderRadius: "50%",
background: "rgba(255,255,255,0.12)",
pointerEvents: "none",
}}
/>
<Box
style={{
position: "absolute",
bottom: -130,
right: 160,
width: 240,
height: 240,
borderRadius: "50%",
background: "rgba(255,255,255,0.06)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: freightBrand.primary }}>
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<Inbox size={28} />
</ThemeIcon>
<Stack gap={4}>
<Text size="xs" fw={700} c="rgba(255,255,255,0.8)" tt="uppercase" style={{ letterSpacing: 1 }}>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
Operations
</Text>
<Title order={2} c="white" fw={700}>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Booking Requests
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
<Text size="sm" c="dimmed" maw={520}>
Track every booking from submission through approval, payment, and
dispatch prioritize what needs action.
</Text>
</Stack>
</Group>
<Group gap="sm">
<Button
variant="white"
c="green.8"
radius="lg"
leftSection={<Plus size={18} />}
onClick={onCreate}
>
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
Create booking
</Button>
<Button
variant="light"
color="white"
variant="default"
radius="lg"
c="white"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={onRefresh}
style={{ background: "rgba(255,255,255,0.15)" }}
>
Refresh
</Button>
@@ -134,19 +102,14 @@ export function BookingRequestsHeader({
</Group>
<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" />
<HeroStat
icon={Clock}
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratioColor="#fbbf24"
ratioColor="#f59e0b"
/>
<HeroStat
icon={AlertTriangle}
@@ -154,14 +117,9 @@ export function BookingRequestsHeader({
value={val(metrics?.urgent)}
hint="High priority score"
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
ratioColor="#fca5a5"
/>
<HeroStat
icon={CheckCircle2}
label="Completed"
value={val(tabs?.completed)}
hint="Fully executed"
ratioColor="#ef4444"
/>
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
</Group>
{tabs ? <PipelineBar tabs={tabs} /> : null}
@@ -173,36 +131,31 @@ export function BookingRequestsHeader({
/** Compact ring gauge with the stat icon at its center. */
function MiniDonut({
pct,
color = "white",
color,
children,
size = 52,
stroke = 5,
}: {
pct?: number | null;
color?: string;
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 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" }}
>
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.18)"
stroke="var(--mantine-color-gray-2)"
strokeWidth={stroke}
/>
{clamped != null ? (
@@ -226,7 +179,7 @@ function MiniDonut({
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
color,
}}
>
{children}
@@ -241,7 +194,7 @@ function HeroStat({
value,
hint,
ratio,
ratioColor = "white",
ratioColor = "var(--mantine-color-green-6)",
}: {
icon: LucideIcon;
label: string;
@@ -252,29 +205,19 @@ function HeroStat({
}) {
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.18)",
backdropFilter: "blur(6px)",
}}
>
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
<Group gap="md" wrap="nowrap" align="center">
<MiniDonut pct={pct} color={ratioColor}>
<Icon size={19} />
</MiniDonut>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
{label}
</Text>
<Text fw={700} size="28px" c="white" lh={1}>
<Text fw={700} size="28px" lh={1} style={{ color: "#0f172a" }}>
{value}
</Text>
<Text size="xs" c="rgba(255,255,255,0.72)" truncate>
<Text size="xs" c="dimmed" truncate>
{pct != null ? `${pct}% of queue` : hint}
</Text>
</Stack>
@@ -288,20 +231,12 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
const total = segments.reduce((sum, s) => sum + s.count, 0);
return (
<Paper
p="md"
radius="lg"
style={{
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.18)",
backdropFilter: "blur(6px)",
}}
>
<Paper p="md" radius="lg" style={CARD_STYLE}>
<Group justify="space-between" mb={10}>
<Text size="sm" fw={700} c="white">
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
Booking pipeline
</Text>
<Text size="xs" c="rgba(255,255,255,0.75)">
<Text size="xs" c="dimmed">
{total} active
</Text>
</Group>
@@ -312,7 +247,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
height: 14,
borderRadius: 999,
overflow: "hidden",
background: "rgba(255,255,255,0.18)",
background: "var(--mantine-color-gray-2)",
gap: 2,
}}
>
@@ -322,11 +257,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
<Box
key={s.key}
title={`${s.label}: ${s.count}`}
style={{
width: `${(s.count / total) * 100}%`,
background: s.color,
transition: "width 200ms ease",
}}
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
/>
) : null,
)
@@ -339,10 +270,10 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
{segments.map((s) => (
<Group key={s.key} gap={6} wrap="nowrap">
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
<Text size="xs" c="rgba(255,255,255,0.85)">
<Text size="xs" c="dimmed">
{s.label}
</Text>
<Text size="xs" fw={700} c="white">
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
{s.count}
</Text>
</Group>

View File

@@ -10,7 +10,7 @@ import {
Wallet,
Weight,
} from "lucide-react";
import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
@@ -18,12 +18,9 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { freightBrand } from "@/theme/freight-brand";
import { formatDate } from "./booking-detail.styles";
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
@@ -55,40 +52,25 @@ export function BookingRequestHero({
style={{
position: "relative",
overflow: "hidden",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Box
style={{
position: "absolute",
top: -110,
right: -50,
width: 300,
height: 300,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="white"
color="white"
c="white"
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.25)" }}
>
Back to list
</Button>
<Button
variant="white"
c="green.8"
variant="light"
color="green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
@@ -101,11 +83,11 @@ export function BookingRequestHero({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="rgba(255,255,255,0.78)" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
<Text size="xs" c="green.7" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700} style={{ letterSpacing: "-0.4px" }}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
@@ -116,7 +98,7 @@ export function BookingRequestHero({
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="rgba(255,255,255,0.85)">
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
@@ -130,7 +112,12 @@ export function BookingRequestHero({
</Group>
{booking.nextStep ? (
<Paper radius="lg" p={4} style={{ background: "rgba(255,255,255,0.92)" }} maw={640}>
<Paper
radius="lg"
p={4}
maw={640}
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
@@ -143,19 +130,22 @@ export function BookingRequestHero({
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
accent="green"
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
<HeroTile
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
accent="teal"
/>
<HeroTile
icon={Flame}
label="Priority score"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
accent="orange"
/>
</Group>
</Stack>
@@ -174,8 +164,8 @@ function MetaItem({
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "white" : "rgba(255,255,255,0.85)"}>
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
{text}
</Text>
</Group>
@@ -187,11 +177,13 @@ function HeroTile({
label,
value,
hint,
accent = "green",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
accent?: string;
}) {
return (
<Paper
@@ -200,36 +192,23 @@ function HeroTile({
style={{
flex: "1 1 160px",
minWidth: 150,
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.18)",
backdropFilter: "blur(6px)",
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: 10,
background: "rgba(255,255,255,0.18)",
color: "white",
flexShrink: 0,
}}
>
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
<Icon size={18} />
</Box>
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
{label}
</Text>
<Text fw={700} size="lg" c="white" lh={1.1} style={{ whiteSpace: "nowrap" }}>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
{value}
</Text>
{hint ? (
<Text size="xs" c="rgba(255,255,255,0.7)" truncate>
<Text size="xs" c="dimmed" truncate>
{hint}
</Text>
) : null}

View File

@@ -5,13 +5,12 @@ import {
SegmentedControl,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { Activity, RefreshCw } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { OverviewRange } from "@/types/overview";
import "./overview.css";
const RANGE_OPTIONS = [
{ label: "7 days", value: "7d" },
@@ -19,8 +18,6 @@ const RANGE_OPTIONS = [
{ label: "90 days", value: "90d" },
];
const HERO_GRADIENT = `linear-gradient(125deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 50%, ${freightBrand.primaryLight} 125%)`;
function formatRelativeTime(iso: string | undefined) {
if (!iso) return "—";
const diffMs = Date.now() - new Date(iso).getTime();
@@ -32,7 +29,7 @@ function formatRelativeTime(iso: string | undefined) {
return new Date(iso).toLocaleString();
}
/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */
/** Decorative line-art locomotive + rails — faint brand tint on the right. */
function TrainArtwork() {
return (
<Box
@@ -43,34 +40,23 @@ function TrainArtwork() {
bottom: -8,
width: 360,
height: 200,
opacity: 0.16,
opacity: 0.06,
pointerEvents: "none",
color: "white",
color: "var(--mantine-color-green-7)",
}}
>
<svg viewBox="0 0 360 200" fill="none" width="100%" height="100%">
{/* rails */}
<path d="M0 168 H360" stroke="currentColor" strokeWidth="2" strokeDasharray="2 10" strokeLinecap="round" />
<path d="M0 180 H360" stroke="currentColor" strokeWidth="2" />
{/* locomotive body */}
<path
d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z"
stroke="currentColor"
strokeWidth="3"
/>
{/* cab windows */}
<path d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z" stroke="currentColor" strokeWidth="3" />
<rect x="84" y="80" width="40" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
<rect x="140" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
<rect x="200" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
{/* lower stripe */}
<path d="M56 130 H264" stroke="currentColor" strokeWidth="3" />
{/* wheels */}
<circle cx="96" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
<circle cx="150" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
<circle cx="214" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
{/* coupling */}
<path d="M264 120 H300 a8 8 0 0 1 8 8 V150 H300" stroke="currentColor" strokeWidth="3" />
{/* headlight beam */}
<path d="M56 100 l-28 -10 M56 112 l-30 0 M56 124 l-28 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Box>
@@ -98,50 +84,28 @@ export function OverviewPageHeader({
position: "relative",
overflow: "hidden",
borderRadius: 20,
padding: "28px 28px",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
padding: "26px 28px",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
{/* decorative glows */}
<Box
style={{
position: "absolute",
top: -120,
right: 120,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<TrainArtwork />
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap={8} align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
background: "rgba(255,255,255,0.2)",
}}
>
<Activity size={16} color="white" />
</Box>
<Text size="xs" fw={700} c="rgba(255,255,255,0.85)" tt="uppercase" style={{ letterSpacing: 1.2 }}>
<ThemeIcon size={28} radius="md" variant="light" color="green">
<Activity size={16} />
</ThemeIcon>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1.2 }}>
Freight Backoffice · Live
</Text>
</Group>
<Title order={1} c="white" style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1 }}>
<Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}>
Operations Overview
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)">
<Text size="sm" c="dimmed">
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
</Text>
</Stack>
@@ -153,14 +117,10 @@ export function OverviewPageHeader({
data={RANGE_OPTIONS}
size="sm"
radius="lg"
classNames={{
root: "ov-seg-root",
indicator: "ov-seg-indicator",
label: "ov-seg-label",
}}
color="green"
/>
<ActionIcon
variant="white"
variant="light"
color="green"
size="lg"
radius="lg"

View File

@@ -115,7 +115,7 @@ export function StatTile({
backdropFilter: "blur(6px)",
}
: {
background: "white",
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}
}