feat(bookings): enhance booking process with customs clearing and document handling

- Update bookings service to prioritize uploaded documents over profile snapshots.
- Refactor pricing data seeder to remove unused service types and streamline cargo type seeding.
- Add customs clearing information to BookingRouteServiceCard, displaying agent details if applicable.
- Extend BookingDetail type to include customs clearing options.
- Modify NewBookingPage to remove the scheduling step, integrating estimated shipment date into the route step.
- Update StepIndicator to reflect the new step structure.
- Revise document handling in StepDocuments to allow for user uploads while displaying onboarding documents.
- Adjust Step2ServiceType to manage customs clearing agent input based on service type.
- Implement shipment date input in Step4Route for one-time bookings.
- Revise Step8Review to reflect changes in document handling and scheduling.
This commit is contained in:
Marshal
2026-06-24 23:37:55 +00:00
parent d7688fce21
commit 15ab9f906e
11 changed files with 566 additions and 224 deletions

View File

@@ -480,7 +480,11 @@ export class BookingsService {
// Reuse the booking profile's onboarding documents instead of asking the
// customer to re-upload. Snapshot them onto the booking now (by reference),
// so a later active-profile switch never changes this booking's documents.
if (companyProfileId) {
//
// Skip this when the customer uploaded documents for this booking — those
// per-booking files take precedence, so auto-attaching the profile snapshots
// would create duplicates.
if (companyProfileId && files.length === 0) {
try {
const onboardingFiles =
await this.companiesService.getProfileOnboardingFiles(companyProfileId);

View File

@@ -32,7 +32,7 @@ export class PricingDataSeeder {
const prRepo = manager.getRepository(PriorityConfig);
const rRepo = manager.getRepository(Rate);
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo);
await this.seedDomesticRoute(manager, yRepo);
await this.seedWeightLimits(wlRepo, ctRepo);
await this.seedPriorityConfigs(prRepo);
@@ -71,7 +71,6 @@ export class PricingDataSeeder {
private async upsertReferenceData(
manager: any,
ctRepo: any,
stRepo: any,
yRepo: any,
slRepo: any,
): Promise<void> {
@@ -155,47 +154,7 @@ export class PricingDataSeeder {
{ conflictPaths: { code: true } },
);
await stRepo.upsert(
[
{
code: "RAIL_CONTAINER",
serviceName: "Rail Container Service",
description: "Standard rail container transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{
code: "RAIL_FORWARDING",
serviceName: "Rail Forwarding Service",
description: "Rail transport with first/last mile and customs",
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: true,
priorityBonusPoints: 15,
isActive: true,
displayOrder: 2,
},
{
code: "RAIL_BULK",
serviceName: "Rail Bulk Transport",
description: "Bulk commodity rail transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 10,
isActive: true,
displayOrder: 3,
},
],
{ conflictPaths: { code: true } },
);
await slRepo.upsert(
[
@@ -238,53 +197,78 @@ export class PricingDataSeeder {
{ conflictPaths: { code: true } },
);
await manager.getRepository(CargoType).upsert(
await this.seedCargoTypes(manager);
}
/**
* Cargo types are a fixed two-level tree: two top-level groups — Bulk and
* Break Bulk — each with a set of commodity children. The groups are the
* stable parents the booking wizard renders; children carry the
* unit_of_measure used when reserving quantity (PER_TON for bulk commodities,
* PER_ITEM for break-bulk items like vehicles/machinery).
*
* Parents are upserted first, then re-read by code to resolve their ids so the
* children can be linked via parent_group_id (upsert doesn't return ids).
*/
private async seedCargoTypes(manager: any): Promise<void> {
const repo = manager.getRepository(CargoType);
const groups = [
{ code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 },
{ code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 },
];
await repo.upsert(
groups.map((g) => ({ ...g, isActive: true })),
{ conflictPaths: { code: true } },
);
const bulk = await repo.findOneBy({ code: "BULK" });
const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" });
if (!bulk || !breakBulk) return;
// Bulk commodities — measured by tonnage (PER_TON).
const bulkChildren = [
{ code: "SUGAR", cargoTypeName: "Sugar" },
{ code: "GRAIN", cargoTypeName: "Grain / Cereals" },
{ code: "WHEAT", cargoTypeName: "Wheat" },
{ code: "FERTILIZER", cargoTypeName: "Fertilizer" },
{ code: "CEMENT", cargoTypeName: "Cement / Clinker" },
{ code: "COAL", cargoTypeName: "Coal" },
];
// Break-bulk items — counted as whole units (PER_ITEM).
const breakBulkChildren = [
{ code: "CARS", cargoTypeName: "Cars / Vehicles" },
{ code: "MACHINERY", cargoTypeName: "Heavy Machinery" },
{ code: "STEEL", cargoTypeName: "Steel / Rebar" },
{ code: "PIPES", cargoTypeName: "Pipes" },
{ code: "TIMBER", cargoTypeName: "Timber" },
];
await repo.upsert(
[
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
requiresDirectorApproval: false,
...bulkChildren.map((c, i) => ({
...c,
parentGroupId: bulk.id,
unitOfMeasure: "PER_TON",
isActive: true,
displayOrder: 1,
},
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
requiresDirectorApproval: false,
displayOrder: i + 1,
})),
...breakBulkChildren.map((c, i) => ({
...c,
parentGroupId: breakBulk.id,
unitOfMeasure: "PER_ITEM",
isActive: true,
displayOrder: 2,
},
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
},
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
},
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
},
displayOrder: i + 1,
})),
],
{ conflictPaths: { code: true } },
);
// Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so
// it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh
// DB where it was never seeded.
await repo.update({ code: "OTHER_BULK" }, { isActive: false });
}
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Train, MapPin, ArrowRight, FileText } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
@@ -44,6 +44,8 @@ export function BookingRouteServiceCard({
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const includesCustoms = booking.serviceType?.includesCustoms;
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
@@ -96,6 +98,47 @@ export function BookingRouteServiceCard({
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
{includesCustoms ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#0A6F4D" />
<Text fz={13} fw={600} c="#0A6F4D">
Customs clearing included automatically
</Text>
</Group>
</Box>
) : booking.customsClearingAgent ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #E6ECF2",
background: "#F8FAFC",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#64748B" />
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
</Text>
</Text>
</Group>
</Box>
) : null}
</SectionCard>
);
}

View File

@@ -157,6 +157,8 @@ export interface BookingDetail {
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
@@ -167,7 +169,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];

View File

@@ -53,11 +53,22 @@ import {
Step5CargoDetails,
Step8Review,
StepDocuments,
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
/** Human-readable label for a rate's charge unit (e.g. "per container"). */
function formatPriceUnit(unit: string): string {
const map: Record<string, string> = {
PER_CONTAINER: "per container",
PER_TON: "per ton",
PER_WAGON: "per wagon",
PER_KM: "per km",
FLAT: "flat",
};
return map[unit] ?? unit.replace(/_/g, " ").toLowerCase();
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -238,15 +249,11 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const bookingType = form.watch("bookingType");
const isGeneralContract = bookingType === "general_contract";
// General contracts have no shipment date at creation — the Schedule step
// (id 5) is skipped; the date is chosen per order against the contract later.
const visibleSteps = useMemo(
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
[isGeneralContract],
);
// The estimated shipment date lives in the Route step now; for general
// contracts that date field is simply hidden there (the date is chosen per
// order against the contract later). No dedicated schedule step remains.
const visibleSteps = useMemo(() => STEPS, []);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
@@ -633,10 +640,7 @@ export default function NewBookingPage() {
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments documents={onboardingDocs} />}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review
form={form}
@@ -731,6 +735,62 @@ export default function NewBookingPage() {
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
: "Your booking has been saved as a draft. Here is your estimated total price."}
</Text>
{pricingData.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{pricingData.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()} {item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text size="sm" fw={600} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Box
p="lg"
style={{
@@ -821,6 +881,69 @@ export default function NewBookingPage() {
{priceChangeResult.currency}
</Text>
</Group>
{priceChangeResult.lineItems &&
priceChangeResult.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{priceChangeResult.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()}{" "}
{item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text
size="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"

View File

@@ -57,7 +57,13 @@ export function StepIndicator({
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
{done ? (
<Check style={{ width: 15, height: 15 }} strokeWidth={3} />
) : (
// Display the 1-based position, not the raw step id — ids can
// be non-contiguous (e.g. the schedule step was removed).
index + 1
)}
</div>
<span
style={{

View File

@@ -8,7 +8,6 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Cargo Details", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Estimated Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
] as const;
@@ -349,8 +348,9 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"extraRoutes",
"isHazardous",
"isRefrigerated",
// Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate",
],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};

View File

@@ -1,16 +1,32 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileText, FileUp } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import {
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepCard, StepHeader } from "./shared";
export interface OnboardingDoc {
name: string;
url: string;
size: number;
mimeType?: string;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function formatSize(bytes: number): string {
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -18,57 +34,58 @@ function formatSize(bytes: number): string {
}
/**
* Read-only documents step: lists the documents the company uploaded during
* onboarding for the active operational profile. These are attached to the
* booking automatically at submission — the customer is never asked to re-upload.
* Editable documents step. Mirrors the company onboarding documents (TIN,
* passport, investment/commercial license, national ID, …) and lets the customer
* attach or replace them FOR THIS BOOKING. Selections are stored on the form's
* `documents` field and saved against the specific booking on submit — editing
* here never touches the company profile.
*
* The documents already on file from onboarding are shown as a reference so the
* customer can see what EDR already has; they only need to upload here if they
* want to override a document for this booking.
*/
export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
const total = documents.length;
export function StepDocuments({ form }: { form: BookingForm }) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
// Documents already on file from onboarding (read-only reference).
const onboardingDocs = (() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
return active?.licenseFiles ?? [];
})();
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const setDocuments = (next: Record<string, File | File[] | null>) => {
form.setValue("documents", next, { shouldDirty: true });
};
return (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Documents"
description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed."
description="Attach the documents for this booking. They default to what you uploaded during onboarding — upload here only to override a document for this specific booking."
/>
<Group
gap={10}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "var(--mantine-color-gray-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
backgroundColor: total > 0 ? "#ECF6F1" : "#FBECEC",
color: total > 0 ? "#0A6F4D" : "#B42318",
}}
>
{total > 0 ? <CheckCircle2 size={16} /> : <FileUp size={16} />}
</Box>
<Text size="sm" c="dimmed">
{total > 0
? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.`
: "No onboarding documents found on your active profile. You can add documents later from the booking page."}
</Text>
</Group>
{total > 0 && (
<Stack gap={10} mt={4}>
{documents.map((doc, i) => (
{onboardingDocs.length > 0 && (
<Stack gap={10} mb="lg">
<Text fz={13} fw={700} c="#10202F">
On file from your onboarding
</Text>
{onboardingDocs.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
gap={12}
@@ -116,13 +133,35 @@ export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
>
<CheckCircle2 size={15} />
<Text size="xs" fw={600} c="#0A6F4D">
Uploaded
On file
</Text>
</Box>
</Group>
))}
</Stack>
)}
<Text fz={13} fw={700} c="#10202F" mb="sm">
Documents for this booking
</Text>
{docSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : docSettingQuery.data ? (
<SmartFileInput
file={docSettingQuery.data}
value={documents}
onChange={setDocuments}
/>
) : (
<Text size="sm" c="dimmed">
No document requirements are configured for your account. The documents
on file from your onboarding will be attached to this booking
automatically.
</Text>
)}
</StepCard>
);
}

View File

@@ -1,6 +1,6 @@
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { FileText, Info, 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";
@@ -40,7 +40,6 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
@@ -65,12 +64,17 @@ export function Step2ServiceType({
if (!prev || prev === serviceType) return;
if (!includesCustoms)
if (includesCustoms) {
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
} else {
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [serviceTypeId, form]);
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
serviceType != null || includesFirstMile || includesLastMile;
return (
<StepCard>
<StepHeader
@@ -263,43 +267,95 @@ export function Step2ServiceType({
)}
{/* Customs Clearing */}
{includesCustoms && (
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
{includesCustoms ? (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<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: "#ECF6F1",
color: "#0A6F4D",
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</ServiceToggle>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Service
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Customs documentation and clearance is included automatically with this service.
</Text>
</Box>
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>
<Group gap={6} align="center">
<Info size={14} color="#0A6F4D" />
<Text fz={12} fw={600} c="#0A6F4D">Included</Text>
</Group>
</Box>
</Group>
</Box>
) : (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #E6ECF2",
background: "#fff",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F4F7",
color: "#64748B",
}}
>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Agent
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Enter the name of the customs clearing agent for this shipment.
</Text>
</Box>
</Group>
<TextInput
{...field}
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
</Box>
)}
/>
)}

View File

@@ -9,8 +9,10 @@ import {
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import {
CalendarDays,
Flame,
MapPin,
Plus,
@@ -18,7 +20,7 @@ import {
Snowflake,
Trash2,
} from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
@@ -48,8 +50,31 @@ export function Step4Route({
}) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const isGeneralContract = form.watch("bookingType") === "general_contract";
// The operation chosen in step 0 fixes which end of the route is inside
// Ethiopia. Djibouti yards stand in for "outside Ethiopia" (the port),
// mirroring getRouteDirection / the backend's deriveTradeDirection:
// import → origin outside (Djibouti), destination Ethiopia
// export → origin Ethiopia, destination outside (Djibouti)
// intercity → both Ethiopia (domestic)
// _ff variants share the trade direction of their base operation.
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
case "import":
case "import_ff":
return { originCountry: "Djibouti", destinationCountry: "Ethiopia" };
case "export":
case "export_ff":
return { originCountry: "Ethiopia", destinationCountry: "Djibouti" };
case "intercity":
return { originCountry: "Ethiopia", destinationCountry: "Ethiopia" };
default:
return { originCountry: null, destinationCountry: null };
}
}, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
@@ -65,25 +90,40 @@ export function Step4Route({
return yardOptions
.filter((o) => o.value !== destinationYard)
.filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if (!dest) return true;
const origin = referenceData?.yard.find((y) => y.id === o.value);
// can't go from Djibouti to Djibouti
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
return false;
return true;
if (!originCountry) return true;
const yard = referenceData?.yard.find((y) => y.id === o.value);
return yard?.country === originCountry;
});
}, [yardOptions, destinationYard]);
}, [yardOptions, destinationYard, originCountry, referenceData]);
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
return yardOptions
.filter((o) => o.value !== originYard)
.filter((o) => {
if (!destinationCountry) return true;
const yard = referenceData?.yard.find((y) => y.id === o.value);
return yard?.country === destinationCountry;
});
}, [yardOptions, originYard, destinationCountry, referenceData]);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
// Changing the operation type (step 0) can invalidate a yard already chosen
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
useEffect(() => {
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}
}, [destinationCountry, dest, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -117,6 +157,13 @@ export function Step4Route({
const quantityStep = isPerItem ? 1 : 0.01;
const showRouteQuantity = isGeneralContract && !isContainer;
// Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => {
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}, []);
return (
<StepCard>
<StepHeader
@@ -168,6 +215,29 @@ export function Step4Route({
{directionLabel[direction]}
</div>
)}
{/* Estimated shipment date — one-time bookings only. General contracts
pick the date per order drawn against the contract later. */}
{!isGeneralContract && (
<Box style={{ maxWidth: 280 }}>
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
type="date"
label="Estimated shipment date *"
description="A planning estimate. You'll confirm the actual date when you request the operation."
min={todayISODate}
leftSection={<CalendarDays size={16} />}
error={fieldState.error?.message}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
radius="md"
/>
)}
/>
</Box>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
<Controller

View File

@@ -35,7 +35,8 @@ export const REVIEW_STEP_TARGETS = {
service: 2,
cargo: 3,
route: 4,
schedule: 5,
// The estimated shipment date now lives in the Route step.
schedule: 4,
documents: 6,
} as const;
@@ -175,8 +176,18 @@ export function Step8Review({
)
: bulkAmount;
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
// Documents the customer attached for THIS booking (keyed by document field).
// Onboarding docs on the active profile are still listed as a fallback so the
// customer can see what is already on file.
const attachedDocs = Object.entries(
(values.documents ?? {}) as Record<string, File | File[] | null>,
)
.filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v)))
.map(([key, v]) => ({
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
}));
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs;
const selectedCommodity = (() => {
if (values.cargoType !== "bulk" || !referenceData) return null;
@@ -347,13 +358,15 @@ export function Step8Review({
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
{!isGeneralContract && (
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
)}
<OverviewSection
icon={<Package size={18} />}
@@ -400,7 +413,7 @@ export function Step8Review({
>
<Stack gap="xs">
{onboardingDocsCount > 0 ? (
onboardingDocs.map((doc, i) => (
docsToShow.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
@@ -413,7 +426,7 @@ export function Step8Review({
</Text>
</Group>
<Text size="xs" c="dimmed">
Uploaded
Attached
</Text>
</Group>
))
@@ -421,13 +434,13 @@ export function Step8Review({
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No onboarding documents found on your active profile.
No documents attached yet.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
Documents from your onboarding will be attached to this booking.
These documents will be attached to this booking.
</Text>
</OverviewSection>
@@ -463,10 +476,12 @@ export function Step8Review({
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
{!isGeneralContract && (
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment date selected"
/>
)}
<ReadinessItem
done={
values.cargoType === "container"
@@ -477,7 +492,7 @@ export function Step8Review({
/>
<ReadinessItem
done={onboardingDocsCount > 0}
label="Onboarding documents attached"
label="Documents attached"
/>
</Stack>
</Paper>