mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: setted up the schedule in the booking
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -75,11 +75,11 @@ function RequireAuth() {
|
||||
* Only redirects on a confirmed "no company" response — never on a
|
||||
* transient query error.
|
||||
*/
|
||||
function RequireCompany() {
|
||||
function RequireCompany({path}: {path: string}) {
|
||||
const { customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (customerQuery.isSuccess && !customerQuery.data)
|
||||
if (customerQuery.isSuccess && !customerQuery.data && path !== "/portal")
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ const App = () => {
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route element={<RequireCompany path={location.pathname} />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpResponse,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
OtpResponse,
|
||||
} from "@/types/auth";
|
||||
import type { Result } from "@/utils/result";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
const expires = new Date();
|
||||
@@ -32,6 +31,8 @@ const useAuth = () => {
|
||||
api.auth.getMyInfo.queryOptions({
|
||||
enabled: hasToken,
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
}),
|
||||
@@ -46,13 +47,6 @@ const useAuth = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (authQuery.isError) {
|
||||
queryClient.clear();
|
||||
localStorage.clear();
|
||||
}
|
||||
}, [authQuery.isError, queryClient]);
|
||||
|
||||
const isPending = authQuery.isPending && hasToken;
|
||||
const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Grid,
|
||||
Group,
|
||||
@@ -255,6 +256,17 @@ export default function MyPortalPage() {
|
||||
</Group>
|
||||
</Link>
|
||||
</Group>
|
||||
|
||||
<Alert>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Setup your Company Profile to start booking shipments and managing invoices.
|
||||
<Link to="/onboarding" className="font-medium text-edr-green.7">
|
||||
Get started
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
|
||||
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
|
||||
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DevTool } from "@hookform/devtools";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
STEPS,
|
||||
@@ -23,9 +22,9 @@ import {
|
||||
Step2ServiceType,
|
||||
Step4Route,
|
||||
Step5CargoDetails,
|
||||
StepScheduling,
|
||||
StepDocuments,
|
||||
Step8Review,
|
||||
StepDocuments,
|
||||
StepScheduling,
|
||||
} from "./new-booking-form/steps";
|
||||
|
||||
export default function NewBookingPage() {
|
||||
@@ -72,11 +71,15 @@ export default function NewBookingPage() {
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
|
||||
const direction = useMemo(
|
||||
() =>
|
||||
getRouteDirection(
|
||||
referenceData?.yard.find((y) => y.id === originYard),
|
||||
referenceData?.yard.find((y) => y.id === destinationYard),
|
||||
),
|
||||
() =>{
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const destination = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
|
||||
const route = getRouteDirection(
|
||||
origin,destination
|
||||
)
|
||||
return route
|
||||
},
|
||||
[originYard, destinationYard],
|
||||
);
|
||||
|
||||
|
||||
@@ -289,13 +289,13 @@ export function getRouteDirection(
|
||||
dest: Freight.BookingReferenceYard | null | undefined,
|
||||
): Freight.ScheduleTradeDirection | null {
|
||||
if (!origin || !dest) return null;
|
||||
if (origin.country === "ethiopia" && dest.country === "ethiopia") {
|
||||
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
|
||||
return "DOMESTIC";
|
||||
}
|
||||
if (origin.country === "ethiopia" && dest.country === "djibouti") {
|
||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||
return "IMPORT";
|
||||
}
|
||||
if (origin.country === "djibouti" && dest.country === "ethiopia") {
|
||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||
return "EXPORT";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Check,
|
||||
Train,
|
||||
Route,
|
||||
Package,
|
||||
Calendar as CalendarIcon,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useMemo, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
@@ -39,25 +39,37 @@ interface StepSchedulingProps {
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
}
|
||||
|
||||
interface DayData {
|
||||
day: number;
|
||||
dateString: string;
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
isSelectedDate: boolean;
|
||||
schedules: Freight.BookableScheduleItem[];
|
||||
hasSchedule: boolean;
|
||||
}
|
||||
|
||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const theme = useMantineTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const selectedDate = form.watch("scheduledDate");
|
||||
const selectedScheduleId = form.watch("trainScheduleId");
|
||||
const originYardId = form.watch("originYard");
|
||||
const destinationYardId = form.watch("destinationYard");
|
||||
const cargoType = form.watch("cargoType");
|
||||
const containers = form.watch("containers");
|
||||
const cargoTypePath = form.watch("cargoTypePath");
|
||||
const cargoWeight = form.watch("cargoWeight");
|
||||
|
||||
const originName = useMemo(
|
||||
() =>
|
||||
referenceData?.yard.find((y) => y.id === originYardId)?.name ??
|
||||
"Not selected",
|
||||
() => referenceData?.yard.find((y) => y.id === originYardId)?.name ?? "—",
|
||||
[referenceData, originYardId],
|
||||
);
|
||||
|
||||
const destinationName = useMemo(
|
||||
() =>
|
||||
referenceData?.yard.find((y) => y.id === destinationYardId)?.name ??
|
||||
"Not selected",
|
||||
referenceData?.yard.find((y) => y.id === destinationYardId)?.name ?? "—",
|
||||
[referenceData, destinationYardId],
|
||||
);
|
||||
|
||||
@@ -68,19 +80,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
const scheduleMap = useMemo(() => {
|
||||
const map = new Map<string, Freight.BookableScheduleItem>();
|
||||
// Group all schedules per date — multiple departures per day are allowed.
|
||||
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
|
||||
// match the format used by the calendar day keys.
|
||||
const schedulesByDate = useMemo(() => {
|
||||
const map = new Map<string, Freight.BookableScheduleItem[]>();
|
||||
if (bookableSchedules) {
|
||||
for (const s of bookableSchedules) {
|
||||
if (!map.has(s.scheduleDate)) {
|
||||
map.set(s.scheduleDate, s);
|
||||
}
|
||||
const dateKey = s.scheduleDate.slice(0, 10);
|
||||
const existing = map.get(dateKey) ?? [];
|
||||
map.set(dateKey, [...existing, s]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [bookableSchedules]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const selectedSchedule = useMemo(
|
||||
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
|
||||
[bookableSchedules, selectedScheduleId],
|
||||
);
|
||||
|
||||
const availableCount = useMemo(() => {
|
||||
let count = 0;
|
||||
schedulesByDate.forEach((schedules) => {
|
||||
if (schedules.some((s) => s.remainingWagons > 0)) count++;
|
||||
});
|
||||
return count;
|
||||
}, [schedulesByDate]);
|
||||
|
||||
const days = useMemo((): DayData[] => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
@@ -88,302 +116,449 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
const schedule = scheduleMap.get(dateString);
|
||||
const schedules = schedulesByDate.get(dateString) ?? [];
|
||||
|
||||
return {
|
||||
day: date.getDate(),
|
||||
dateString,
|
||||
isToday: isToday(date),
|
||||
isSelected: selectedDate === dateString,
|
||||
isCurrentMonth: isSameMonth(date, currentDate),
|
||||
isFull: schedule ? schedule.remainingWagons <= 0 : false,
|
||||
hasSchedule: !!schedule,
|
||||
remainingWagons: schedule?.remainingWagons ?? 0,
|
||||
scheduleId: schedule?.id ?? "",
|
||||
isSelectedDate: selectedDate === dateString,
|
||||
schedules,
|
||||
hasSchedule: schedules.length > 0,
|
||||
};
|
||||
});
|
||||
}, [currentDate, scheduleMap, selectedDate]);
|
||||
}, [currentDate, schedulesByDate, selectedDate]);
|
||||
|
||||
const legendItems = [
|
||||
{ label: "Available", color: theme.colors.gray[1] },
|
||||
{ label: "Full", color: theme.colors["edr-red-soft"][0] },
|
||||
{ label: "No Service", color: "transparent" },
|
||||
{ label: "Selected", color: theme.colors["edr-green"][5] },
|
||||
];
|
||||
const cargoSummary = useMemo(() => {
|
||||
if (!cargoType) return "Not selected";
|
||||
if (cargoType === "container") {
|
||||
const parts = (containers ?? [])
|
||||
.filter((c) => Number(c.qty) > 0)
|
||||
.map((c) => `${c.qty} × ${c.type}`);
|
||||
return parts.length > 0 ? `Container · ${parts.join(", ")}` : "Container";
|
||||
}
|
||||
const childId = cargoTypePath?.[1];
|
||||
const commodity = referenceData?.cargo_type
|
||||
.flatMap((g) => g.children ?? [])
|
||||
.find((c) => c.id === childId);
|
||||
const weight = cargoWeight ? ` · ${cargoWeight} t` : "";
|
||||
return commodity ? `${commodity.name}${weight}` : "Bulk freight";
|
||||
}, [cargoType, containers, cargoTypePath, cargoWeight, referenceData]);
|
||||
|
||||
const weeksCount = Math.ceil(days.length / 7);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 7.5 }}>
|
||||
<Card>
|
||||
<Box>
|
||||
<Title order={3} fw={700}>
|
||||
Shipment Date
|
||||
</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4}>
|
||||
Pick a shipment date for your booking from the available
|
||||
schedule.
|
||||
<Group gap={24} align="flex-start" wrap="nowrap">
|
||||
{/* ── Calendar Card ───────────────────────────────────── */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Card header */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
|
||||
}}
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
Select a shipment date
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Confirmed train departures · {originName} → {destinationName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={10}>
|
||||
<Button
|
||||
variant="default"
|
||||
w={34}
|
||||
h={34}
|
||||
p={0}
|
||||
radius="xl"
|
||||
onClick={() => setCurrentDate((d) => addMonths(d, -1))}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
<Text fw={700} fz={14} c="edr-text.0" style={{ minWidth: 90, textAlign: "center" }}>
|
||||
{format(currentDate, "MMMM yyyy")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
w={34}
|
||||
h={34}
|
||||
p={0}
|
||||
radius="xl"
|
||||
onClick={() => setCurrentDate((d) => addMonths(d, 1))}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Card body */}
|
||||
<Stack gap={14} px={24} py={18}>
|
||||
<Text fz={13} fw={600} c="edr-text.0">
|
||||
{originYardId && destinationYardId
|
||||
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||
: "Select origin and destination to see available departures"}
|
||||
</Text>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 6,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
{["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => (
|
||||
<Text
|
||||
key={d}
|
||||
ta="center"
|
||||
fz={10.5}
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="xl">
|
||||
{format(currentDate, "MMMM yyyy")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
p={6}
|
||||
radius="md"
|
||||
onClick={() => setCurrentDate((d) => addMonths(d, -1))}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
p={6}
|
||||
radius="md"
|
||||
onClick={() => setCurrentDate((d) => addMonths(d, 1))}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "4px",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
}}
|
||||
>
|
||||
{["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => (
|
||||
<Text
|
||||
key={d}
|
||||
ta="center"
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
mb={8}
|
||||
>
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
{days.map((d, i) => {
|
||||
const canSelect =
|
||||
d.isCurrentMonth && d.hasSchedule && !d.isFull;
|
||||
return (
|
||||
<Button
|
||||
key={i}
|
||||
variant="unstyled"
|
||||
disabled={!canSelect}
|
||||
onClick={() => {
|
||||
form.setValue("scheduledDate", d.dateString, {
|
||||
{/* Date grid */}
|
||||
<Stack gap={2}>
|
||||
{Array.from({ length: weeksCount }, (_, wi) => (
|
||||
<Box
|
||||
key={wi}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
||||
<DayCell
|
||||
key={di}
|
||||
day={d}
|
||||
selectedScheduleId={selectedScheduleId}
|
||||
onSelectSchedule={(scheduleId, dateString) => {
|
||||
form.setValue("scheduledDate", dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", d.scheduleId, {
|
||||
form.setValue("trainScheduleId", scheduleId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
height: "80px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "start",
|
||||
justifyContent: "start",
|
||||
gap: "4px",
|
||||
borderRadius: theme.radius.md,
|
||||
cursor: canSelect ? "pointer" : "default",
|
||||
backgroundColor: d.isSelected
|
||||
? theme.colors["edr-green"][5]
|
||||
: d.isFull
|
||||
? theme.colors["edr-red-soft"][0]
|
||||
: d.hasSchedule
|
||||
? theme.colors.gray[0]
|
||||
: "transparent",
|
||||
border:
|
||||
d.isToday && !d.isSelected
|
||||
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||
: "none",
|
||||
opacity: d.isCurrentMonth ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
c={d.isSelected ? "white" : "edr-text.0"}
|
||||
>
|
||||
{d.day}
|
||||
</Text>
|
||||
{d.isCurrentMonth && !d.isSelected && (
|
||||
<>
|
||||
{d.isFull && (
|
||||
<Text
|
||||
size="9px"
|
||||
fw={800}
|
||||
c="edr-red.0"
|
||||
style={{ letterSpacing: "0.05em" }}
|
||||
>
|
||||
FULL
|
||||
</Text>
|
||||
)}
|
||||
{d.hasSchedule && !d.isFull && (
|
||||
<Text
|
||||
size="9px"
|
||||
fw={800}
|
||||
c="edr-green.6"
|
||||
style={{ letterSpacing: "0.05em" }}
|
||||
>
|
||||
{d.remainingWagons} WGN
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Group gap="xl" mt="xs">
|
||||
{legendItems.map((item) => (
|
||||
<Group key={item.label} gap={8}>
|
||||
<Box
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
backgroundColor: item.color,
|
||||
border:
|
||||
item.label === "No Service"
|
||||
? `2px dashed ${theme.colors.gray[3]}`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="edr-muted" fw={600}>
|
||||
{item.label}
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* ── Side Panel ──────────────────────────────────────── */}
|
||||
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
|
||||
{/* Booking summary card */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={20}
|
||||
py={18}
|
||||
style={{
|
||||
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={800} fz={15} c="edr-text.0">
|
||||
Booking summary
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap={14} px={20} py={18}>
|
||||
<SummaryRow
|
||||
icon={<Route size={17} color={theme.colors["edr-muted"][0]} />}
|
||||
label="ROUTE"
|
||||
value={`${originName} → ${destinationName}`}
|
||||
/>
|
||||
<SummaryRow
|
||||
icon={<Package size={17} color={theme.colors["edr-muted"][0]} />}
|
||||
label="CARGO"
|
||||
value={cargoSummary}
|
||||
/>
|
||||
|
||||
{selectedSchedule && selectedDate && (
|
||||
<Box
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: theme.radius.lg,
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
}}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Group gap={7}>
|
||||
<Train size={16} color={theme.colors["edr-green"][7]} />
|
||||
<Text
|
||||
fz={10}
|
||||
fw={700}
|
||||
c="edr-green.7"
|
||||
style={{ letterSpacing: "0.08em" }}
|
||||
>
|
||||
SELECTED DEPARTURE
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4.5 }}>
|
||||
<Card>
|
||||
<Stack gap="xl">
|
||||
<Title order={4} fw={800} style={{ letterSpacing: "-0.02em" }}>
|
||||
Booking Summary
|
||||
</Title>
|
||||
|
||||
<Stack gap="md">
|
||||
<SummaryRow label="Origin Yard" value={originName} />
|
||||
<SummaryRow label="Destination Yard" value={destinationName} />
|
||||
<SummaryRow
|
||||
label="Cargo Type"
|
||||
value={
|
||||
cargoType
|
||||
? cargoType === "container"
|
||||
? "Container Freight"
|
||||
: "Bulk Freight"
|
||||
: "Not selected"
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider my="sm" color="gray.2" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="edr-muted"
|
||||
fw={600}
|
||||
style={{ letterSpacing: "0.05em" }}
|
||||
>
|
||||
Shipment Date
|
||||
<Text fw={800} fz={16} c="edr-text.0">
|
||||
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Train
|
||||
</Text>
|
||||
<Text
|
||||
fw={800}
|
||||
size="lg"
|
||||
c={selectedDate ? "edr-green.6" : "edr-muted"}
|
||||
>
|
||||
{selectedDate || "Not selected"}
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
p={8}
|
||||
style={{
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: selectedDate
|
||||
? theme.colors["edr-green"][0]
|
||||
: theme.colors.gray[1],
|
||||
}}
|
||||
>
|
||||
<CalendarIcon
|
||||
size={20}
|
||||
color={
|
||||
selectedDate
|
||||
? theme.colors["edr-green"][6]
|
||||
: theme.colors["edr-muted"][0]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Wagons available
|
||||
</Text>
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Help card */}
|
||||
<Box
|
||||
p={18}
|
||||
style={{
|
||||
borderRadius: theme.radius.xl,
|
||||
backgroundColor: theme.colors["edr-ink"][0],
|
||||
}}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Group gap={8} align="center">
|
||||
<CalendarIcon size={18} color={theme.colors["edr-green"][5]} />
|
||||
<Text fw={700} fz={14} c="white">
|
||||
Need a different date?
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12.5} style={{ color: "#A7B6C2", lineHeight: 1.5 }}>
|
||||
Our freight desk can arrange charter departures for full-train loads.
|
||||
</Text>
|
||||
<Text fz={13} fw={700} c="edr-green.5" style={{ cursor: "pointer" }}>
|
||||
Contact freight desk →
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
day: DayData;
|
||||
selectedScheduleId: string;
|
||||
onSelectSchedule: (scheduleId: string, dateString: string) => void;
|
||||
}
|
||||
|
||||
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!d.isCurrentMonth) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
height: 92,
|
||||
borderRadius: theme.radius.md,
|
||||
opacity: 0.4,
|
||||
overflow: "hidden",
|
||||
padding: "4px 6px",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<Text fz={14} fw={600} c="edr-muted">
|
||||
{d.day}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const cellBg = d.isSelectedDate
|
||||
? theme.colors["edr-soft"][0]
|
||||
: d.hasSchedule
|
||||
? "#FFFFFF"
|
||||
: "transparent";
|
||||
|
||||
const cellBorder = d.isSelectedDate
|
||||
? `1.5px solid ${theme.colors["edr-green"][5]}`
|
||||
: d.hasSchedule
|
||||
? "1px solid #E7E8E5"
|
||||
: "none";
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
height: 92,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: cellBg,
|
||||
border: cellBorder,
|
||||
overflow: "hidden",
|
||||
padding: "4px 6px 6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* Day number + check icon */}
|
||||
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
|
||||
<Text
|
||||
fz={14}
|
||||
fw={d.hasSchedule ? 800 : 600}
|
||||
c={
|
||||
d.isToday && !d.isSelectedDate
|
||||
? "edr-green.6"
|
||||
: d.hasSchedule
|
||||
? "edr-text.0"
|
||||
: "edr-muted"
|
||||
}
|
||||
>
|
||||
{d.day}
|
||||
</Text>
|
||||
{d.isSelectedDate && (
|
||||
<Check
|
||||
size={15}
|
||||
color={theme.colors["edr-green"][5]}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Departure chips */}
|
||||
{d.hasSchedule && (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
|
||||
{d.schedules.slice(0, 2).map((s) => {
|
||||
const isChipSelected = s.id === selectedScheduleId;
|
||||
const isFull = s.remainingWagons <= 0;
|
||||
const canSelect = !isFull;
|
||||
|
||||
return (
|
||||
<Box
|
||||
p="md"
|
||||
key={s.id}
|
||||
onClick={() =>
|
||||
canSelect && onSelectSchedule(s.id, d.dateString)
|
||||
}
|
||||
style={{
|
||||
borderRadius: theme.radius.md,
|
||||
border: `1px dashed ${theme.colors.gray[4]}`,
|
||||
backgroundColor: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 7,
|
||||
padding: "4px 6px",
|
||||
cursor: canSelect ? "pointer" : "default",
|
||||
backgroundColor: isChipSelected
|
||||
? theme.colors["edr-green"][5]
|
||||
: isFull
|
||||
? theme.colors["edr-red-soft"][0]
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
isChipSelected
|
||||
? theme.colors["edr-green"][5]
|
||||
: isFull
|
||||
? "#EFCFCA"
|
||||
: "#BFE3D4"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap">
|
||||
<Info
|
||||
size={16}
|
||||
color={theme.colors["edr-muted"][0]}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
c="edr-muted"
|
||||
fw={500}
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
Final confirmation of your selected date will be provided
|
||||
after review of your booking details.
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: isChipSelected
|
||||
? "white"
|
||||
: isFull
|
||||
? theme.colors["edr-red"][0]
|
||||
: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
|
||||
truncate
|
||||
c={
|
||||
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
|
||||
}
|
||||
>
|
||||
{isFull
|
||||
? "Full"
|
||||
: s.trainNumber
|
||||
? s.trainNumber
|
||||
: `${s.remainingWagons} wgn`}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
color={
|
||||
isChipSelected
|
||||
? "white"
|
||||
: isFull
|
||||
? theme.colors["edr-muted"][0]
|
||||
: theme.colors["edr-green"][7]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
function SummaryRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="edr-muted"
|
||||
fw={600}
|
||||
textTransform="uppercase"
|
||||
style={{ letterSpacing: "0.05em" }}
|
||||
<Group gap={12} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
w={34}
|
||||
h={34}
|
||||
style={{
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors.gray[0],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="edr-text.0">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
{icon}
|
||||
</Box>
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={10} fw={700} style={{ color: "#9AA8B5", letterSpacing: "0.08em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={13} fw={600} c="edr-text.0">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
import { FileText, Train, Truck } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FileText, Train, Truck } from "lucide-react";
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./file_upload_settings";
|
||||
export * from "./dropdown_settings";
|
||||
export * from "./file_upload_settings";
|
||||
export * from "./overview";
|
||||
|
||||
export enum TradeDirection {
|
||||
|
||||
2804
pnpm-lock.yaml
generated
2804
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user