mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
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
296 lines
11 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|