mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat: enhance booking operations to support freight forwarder variants and improve consolidation handling
This commit is contained in:
@@ -115,8 +115,9 @@ export function PaymentCard({
|
||||
pricing: Pricing;
|
||||
}) {
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
// Customer sees the grand total only. A staff adjustment, when present,
|
||||
// overrides the computed total and is flagged with an "Adjusted by EDR" badge.
|
||||
// Customer sees the grand total plus the price breakdown that makes it up.
|
||||
// A staff adjustment, when present, overrides the computed total and is
|
||||
// flagged with an "Adjusted by EDR" badge.
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
@@ -124,6 +125,7 @@ export function PaymentCard({
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const hasItems = priceLineItems(pricing).length > 0;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
@@ -183,6 +185,25 @@ export function PaymentCard({
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{hasItems && (
|
||||
<>
|
||||
<Divider />
|
||||
<LineItems pricing={pricing} />
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{isAdjusted ? "Adjusted total" : "Total"}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -177,6 +178,12 @@ export default function NewBookingPage() {
|
||||
}
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
// A partial-wagon booking is parked until a partner is found — explain the
|
||||
// wait in a modal before sending the customer to the detail page.
|
||||
if (result.status === "PENDING_CONSOLIDATION") {
|
||||
setConsolidationPending(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
@@ -186,10 +193,14 @@ export default function NewBookingPage() {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
return api.bookings.confirmSubmit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
setPriceChangeResult(null);
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
if (result.status === "PENDING_CONSOLIDATION") {
|
||||
setConsolidationPending(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
@@ -262,20 +273,25 @@ export default function NewBookingPage() {
|
||||
return route;
|
||||
}, [originYard, destinationYard]);
|
||||
|
||||
// Operations the customer may book, gated by the company's onboarded profiles.
|
||||
const allowedOperations = useMemo<OperationType[]>(() => {
|
||||
const profileTypes = (auth.company?.company?.companyProfiles ?? []).map(
|
||||
(p) => p.type,
|
||||
);
|
||||
return allowedOperationsForProfiles(profileTypes);
|
||||
}, [auth.company]);
|
||||
// The company's onboarded profile types — drives which operations are offered
|
||||
// and which profile each operation stamps the booking to.
|
||||
const profileTypes = useMemo(
|
||||
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
|
||||
[auth.company],
|
||||
);
|
||||
|
||||
// Stamp the booking to the right operational profile. Import/Export switch the
|
||||
// active mode so the matching onboarding documents are attached; Intercity uses
|
||||
// whatever profile is already active.
|
||||
// Operations the customer may book, gated by the company's onboarded profiles.
|
||||
const allowedOperations = useMemo<OperationType[]>(
|
||||
() => allowedOperationsForProfiles(profileTypes),
|
||||
[profileTypes],
|
||||
);
|
||||
|
||||
// Stamp the booking to the right operational profile. Import/Export (and their
|
||||
// "as FF" variants) switch the active mode so the matching onboarding documents
|
||||
// are attached; Intercity uses whatever profile is already active.
|
||||
const handleOperationSelect = (op: OperationType) => {
|
||||
if (op === "intercity") return;
|
||||
const target = operationToProfileType(op);
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
@@ -301,6 +317,8 @@ export default function NewBookingPage() {
|
||||
useState<SubmitBookingResponse | null>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
// Shown when a submitted booking is parked waiting for a consolidation partner.
|
||||
const [consolidationPending, setConsolidationPending] = useState(false);
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
@@ -804,6 +822,66 @@ export default function NewBookingPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={consolidationPending}
|
||||
onClose={() => {
|
||||
setConsolidationPending(false);
|
||||
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
|
||||
}}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center rounded-[11px] border border-[#F4D9A8]"
|
||||
style={{ width: 38, height: 38, backgroundColor: "#FDF3E0", color: "#C77F12" }}
|
||||
>
|
||||
<Link2 size={20} />
|
||||
</div>
|
||||
<Text fw={700}>Waiting to share a wagon</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Your booking is submitted, but your cargo only fills part of a wagon.
|
||||
We're pairing it with another shipment on the same route to share
|
||||
the space.
|
||||
</Text>
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #F4D9A8",
|
||||
background: "#FDF3E0",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} c="#10202F">
|
||||
What happens next
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="#7A6A4E">
|
||||
As soon as a matching shipment is found, your booking continues
|
||||
automatically — you'll be notified, and no action is needed
|
||||
from you until then. Your acceptance, approval and contract stay
|
||||
independent and yours alone.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setConsolidationPending(false);
|
||||
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
|
||||
@@ -13,7 +13,16 @@ export const STEPS = [
|
||||
{ id: 7, label: "Review & Submit", short: "Submit" },
|
||||
] as const;
|
||||
|
||||
export const OPERATION_TYPES = ["import", "export", "intercity"] as const;
|
||||
export const OPERATION_TYPES = [
|
||||
"import",
|
||||
"export",
|
||||
"intercity",
|
||||
// Freight-forwarder variants: same trade direction as import/export but the
|
||||
// booking is stamped to the company's freight_forwarder profile instead of a
|
||||
// direct importer/exporter profile.
|
||||
"import_ff",
|
||||
"export_ff",
|
||||
] as const;
|
||||
export type OperationType = (typeof OPERATION_TYPES)[number];
|
||||
|
||||
/**
|
||||
@@ -341,10 +350,15 @@ export function getRouteDirection(
|
||||
|
||||
/**
|
||||
* Operations a company may book, derived from its onboarded profile types.
|
||||
* - freight forwarder (or DJ forwarder) → import, export, intercity
|
||||
* - importer → import, intercity
|
||||
* - exporter → export, intercity
|
||||
* - importer + exporter → import, export, intercity
|
||||
*
|
||||
* - pure freight forwarder → import, export, intercity
|
||||
* (these run as FF: the booking is stamped to the freight_forwarder profile)
|
||||
* - importer → import, intercity
|
||||
* - exporter → export, intercity
|
||||
* - importer + exporter → import, export, intercity
|
||||
* - importer (+/- exporter) + FF → direct import/export PLUS the matching
|
||||
* "as FF" variants, so the company can book either directly or as a forwarder
|
||||
*
|
||||
* Intercity (DOMESTIC) is always available to any customer-side profile.
|
||||
*/
|
||||
export function allowedOperationsForProfiles(
|
||||
@@ -352,26 +366,75 @@ export function allowedOperationsForProfiles(
|
||||
): OperationType[] {
|
||||
const has = (t: string) => profileTypes.includes(t);
|
||||
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
|
||||
const isImporter = has("importer");
|
||||
const isExporter = has("exporter");
|
||||
const isDirect = isImporter || isExporter;
|
||||
|
||||
const ops = new Set<OperationType>();
|
||||
if (isForwarder || has("importer")) ops.add("import");
|
||||
if (isForwarder || has("exporter")) ops.add("export");
|
||||
// Any importer/exporter/forwarder profile can also run domestic (intercity).
|
||||
if (isForwarder || has("importer") || has("exporter")) ops.add("intercity");
|
||||
|
||||
// Direct importer/exporter capabilities.
|
||||
if (isImporter) ops.add("import");
|
||||
if (isExporter) ops.add("export");
|
||||
|
||||
if (isForwarder) {
|
||||
if (isDirect) {
|
||||
// Mixed: keep the direct options above and add explicit "as FF" variants
|
||||
// so the customer can disambiguate which profile the booking belongs to.
|
||||
ops.add("import_ff");
|
||||
ops.add("export_ff");
|
||||
} else {
|
||||
// Pure forwarder: shows plain Import/Export/Intercity, but these run on the
|
||||
// freight_forwarder profile (see operationToProfileType).
|
||||
ops.add("import");
|
||||
ops.add("export");
|
||||
}
|
||||
}
|
||||
|
||||
// Any customer-side profile can also run domestic (intercity).
|
||||
if (isForwarder || isDirect) ops.add("intercity");
|
||||
|
||||
// Preserve a stable display order.
|
||||
return OPERATION_TYPES.filter((o) => ops.has(o));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation runs on the freight_forwarder profile. True for the
|
||||
* explicit FF variants, and for plain import/export when the company is a pure
|
||||
* forwarder (no direct importer/exporter profile).
|
||||
*/
|
||||
export function isForwarderOperation(
|
||||
op: OperationType,
|
||||
profileTypes: string[],
|
||||
): boolean {
|
||||
if (op === "import_ff" || op === "export_ff") return true;
|
||||
const has = (t: string) => profileTypes.includes(t);
|
||||
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
|
||||
const isDirect = has("importer") || has("exporter");
|
||||
if ((op === "import" || op === "export") && isForwarder && !isDirect) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Trade direction the backend will derive for a given operation type. */
|
||||
export function operationToTradeDirection(
|
||||
op: OperationType,
|
||||
): Freight.ScheduleTradeDirection {
|
||||
if (op === "import") return "IMPORT";
|
||||
if (op === "export") return "EXPORT";
|
||||
if (op === "import" || op === "import_ff") return "IMPORT";
|
||||
if (op === "export" || op === "export_ff") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
/** The company_profile type a booking for this operation should be stamped to. */
|
||||
export function operationToProfileType(op: OperationType): string {
|
||||
/**
|
||||
* The company_profile type a booking for this operation should be stamped to.
|
||||
* FF variants (and a pure forwarder's plain import/export) → freight_forwarder.
|
||||
*/
|
||||
export function operationToProfileType(
|
||||
op: OperationType,
|
||||
profileTypes: string[] = [],
|
||||
): string {
|
||||
if (op === "import_ff" || op === "export_ff") return "freight_forwarder";
|
||||
if (isForwarderOperation(op, profileTypes)) return "freight_forwarder";
|
||||
if (op === "import") return "importer";
|
||||
if (op === "export") return "exporter";
|
||||
return "freight_forwarder";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Truck } from "lucide-react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { Text } from "@mantine/core";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
@@ -52,6 +58,22 @@ const OPTIONS: Array<{
|
||||
iconBg: "#F1ECFB",
|
||||
iconColor: "#6A40B8",
|
||||
},
|
||||
{
|
||||
value: "import_ff",
|
||||
title: "Import as FF",
|
||||
description: "Import handled on behalf of a client as a freight forwarder.",
|
||||
icon: <PackageOpen className="h-5 w-5" />,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A6F4D",
|
||||
},
|
||||
{
|
||||
value: "export_ff",
|
||||
title: "Export as FF",
|
||||
description: "Export handled on behalf of a client as a freight forwarder.",
|
||||
icon: <PackageCheck className="h-5 w-5" />,
|
||||
iconBg: "#EAF1FB",
|
||||
iconColor: "#2E5B96",
|
||||
},
|
||||
];
|
||||
|
||||
export function Step0OperationType({
|
||||
@@ -84,26 +106,23 @@ export function Step0OperationType({
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{OPTIONS.map((opt) => {
|
||||
const enabled = allowedOperations.includes(opt.value);
|
||||
return (
|
||||
<OptionCard
|
||||
key={opt.value}
|
||||
selected={field.value === opt.value}
|
||||
disabled={!enabled}
|
||||
icon={opt.icon}
|
||||
iconBg={opt.iconBg}
|
||||
iconColor={opt.iconColor}
|
||||
title={opt.title}
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
if (!enabled) return;
|
||||
field.onChange(opt.value);
|
||||
onSelect?.(opt.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{OPTIONS.filter((opt) =>
|
||||
allowedOperations.includes(opt.value),
|
||||
).map((opt) => (
|
||||
<OptionCard
|
||||
key={opt.value}
|
||||
selected={field.value === opt.value}
|
||||
icon={opt.icon}
|
||||
iconBg={opt.iconBg}
|
||||
iconColor={opt.iconColor}
|
||||
title={opt.title}
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
onSelect?.(opt.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user