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 <SectionCard
icon={CheckCircle} icon={CheckCircle}
title="Approval Workflow" title="Approval Workflow"
accent="green"
extra={ extra={
<Badge color="green" variant="light" radius="sm"> <Badge color="green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved {approvedCount} / {steps.length} approved

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
} }
return ( return (
<SectionCard icon={Truck} title="Mile services"> <SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm"> <SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && ( {booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={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 type { ReactNode } from "react";
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core"; 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 type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner"; 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 { export interface BookingRequestHeroProps {
booking: BookingDetail; booking: BookingDetail;
@@ -17,7 +32,7 @@ export interface BookingRequestHeroProps {
isFetching?: boolean; 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({ export function BookingRequestHero({
booking, booking,
customerLabel, customerLabel,
@@ -26,93 +41,198 @@ export function BookingRequestHero({
isFetching, isFetching,
}: BookingRequestHeroProps) { }: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount); 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 ( return (
<Paper radius="md" withBorder p="xl" style={detailStyles.card}> <Paper
<Button radius="xl"
variant="subtle" p="xl"
color="gray" style={{
size="compact-sm" position: "relative",
leftSection={<ArrowLeft size={16} />} overflow: "hidden",
onClick={onBack} background: HERO_GRADIENT,
mb="md" boxShadow: freightBrand.shadow,
ml={-8} }}
fw={600} >
> <Box
Back to list style={{
</Button> 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="lg" style={{ position: "relative" }}>
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}> <Group justify="space-between" align="flex-start" wrap="wrap">
<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>
<Button <Button
variant="default" variant="white"
size="sm" 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} />} leftSection={<RefreshCw size={15} />}
loading={isFetching} loading={isFetching}
onClick={onRefresh} onClick={onRefresh}
> >
Refresh Refresh
</Button> </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> </Stack>
</Group> </Group>
</Paper> </Paper>

View File

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

View File

@@ -10,7 +10,7 @@ export interface BookingRouteCardProps {
export function BookingRouteCard({ booking }: BookingRouteCardProps) { export function BookingRouteCard({ booking }: BookingRouteCardProps) {
return ( 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"> <Group justify="space-between" align="center" wrap="nowrap" gap="xl">
{/* Origin */} {/* Origin */}
<Stack gap={2} style={{ flex: 1 }}> <Stack gap={2} style={{ flex: 1 }}>

View File

@@ -63,7 +63,7 @@ export function BookingRouteServiceCard({
]; ];
return ( 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"> <Group justify="space-between" align="center" wrap="nowrap" gap="md">
<Endpoint label="Origin" station={originLabel} /> <Endpoint label="Origin" station={originLabel} />
<Stack gap={6} align="center" style={{ flexShrink: 0 }}> <Stack gap={6} align="center" style={{ flexShrink: 0 }}>

View File

@@ -7,20 +7,68 @@ import { detailStyles } from "./booking-detail.styles";
export interface SectionCardProps { export interface SectionCardProps {
icon: LucideIcon; icon: LucideIcon;
title: string; 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; extra?: ReactNode;
children: ReactNode; children: ReactNode;
} }
/** Consistent flat card with a minimal icon + title header used by every detail section. */ /** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) { export function SectionCard({
icon: Icon,
title,
subtitle,
accent = "green",
extra,
children,
}: SectionCardProps) {
return ( return (
<Paper radius="md" withBorder style={detailStyles.card}> <Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}> <Box
<Group gap="sm"> style={{
<Icon size={16} color="var(--mantine-color-gray-6)" /> height: 3,
<Text fw={600} size="sm" c="dark"> background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
{title} }}
</Text> />
<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> </Group>
{extra} {extra}
</Group> </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, Sun,
User, User,
} from "lucide-react"; } 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 type { PageMeta } from "./types";
import { freightBrand } from "@/theme/freight-brand"; import "./FreightDashboardHeader.css";
export interface FreightDashboardHeaderProps { export interface FreightDashboardHeaderProps {
pageMeta: PageMeta; pageMeta: PageMeta;
@@ -39,12 +39,13 @@ const FreightDashboardHeader = ({
}: FreightDashboardHeaderProps) => { }: FreightDashboardHeaderProps) => {
const initials = const initials =
userInitials ?? userInitials ??
userName (userName
.split(" ") .split(" ")
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.map((n) => n[0].toUpperCase()) .map((n) => n[0].toUpperCase())
.join(""); .join("") ||
"U");
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null); const userMenuRef = useRef<HTMLDivElement | null>(null);
@@ -74,133 +75,137 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]); }, [isUserMenuOpen]);
return ( return (
<header <header className="fdh-root">
style={{ <Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
display: "flex", <span className="fdh-eyebrow">
height: "80px", <span className="fdh-eyebrow-dot" />
alignItems: "center", Freight Backoffice
justifyContent: "space-between", </span>
gap: "16px", <Text
padding: "0 24px", fw={700}
// borderBottom: `3px solid ${freightBrand.primary}`, truncate
}} style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
> >
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
{pageMeta.title} {pageMeta.title}
</Text> </Text>
<Text size="sm" c="dimmed" truncate> <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle} {pageMeta.subtitle}
</Text> </Text>
</Stack> </Stack>
<Group gap="sm" wrap="nowrap"> <Group gap={10} wrap="nowrap">
{enableThemeToggle && ( {enableThemeToggle && (
<ActionIcon <Tooltip
variant="default" label={theme === "dark" ? "Light mode" : "Dark mode"}
size={40} withArrow
radius="lg" openDelay={300}
onClick={onToggleTheme}
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
> >
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />} <button
</ActionIcon> type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
</Tooltip>
)} )}
<ActionIcon <Tooltip label="Language" withArrow openDelay={300}>
variant="default" <button type="button" className="fdh-icon-btn" aria-label="Language">
size={40} <Languages size={18} />
radius="lg" </button>
style={{ </Tooltip>
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
>
<Languages size={18} />
</ActionIcon>
<ActionIcon <Tooltip label="Messages" withArrow openDelay={300}>
variant="default" <button type="button" className="fdh-icon-btn" aria-label="Messages">
size={40} <MessageSquare size={18} />
radius="lg" <span className="fdh-badge">3</span>
style={{ </button>
background: "var(--mantine-color-gray-1)", </Tooltip>
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>
<ActionIcon <Tooltip label="Notifications" withArrow openDelay={300}>
variant="default" <button
size={40} type="button"
radius="lg" className="fdh-icon-btn"
style={{ aria-label="Notifications"
background: "var(--mantine-color-gray-1)", >
border: "1px solid var(--mantine-color-gray-2)", <Bell size={18} />
color: "var(--mantine-color-gray-7)", <span className="fdh-badge">5</span>
position: "relative", </button>
}} </Tooltip>
>
<Bell size={18} />
<Badge
size="xs"
color="red"
circle
style={{
position: "absolute",
top: "-3px",
right: "-3px",
}}
/>
</ActionIcon>
<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> <Menu.Target>
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}> <div className="fdh-user">
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} /> <div className="fdh-avatar-ring">
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} /> <div className="fdh-avatar">{initials}</div>
</Group> </div>
</Menu.Target> <Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Menu.Dropdown> <Text
<Menu.Item disabled> size="sm"
<Stack gap={0}> fw={600}
<Text size="sm" fw={600}> truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName} {userName}
</Text> </Text>
{userEmail && ( <Text
<Text size="xs" c="dimmed"> size="xs"
{userEmail} truncate
</Text> style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
)} >
{userEmail ?? "Administrator"}
</Text>
</Stack> </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.Divider />
<Menu.Item <Menu.Item
leftSection={<User size={14} />} leftSection={<User size={15} />}
onClick={() => setIsUserMenuOpen(false)} onClick={() => setIsUserMenuOpen(false)}
> >
Profile Profile
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<LogOut size={14} />} leftSection={<LogOut size={15} />}
color="red" color="red"
onClick={() => { onClick={() => {
setIsUserMenuOpen(false); 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 { import {
type MouseEvent, type MouseEvent,
type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { ChevronDown, ChevronRight, Train } from "lucide-react"; import { ChevronDown, Train } from "lucide-react";
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core"; import { Box, Stack, Text } from "@mantine/core";
import type { SidebarItem, SidebarSection } from "./types"; import type { SidebarItem, SidebarSection } from "./types";
import { freightBrand } from "@/theme/freight-brand"; import "./FreightSidebar.css";
export interface FreightSidebarProps { export interface FreightSidebarProps {
sections: SidebarSection[]; sections: SidebarSection[];
@@ -105,7 +106,7 @@ const FreightSidebar = ({
children: SidebarItem[], children: SidebarItem[],
depth: number, depth: number,
parentKey: string, parentKey: string,
) => ): ReactNode =>
children.map((child) => { children.map((child) => {
const key = sidebarItemKey(child, parentKey); const key = sidebarItemKey(child, parentKey);
const isGroup = Boolean(child.children?.length) && !child.href; const isGroup = Boolean(child.children?.length) && !child.href;
@@ -115,87 +116,50 @@ const FreightSidebar = ({
const groupActive = branchContainsActive(child.children!); const groupActive = branchContainsActive(child.children!);
return ( return (
<Stack key={key} gap={4}> <div key={key}>
<UnstyledButton <button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)} onClick={() => toggleExpanded(key)}
style={{
background: groupActive ? freightBrand.mutedBg : "transparent",
padding: "8px 12px",
borderRadius: "8px",
width: "100%",
cursor: "pointer",
}}
> >
<Group justify="space-between"> <span className="fsb-group-label">{child.label}</span>
<Text size="xs" fw={600} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase"> <ChevronDown
{child.label} size={13}
</Text> className="fsb-chevron"
<ChevronDown style={{
size={14} transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
style={{ color: groupActive ? "#15803d" : undefined,
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)", }}
transition: "transform 0.2s", />
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)", </button>
}}
/>
</Group>
</UnstyledButton>
{isOpen && ( {isOpen && (
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}> <div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)} {renderNavBranch(child.children!, depth + 1, key)}
</Stack> </div>
)} )}
</Stack> </div>
); );
} }
if (!child.href) return null; if (!child.href) return null;
const childHref = child.href.toLowerCase(); const childActive = isHrefActive(child.href);
const childActiveHref = isHrefActive(childHref);
return ( return (
<NavLink <a
key={key} key={key}
component="a"
href={child.href} href={child.href}
onClick={(e) => navigateTo(e as any, child.href!)} className="fsb-child"
label={child.label} data-active={childActive}
active={childActiveHref} onClick={(e) => navigateTo(e, child.href!)}
color="green" >
style={{ <span className="fsb-dot" />
borderRadius: "8px", <span className="fsb-item-label">{child.label}</span>
cursor: "pointer", </a>
fontSize: "14px",
}}
rightSection={<ChevronRight size={16} />}
/>
); );
}); });
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) => { const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null; if (!item.href) return null;
@@ -207,132 +171,92 @@ const FreightSidebar = ({
const isCurrentItem = hasChildren const isCurrentItem = hasChildren
? activePath === itemHref ? activePath === itemHref
: isHrefActive(itemHref); : isHrefActive(itemHref);
const isSectionActive = childActive && !isCurrentItem; const isActive = isCurrentItem || childActive;
const isActive = isCurrentItem || isSectionActive;
const isOpen = expanded[item.href] ?? false; const isOpen = expanded[item.href] ?? false;
const leafActive = isCurrentItem && !hasChildren;
return ( return (
<Stack key={item.href} gap={0}> <Box key={item.href}>
<NavLink <a
component="a"
href={item.href} href={item.href}
onClick={(e) => navigateTo(e as any, item.href!)} className="fsb-item"
label={item.label} data-active={isActive}
leftSection={renderIconWell(item.icon, isActive)} onClick={(e) => navigateTo(e, item.href!)}
active={leafActive} >
color="green" {item.icon && <span className="fsb-icon">{item.icon}</span>}
variant="light" <span className="fsb-item-label">{item.label}</span>
style={{ {hasChildren && (
borderRadius: "10px", <ChevronDown
cursor: "pointer", size={16}
fontSize: "14px", className="fsb-chevron"
fontWeight: 500, style={{
padding: "8px 10px", transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}} }}
rightSection={ onClick={(e) => {
hasChildren ? ( e.preventDefault();
<ChevronDown e.stopPropagation();
size={16} toggleExpanded(item.href!);
style={{ }}
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)", />
transition: "transform 0.2s", )}
}} </a>
onClick={(e) => {
e.preventDefault();
toggleExpanded(item.href!);
}}
/>
) : (
<ChevronRight size={16} />
)
}
/>
{hasChildren && isOpen && ( {hasChildren && isOpen && (
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}> <div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)} {renderNavBranch(item.children!, 0, item.href)}
</Stack> </div>
)} )}
</Stack> </Box>
); );
}; };
return ( return (
<Box <Box component="aside" className="fsb-aside">
component="aside" <div className="fsb-brand">
style={{ <div className="fsb-logo">
height: "100%", <Train size={23} color="white" strokeWidth={2.1} />
maxHeight: "100%", </div>
width: "280px", <Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
flexShrink: 0, <Text
borderRadius: "12px", size="md"
border: "1px solid var(--mantine-color-gray-2)", fw={700}
background: "white", style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
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 }}>
EDR Freight EDR Freight
</Text> </Text>
<Text size="xs" c="dimmed" fw={500} style={{ letterSpacing: "0.3px" }}> <Text
Backoffice size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
>
Backoffice Console
</Text> </Text>
</Stack> </Stack>
</Group> </div>
<Stack <nav className="fsb-nav">
component="nav"
gap="lg"
p="md"
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overscrollBehavior: "contain",
}}
>
{sections.map((section) => ( {sections.map((section) => (
<Stack key={section.title} gap={8}> <div key={section.title}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}> <div className="fsb-section-label">{section.title}</div>
{section.title} <Stack gap={3}>
</Text>
<Stack gap={2}>
{section.items.map((item) => renderTopLevelItem(item))} {section.items.map((item) => renderTopLevelItem(item))}
</Stack> </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> </Box>
); );
}; };

View File

@@ -1,6 +1,12 @@
import { useId } from "react";
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 {
overviewAccentGradients,
type OverviewAccent,
} from "./overview.styles";
const accentColors = { const accentColors = {
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" }, 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)" }, 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)" }, 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 { export interface OverviewKpiItem {
label: string; label: string;
value: number | string; value: number | string;
hint?: string; hint?: string;
icon: LucideIcon; icon: LucideIcon;
accent?: keyof typeof accentColors; 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 }) { export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
const Icon = item.icon; const Icon = item.icon;
const accent = item.accent ?? "default"; 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 ( return (
<Card <Card
@@ -28,42 +111,57 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
radius="lg" radius="lg"
withBorder withBorder
style={{ style={{
background: "white", background: `linear-gradient(160deg, ${ACCENT_TINT[accent] ?? "#f8fafc"} 0%, #ffffff 55%)`,
border: "1px solid var(--mantine-color-gray-2)", border: "1px solid var(--mantine-color-gray-2)",
minWidth: "240px", flex: "1 1 240px",
width: "240px", minWidth: 220,
flexShrink: 0, 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"> <div
<Stack gap="xs" style={{ flex: 1 }}> style={{
<Text size="xs" fw={600} c="dimmed" tt="uppercase"> 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} {item.label}
</Text> </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} {item.value}
</Text> </Text>
{item.hint && ( <Group gap={6} wrap="nowrap">
<Text size="xs" c="dimmed"> <span
{item.hint} 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> </Text>
)} </Group>
</Stack> </Stack>
<div <KpiGauge progress={progress} accent={accentKey} Icon={Icon} />
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>
</Group> </Group>
</Card> </Card>
); );

View File

@@ -7,26 +7,41 @@ interface OverviewKpiStripProps {
items: OverviewKpiItem[]; 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) { export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
const max = Math.max(...items.map((item) => toNumber(item.value)), 0);
return ( return (
<Paper <Paper
p="md" p="lg"
radius="lg" radius="lg"
withBorder withBorder
style={{ style={{
background: "linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%)", background: "linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%)",
border: "1px solid var(--freight-brand-border, #bbf7d0)", border: "1px solid var(--freight-brand-border, #bbf7d0)",
overflowX: "auto",
}} }}
> >
{title && ( {title && (
<Text size="sm" fw={600} mb="sm" c="dimmed"> <Text size="sm" fw={600} mb="md" c="dimmed">
{title} {title}
</Text> </Text>
)} )}
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}> <Group gap="md" align="stretch" wrap="wrap">
{items.map((item) => ( {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> </Group>
</Paper> </Paper>

View File

@@ -1,7 +1,17 @@
import { ActionIcon, Group, SegmentedControl, Stack, Text, Title } from "@mantine/core"; import {
import { RefreshCw } from "lucide-react"; 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 type { OverviewRange } from "@/types/overview";
import "./overview.css";
const RANGE_OPTIONS = [ const RANGE_OPTIONS = [
{ label: "7 days", value: "7d" }, { label: "7 days", value: "7d" },
@@ -9,6 +19,8 @@ const RANGE_OPTIONS = [
{ label: "90 days", value: "90d" }, { 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) { function formatRelativeTime(iso: string | undefined) {
if (!iso) return "—"; if (!iso) return "—";
const diffMs = Date.now() - new Date(iso).getTime(); const diffMs = Date.now() - new Date(iso).getTime();
@@ -20,6 +32,51 @@ function formatRelativeTime(iso: string | undefined) {
return new Date(iso).toLocaleString(); 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 { interface OverviewPageHeaderProps {
range: OverviewRange; range: OverviewRange;
onRangeChange: (range: OverviewRange) => void; onRangeChange: (range: OverviewRange) => void;
@@ -36,34 +93,85 @@ export function OverviewPageHeader({
isRefreshing, isRefreshing,
}: OverviewPageHeaderProps) { }: OverviewPageHeaderProps) {
return ( return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md"> <Box
<Stack gap={4}> style={{
<Title order={2} style={{ letterSpacing: "-0.02em" }}> position: "relative",
Operations overview overflow: "hidden",
</Title> borderRadius: 20,
<Text size="sm" c="dimmed"> padding: "28px 28px",
Updated {formatRelativeTime(generatedAt)} background: HERO_GRADIENT,
</Text> boxShadow: freightBrand.shadow,
</Stack> }}
>
{/* 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"> <Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
<SegmentedControl <Stack gap={6} style={{ minWidth: 0 }}>
value={range} <Group gap={8} align="center">
onChange={(value) => onRangeChange(value as OverviewRange)} <Box
data={RANGE_OPTIONS} style={{
size="sm" display: "flex",
/> alignItems: "center",
<ActionIcon justifyContent: "center",
variant="light" width: 28,
color="green" height: 28,
size="lg" borderRadius: 8,
aria-label="Refresh dashboard" background: "rgba(255,255,255,0.2)",
onClick={onRefresh} }}
loading={isRefreshing} >
> <Activity size={16} color="white" />
<RefreshCw size={18} /> </Box>
</ActionIcon> <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>
</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, muted: freightBrand.mutedBg,
etb: freightBrand.primary, etb: freightBrand.primary,
usd: "#0369a1", usd: "#0369a1",
/** Vibrant, well-separated categorical palette for charts. */
pipeline: [ pipeline: [
freightBrand.primary, "#16a34a", // green
"#22c55e", "#0ea5e9", // sky
"#0ea5e9", "#8b5cf6", // violet
"#6366f1", "#f59e0b", // amber
"#f59e0b", "#14b8a6", // teal
"#14b8a6", "#ec4899", // pink
"#64748b", "#f43f5e", // rose
"#6366f1", // indigo
"#eab308", // yellow
"#06b6d4", // cyan
], ],
} as const; } 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 = { export const overviewCardStyle = {
background: "white", background: "white",
border: "1px solid var(--mantine-color-gray-2)", border: "1px solid var(--mantine-color-gray-2)",

View File

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

View File

@@ -1,20 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react";
AlertCircle,
ArrowRight,
Calendar,
Clock,
FileText,
Inbox,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { import {
Container, Container,
Stack, Stack,
@@ -33,7 +19,7 @@ import {
BookingStatusTabs, BookingStatusTabs,
type BookingStatusTabKey, type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs"; } from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid"; import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
@@ -58,8 +44,6 @@ import {
type ColumnDef, type ColumnDef,
usePagination, usePagination,
Badge, Badge,
Button,
Input,
} from "@edr/ui-common"; } from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
@@ -159,8 +143,6 @@ export default function BookingRequestsPage() {
const metrics = summary?.metrics; const metrics = summary?.metrics;
const tabCounts = summary?.tabs; const tabCounts = summary?.tabs;
const statValue = (value: number | undefined) =>
summaryLoading ? "—" : (value ?? 0);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refetch(); void refetch();
@@ -315,88 +297,15 @@ export default function BookingRequestsPage() {
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}> <div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl"> <Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} /> <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" mt="md">
<Stack gap="lg"> <BookingRequestsHeader
<BookingStatGrid metrics={metrics}
items={[ tabs={tabCounts}
{ loading={summaryLoading}
label: "In queue", isFetching={isFetching}
value: statValue(metrics?.inQueue), onCreate={() => navigate("/dashboard/booking-requests/new")}
hint: "Total matching filter", onRefresh={handleRefresh}
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",
},
]}
/> />
<Paper <Paper
@@ -450,18 +359,9 @@ export default function BookingRequestsPage() {
style={{ flex: 1, minWidth: "200px" }} style={{ flex: 1, minWidth: "200px" }}
radius="lg" radius="lg"
/> />
<Group gap="sm"> <Text size="sm" c="dimmed">
<Button {total} record{total !== 1 ? "s" : ""}
variant="filled" </Text>
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>
</Group> </Group>
{isOperationsTab ? ( {isOperationsTab ? (

View File

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