mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user