mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
ui design for schedule
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user