mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
fix ui
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user