ui design for schedule

This commit is contained in:
Marshal
2026-06-10 00:12:52 +00:00
parent 257428be18
commit 2b5a0f4e96
25 changed files with 1687 additions and 611 deletions

View File

@@ -0,0 +1,353 @@
import type { ReactNode } from "react";
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
CheckCircle2,
Clock,
Inbox,
LayoutList,
Plus,
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;
label: string;
color: string;
}> = [
{ key: "intake", label: "Intake", color: "#38bdf8" },
{ key: "in_approval", label: "Approval", color: "#fbbf24" },
{ key: "approved_contract", label: "Contract", color: "#a78bfa" },
{ key: "payment", label: "Payment", color: "#fb923c" },
{ key: "operations", label: "Operations", color: "#2dd4bf" },
{ key: "completed", label: "Completed", color: "#86efac" },
];
export interface BookingRequestsHeaderProps {
metrics?: BookingListSummaryMetrics;
tabs?: BookingListSummaryTabs;
loading?: boolean;
isFetching?: boolean;
onCreate: () => void;
onRefresh: () => void;
}
export function BookingRequestsHeader({
metrics,
tabs,
loading,
isFetching,
onCreate,
onRefresh,
}: BookingRequestsHeaderProps) {
const val = (n?: number) => (loading ? "—" : (n ?? 0));
return (
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
}}
>
{/* 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 }}>
<Inbox size={28} />
</ThemeIcon>
<Stack gap={4}>
<Text size="xs" fw={700} c="rgba(255,255,255,0.8)" tt="uppercase" style={{ letterSpacing: 1 }}>
Operations
</Text>
<Title order={2} c="white" fw={700}>
Booking Requests
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" 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}
>
Create booking
</Button>
<Button
variant="light"
color="white"
radius="lg"
c="white"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={onRefresh}
style={{ background: "rgba(255,255,255,0.15)" }}
>
Refresh
</Button>
</Group>
</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={Clock}
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratioColor="#fbbf24"
/>
<HeroStat
icon={AlertTriangle}
label="Urgent"
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"
/>
</Group>
{tabs ? <PipelineBar tabs={tabs} /> : null}
</Stack>
</Paper>
);
}
/** Compact ring gauge with the stat icon at its center. */
function MiniDonut({
pct,
color = "white",
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="rgba(255,255,255,0.18)"
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: "white",
}}
>
{children}
</Box>
</Box>
);
}
function HeroStat({
icon: Icon,
label,
value,
hint,
ratio,
ratioColor = "white",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: string;
ratio?: number;
ratioColor?: string;
}) {
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)",
}}
>
<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 }}>
{label}
</Text>
<Text fw={700} size="28px" c="white" lh={1}>
{value}
</Text>
<Text size="xs" c="rgba(255,255,255,0.72)" truncate>
{pct != null ? `${pct}% of queue` : hint}
</Text>
</Stack>
</Group>
</Paper>
);
}
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
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)",
}}
>
<Group justify="space-between" mb={10}>
<Text size="sm" fw={700} c="white">
Booking pipeline
</Text>
<Text size="xs" c="rgba(255,255,255,0.75)">
{total} active
</Text>
</Group>
<Box
style={{
display: "flex",
height: 14,
borderRadius: 999,
overflow: "hidden",
background: "rgba(255,255,255,0.18)",
gap: 2,
}}
>
{total > 0 ? (
segments.map((s) =>
s.count > 0 ? (
<Box
key={s.key}
title={`${s.label}: ${s.count}`}
style={{
width: `${(s.count / total) * 100}%`,
background: s.color,
transition: "width 200ms ease",
}}
/>
) : null,
)
) : (
<Box style={{ width: "100%" }} />
)}
</Box>
<Group gap="md" mt={10} wrap="wrap">
{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)">
{s.label}
</Text>
<Text size="xs" fw={700} c="white">
{s.count}
</Text>
</Group>
))}
</Group>
</Paper>
);
}

View File

@@ -19,6 +19,7 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="green"
extra={
<Badge color="green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved

View File

@@ -15,7 +15,7 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
return (
<SectionCard icon={Package} title="Cargo specifications">
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"

View File

@@ -13,6 +13,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
<SectionCard
icon={Boxes}
title="Containers & Cargo"
accent="teal"
extra={
<Badge color="gray" variant="light" radius="sm">
{containers.length} line{containers.length === 1 ? "" : "s"}

View File

@@ -10,7 +10,7 @@ export interface BookingContractSummaryCardProps {
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary">
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
<Code
block
style={{

View File

@@ -15,6 +15,7 @@ export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCard
<SectionCard
icon={FileText}
title="Documents"
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}

View File

@@ -43,7 +43,7 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
];
return (
<SectionCard icon={Hash} title="Booking Details">
<SectionCard icon={Hash} title="Booking Details" accent="cyan">
<Stack gap={0}>
{facts.map((fact, index) => (
<div key={fact.label}>

View File

@@ -17,7 +17,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
}
return (
<SectionCard icon={Truck} title="Mile services">
<SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />

View File

@@ -1,13 +1,28 @@
import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react";
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
import type { ReactNode } from "react";
import {
ArrowLeft,
Building2,
Calendar,
Clock,
Container as ContainerIcon,
Flame,
RefreshCw,
Wallet,
Weight,
} from "lucide-react";
import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
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 { detailStyles, formatDate } from "./booking-detail.styles";
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;
@@ -17,7 +32,7 @@ export interface BookingRequestHeroProps {
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, total value. */
/** Top hero for the request detail page: identity, status, next step, key figures. */
export function BookingRequestHero({
booking,
customerLabel,
@@ -26,93 +41,198 @@ export function BookingRequestHero({
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return (
<Paper radius="md" withBorder p="xl" style={detailStyles.card}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
mb="md"
ml={-8}
fw={600}
>
Back to list
</Button>
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
}}
>
<Box
style={{
position: "absolute",
top: -110,
right: -50,
width: 300,
height: 300,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.06em">
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="yellow.8">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep && (
<Box maw={520}>
<NextStepBanner nextStep={booking.nextStep} />
</Box>
)}
<Group gap="lg" mt={4}>
<Group gap={6} wrap="nowrap">
<Building2 size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{customerLabel}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Calendar size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Scheduled {booking.scheduledDate}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Clock size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Created {formatDate(booking.createdAt)}
</Text>
</Group>
</Group>
</Stack>
<Stack gap="sm" align="flex-end">
<Paper radius="md" withBorder p="md" miw={200} style={detailStyles.highlightCard}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em" ta="right">
Total value
</Text>
<Text size="xl" fw={700} c="green.9" ta="right" mt={4} style={{ fontVariantNumeric: "tabular-nums" }}>
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Text size="xs" c="dimmed" ta="right" mt={2}>
{booking.paymentStatus}
</Text>
</Paper>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="sm"
variant="white"
color="white"
c="white"
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"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
<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 }}>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="rgba(255,255,255,0.85)">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} />
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} />
</Group>
</Stack>
</Group>
{booking.nextStep ? (
<Paper radius="lg" p={4} style={{ background: "rgba(255,255,255,0.92)" }} maw={640}>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroTile
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<HeroTile
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
/>
<HeroTile
icon={Flame}
label="Priority score"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
/>
</Group>
</Stack>
</Paper>
);
}
function MetaItem({
icon: Icon,
text,
strong,
}: {
icon: LucideIcon;
text: ReactNode;
strong?: boolean;
}) {
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)"}>
{text}
</Text>
</Group>
);
}
function HeroTile({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
}) {
return (
<Paper
p="md"
radius="lg"
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)",
}}
>
<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,
}}
>
<Icon size={18} />
</Box>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
{label}
</Text>
<Text fw={700} size="lg" c="white" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (
<Text size="xs" c="rgba(255,255,255,0.7)" truncate>
{hint}
</Text>
) : null}
</Stack>
</Group>
</Paper>

View File

@@ -12,7 +12,7 @@ export interface BookingReviewNotesCardProps {
export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
if (notes.length === 0) {
return (
<SectionCard icon={MessageSquare} title="Review Notes">
<SectionCard icon={MessageSquare} title="Review Notes" accent="grape">
<Text size="sm" c="dimmed">
No review notes have been added yet.
</Text>

View File

@@ -10,7 +10,7 @@ export interface BookingRouteCardProps {
export function BookingRouteCard({ booking }: BookingRouteCardProps) {
return (
<SectionCard icon={MapPin} title="Shipment Route">
<SectionCard icon={MapPin} title="Shipment Route" accent="blue">
<Group justify="space-between" align="center" wrap="nowrap" gap="xl">
{/* Origin */}
<Stack gap={2} style={{ flex: 1 }}>

View File

@@ -63,7 +63,7 @@ export function BookingRouteServiceCard({
];
return (
<SectionCard icon={Train} title="Route & service">
<SectionCard icon={Train} title="Route & service" accent="blue">
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<Endpoint label="Origin" station={originLabel} />
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>

View File

@@ -7,20 +7,68 @@ import { detailStyles } from "./booking-detail.styles";
export interface SectionCardProps {
icon: LucideIcon;
title: string;
/** Optional one-line context shown under the title. */
subtitle?: string;
/** Mantine palette key used to tint the icon chip + top accent (default green). */
accent?: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent flat card with a minimal icon + title header used by every detail section. */
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) {
/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
export function SectionCard({
icon: Icon,
title,
subtitle,
accent = "green",
extra,
children,
}: SectionCardProps) {
return (
<Paper radius="md" withBorder style={detailStyles.card}>
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}>
<Group gap="sm">
<Icon size={16} color="var(--mantine-color-gray-6)" />
<Text fw={600} size="sm" c="dark">
{title}
</Text>
<Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
<Box
style={{
height: 3,
background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
}}
/>
<Group
justify="space-between"
px="xl"
py="md"
wrap="nowrap"
style={{
...detailStyles.cardHeader,
background: `linear-gradient(180deg, var(--mantine-color-${accent}-0) 0%, white 100%)`,
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
border: `1px solid var(--mantine-color-${accent}-2)`,
}}
>
<Icon size={17} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={600} size="sm" c="dark" truncate>
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
) : null}
</Box>
</Group>
{extra}
</Group>

View File

@@ -0,0 +1,126 @@
/* ============================================================
EDR Freight — Header styles
============================================================ */
.fdh-root {
display: flex;
height: 80px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 22px;
}
/* eyebrow above the page title */
.fdh-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #15803d;
margin-bottom: 3px;
}
.fdh-eyebrow-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
/* action icon buttons */
.fdh-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #f7f9fb;
border: 1px solid #eef1f4;
color: #475569;
cursor: pointer;
transition: all 160ms ease;
}
.fdh-icon-btn:hover {
background: #ffffff;
border-color: rgba(21, 128, 61, 0.28);
color: #15803d;
box-shadow: 0 4px 12px -4px rgba(21, 128, 61, 0.28);
transform: translateY(-1px);
}
.fdh-icon-btn:active {
transform: translateY(0);
}
/* notification badge */
.fdh-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 1;
border: 2px solid #ffffff;
box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45);
}
.fdh-divider {
width: 1px;
height: 30px;
background: #e9eef3;
margin: 0 2px;
}
/* user button */
.fdh-user {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 12px 5px 5px;
border-radius: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: all 160ms ease;
}
.fdh-user:hover {
background: #f7f9fb;
border-color: #eef1f4;
}
.fdh-avatar-ring {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
box-shadow: 0 4px 10px -3px rgba(21, 128, 61, 0.4);
}
.fdh-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #16a34a 0%, #166534 100%);
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3px;
border: 2px solid #ffffff;
}

View File

@@ -9,10 +9,10 @@ import {
Sun,
User,
} from "lucide-react";
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
import type { PageMeta } from "./types";
import { freightBrand } from "@/theme/freight-brand";
import "./FreightDashboardHeader.css";
export interface FreightDashboardHeaderProps {
pageMeta: PageMeta;
@@ -39,12 +39,13 @@ const FreightDashboardHeader = ({
}: FreightDashboardHeaderProps) => {
const initials =
userInitials ??
userName
(userName
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join("");
.join("") ||
"U");
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
@@ -74,133 +75,137 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]);
return (
<header
style={{
display: "flex",
height: "80px",
alignItems: "center",
justifyContent: "space-between",
gap: "16px",
padding: "0 24px",
// borderBottom: `3px solid ${freightBrand.primary}`,
}}
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
<header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow">
<span className="fdh-eyebrow-dot" />
Freight Backoffice
</span>
<Text
fw={700}
truncate
style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
>
{pageMeta.title}
</Text>
<Text size="sm" c="dimmed" truncate>
<Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle}
</Text>
</Stack>
<Group gap="sm" wrap="nowrap">
<Group gap={10} wrap="nowrap">
{enableThemeToggle && (
<ActionIcon
variant="default"
size={40}
radius="lg"
onClick={onToggleTheme}
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
<button
type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
</Tooltip>
)}
<ActionIcon
variant="default"
size={40}
radius="lg"
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
>
<Languages size={18} />
</ActionIcon>
<Tooltip label="Language" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Language">
<Languages size={18} />
</button>
</Tooltip>
<ActionIcon
variant="default"
size={40}
radius="lg"
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
position: "relative",
}}
>
<MessageSquare size={18} />
<Badge
size="xs"
color="red"
circle
style={{
position: "absolute",
top: "-3px",
right: "-3px",
}}
/>
</ActionIcon>
<Tooltip label="Messages" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Messages">
<MessageSquare size={18} />
<span className="fdh-badge">3</span>
</button>
</Tooltip>
<ActionIcon
variant="default"
size={40}
radius="lg"
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
position: "relative",
}}
>
<Bell size={18} />
<Badge
size="xs"
color="red"
circle
style={{
position: "absolute",
top: "-3px",
right: "-3px",
}}
/>
</ActionIcon>
<Tooltip label="Notifications" withArrow openDelay={300}>
<button
type="button"
className="fdh-icon-btn"
aria-label="Notifications"
>
<Bell size={18} />
<span className="fdh-badge">5</span>
</button>
</Tooltip>
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
<div className="fdh-divider" />
<Menu
position="bottom-end"
shadow="lg"
radius="md"
width={240}
opened={isUserMenuOpen}
onOpen={() => setIsUserMenuOpen(true)}
onClose={() => setIsUserMenuOpen(false)}
>
<Menu.Target>
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
</Group>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item disabled>
<Stack gap={0}>
<Text size="sm" fw={600}>
<div className="fdh-user">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed">
{userEmail}
</Text>
)}
<Text
size="xs"
truncate
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
>
{userEmail ?? "Administrator"}
</Text>
</Stack>
</Menu.Item>
<ChevronDown
size={16}
style={{
color: "#94a3b8",
flexShrink: 0,
transition: "transform 0.2s",
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Group gap={10} wrap="nowrap">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate style={{ color: "#0f172a" }}>
{userName}
</Text>
{userEmail && (
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
{userEmail}
</Text>
)}
</Stack>
</Group>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<User size={14} />}
leftSection={<User size={15} />}
onClick={() => setIsUserMenuOpen(false)}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={14} />}
leftSection={<LogOut size={15} />}
color="red"
onClick={() => {
setIsUserMenuOpen(false);

View File

@@ -0,0 +1,304 @@
/* ============================================================
EDR Freight — Sidebar styles
Polished, professional navigation surface.
============================================================ */
.fsb-aside {
height: 100%;
max-height: 100%;
width: 280px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid #eef1f4;
background: #ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 8px 24px -16px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- Brand header ---- */
.fsb-brand {
position: relative;
display: flex;
align-items: center;
gap: 12px;
height: 80px;
padding: 0 20px;
flex-shrink: 0;
border-bottom: 1px solid #f1f5f9;
overflow: hidden;
}
.fsb-brand::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%);
pointer-events: none;
}
.fsb-logo {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 13px;
background: linear-gradient(135deg, #22c55e 0%, #15803d 60%, #166534 100%);
box-shadow:
0 6px 16px -4px rgba(21, 128, 61, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
/* ---- Nav scroll region ---- */
.fsb-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 12px 12px;
display: flex;
flex-direction: column;
gap: 20px;
}
.fsb-nav::-webkit-scrollbar {
width: 6px;
}
.fsb-nav::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.fsb-nav::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.fsb-nav::-webkit-scrollbar-track {
background: transparent;
}
.fsb-section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.7px;
text-transform: uppercase;
color: #94a3b8;
padding: 0 12px;
margin-bottom: 6px;
}
/* ---- Top-level item ---- */
.fsb-item {
position: relative;
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 9px 12px;
border-radius: 11px;
cursor: pointer;
color: #475569;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
text-align: left;
text-decoration: none;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.fsb-item:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-item[data-active="true"] {
background: linear-gradient(
135deg,
rgba(34, 197, 94, 0.12) 0%,
rgba(21, 128, 61, 0.06) 100%
);
color: #15803d;
font-weight: 600;
}
.fsb-item[data-active="true"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 4px 4px 0;
background: linear-gradient(180deg, #22c55e 0%, #15803d 100%);
}
.fsb-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Icon well ---- */
.fsb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 31px;
height: 31px;
border-radius: 9px;
flex-shrink: 0;
background: #f1f5f9;
color: #64748b;
transition: all 160ms ease;
}
.fsb-item:hover .fsb-icon {
background: #e6ebf1;
color: #334155;
}
.fsb-item[data-active="true"] .fsb-icon {
background: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
color: #ffffff;
box-shadow: 0 5px 12px -2px rgba(21, 128, 61, 0.45);
}
.fsb-chevron {
flex-shrink: 0;
color: #94a3b8;
transition: transform 220ms ease;
}
/* ---- Nested branch ---- */
.fsb-branch {
margin: 2px 0 2px 22px;
padding-left: 12px;
border-left: 1.5px solid #eef2f6;
display: flex;
flex-direction: column;
gap: 2px;
}
/* group header (non-navigable) */
.fsb-group {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
background: transparent;
transition: background-color 150ms ease;
}
.fsb-group:hover {
background-color: #f5f7fa;
}
.fsb-group-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #94a3b8;
}
.fsb-group[data-active="true"] .fsb-group-label {
color: #15803d;
}
/* child leaf */
.fsb-child {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
color: #64748b;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition:
background-color 150ms ease,
color 150ms ease;
}
.fsb-child:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-child[data-active="true"] {
color: #15803d;
font-weight: 600;
background-color: rgba(21, 128, 61, 0.08);
}
.fsb-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: #cbd5e1;
transition: all 150ms ease;
}
.fsb-child:hover .fsb-dot {
background: #94a3b8;
}
.fsb-child[data-active="true"] .fsb-dot {
background: #15803d;
box-shadow: 0 0 0 3px rgba(21, 128, 61, 0.16);
}
/* ---- Footer status card ---- */
.fsb-footer {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #f1f5f9;
}
.fsb-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 11px;
background: linear-gradient(135deg, #f0fdf4 0%, #f8fafc 100%);
border: 1px solid #e7f3ec;
}
.fsb-pulse {
position: relative;
width: 9px;
height: 9px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
.fsb-pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: #22c55e;
animation: fsb-pulse 2s ease-out infinite;
}
@keyframes fsb-pulse {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.6);
opacity: 0;
}
}

View File

@@ -1,15 +1,16 @@
import {
type MouseEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ChevronDown, ChevronRight, Train } from "lucide-react";
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
import { ChevronDown, Train } from "lucide-react";
import { Box, Stack, Text } from "@mantine/core";
import type { SidebarItem, SidebarSection } from "./types";
import { freightBrand } from "@/theme/freight-brand";
import "./FreightSidebar.css";
export interface FreightSidebarProps {
sections: SidebarSection[];
@@ -105,7 +106,7 @@ const FreightSidebar = ({
children: SidebarItem[],
depth: number,
parentKey: string,
) =>
): ReactNode =>
children.map((child) => {
const key = sidebarItemKey(child, parentKey);
const isGroup = Boolean(child.children?.length) && !child.href;
@@ -115,87 +116,50 @@ const FreightSidebar = ({
const groupActive = branchContainsActive(child.children!);
return (
<Stack key={key} gap={4}>
<UnstyledButton
<div key={key}>
<button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)}
style={{
background: groupActive ? freightBrand.mutedBg : "transparent",
padding: "8px 12px",
borderRadius: "8px",
width: "100%",
cursor: "pointer",
}}
>
<Group justify="space-between">
<Text size="xs" fw={600} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase">
{child.label}
</Text>
<ChevronDown
size={14}
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 0.2s",
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)",
}}
/>
</Group>
</UnstyledButton>
<span className="fsb-group-label">{child.label}</span>
<ChevronDown
size={13}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
color: groupActive ? "#15803d" : undefined,
}}
/>
</button>
{isOpen && (
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
<div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)}
</Stack>
</div>
)}
</Stack>
</div>
);
}
if (!child.href) return null;
const childHref = child.href.toLowerCase();
const childActiveHref = isHrefActive(childHref);
const childActive = isHrefActive(child.href);
return (
<NavLink
<a
key={key}
component="a"
href={child.href}
onClick={(e) => navigateTo(e as any, child.href!)}
label={child.label}
active={childActiveHref}
color="green"
style={{
borderRadius: "8px",
cursor: "pointer",
fontSize: "14px",
}}
rightSection={<ChevronRight size={16} />}
/>
className="fsb-child"
data-active={childActive}
onClick={(e) => navigateTo(e, child.href!)}
>
<span className="fsb-dot" />
<span className="fsb-item-label">{child.label}</span>
</a>
);
});
const renderIconWell = (icon: React.ReactNode, active: boolean) => {
if (!icon) return null;
return (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "30px",
height: "30px",
borderRadius: "8px",
flexShrink: 0,
background: active ? freightBrand.gradient : "var(--mantine-color-gray-1)",
color: active ? "white" : "var(--mantine-color-gray-6)",
boxShadow: active ? freightBrand.shadowSm : "none",
transition: "all 0.2s ease",
}}
>
{icon}
</Box>
);
};
const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null;
@@ -207,132 +171,92 @@ const FreightSidebar = ({
const isCurrentItem = hasChildren
? activePath === itemHref
: isHrefActive(itemHref);
const isSectionActive = childActive && !isCurrentItem;
const isActive = isCurrentItem || isSectionActive;
const isActive = isCurrentItem || childActive;
const isOpen = expanded[item.href] ?? false;
const leafActive = isCurrentItem && !hasChildren;
return (
<Stack key={item.href} gap={0}>
<NavLink
component="a"
<Box key={item.href}>
<a
href={item.href}
onClick={(e) => navigateTo(e as any, item.href!)}
label={item.label}
leftSection={renderIconWell(item.icon, isActive)}
active={leafActive}
color="green"
variant="light"
style={{
borderRadius: "10px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 500,
padding: "8px 10px",
}}
rightSection={
hasChildren ? (
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 0.2s",
}}
onClick={(e) => {
e.preventDefault();
toggleExpanded(item.href!);
}}
/>
) : (
<ChevronRight size={16} />
)
}
/>
className="fsb-item"
data-active={isActive}
onClick={(e) => navigateTo(e, item.href!)}
>
{item.icon && <span className="fsb-icon">{item.icon}</span>}
<span className="fsb-item-label">{item.label}</span>
{hasChildren && (
<ChevronDown
size={16}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleExpanded(item.href!);
}}
/>
)}
</a>
{hasChildren && isOpen && (
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
<div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)}
</Stack>
</div>
)}
</Stack>
</Box>
);
};
return (
<Box
component="aside"
style={{
height: "100%",
maxHeight: "100%",
width: "280px",
flexShrink: 0,
borderRadius: "12px",
border: "1px solid var(--mantine-color-gray-2)",
background: "white",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Group
gap={12}
px="lg"
py="md"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
height: "80px",
}}
wrap="nowrap"
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "44px",
height: "44px",
borderRadius: "12px",
background: freightBrand.gradient,
boxShadow: freightBrand.shadow,
flexShrink: 0,
}}
>
<Train size={24} color="white" strokeWidth={2} />
</Box>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="md" fw={700} style={{ letterSpacing: "-0.3px", lineHeight: 1.2 }}>
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
<Text
size="md"
fw={700}
style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
>
EDR Freight
</Text>
<Text size="xs" c="dimmed" fw={500} style={{ letterSpacing: "0.3px" }}>
Backoffice
<Text
size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
>
Backoffice Console
</Text>
</Stack>
</Group>
</div>
<Stack
component="nav"
gap="lg"
p="md"
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overscrollBehavior: "contain",
}}
>
<nav className="fsb-nav">
{sections.map((section) => (
<Stack key={section.title} gap={8}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}>
{section.title}
</Text>
<Stack gap={2}>
<div key={section.title}>
<div className="fsb-section-label">{section.title}</div>
<Stack gap={3}>
{section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</Stack>
</div>
))}
</Stack>
</nav>
<div className="fsb-footer">
<div className="fsb-status">
<span className="fsb-pulse" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} style={{ color: "#166534", lineHeight: 1.3 }}>
All systems operational
</Text>
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
EDR Platform · v1.0
</Text>
</Stack>
</div>
</div>
</Box>
);
};

View File

@@ -1,6 +1,12 @@
import { useId } from "react";
import type { LucideIcon } from "lucide-react";
import { Card, Group, Stack, Text } from "@mantine/core";
import {
overviewAccentGradients,
type OverviewAccent,
} from "./overview.styles";
const accentColors = {
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
@@ -9,18 +15,95 @@ const accentColors = {
sky: { bg: "var(--mantine-color-blue-1)", color: "var(--mantine-color-blue-6)" },
};
const ACCENT_TINT: Record<string, string> = {
default: "#f8fafc",
amber: "#fffbeb",
emerald: "#f0fdf4",
rose: "#fff1f2",
sky: "#f0f9ff",
};
export interface OverviewKpiItem {
label: string;
value: number | string;
hint?: string;
icon: LucideIcon;
accent?: keyof typeof accentColors;
/** Share 0..1 used to fill the radial gauge. Auto-computed by the strip when omitted. */
progress?: number;
}
/** Radial gauge with the KPI icon at its center. */
function KpiGauge({
progress,
accent,
Icon,
}: {
progress: number;
accent: OverviewAccent;
Icon: LucideIcon;
}) {
const gradientId = useId();
const size = 76;
const stroke = 9;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.min(1, Math.max(0, progress));
const dash = clamped * circumference;
const [from, to] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
return (
<div style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={from} />
<stop offset="100%" stopColor={to} />
</linearGradient>
</defs>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--mantine-color-gray-2)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${circumference}`}
style={{ transition: "stroke-dasharray 500ms ease" }}
/>
</svg>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: to,
}}
>
<Icon size={24} strokeWidth={2} />
</div>
</div>
);
}
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
const Icon = item.icon;
const accent = item.accent ?? "default";
const accentStyle = accentColors[accent];
const accentKey = (accent === "emerald" ? "emerald" : accent) as OverviewAccent;
const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default;
const progress = item.progress ?? 0;
const pct = Math.round(Math.min(1, Math.max(0, progress)) * 100);
return (
<Card
@@ -28,42 +111,57 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
radius="lg"
withBorder
style={{
background: "white",
background: `linear-gradient(160deg, ${ACCENT_TINT[accent] ?? "#f8fafc"} 0%, #ffffff 55%)`,
border: "1px solid var(--mantine-color-gray-2)",
minWidth: "240px",
width: "240px",
flexShrink: 0,
flex: "1 1 240px",
minWidth: 220,
transition: "transform 160ms ease, box-shadow 160ms ease",
position: "relative",
overflow: "hidden",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-3px)";
e.currentTarget.style.boxShadow = "0 12px 28px -12px rgba(15,23,42,0.22)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
}}
>
<Group justify="space-between" align="flex-start">
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 4,
background: `linear-gradient(90deg, ${(overviewAccentGradients[accentKey] ?? overviewAccentGradients.default)[0]} 0%, ${accentDeep} 100%)`,
}}
/>
<Group justify="space-between" align="center" wrap="nowrap" gap="md" mt={4}>
<Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.04em" }}>
{item.label}
</Text>
<Text size="28px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
<Text size="32px" fw={800} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
{item.value}
</Text>
{item.hint && (
<Text size="xs" c="dimmed">
{item.hint}
<Group gap={6} wrap="nowrap">
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: accentDeep,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed" truncate>
{item.hint ?? (item.progress != null ? `${pct}% of peak` : "")}
</Text>
)}
</Group>
</Stack>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "40px",
height: "40px",
borderRadius: "10px",
background: accentStyle.bg,
color: accentStyle.color,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={1.75} />
</div>
<KpiGauge progress={progress} accent={accentKey} Icon={Icon} />
</Group>
</Card>
);

View File

@@ -7,26 +7,41 @@ interface OverviewKpiStripProps {
items: OverviewKpiItem[];
}
/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */
function toNumber(value: number | string): number {
if (typeof value === "number") return value;
const parsed = Number(String(value).replace(/[^0-9.-]/g, ""));
return Number.isFinite(parsed) ? parsed : 0;
}
export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
const max = Math.max(...items.map((item) => toNumber(item.value)), 0);
return (
<Paper
p="md"
p="lg"
radius="lg"
withBorder
style={{
background: "linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%)",
border: "1px solid var(--freight-brand-border, #bbf7d0)",
overflowX: "auto",
}}
>
{title && (
<Text size="sm" fw={600} mb="sm" c="dimmed">
<Text size="sm" fw={600} mb="md" c="dimmed">
{title}
</Text>
)}
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
<Group gap="md" align="stretch" wrap="wrap">
{items.map((item) => (
<OverviewKpiCard key={item.label} item={item} />
<OverviewKpiCard
key={item.label}
item={{
...item,
progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
}}
/>
))}
</Group>
</Paper>

View File

@@ -1,7 +1,17 @@
import { ActionIcon, Group, SegmentedControl, Stack, Text, Title } from "@mantine/core";
import { RefreshCw } from "lucide-react";
import {
ActionIcon,
Box,
Group,
SegmentedControl,
Stack,
Text,
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" },
@@ -9,6 +19,8 @@ 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();
@@ -20,6 +32,51 @@ function formatRelativeTime(iso: string | undefined) {
return new Date(iso).toLocaleString();
}
/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */
function TrainArtwork() {
return (
<Box
aria-hidden
style={{
position: "absolute",
right: -10,
bottom: -8,
width: 360,
height: 200,
opacity: 0.16,
pointerEvents: "none",
color: "white",
}}
>
<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 */}
<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>
);
}
interface OverviewPageHeaderProps {
range: OverviewRange;
onRangeChange: (range: OverviewRange) => void;
@@ -36,34 +93,85 @@ export function OverviewPageHeader({
isRefreshing,
}: OverviewPageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Stack gap={4}>
<Title order={2} style={{ letterSpacing: "-0.02em" }}>
Operations overview
</Title>
<Text size="sm" c="dimmed">
Updated {formatRelativeTime(generatedAt)}
</Text>
</Stack>
<Box
style={{
position: "relative",
overflow: "hidden",
borderRadius: 20,
padding: "28px 28px",
background: HERO_GRADIENT,
boxShadow: freightBrand.shadow,
}}
>
{/* 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 gap="sm">
<SegmentedControl
value={range}
onChange={(value) => onRangeChange(value as OverviewRange)}
data={RANGE_OPTIONS}
size="sm"
/>
<ActionIcon
variant="light"
color="green"
size="lg"
aria-label="Refresh dashboard"
onClick={onRefresh}
loading={isRefreshing}
>
<RefreshCw size={18} />
</ActionIcon>
<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 }}>
Freight Backoffice · Live
</Text>
</Group>
<Title order={1} c="white" style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1 }}>
Operations Overview
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)">
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
</Text>
</Stack>
<Group gap="sm" style={{ position: "relative" }}>
<SegmentedControl
value={range}
onChange={(value) => onRangeChange(value as OverviewRange)}
data={RANGE_OPTIONS}
size="sm"
radius="lg"
classNames={{
root: "ov-seg-root",
indicator: "ov-seg-indicator",
label: "ov-seg-label",
}}
/>
<ActionIcon
variant="white"
color="green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"
onClick={onRefresh}
loading={isRefreshing}
>
<RefreshCw size={18} />
</ActionIcon>
</Group>
</Group>
</Group>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
/* ============================================================
EDR Freight — Overview page styles (hero controls + tabs)
============================================================ */
/* ---- Hero range segmented control (on gradient) ---- */
.ov-seg-root {
background: rgba(255, 255, 255, 0.18) !important;
border: 1px solid rgba(255, 255, 255, 0.28);
}
.ov-seg-indicator {
background: #ffffff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.ov-seg-label {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.ov-seg-label[data-active] {
color: #15803d;
}
/* ---- Premium tab bar ---- */
.ov-tablist {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px;
background: #f1f5f9;
border-radius: 16px;
border: 1px solid #e2e8f0;
}
.ov-tab {
border-radius: 11px;
padding: 10px 18px;
font-weight: 600;
color: #475569;
border: 1px solid transparent;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.ov-tab:hover {
background: #ffffff;
color: #0f172a;
box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18);
}
.ov-tab[data-active] {
background: linear-gradient(135deg, #22c55e 0%, #15803d 100%) !important;
color: #ffffff !important;
box-shadow: 0 10px 20px -8px rgba(21, 128, 61, 0.55);
transform: translateY(-1px);
}
.ov-tab[data-active]:hover {
color: #ffffff;
}

View File

@@ -7,17 +7,33 @@ export const overviewChartColors = {
muted: freightBrand.mutedBg,
etb: freightBrand.primary,
usd: "#0369a1",
/** Vibrant, well-separated categorical palette for charts. */
pipeline: [
freightBrand.primary,
"#22c55e",
"#0ea5e9",
"#6366f1",
"#f59e0b",
"#14b8a6",
"#64748b",
"#16a34a", // green
"#0ea5e9", // sky
"#8b5cf6", // violet
"#f59e0b", // amber
"#14b8a6", // teal
"#ec4899", // pink
"#f43f5e", // rose
"#6366f1", // indigo
"#eab308", // yellow
"#06b6d4", // cyan
],
} as const;
/** Two-stop gradients keyed by KPI accent — used for radial gauges + accent bars. */
export const overviewAccentGradients = {
default: ["#94a3b8", "#475569"],
emerald: ["#34d399", "#059669"],
amber: ["#fbbf24", "#d97706"],
rose: ["#fb7185", "#e11d48"],
sky: ["#38bdf8", "#0284c7"],
violet: ["#a78bfa", "#7c3aed"],
} as const;
export type OverviewAccent = keyof typeof overviewAccentGradients;
export const overviewCardStyle = {
background: "white",
border: "1px solid var(--mantine-color-gray-2)",

View File

@@ -29,29 +29,34 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
value: data.kpis.totalActive,
icon: FileText,
accent: "emerald",
hint: "Currently in workflow",
},
{
label: "Needs action",
value: data.kpis.needsAction,
icon: AlertCircle,
accent: "amber",
hint: "Awaiting your review",
},
{
label: "Urgent",
value: data.kpis.urgent,
icon: Clock,
accent: "rose",
hint: "High priority queue",
},
{
label: "In approval",
value: data.kpis.inApproval,
icon: UserCheck,
accent: "sky",
hint: "Pending sign-off",
},
{
label: "Submitted today",
value: data.kpis.submittedToday,
icon: FileText,
hint: "New since midnight",
},
]}
/>

View File

@@ -1,20 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
FileText,
Inbox,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react";
import {
Container,
Stack,
@@ -33,7 +19,7 @@ import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
@@ -58,8 +44,6 @@ import {
type ColumnDef,
usePagination,
Badge,
Button,
Input,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
@@ -159,8 +143,6 @@ export default function BookingRequestsPage() {
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const statValue = (value: number | undefined) =>
summaryLoading ? "—" : (value ?? 0);
const handleRefresh = useCallback(() => {
void refetch();
@@ -315,88 +297,15 @@ export default function BookingRequestsPage() {
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
{/*
<Card
p="lg"
radius="lg"
withBorder
mb="xl"
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<Group justify="space-between" align="flex-start">
<Group gap="md" align="flex-start">
<ThemeIcon
size="lg"
radius="lg"
color="green"
variant="light"
>
<Inbox size={28} />
</ThemeIcon>
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Operations
</Text>
<Title order={1} size="h2">
Booking Requests
</Title>
<Text size="sm" c="dimmed" maw="500px">
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
</Text>
</Stack>
</Group>
<MantineButton
variant="light"
color="green"
leftSection={<RefreshCw size={18} />}
disabled={isFetching}
onClick={handleRefresh}
loading={isFetching}
>
Refresh
</MantineButton>
</Group>
</Card> */}
<div className="mt-6"></div>
<Stack gap="lg">
<BookingStatGrid
items={[
{
label: "In queue",
value: statValue(metrics?.inQueue),
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: statValue(metrics?.onThisPage),
hint: "Current page",
icon: FileText,
},
{
label: "Needs action",
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
<Stack gap="lg" mt="md">
<BookingRequestsHeader
metrics={metrics}
tabs={tabCounts}
loading={summaryLoading}
isFetching={isFetching}
onCreate={() => navigate("/dashboard/booking-requests/new")}
onRefresh={handleRefresh}
/>
<Paper
@@ -450,18 +359,9 @@ export default function BookingRequestsPage() {
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Group gap="sm">
<Button
variant="filled"
leftSection={<Plus size={16} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{isOperationsTab ? (

View File

@@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
@@ -127,60 +128,53 @@ const OverviewPage = () => {
</Alert>
)}
<Paper
radius="lg"
withBorder
p="md"
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
}}
<Tabs
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="green"
keepMounted={false}
>
<Tabs.List
style={{
flexWrap: "wrap",
gap: 8,
background: "var(--freight-brand-muted, #f0fdf4)",
padding: 8,
borderRadius: 12,
}}
>
{TAB_ITEMS.map((tab) => {
const Icon = tab.icon;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={16} />}
rightSection={
summary ? (
<Badge size="sm" variant="light" color="green">
{getTabBadge(tab)}
</Badge>
) : undefined
}
style={{ fontWeight: 600 }}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
<Tabs.List>
{TAB_ITEMS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15803d" } }
: undefined
}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{TAB_ITEMS.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
</Paper>
{TAB_ITEMS.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />