Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
ghost2023 f451d0106d feat(freight-portal): support DJF as a billing and payment currency
lib/currency.ts re-exports the shared @edr/ui-common formatter (was a
local implementation always forcing 2 decimals, wrong for DJF).
Currency pickers (new-booking-form, new-contract-form, new-shipment
Currency selector and schemas) offer DJF wherever USD is offered.

The bigger piece: offline-payment.ts's isUsdCurrency/isUsdOfflineBooking
assumed exactly two payment rails (ETB online, USD offline) and picked
one. DJF supports BOTH, so it's replaced with independent
canPayOnline/canPayOffline predicates, updated across the 5 call sites
that gated the Pay button vs. the bank-transfer badge. PaymentMethodModal's
WAAFI/CAC Bank entries (Djibouti gateways mislabeled USD-only) now list
DJF, and a currency that matches no provider returns no providers
instead of silently offering all of them (was returning every provider,
including the ETB-only one, on any unmatched currency).

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
2026-09-04 11:53:19 +03:00

296 lines
11 KiB
TypeScript

import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
Loader,
NumberInput,
Paper,
Stack,
Text,
Textarea,
Title,
} from "@mantine/core";
import { CurrencySelector } from "@edr/ui-common";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
import { contractsService } from "@/services/contracts.service";
const BORDER = "#E6ECF2";
export default function NewShipmentRequestPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
// Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
const [qtyBySize, setQtyBySize] = useState<Record<string, number | string>>(
{},
);
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
const [bulkAmount, setBulkAmount] = useState<number | string>("");
// GL books this shipment on the customer's behalf, so the currency they want
// to be invoiced in has to be stated here — the contract itself quotes USD.
// Starts empty so the billing-currency choice is deliberate — required at
// submit. Intercity/export are forced to ETB (server-enforced too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
const [currencyError, setCurrencyError] = useState<string | undefined>();
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
queryKey: ["contract", id],
queryFn: () => contractsService.get(id!),
enabled: Boolean(id),
});
const { data: capacity } = useQuery({
queryKey: ["contract-capacity", id],
queryFn: () => contractsService.getCapacity(id!),
enabled: Boolean(id) && contract?.contractKind === "GENERAL",
});
const submit = useMutation({
mutationFn: (dto: Freight.CreateBookingRequestDto) =>
contractsService.submitBookingRequest(id!, dto),
onSuccess: (request) => {
// Clearance-first flow: the request auto-initiates a booking instance —
// send the customer straight to it. The clearance service fee is due
// first; document upload unlocks once it settles.
if (request.createdBookingId) {
toast.success(
"Shipment initiated — pay the clearance service fee to unlock the document upload.",
);
navigate(`/bookings/${request.createdBookingId}`);
} else {
toast.success("Shipment request submitted");
navigate(`/contracts/${id}`);
}
},
onError: (e: Error) => toast.error(e.message || "Could not submit request"),
});
if (isLoading || !contract) {
return (
<Group justify="center" py={80}>
<Loader color="teal" />
</Group>
);
}
const isContainer = contract.freightType === "CONTAINER";
const route = contract.routes?.[0];
// GENERAL customs contracts: GL schedules the shipment during clearance —
// the customer only states the quantity (and billing currency), never a date.
const hasCustoms =
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
const isIntercity = contract.tradeDirection === "DOMESTIC";
// Export shipments are invoiced in ETB only — USD is not offered.
const isExport = contract.tradeDirection === "EXPORT";
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
const enabledSizes = SIZE_ORDER.filter((s) =>
contract.cargoScope?.some(
(l) => (l.containerSize ?? "").toLowerCase() === s,
),
);
// A CONTAINER contract should always carry scope lines; fall back to both.
const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
// Bulk: pick the bulk scope line and read how it's measured.
const bulkScope =
contract.cargoScope?.find((l) => !l.containerSize) ??
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon. An odd total no longer blocks the
// request — the server auto-pairs it with another customer's odd booking, or
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
// gate the direct-booking flow already uses).
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (!isIntercity && !isExport && !paymentCurrency) {
setCurrencyError("Select the billing currency for this shipment.");
return;
}
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency:
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB" | "DJF"),
notes: notes.trim() || undefined,
};
if (isContainer) {
// One line per size the user filled; 0 (or blank) sizes are dropped.
const containers = sizes
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
.filter((line) => line.quantity > 0);
if (containers.length === 0) {
toast.error("Enter a quantity for at least one container size");
return;
}
dto.containers = containers;
} else {
const amount = Number(bulkAmount) || 0;
if (amount <= 0) {
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
return;
}
dto.bulk = {
cargoTypeId: bulkScope?.cargoTypeId ?? null,
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
};
}
submit.mutate(dto);
};
return (
<Box style={{ padding: "28px 32px 40px", maxWidth: 720, margin: "0 auto" }}>
<Stack gap="lg">
<Group gap="md">
<Button variant="subtle" color="gray" onClick={() => navigate(`/contracts/${id}`)}>
<ArrowLeft size={18} />
</Button>
<div>
<Title order={3}>Request shipment</Title>
<Text size="sm" c="dimmed">
{contract.reference} Global Logistics will review and create your booking.
</Text>
</div>
</Group>
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
<Stack gap="md">
{!hasCustoms && (
<DatePickerInput
label="Preferred shipment date"
placeholder="Pick a date"
leftSection={<CalendarDays size={16} />}
minDate={new Date().toISOString().slice(0, 10)}
value={scheduledDate || null}
onChange={(v) => setScheduledDate(v ?? "")}
radius="md"
popoverProps={{ withinPortal: true }}
/>
)}
{isContainer ? (
<Stack gap="sm">
{sizes.map((size) => (
<NumberInput
key={size}
label={`Number of ${size} containers`}
value={qtyBySize[size] ?? 0}
onChange={(v) =>
setQtyBySize((prev) => ({ ...prev, [size]: v }))
}
min={0}
allowDecimal={false}
/>
))}
{sizes.length > 1 ? (
<Text size="xs" c="dimmed">
Enter a quantity for each size you need leave a size at 0 if
you don&apos;t need it.
</Text>
) : null}
{hasCustoms ? (
<Text size="xs" c="dimmed">
Global Logistics schedules the shipment date during customs
clearance you only state the quantity.
</Text>
) : null}
</Stack>
) : (
<NumberInput
label={isPerItem ? "Number of items" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={bulkAmount}
onChange={setBulkAmount}
min={0}
allowDecimal={!isPerItem}
/>
)}
{hasOdd20ft ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon. This request will be
paired with another customer&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
) : null}
{capacity?.length ? (
<Text size="xs" c="dimmed">
Remaining capacity is shown on the contract GL will validate your request.
</Text>
) : null}
<Box>
<Text size="sm" fw={600} mb={4}>
Billing currency *
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: isExport
? "Export shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
</Text>
<CurrencySelector
value={isIntercity || isExport ? "ETB" : paymentCurrency}
onChange={(v) => {
setPaymentCurrency(v);
setCurrencyError(undefined);
}}
disabled={isIntercity || isExport}
allowUsd={!isIntercity && !isExport}
allowDjf={!isIntercity && !isExport}
error={currencyError}
/>
</Box>
<Textarea
label="Notes (optional)"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={2}
/>
<Button
color="teal"
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
>
Submit shipment request
</Button>
</Stack>
</Paper>
</Stack>
</Box>
);
}