mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
enhance booking form UI with new components and improved layouts
This commit is contained in:
@@ -2,62 +2,67 @@ import { Check } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { STEPS } from "./schema";
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-5)";
|
||||
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
|
||||
const BORDER = "var(--mantine-color-edr-border-0)";
|
||||
const MUTED = "var(--mantine-color-edr-muted-0)";
|
||||
const INK = "var(--mantine-color-edr-text-0)";
|
||||
|
||||
export function StepIndicator({ step }: { step: number }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((item, index) => (
|
||||
<div className="flex items-start">
|
||||
{STEPS.map((item, index) => {
|
||||
const done = step > item.id;
|
||||
const active = step === item.id;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-1">
|
||||
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
transition: "all 0.2s",
|
||||
...(step > item.id
|
||||
...(done
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-edr-green-5)",
|
||||
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
|
||||
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
|
||||
}
|
||||
: step === item.id
|
||||
: active
|
||||
? {
|
||||
border: "2.5px solid var(--mantine-color-edr-green-5)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
border: `2.5px solid ${GREEN}`,
|
||||
color: GREEN_DEEP,
|
||||
backgroundColor: "#fff",
|
||||
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
|
||||
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "#fff",
|
||||
color: "var(--mantine-color-edr-muted-0)",
|
||||
border: "2px solid var(--mantine-color-edr-border-0)",
|
||||
color: MUTED,
|
||||
border: `2px solid ${BORDER}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{step > item.id ? (
|
||||
<Check style={{ width: 13, height: 13 }} />
|
||||
) : (
|
||||
item.id
|
||||
)}
|
||||
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
fontWeight: active ? 700 : 500,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: 72,
|
||||
display: "none",
|
||||
transition: "color 0.2s",
|
||||
color:
|
||||
step >= item.id
|
||||
? "var(--mantine-color-edr-text-0)"
|
||||
: "var(--mantine-color-edr-muted-0)",
|
||||
color: step >= item.id ? INK : MUTED,
|
||||
}}
|
||||
className="lg:!block"
|
||||
className="md:!block"
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
@@ -66,20 +71,19 @@ export function StepIndicator({ step }: { step: number }) {
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
margin: "0 6px",
|
||||
marginBottom: 14,
|
||||
transition: "background-color 0.3s",
|
||||
backgroundColor:
|
||||
step > item.id
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-edr-border-0)",
|
||||
margin: "16px 8px 0",
|
||||
transition: "background 0.3s",
|
||||
background: done
|
||||
? "linear-gradient(90deg, #0A8A5F, #12B981)"
|
||||
: BORDER,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,167 @@
|
||||
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
|
||||
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Paper,
|
||||
Select,
|
||||
Text,
|
||||
Title,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import type { BookingFormInputValues } from "./schema";
|
||||
|
||||
// Brand tokens (kept local so the form reads consistently with the booking
|
||||
// detail page and the scheduling step).
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const GREEN = "#0EA371";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||
if (!error?.message) return null;
|
||||
return (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{error.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium selectable option card with an icon tile, title, and description.
|
||||
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
|
||||
* `children` (legacy callers still work).
|
||||
*/
|
||||
export function OptionCard({
|
||||
selected,
|
||||
onClick,
|
||||
disabled,
|
||||
icon,
|
||||
iconBg = "#ECF6F1",
|
||||
iconColor = GREEN_DARK,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconBg?: string;
|
||||
iconColor?: string;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 150ms ease",
|
||||
border: `1.5px solid ${
|
||||
disabled ? BORDER : selected ? GREEN : BORDER
|
||||
}`,
|
||||
background: disabled
|
||||
? "#F6F8FA"
|
||||
: selected
|
||||
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
|
||||
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
|
||||
}`}
|
||||
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
|
||||
: "#FFFFFF",
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
opacity: disabled ? 0.65 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = "#BFE3D2";
|
||||
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = BORDER;
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected && !disabled && (
|
||||
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
height: 22,
|
||||
width: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
background: GREEN,
|
||||
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
|
||||
}}
|
||||
>
|
||||
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Structured form (icon + title + description) */}
|
||||
{(icon || title || description) && (
|
||||
<Box>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: "flex",
|
||||
height: 42,
|
||||
width: 42,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
{title && (
|
||||
<Text fz={15} fw={800} c={INK}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -63,7 +182,7 @@ export function AlertBox({
|
||||
};
|
||||
const { color, icon } = map[tone];
|
||||
return (
|
||||
<Alert color={color} icon={icon} radius="md" fz="sm">
|
||||
<Alert color={color} icon={icon} radius="lg" fz="sm">
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
@@ -71,31 +190,89 @@ export function AlertBox({
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={MUTED}
|
||||
style={{ letterSpacing: "0.07em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card shell that wraps a step's body. Gives every step the same premium
|
||||
* surface, padding, and an optional eyebrow.
|
||||
*/
|
||||
export function StepCard({
|
||||
children,
|
||||
eyebrow,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p={{ base: "lg", sm: 28 }}
|
||||
withBorder
|
||||
bg="white"
|
||||
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||
>
|
||||
{eyebrow}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Title order={3} className="tracking-tight">
|
||||
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||
color: GREEN_DARK,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared Mantine input styling so every field in the form matches. */
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
|
||||
} as const;
|
||||
|
||||
export function SelectField({
|
||||
field,
|
||||
error,
|
||||
@@ -103,6 +280,7 @@ export function SelectField({
|
||||
placeholder,
|
||||
disabled,
|
||||
data,
|
||||
leftSection,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormInputValues>;
|
||||
error?: RhfFieldError;
|
||||
@@ -110,6 +288,7 @@ export function SelectField({
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
data: string[] | { value: string; label: string }[];
|
||||
leftSection?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
@@ -122,6 +301,11 @@ export function SelectField({
|
||||
onBlur={field.onBlur}
|
||||
error={error?.message}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
|
||||
};
|
||||
|
||||
return (
|
||||
<Input.Wrapper label={label} error={error?.message}>
|
||||
<Combobox store={combobox} disabled={disabled}>
|
||||
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
|
||||
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircle2, FileUp } from "lucide-react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BookingDocuments,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
const total = BOOKING_DOCS_SETTING.fields.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileUp size={22} />}
|
||||
title="Shipment Documents"
|
||||
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
|
||||
description="Attach your shipment documents now, or skip and upload them later from the booking page."
|
||||
/>
|
||||
|
||||
<Group
|
||||
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
|
||||
centered
|
||||
size="sm"
|
||||
styles={{
|
||||
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
|
||||
body: { padding: 24 },
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
<Text fz={13} c="edr-muted" fw={500}>
|
||||
Choose a departure time
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
{selectedDayForModal?.schedules.map((schedule) => (
|
||||
<Button
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
variant="outline"
|
||||
fullWidth
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
style={{ height: 64, justifyContent: "flex-start" }}
|
||||
styles={{
|
||||
inner: { justifyContent: "flex-start" },
|
||||
root: {
|
||||
borderColor: theme.colors["edr-border"][0],
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition: "all 150ms ease",
|
||||
"&:hover": {
|
||||
borderColor: theme.colors["edr-green"][5],
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group gap={16} w="100%">
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][5]} />
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
|
||||
<Text fw={700} fz={18} c="edr-text.0">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
{schedule.trainNumber && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
Train {schedule.trainNumber}
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Button>
|
||||
))}
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Schedule times */}
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{d.hasSchedule && (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
|
||||
{d.schedules.slice(0, 2).map((s) => (
|
||||
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={12}
|
||||
fw={700}
|
||||
c="edr-text.0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{format(new Date(s.scheduleDate), "HH:mm")}
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{d.schedules.length > 2 && (
|
||||
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
|
||||
+{d.schedules.length - 2} more
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
AsyncComboboxField,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
import { FileSignature } from "lucide-react";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -52,7 +55,6 @@ export function Step1ContractType({
|
||||
);
|
||||
|
||||
const contractOptions = useMemo<PreviousContractOption[]>(() => {
|
||||
console.log("Bookings data:", bookings);
|
||||
if (!bookings) return [];
|
||||
|
||||
return bookings?.items
|
||||
@@ -182,10 +184,11 @@ export function Step1ContractType({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileSignature size={22} />}
|
||||
title="Contract Type"
|
||||
description="New contract or renewal of an existing one."
|
||||
description="Start a new contract or renew an existing one to reuse its details."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -193,40 +196,33 @@ export function Step1ContractType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value === "new"}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="New Contract"
|
||||
description="Create a fresh freight contract from scratch."
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
form.setValue("previousContractRef", "");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<FileText className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">New Contract</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Create a new contract.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
|
||||
<OptionCard
|
||||
selected={field.value === "renewal"}
|
||||
icon={<RefreshCw className="h-5 w-5" />}
|
||||
iconBg="#EAF1FB"
|
||||
iconColor="#2E5B96"
|
||||
title="Contract Renewal"
|
||||
description="Pick a previous reference to auto-fill historical parameters."
|
||||
onClick={() => {
|
||||
field.onChange("renewal");
|
||||
form.clearErrors("contractType");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
|
||||
<RefreshCw className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Contract Renewal</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Select a previous reference to auto-populate historical
|
||||
parameters.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -234,7 +230,7 @@ export function Step1ContractType({
|
||||
/>
|
||||
|
||||
{contractType === "renewal" && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<Stack gap={12} mt={22}>
|
||||
{error && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load previous contracts. Please try again later.
|
||||
@@ -263,8 +259,8 @@ export function Step1ContractType({
|
||||
details will be pre-filled.
|
||||
</AlertBox>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
import { FileText, Train, Truck } from "lucide-react";
|
||||
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileText, Layers, Train, Truck } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
import {
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -61,10 +69,11 @@ export function Step2ServiceType({
|
||||
const showServiceSections =
|
||||
includesCustoms || includesFirstMile || includesLastMile;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Layers size={22} />}
|
||||
title="Service Type"
|
||||
description="Select the service combination and configure trucking options."
|
||||
description="Choose the service combination, then configure your trucking options."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -72,25 +81,21 @@ export function Step2ServiceType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{referenceData?.service
|
||||
.filter((s) => s.canBeBookedAlone)
|
||||
.map((s) => {
|
||||
return (
|
||||
.map((s) => (
|
||||
<OptionCard
|
||||
key={s.id}
|
||||
selected={field.value === s.id}
|
||||
onClick={() => field.onChange(s.id)}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
|
||||
<Train className="h-4 w-4 text-indigo-600" />
|
||||
</div>
|
||||
<p className="font-semibold">{s.serviceName}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{s.description}
|
||||
</p>
|
||||
</OptionCard>
|
||||
);
|
||||
})}
|
||||
icon={<Train className="h-5 w-5" />}
|
||||
iconBg="#EEF0FB"
|
||||
iconColor="#4F46E5"
|
||||
title={s.serviceName}
|
||||
description={s.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -98,31 +103,20 @@ export function Step2ServiceType({
|
||||
/>
|
||||
|
||||
{showServiceSections && (
|
||||
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
|
||||
<Stack gap={12} mt={24}>
|
||||
<StepLabel>Trucking & customs options</StepLabel>
|
||||
{/* First Mile */}
|
||||
{includesFirstMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
First Mile — Pick-up
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck pick-up from your premises (Door to Port) to the
|
||||
origin rail yard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="First Mile — Pick-up"
|
||||
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("firstMile.pickUpAddress", "", {
|
||||
@@ -131,53 +125,40 @@ export function Step2ServiceType({
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="firstMile.pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Pick-up address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last Mile */}
|
||||
{includesLastMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="lastMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Last Mile — Delivery
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck delivery from the destination rail yard to the
|
||||
final address (Port to Door).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Last Mile — Delivery"
|
||||
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("lastMile.deliveryAddress", "", {
|
||||
@@ -189,94 +170,134 @@ export function Step2ServiceType({
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="lastMile.deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Delivery address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Equipment Return */}
|
||||
{includesLastMile && lastMileEnabled && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Equipment Return</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{field.value === "with_return"
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Equipment Return"
|
||||
description={
|
||||
field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
: "Container retained by the customer after delivery."
|
||||
}
|
||||
checked={field.value === "with_return"}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.currentTarget.checked
|
||||
? "with_return"
|
||||
: "without_return",
|
||||
);
|
||||
}}
|
||||
color="edr-green"
|
||||
onChange={(v) =>
|
||||
field.onChange(v ? "with_return" : "without_return")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customs Clearing */}
|
||||
{includesCustoms && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="customsClearingEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Customs Clearing Service
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
EDR handles customs documentation and clearance on
|
||||
your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<FileText size={18} />}
|
||||
title="Customs Clearing Service"
|
||||
description="EDR handles customs documentation and clearance on your behalf."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#ECF6F1" : "#F1F4F7",
|
||||
color: checked ? "#0A6F4D" : "#64748B",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
|
||||
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type BookingFormValues,
|
||||
getRouteDirection,
|
||||
} from "./schema";
|
||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -63,7 +63,6 @@ export function Step4Route({
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
@@ -85,10 +84,11 @@ export function Step4Route({
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<RouteIcon size={22} />}
|
||||
title="Route"
|
||||
description="Select the origin and destination yards."
|
||||
description="Choose the origin and destination yards for your shipment."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -96,7 +96,7 @@ export function Step4Route({
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
control={form.control}
|
||||
@@ -153,56 +153,108 @@ export function Step4Route({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Divider my={22} color="#EEF2F6" />
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hazardous Material</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies a Hazard Surcharge to the final bill.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge to the final bill."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Refrigerated Cargo</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Temperature-controlled transport applies a Refrigerator
|
||||
Surcharge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a refrigeration surcharge."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Skeleton,
|
||||
InputLabel,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
@@ -17,9 +16,11 @@ import {
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
SelectField,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton height={14} w={96} radius="sm" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="xl" />
|
||||
<Skeleton height={96} radius="xl" />
|
||||
</div>
|
||||
<Skeleton height={40} radius="md" />
|
||||
<Skeleton height={40} w="33%" radius="md" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="lg" />
|
||||
<Skeleton height={96} radius="lg" />
|
||||
</div>
|
||||
<Skeleton height={44} radius="md" />
|
||||
<Skeleton height={44} w="33%" radius="md" />
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
|
||||
{/* Cargo Type */}
|
||||
<div className="space-y-3">
|
||||
<InputLabel>Cargo Type *</InputLabel>
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={cargoType === "container"}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="Containerized"
|
||||
description="Pre-packed containerized cargo (20ft / 40ft)."
|
||||
onClick={() => {
|
||||
field.onChange("container");
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<Package className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Containerized</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Pre-packed containerized cargo (20ft / 40ft).
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
<OptionCard
|
||||
selected={cargoType === "bulk"}
|
||||
icon={<Weight className="h-5 w-5" />}
|
||||
iconBg="#FDF3E0"
|
||||
iconColor="#C77F09"
|
||||
title="General Cargo"
|
||||
description="Bulk commodities or break-bulk cargo."
|
||||
onClick={() => {
|
||||
field.onChange("bulk");
|
||||
form.setValue("containers", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Weight className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<p className="font-semibold">General Cargo</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Bulk commodities or break-bulk cargo.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
|
||||
placeholder="0.00"
|
||||
leftSection={<Weight className="h-4 w-4" />}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
@@ -154,6 +155,7 @@ export function Step8Review({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<StepHeader
|
||||
icon={<ClipboardCheck size={22} />}
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user