mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
889 lines
32 KiB
TypeScript
889 lines
32 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Container,
|
|
Divider,
|
|
Grid,
|
|
Group,
|
|
NumberInput,
|
|
Paper,
|
|
SegmentedControl,
|
|
Select,
|
|
Stack,
|
|
Switch,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
Boxes,
|
|
CalendarClock,
|
|
Container as ContainerIcon,
|
|
Flame,
|
|
Info,
|
|
Layers,
|
|
MapPin,
|
|
Package,
|
|
Plus,
|
|
Settings2,
|
|
Ship,
|
|
Trash2,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|
import { api as appApi } from "@/services/api";
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import { customersService } from "@/services/customers.service";
|
|
|
|
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;
|
|
}
|
|
interface RefCargoGroup {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
children?: RefCargoChild[];
|
|
}
|
|
interface ReferenceData {
|
|
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`;
|
|
|
|
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
|
|
|
function deriveTradeDirectionFromYards(
|
|
origin?: RefNamed | null,
|
|
destination?: RefNamed | null,
|
|
): TradeDirection | null {
|
|
const originCountry = origin?.country?.trim();
|
|
const destinationCountry = destination?.country?.trim();
|
|
if (!originCountry || !destinationCountry) return null;
|
|
if (originCountry === "Djibouti") return "IMPORT";
|
|
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
|
|
return "DOMESTIC";
|
|
}
|
|
|
|
const tradeDirectionLabel: Record<TradeDirection, string> = {
|
|
IMPORT: "Import",
|
|
EXPORT: "Export",
|
|
DOMESTIC: "Domestic",
|
|
};
|
|
|
|
const parseBookingError = (error: unknown, fallback: string) => {
|
|
if (isAxiosError(error)) {
|
|
const message = error.response?.data?.message;
|
|
if (Array.isArray(message)) return message.join(", ");
|
|
if (typeof message === "string") return message;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
/** Section card with a colored icon chip header. */
|
|
function FormSection({
|
|
icon: Icon,
|
|
title,
|
|
subtitle,
|
|
accent = "edr-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 [companyId, setCompanyId] = useState<string | null>(null);
|
|
// Government bookings bill to a real government company + an explicit profile.
|
|
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
|
const [govProfileId, setGovProfileId] = 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);
|
|
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
|
|
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
|
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: companiesPage, isLoading: companiesLoading } = useQuery({
|
|
queryKey: ["companies", "list"],
|
|
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
|
});
|
|
|
|
const companyOptions = (companiesPage?.items ?? []).map((c) => ({
|
|
value: c.id,
|
|
label: c.name || c.email || c.tin || c.id,
|
|
}));
|
|
|
|
// Active government companies (kind=government) the booking can bill to.
|
|
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
|
queryKey: ["companies", "government", "active"],
|
|
queryFn: () =>
|
|
customersService.list({
|
|
page: 1,
|
|
pageSize: 1000,
|
|
kind: "government",
|
|
status: "active",
|
|
}),
|
|
enabled: isGovernment,
|
|
});
|
|
|
|
const govCompanies = govCompaniesPage?.items ?? [];
|
|
const govCompanyOptions = govCompanies.map((c) => ({
|
|
value: c.id,
|
|
label: c.name || c.tin || c.id,
|
|
}));
|
|
|
|
// Profiles (importer/exporter) of the chosen government company — the booking
|
|
// must link to one explicitly.
|
|
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
|
|
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
|
|
.filter((p) => p.status === "active")
|
|
.map((p) => ({
|
|
value: p.id,
|
|
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
|
|
p.reference ? ` — ${p.reference}` : ""
|
|
}`,
|
|
}));
|
|
|
|
// Reset the chosen profile when the government company changes.
|
|
useEffect(() => {
|
|
setGovProfileId(null);
|
|
}, [govCompanyId]);
|
|
|
|
// Day-level pool: fetch only the days that have a departure on the route (no
|
|
// train, no capacity). The batch engine assigns the train after booking.
|
|
const { data: availableDays, isLoading: daysLoading } = useQuery(
|
|
appApi.trainScheduling.availableDays.queryOptions({
|
|
input: { originYardId, destinationYardId },
|
|
enabled: Boolean(originYardId && destinationYardId),
|
|
}),
|
|
);
|
|
const dayOptions = (availableDays ?? []).map((day) => ({
|
|
value: day,
|
|
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
|
weekday: "short",
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
}),
|
|
}));
|
|
const hasAvailableDays = (availableDays ?? []).length > 0;
|
|
|
|
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
|
|
const effectiveDepartureIso = scheduledDay
|
|
? new Date(`${scheduledDay}T00:00:00`).toISOString()
|
|
: "";
|
|
|
|
const yardRecords = refData?.yard ?? [];
|
|
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
|
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
|
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
|
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
|
|
|
// Reset the day when the route changes — available days depend on the route.
|
|
useEffect(() => {
|
|
setScheduledDay(null);
|
|
}, [originYardId, destinationYardId]);
|
|
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 } = useMemo(() => {
|
|
const groups = refData?.cargo_type ?? [];
|
|
const data = groups.map((g) => {
|
|
if (g.children?.length) {
|
|
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 };
|
|
}, [refData?.cargo_type]);
|
|
|
|
const showFreeText = freightType === "BULK" && Boolean(cargoTypeId);
|
|
|
|
// ---- 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);
|
|
|
|
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
|
|
// the train afterwards (same flow as the customer portal).
|
|
const departureSatisfied = Boolean(scheduledDay);
|
|
|
|
const canSubmit =
|
|
Boolean(originYardId) &&
|
|
Boolean(destinationYardId) &&
|
|
!sameYard &&
|
|
Boolean(tradeDirection) &&
|
|
Boolean(serviceTypeId) &&
|
|
departureSatisfied &&
|
|
(isGovernment ? Boolean(govCompanyId && govProfileId) : 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,
|
|
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
|
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
|
freightType,
|
|
contractType: "NEW",
|
|
equipmentReturn,
|
|
tradeDirection: tradeDirection!,
|
|
paymentCurrency,
|
|
isHazardous,
|
|
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
|
originYardId,
|
|
destinationYardId,
|
|
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
|
serviceTypeId,
|
|
shippingLineId: shippingLineId || undefined,
|
|
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
|
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
|
cargoTotalWeightVgm,
|
|
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
|
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined,
|
|
containers:
|
|
freightType === "CONTAINER"
|
|
? lines.map((l) => ({
|
|
containerTypeId: l.containerTypeId,
|
|
quantity: l.quantity,
|
|
vgmPerUnitTons: l.vgmPerUnitTons,
|
|
}))
|
|
: undefined,
|
|
}),
|
|
onSuccess: async (booking) => {
|
|
if (isGovernment) {
|
|
await bookingsService.governmentExpedite(booking.id);
|
|
toast.success("Government booking created and expedited to scheduling");
|
|
} else {
|
|
toast.success("Booking created as draft");
|
|
}
|
|
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
|
navigate(`/dashboard/booking-requests/${booking.id}`);
|
|
},
|
|
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
|
|
});
|
|
|
|
return (
|
|
<Container size="xl" py="lg">
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: "Operations" },
|
|
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
|
{ label: "Create" },
|
|
]}
|
|
/>
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
<Button
|
|
variant="default"
|
|
radius="lg"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() => navigate("/dashboard/booking-requests")}
|
|
>
|
|
Back to list
|
|
</Button>
|
|
</Group>
|
|
|
|
<Grid gap="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="edr-green">
|
|
<Stack gap="md">
|
|
<Switch
|
|
label="Government booking"
|
|
description="Bills to a government entity + profile. Expedited to the scheduling queue."
|
|
checked={isGovernment}
|
|
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
|
/>
|
|
{isGovernment ? (
|
|
<Group grow align="flex-start">
|
|
<Select
|
|
label="Government entity"
|
|
placeholder="Select government company"
|
|
data={govCompanyOptions}
|
|
value={govCompanyId}
|
|
onChange={setGovCompanyId}
|
|
searchable
|
|
required
|
|
disabled={govCompaniesLoading}
|
|
nothingFoundMessage="No active government companies"
|
|
/>
|
|
<Select
|
|
label="Profile"
|
|
placeholder={
|
|
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
|
}
|
|
data={govProfileOptions}
|
|
value={govProfileId}
|
|
onChange={setGovProfileId}
|
|
searchable
|
|
required
|
|
disabled={!govCompanyId}
|
|
nothingFoundMessage="No active profiles for this entity"
|
|
/>
|
|
</Group>
|
|
) : (
|
|
<Select
|
|
label="Customer"
|
|
placeholder="Select company"
|
|
data={companyOptions}
|
|
value={companyId}
|
|
onChange={setCompanyId}
|
|
searchable
|
|
required
|
|
disabled={companiesLoading}
|
|
nothingFoundMessage="No companies found"
|
|
/>
|
|
)}
|
|
|
|
<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>
|
|
|
|
<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={(v) => {
|
|
setOriginYardId(v);
|
|
}}
|
|
searchable
|
|
disabled={isLoading}
|
|
error={sameYard ? "Same as destination" : undefined}
|
|
/>
|
|
<Select
|
|
label="Destination yard"
|
|
placeholder="Select destination"
|
|
data={yards}
|
|
value={destinationYardId}
|
|
onChange={(v) => {
|
|
setDestinationYardId(v);
|
|
setScheduledDay(null);
|
|
}}
|
|
searchable
|
|
disabled={isLoading}
|
|
error={sameYard ? "Same as origin" : undefined}
|
|
/>
|
|
</Group>
|
|
<Select
|
|
label="Shipment day"
|
|
placeholder={
|
|
originYardId && destinationYardId
|
|
? "Select a day with a departure"
|
|
: "Pick origin & destination first"
|
|
}
|
|
data={dayOptions}
|
|
value={scheduledDay}
|
|
onChange={setScheduledDay}
|
|
searchable
|
|
disabled={!originYardId || !destinationYardId || daysLoading}
|
|
nothingFoundMessage={
|
|
hasAvailableDays ? "No match" : "No departures on this route"
|
|
}
|
|
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
|
/>
|
|
<Group grow align="flex-end">
|
|
<Select
|
|
label="Service type"
|
|
placeholder="Select service"
|
|
data={services}
|
|
value={serviceTypeId}
|
|
onChange={setServiceTypeId}
|
|
searchable
|
|
disabled={isLoading}
|
|
/>
|
|
<Box>
|
|
<Text size="sm" fw={500} mb={4}>
|
|
Trade direction
|
|
</Text>
|
|
<Badge
|
|
size="lg"
|
|
variant="light"
|
|
color={
|
|
tradeDirection === "DOMESTIC"
|
|
? "grape"
|
|
: tradeDirection === "EXPORT"
|
|
? "orange"
|
|
: "blue"
|
|
}
|
|
>
|
|
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
|
|
</Badge>
|
|
</Box>
|
|
</Group>
|
|
</Stack>
|
|
</FormSection>
|
|
|
|
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
|
<Group grow align="flex-start">
|
|
<TextInput
|
|
label="Shipment day"
|
|
value={
|
|
scheduledDay
|
|
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
|
|
weekday: "short",
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
: ""
|
|
}
|
|
placeholder="Pick a day in the Route section"
|
|
readOnly
|
|
description="The engine assigns the train on this day"
|
|
/>
|
|
<Select
|
|
label="Payment currency"
|
|
data={[
|
|
{ value: "ETB", label: "ETB — Birr" },
|
|
{ value: "USD", label: "USD — Dollar" },
|
|
]}
|
|
value={paymentCurrency}
|
|
onChange={(v) => setPaymentCurrency(v ?? "ETB")}
|
|
/>
|
|
</Group>
|
|
</FormSection>
|
|
|
|
{/* 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>
|
|
);
|
|
})}
|
|
|
|
<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={(value) => setCargoTypeId(value as string | null)}
|
|
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="edr-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={effectiveDepartureIso ? new Date(effectiveDepartureIso).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>
|
|
);
|
|
}
|