mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
add Group 9 delivery scenarios for self-haul and last-mile paths
- Implemented G9·S38 tests for customer self-haul truck assignments, ensuring compliance with container limits and truck assignments. - Added G9·S39 tests for last-mile delivery, self-haul, and yard pickup, verifying independent paths for multiple bookings on the same train. - Created seed data for Group 1 and Group 2 scenarios, ensuring proper setup for weight and capacity tests. - Updated booking interface to deprecate in favor of for better clarity in allocations.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { createRequire } from "node:module";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
|
||||
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
|
||||
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
|
||||
* connection only — so TLS still validates against the certificate's CN.
|
||||
*
|
||||
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
|
||||
* namespace object is frozen, so assigning to it is silently dropped and the
|
||||
* patch becomes a no-op. `require` returns the live module object every other
|
||||
* caller (minio's http agent included) reads `lookup` off.
|
||||
*/
|
||||
function applyDnsHostOverrides(): void {
|
||||
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const overrides = new Map<string, string>();
|
||||
for (const entry of raw.split(",")) {
|
||||
const [host, ip] = entry.split("=").map((part) => part?.trim());
|
||||
if (host && ip) overrides.set(host.toLowerCase(), ip);
|
||||
}
|
||||
if (overrides.size === 0) return;
|
||||
|
||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||
const originalLookup = dns.lookup.bind(dns);
|
||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||
(dns as { lookup: unknown }).lookup = ((
|
||||
hostname: string,
|
||||
options: unknown,
|
||||
callback?: unknown,
|
||||
) => {
|
||||
const ip = overrides.get(hostname?.toLowerCase?.());
|
||||
if (!ip) return (originalLookup as Function)(hostname, options, callback);
|
||||
|
||||
const done = (typeof options === "function" ? options : callback) as (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | { address: string; family: number }[],
|
||||
family?: number,
|
||||
) => void;
|
||||
const family = ip.includes(":") ? 6 : 4;
|
||||
const wantsAll =
|
||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||
|
||||
process.nextTick(() =>
|
||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||
);
|
||||
}) as typeof dns.lookup;
|
||||
|
||||
console.log(
|
||||
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
applyDnsHostOverrides();
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
@@ -665,6 +666,17 @@ export default function GlCreateBookingForm() {
|
||||
),
|
||||
);
|
||||
|
||||
// Drop one container row and shrink quantity to match — the inverse of
|
||||
// syncUnits growing the array when quantity goes up.
|
||||
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) => {
|
||||
if (i !== lineIdx) return l;
|
||||
const units = l.units.filter((_, j) => j !== unitIdx);
|
||||
return withDerivedCounts({ ...l, quantity: String(units.length), units });
|
||||
}),
|
||||
);
|
||||
|
||||
// Same client-side validation as the customer portal shipment form
|
||||
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
||||
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
|
||||
@@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
onChange={(e) => {
|
||||
patchLine(lineIdx, { quantity: e.currentTarget.value });
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
|
||||
}}
|
||||
radius={10}
|
||||
@@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() {
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={`Remove container ${unitIdx + 1}`}
|
||||
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -223,15 +223,27 @@ export function ReadonlyBookingView({
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
keepMounted={false}
|
||||
color="edr-green"
|
||||
styles={{
|
||||
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
|
||||
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
|
||||
tab: {
|
||||
borderRadius: "10px 10px 0 0",
|
||||
fontWeight: 700,
|
||||
padding: "10px 16px",
|
||||
transition: "background-color 120ms ease, color 120ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
||||
Logistics
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="activity" leftSection={<Clock size={15} />}>
|
||||
Activity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
@@ -254,23 +266,9 @@ export function ReadonlyBookingView({
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
<ActivityCard booking={booking} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={
|
||||
@@ -295,6 +293,34 @@ export function ReadonlyBookingView({
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="logistics">
|
||||
<div className="flex flex-col gap-6">
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={<WarehousePaymentsSection bookingId={booking.id} />}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="activity">
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 700 }}>
|
||||
<ActivityCard booking={booking} />
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
<DocumentsTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
|
||||
import {
|
||||
fmtDate,
|
||||
isDraftLike,
|
||||
isNegative,
|
||||
serviceTypeLabel,
|
||||
yardLabel,
|
||||
} from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
type Row = { label: string; value: ReactNode; muted?: boolean };
|
||||
@@ -53,14 +61,39 @@ export function ScheduleCard({
|
||||
title: string;
|
||||
consignment?: boolean;
|
||||
}) {
|
||||
const [trackingOpen, setTrackingOpen] = useState(false);
|
||||
const service = serviceTypeLabel(booking);
|
||||
const equipmentReturn =
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||
const assignedTrain: Row = {
|
||||
label: "Assigned train",
|
||||
value: booking.trainId ?? "Not yet assigned",
|
||||
muted: !booking.trainId,
|
||||
};
|
||||
const assignedTrain: Row = booking.trainScheduleId
|
||||
? {
|
||||
label: "Assigned train",
|
||||
value: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrackingOpen(true)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "#0A6F4D",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<MapPin size={13} /> Track shipment
|
||||
</button>
|
||||
),
|
||||
}
|
||||
: {
|
||||
label: "Assigned train",
|
||||
value: "Not yet assigned",
|
||||
muted: true,
|
||||
};
|
||||
|
||||
const statusRow: Row = {
|
||||
label: "Status",
|
||||
@@ -115,6 +148,17 @@ export function ScheduleCard({
|
||||
</Group>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{booking.trainScheduleId && (
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingOpen}
|
||||
onClose={() => setTrackingOpen(false)}
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
originLabel={yardLabel(booking.originYard)}
|
||||
destinationLabel={yardLabel(booking.destinationYard)}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
|
||||
],
|
||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||
],
|
||||
[
|
||||
["Shipping line", shippingLineLabel(booking)],
|
||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
||||
],
|
||||
[["Shipping line", shippingLineLabel(booking)]],
|
||||
];
|
||||
|
||||
const badges: string[] = [];
|
||||
|
||||
@@ -77,6 +77,7 @@ const STATUS_FILTERS = [
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
assignedToSchedule: undefined as "true" | "false" | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
@@ -89,9 +90,16 @@ const STATUS_FILTERS = [
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
|
||||
{
|
||||
key: "allocated",
|
||||
label: "Allocated to a train",
|
||||
statuses: undefined as string | undefined,
|
||||
assignedToSchedule: "true" as const,
|
||||
},
|
||||
{ key: "expired", label: "Expired", statuses: "EXPIRED" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{
|
||||
key: "closed",
|
||||
@@ -348,7 +356,10 @@ export default function BookingsListPage() {
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter);
|
||||
const statuses = activeFilter?.statuses;
|
||||
const assignedToSchedule =
|
||||
"assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined;
|
||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
||||
|
||||
const resetPage = () =>
|
||||
@@ -372,6 +383,7 @@ export default function BookingsListPage() {
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
assignedToSchedule,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
@@ -386,6 +398,7 @@ export default function BookingsListPage() {
|
||||
}),
|
||||
[
|
||||
statuses,
|
||||
assignedToSchedule,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
@@ -423,6 +436,8 @@ export default function BookingsListPage() {
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
allocated: undefined,
|
||||
expired: undefined,
|
||||
closed: undefined,
|
||||
};
|
||||
|
||||
@@ -557,7 +572,12 @@ export default function BookingsListPage() {
|
||||
size: 130,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
cell: ({ row }) => (
|
||||
<PaymentBadge
|
||||
status={row.original.paymentStatus}
|
||||
bookingStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduling",
|
||||
|
||||
@@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string {
|
||||
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
|
||||
}
|
||||
|
||||
/** Payment status pill. */
|
||||
export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return <Text fz={13} c="dimmed">—</Text>;
|
||||
/**
|
||||
* Payment status pill. Once the booking's own lifecycle status has moved past
|
||||
* payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled
|
||||
* fact: show "Paid" even if a stale/lagging `paymentStatus` value says
|
||||
* otherwise, rather than surface a contradictory "Paid booking, pending
|
||||
* payment" row.
|
||||
*/
|
||||
export function PaymentBadge({
|
||||
status,
|
||||
bookingStatus,
|
||||
}: {
|
||||
status?: string | null;
|
||||
bookingStatus?: string | null;
|
||||
}) {
|
||||
const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false;
|
||||
const effective = settled ? "PAID" : status;
|
||||
if (!effective) return <Text fz={13} c="dimmed">—</Text>;
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={PAYMENT_COLORS[status] ?? "gray"}
|
||||
color={PAYMENT_COLORS[effective] ?? "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
|
||||
{PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1316,7 +1316,10 @@ export default function ContractDetailPage() {
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<PaymentBadge status={booking.paymentStatus} />
|
||||
<PaymentBadge
|
||||
status={booking.paymentStatus}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<SchedulingCell booking={booking} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
@@ -515,7 +516,9 @@ function NewShipmentBookingForm({
|
||||
routes={routes}
|
||||
completeBookingId={completeBookingId ?? null}
|
||||
/>
|
||||
<NotesSection form={form} />
|
||||
{/* Notes are captured when the booking is initiated — completing
|
||||
a bare booking does not re-ask for them. */}
|
||||
{!completeBookingId && <NotesSection form={form} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -1694,6 +1697,18 @@ function ContainerLineEditor({
|
||||
syncHandlingCounts(next);
|
||||
};
|
||||
|
||||
// Drop one container row and shrink quantity to match — the inverse of
|
||||
// syncUnits growing the array when quantity goes up.
|
||||
const removeUnit = (unitIdx: number) => {
|
||||
const current = form.getValues(`containers.${index}.units`) ?? [];
|
||||
const next = current.filter((_, j) => j !== unitIdx);
|
||||
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
||||
form.setValue(`containers.${index}.quantity`, String(next.length), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
syncHandlingCounts(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Line totals are a roll-up of the per-container switches — the count is
|
||||
* however many containers ticked each service. Kept in form state so the
|
||||
@@ -1781,8 +1796,10 @@ function ContainerLineEditor({
|
||||
styles={fieldStyles}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.currentTarget.value);
|
||||
const qty = Number(e.currentTarget.value || 0);
|
||||
syncUnits(qty);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
syncUnits(Number(e.currentTarget.value || 0));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1906,6 +1923,14 @@ function ContainerLineEditor({
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={`Remove container ${u + 1}`}
|
||||
onClick={() => removeUnit(u)}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ClipboardCheck,
|
||||
Coins,
|
||||
FileText,
|
||||
MapPin,
|
||||
Package,
|
||||
@@ -220,13 +218,13 @@ export function Step8Review({
|
||||
// not of the stored form flag — a stale draft flag must not misreport it.
|
||||
// Without bundling, the customer may still name their own clearing agent.
|
||||
const ownAgent = values.customsClearingAgent?.trim();
|
||||
const customsValue = isIntercity
|
||||
? "Not applicable — domestic transport"
|
||||
const customsTag: { label: string; color: string } = isIntercity
|
||||
? { label: "Not applicable · domestic", color: "gray" }
|
||||
: serviceType?.includesCustoms || values.customsClearingEnabled
|
||||
? "Included — Global Logistics"
|
||||
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
|
||||
: ownAgent
|
||||
? `Own agent — ${ownAgent}`
|
||||
: "Not requested";
|
||||
? { label: `Own agent · ${ownAgent}`, color: "blue" }
|
||||
: { label: "Not requested", color: "gray" };
|
||||
|
||||
// Mirror the step-2 gating: imports never truck the first mile, exports never
|
||||
// truck the last mile, and a service that doesn't bundle a mile can't have it.
|
||||
@@ -351,27 +349,26 @@ export function Step8Review({
|
||||
label="Service"
|
||||
value={serviceType?.serviceName ?? "—"}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Coins size={18} />}
|
||||
label="Quotation currency"
|
||||
value={
|
||||
<>
|
||||
USD
|
||||
<Text fz="sm" c="dimmed" mt={4}>
|
||||
You choose the billing currency on each shipment.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Route size={18} />}
|
||||
label="Route"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<MapPin size={18} />}
|
||||
label="Trade direction"
|
||||
value={directionLabel}
|
||||
value={
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Text fz={14} fw={600} c="#10202F" truncate>
|
||||
{originYardName} → {destinationYardName}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<MapPin size={11} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{directionLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
@@ -420,7 +417,16 @@ export function Step8Review({
|
||||
<SummaryItem
|
||||
icon={<FileText size={18} />}
|
||||
label="Customs clearing"
|
||||
value={customsValue}
|
||||
value={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={customsTag.color}
|
||||
radius="sm"
|
||||
>
|
||||
{customsTag.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{/* Step-3 toggles appear only when the customer selected them —
|
||||
an off toggle is left off the summary entirely. */}
|
||||
@@ -478,20 +484,6 @@ export function Step8Review({
|
||||
{documentsEditor}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for EDR operations…"
|
||||
rows={3}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Right — sticky actions */}
|
||||
|
||||
@@ -172,6 +172,8 @@ export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
statuses?: string;
|
||||
/** 'true' = has a train assigned (allocated), 'false' = not yet assigned. */
|
||||
assignedToSchedule?: "true" | "false";
|
||||
/** ONE_TIME or GENERAL_CONTRACT. */
|
||||
bookingType?: string;
|
||||
/** CONTAINER or BULK. */
|
||||
|
||||
Reference in New Issue
Block a user