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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,299 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
Grid,
Group,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
Boxes,
CalendarClock,
Container as ContainerIcon,
Flame,
Info,
Layers,
MapPin,
Package,
Plus,
Settings2,
Ship,
Trash2,
Weight,
} from "lucide-react";
import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
interface CustomerOption {
id: string;
companyName?: string | null;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
}
type FreightType = "CONTAINER" | "BULK";
interface RefNamed {
id: string;
name: string;
code: string;
country?: string;
}
interface RefContainerType {
id: string;
name: string;
code: string;
is_reefer?: boolean;
wagons_per_unit?: number;
}
interface RefContainerGroup {
size: string;
types: RefContainerType[];
}
interface RefCargoChild {
id: string;
name: string;
code: string;
show_free_text_box?: boolean;
}
interface RefCargoGroup {
id: string;
name: string;
code: string;
children?: RefCargoChild[];
}
interface ReferenceData {
yard?: Array<{ id: string; name: string; code: string }>;
service?: Array<{ id: string; name: string; code: string }>;
containers?: Array<{
size: string;
types: Array<{ id: string; name: string; code: string }>;
}>;
cargo_type?: Array<{ id: string; name: string; code: string }>;
yard?: RefNamed[];
service?: RefNamed[];
shipping_line?: RefNamed[];
containers?: RefContainerGroup[];
cargo_type?: RefCargoGroup[];
}
interface ContainerLine {
key: string;
containerTypeId: string | null;
quantity: number;
vgmPerUnitTons: number;
}
let lineCounter = 0;
const newLine = (): ContainerLine => ({
key: `line-${lineCounter++}`,
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 20,
});
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
/** Section card with a colored icon chip header. */
function FormSection({
icon: Icon,
title,
subtitle,
accent = "green",
right,
children,
}: {
icon: typeof Package;
title: string;
subtitle?: string;
accent?: string;
right?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
<Box style={{ height: 3, background: `linear-gradient(90deg, var(--mantine-color-${accent}-5), var(--mantine-color-${accent}-7))` }} />
<Group justify="space-between" px="lg" py="md" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-gray-1)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color={accent}>
<Icon size={17} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
) : null}
</Box>
</Group>
{right}
</Group>
<Box p="lg">{children}</Box>
</Paper>
);
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER");
const [companyId, setCompanyId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [weight, setWeight] = useState<number>(100);
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
const [tradeDirection, setTradeDirection] = useState("IMPORT");
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
const [lines, setLines] = useState<ContainerLine[]>([newLine()]);
// bulk freight
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const [cargoFreeText, setCargoFreeText] = useState("");
const [bulkWeight, setBulkWeight] = useState<number>(100);
// extra options
const [equipmentReturn, setEquipmentReturn] = useState("NA");
const [isHazardous, setIsHazardous] = useState(false);
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const [firstMilePickupAddress, setFirstMile] = useState("");
const [lastMileDeliveryAddress, setLastMile] = useState("");
const { data: refData, isLoading } = useQuery({
queryKey: ["bookings", "reference-data"],
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
});
const { data: customers, isLoading: customersLoading } = useQuery({
queryKey: ["customers", "list"],
queryFn: async () => {
const res = await api.get(URL_CONSTANTS.CUSTOMERS.BASE);
return unwrap(res.data) as CustomerOption[];
},
});
const customerOptions = (customers ?? []).map((c) => ({
value: c.id,
label:
c.companyName ||
[c.firstName, c.lastName].filter(Boolean).join(" ") ||
c.email ||
c.id,
}));
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const containerGroupData = useMemo(
() =>
(refData?.containers ?? []).map((g) => ({
group: g.size,
items: g.types.map((t) => ({
value: t.id,
label: `${t.code}${t.name && t.name !== t.code ? `${t.name}` : ""}`,
})),
})),
[refData?.containers],
);
const { cargoData, freeTextById } = useMemo(() => {
const groups = refData?.cargo_type ?? [];
const freeText = new Map<string, boolean>();
const data = groups.map((g) => {
if (g.children?.length) {
g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box)));
return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
}
return { value: g.id, label: g.name };
});
return { cargoData: data, freeTextById: freeText };
}, [refData?.cargo_type]);
const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false;
// ---- derived totals ----
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
// ---- validation ----
const lineValid = (l: ContainerLine) =>
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(serviceTypeId) &&
Boolean(scheduledDate) &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
const removeLine = (key: string) =>
setLines((prev) => (prev.length === 1 ? prev : prev.filter((l) => l.key !== key)));
const createMutation = useMutation({
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
companyId: isGovernment ? undefined : companyId || undefined,
freightType,
contractType: "NEW",
equipmentReturn: "NA",
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
scheduledDate: scheduledDate || new Date().toISOString(),
equipmentReturn,
tradeDirection,
paymentCurrency,
isHazardous,
scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(),
originYardId,
destinationYardId,
serviceTypeId,
cargoTotalWeightVgm: weight,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
cargoTotalWeightVgm,
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined,
containers:
freightType === "CONTAINER" && containerTypeId
? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }]
freightType === "CONTAINER"
? lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: l.vgmPerUnitTons,
}))
: undefined,
}),
onSuccess: async (booking) => {
@@ -82,33 +309,8 @@ export default function NewBookingPage() {
onError: () => toast.error("Failed to create booking"),
});
const yards = (refData?.yard ?? []).map((y) => ({
value: y.id,
label: y.name ?? y.code,
}));
const services = (refData?.service ?? []).map((s) => ({
value: s.id,
label: s.name ?? s.code,
}));
const containerTypes =
refData?.containers?.flatMap((g) =>
g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })),
) ?? [];
const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({
value: c.id,
label: c.name ?? c.code,
}));
const canSubmit =
originYardId &&
destinationYardId &&
serviceTypeId &&
scheduledDate &&
(!isGovernment || governmentInstitution.trim().length >= 2) &&
(freightType === "BULK" ? cargoTypeId : containerTypeId);
return (
<Container size="md" py="xl">
<Container size="xl" py="lg">
<Breadcrumbs
items={[
{ label: "Operations" },
@@ -116,122 +318,456 @@ export default function NewBookingPage() {
{ label: "Create" },
]}
/>
<Title order={2} mt="lg" mb="md">
Create booking (staff)
</Title>
<Card withBorder padding="lg" radius="lg">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
) : null}
<Select
label="Freight type"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightType}
onChange={(v) => setFreightType((v as "CONTAINER" | "BULK") ?? "CONTAINER")}
/>
<Group grow>
<Select
label="Origin yard"
data={yards}
value={originYardId}
onChange={setOriginYardId}
searchable
disabled={isLoading}
/>
<Select
label="Destination yard"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
searchable
disabled={isLoading}
/>
{/* Hero */}
<Paper
radius="xl"
p="xl"
mt="md"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" style={{ position: "relative" }}>
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={54} radius="lg" variant="light" color="green">
<Package size={26} />
</ThemeIcon>
<Stack gap={2}>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
New booking · Staff
</Text>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Create freight booking
</Title>
<Text size="sm" c="dimmed" maw={520}>
Capture cargo details, container lines, and routing saved as a draft and pushed
into the approval workflow.
</Text>
</Stack>
</Group>
<Button
variant="default"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")}
>
Back to list
</Button>
</Group>
</Paper>
<Select
label="Service type"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<Grid gutter="lg" mt="lg">
{/* LEFT — form */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="green">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
) : (
<Select
label="Customer"
placeholder="Select customer"
data={customerOptions}
value={companyId}
onChange={setCompanyId}
searchable
required
disabled={customersLoading}
nothingFoundMessage="No customers found"
/>
)}
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
<Box>
<Text size="sm" fw={500} mb={6}>
Freight type
</Text>
<SegmentedControl
fullWidth
value={freightType}
onChange={(v) => setFreightType(v as FreightType)}
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
/>
</Box>
</Stack>
</FormSection>
<NumberInput
label="Total weight (tons)"
value={weight}
onChange={(v) => setWeight(Number(v) || 0)}
min={0}
/>
<FormSection icon={MapPin} title="Route & service" subtitle="Origin, destination and service type" accent="blue">
<Stack gap="md">
<Group grow align="flex-start">
<Select
label="Origin yard"
placeholder="Select origin"
data={yards}
value={originYardId}
onChange={setOriginYardId}
searchable
disabled={isLoading}
error={sameYard ? "Same as destination" : undefined}
/>
<Select
label="Destination yard"
placeholder="Select destination"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
<Group grow>
<Select
label="Service type"
placeholder="Select service"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<Select
label="Trade direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
]}
value={tradeDirection}
onChange={(v) => setTradeDirection(v ?? "IMPORT")}
/>
</Group>
</Stack>
</FormSection>
{freightType === "CONTAINER" ? (
<Select
label="Container type"
data={containerTypes}
value={containerTypeId}
onChange={setContainerTypeId}
searchable
/>
) : (
<Select
label="Cargo type"
data={cargoTypes}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
/>
)}
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
<Group grow align="flex-start">
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
<Select
label="Payment currency"
data={[
{ value: "ETB", label: "ETB — Birr" },
{ value: "USD", label: "USD — Dollar" },
]}
value={paymentCurrency}
onChange={(v) => setPaymentCurrency(v ?? "ETB")}
/>
</Group>
</FormSection>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate("/dashboard/booking-requests")}
>
Cancel
</Button>
<Button
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
</Group>
{/* Cargo */}
{freightType === "CONTAINER" ? (
<FormSection
icon={ContainerIcon}
title="Container lines"
subtitle="Add one row per container type"
accent="teal"
right={
<Badge variant="light" color="teal" radius="sm">
{totalContainers} container{totalContainers === 1 ? "" : "s"}
</Badge>
}
>
<Stack gap="sm">
{lines.map((line, idx) => {
const invalid = !lineValid(line);
return (
<Paper
key={line.key}
p="sm"
radius="md"
withBorder
style={{
borderColor: invalid ? "var(--mantine-color-gray-3)" : "var(--mantine-color-teal-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Group align="flex-end" gap="sm" wrap="nowrap">
<Text fw={700} c="dimmed" size="sm" w={22} ta="center" style={{ flexShrink: 0 }}>
{idx + 1}
</Text>
<Select
label="Container type"
placeholder="Select type"
data={containerGroupData}
value={line.containerTypeId}
onChange={(v) => updateLine(line.key, { containerTypeId: v })}
searchable
disabled={isLoading}
style={{ flex: 2, minWidth: 160 }}
/>
<NumberInput
label="Qty"
value={line.quantity}
onChange={(v) => updateLine(line.key, { quantity: Number(v) || 0 })}
min={1}
step={1}
style={{ width: 90, flexShrink: 0 }}
/>
<NumberInput
label="VGM / unit"
value={line.vgmPerUnitTons}
onChange={(v) => updateLine(line.key, { vgmPerUnitTons: Number(v) || 0 })}
min={0}
suffix=" t"
decimalScale={2}
style={{ width: 130, flexShrink: 0 }}
/>
<Stack gap={2} style={{ width: 92, flexShrink: 0 }}>
<Text size="xs" c="dimmed">
Line total
</Text>
<Text fw={700} size="sm">
{fmtTons((line.quantity || 0) * (line.vgmPerUnitTons || 0))}
</Text>
</Stack>
<Tooltip label={lines.length === 1 ? "At least one line" : "Remove line"}>
<ActionIcon
variant="subtle"
color="red"
disabled={lines.length === 1}
onClick={() => removeLine(line.key)}
style={{ flexShrink: 0 }}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
);
})}
{isGovernment ? (
<Text size="xs" c="dimmed">
Government bookings skip the commercial 3-hour hold and appear in the
priority lane on the Operations tab.
</Text>
) : null}
</Stack>
</Card>
<Group justify="space-between">
<Button
variant="light"
color="teal"
size="compact-sm"
leftSection={<Plus size={15} />}
onClick={() => setLines((prev) => [...prev, newLine()])}
>
Add container line
</Button>
<Text size="sm" c="dimmed">
Total VGM:{" "}
<Text span fw={700} c="dark">
{fmtTons(containerWeight)}
</Text>
</Text>
</Group>
</Stack>
</FormSection>
) : (
<FormSection icon={Boxes} title="Bulk cargo" subtitle="Select the bulk cargo type and total weight" accent="orange">
<Stack gap="md">
<Select
label="Cargo type"
placeholder="Select bulk cargo type"
data={cargoData}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
disabled={isLoading}
/>
{showFreeText ? (
<Textarea
label="Cargo description"
placeholder="Describe the cargo"
autosize
minRows={2}
value={cargoFreeText}
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
/>
) : null}
<NumberInput
label="Total weight (VGM)"
value={bulkWeight}
onChange={(v) => setBulkWeight(Number(v) || 0)}
min={0}
suffix=" t"
decimalScale={2}
/>
</Stack>
</FormSection>
)}
<FormSection icon={Settings2} title="Additional details" subtitle="Optional — equipment, handling and references" accent="indigo">
<Stack gap="md">
<Group grow>
<Select
label="Equipment return"
data={[
{ value: "NA", label: "Not applicable" },
{ value: "WITH_RETURN", label: "With return" },
{ value: "WITHOUT_RETURN", label: "Without return" },
]}
value={equipmentReturn}
onChange={(v) => setEquipmentReturn(v ?? "NA")}
/>
<Select
label="Shipping line (optional)"
placeholder="Select shipping line"
data={shippingLines}
value={shippingLineId}
onChange={setShippingLineId}
searchable
clearable
disabled={isLoading}
/>
</Group>
<Group grow>
<TextInput
label="First-mile pickup (optional)"
placeholder="Pickup address"
value={firstMilePickupAddress}
onChange={(e) => setFirstMile(e.currentTarget.value)}
/>
<TextInput
label="Last-mile delivery (optional)"
placeholder="Delivery address"
value={lastMileDeliveryAddress}
onChange={(e) => setLastMile(e.currentTarget.value)}
/>
</Group>
<Switch
label="Hazardous cargo"
checked={isHazardous}
onChange={(e) => setIsHazardous(e.currentTarget.checked)}
thumbIcon={isHazardous ? <Flame size={12} /> : undefined}
color="red"
/>
</Stack>
</FormSection>
</Stack>
</Grid.Col>
{/* RIGHT — sticky summary */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 16 }}>
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group gap="sm" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<Weight size={17} />
</ThemeIcon>
<Text fw={700}>Summary</Text>
</Group>
<Group gap="xs" mb="md">
<Badge variant="light" color={freightType === "BULK" ? "orange" : "teal"} radius="sm">
{freightType === "BULK" ? "Bulk" : "Container"}
</Badge>
<Badge variant="light" color="blue" radius="sm">
{tradeDirection}
</Badge>
{isGovernment ? (
<Badge variant="light" color="grape" radius="sm">
Government
</Badge>
) : null}
{isHazardous ? (
<Badge variant="light" color="red" radius="sm" leftSection={<Flame size={10} />}>
Hazardous
</Badge>
) : null}
</Group>
<Stack gap={8}>
{freightType === "CONTAINER" ? (
<>
<SummaryRow label="Container lines" value={String(lines.length)} />
<SummaryRow label="Total containers" value={String(totalContainers)} />
</>
) : null}
<SummaryRow label="Total VGM weight" value={fmtTons(cargoTotalWeightVgm)} strong />
<Divider my={4} />
<SummaryRow
label="Route"
value={
originYardId && destinationYardId
? `${yards.find((y) => y.value === originYardId)?.label ?? "—"}${
yards.find((y) => y.value === destinationYardId)?.label ?? "—"
}`
: "Not set"
}
/>
<SummaryRow
label="Departure"
value={scheduledDate ? new Date(scheduledDate).toLocaleString() : "Not set"}
/>
</Stack>
{sameYard ? (
<Group gap={6} mt="md" c="red.7">
<AlertTriangle size={14} />
<Text size="xs">Origin and destination must differ.</Text>
</Group>
) : null}
<Stack gap="sm" mt="lg">
<Button
size="md"
fullWidth
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
leftSection={<Ship size={18} />}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
Cancel
</Button>
</Stack>
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
<Text size="xs" c="dimmed">
{isGovernment
? "Government bookings skip the commercial 3-hour hold and enter the priority lane."
: "Overweight container lines are allowed here and flagged later at scheduling."}
</Text>
</Group>
</Paper>
</Box>
</Grid.Col>
</Grid>
</Container>
);
}
function SummaryRow({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" fw={strong ? 700 : 500} ta="right" style={{ minWidth: 0 }} truncate>
{value}
</Text>
</Group>
);
}

View File

@@ -711,52 +711,30 @@ export default function TrainScheduleV2DetailPage() {
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge
variant="white"
c="green.8"
radius="sm"
style={{ fontWeight: 600 }}
>
<Badge variant="light" color="green" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
onDark
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
@@ -777,8 +755,7 @@ export default function TrainScheduleV2DetailPage() {
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
variant="white"
c="green.8"
color="green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
@@ -788,8 +765,7 @@ export default function TrainScheduleV2DetailPage() {
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="white"
c="green.8"
variant="default"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
@@ -802,7 +778,6 @@ export default function TrainScheduleV2DetailPage() {
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
onDark
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
@@ -815,13 +790,11 @@ export default function TrainScheduleV2DetailPage() {
}
/>
<StatTile
onDark
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
/>
<StatTile
onDark
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
@@ -829,7 +802,6 @@ export default function TrainScheduleV2DetailPage() {
}T`}
/>
<StatTile
onDark
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
@@ -847,8 +819,8 @@ export default function TrainScheduleV2DetailPage() {
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
variant="light"
color={previewResult.valid ? "green" : "red"}
leftSection={
<Box
w={8}

View File

@@ -335,52 +335,22 @@ export default function TrainScheduleV2ListPage() {
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
{/* decorative glow */}
<Box
style={{
position: "absolute",
top: -90,
right: -60,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.12)",
filter: "blur(8px)",
pointerEvents: "none",
}}
/>
<Box
style={{
position: "absolute",
bottom: -120,
right: 120,
width: 220,
height: 220,
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">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<Train size={28} />
</ThemeIcon>
<Stack gap={4}>
<Title order={2} c="white" fw={700}>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Train Schedules
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
<Text size="sm" c="dimmed" maw={520}>
Plan departures, allocate bookings, and dispatch trains across
every corridor.
</Text>
@@ -389,8 +359,7 @@ export default function TrainScheduleV2ListPage() {
<Button
size="md"
radius="lg"
variant="white"
c="green.8"
color="green"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
@@ -399,20 +368,10 @@ export default function TrainScheduleV2ListPage() {
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile onDark icon={Train} label="Total trains" value={stats.total} />
<StatTile
onDark
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
/>
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile
onDark
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
/>
<StatTile icon={Train} label="Total trains" value={stats.total} />
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} />
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} />
</SimpleGrid>
</Stack>
</Paper>