mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 04:33:40 +00:00
feat(web): offer the currencies the API actually accepts
Every currency picker decided for itself which currencies existed, from a hardcoded pair, so an administrator's setting and the form could disagree and the customer would only find out on submit. They now read GET /exchange-settings/currencies. CurrencySelector's allowUsd boolean becomes an allowed list. The caller is choosing on two independent axes — trade direction (export and intercity invoice in ETB whatever is picked) and what is switched on — so a second boolean would have needed a third one next time. Adds the DJF card and option with its own hint, the DJF tab on the finance hub and the DJF row on the manual-payment settings card, both driven by the per-currency flag rather than an if/else on two currencies. The exchange-rate settings card gets the toggle itself, with the wording that turning it off stops new choices rather than changing bookings already priced in DJF. The inline "ETB" | "USD" unions on the invoice filter and the customer shipment and payment types are widened, so a DJF invoice is not mistyped on arrival. formatMoney needs no change — it already defaults to 0 fraction digits, which is correct for DJF.
This commit is contained in:
@@ -92,6 +92,8 @@ import {
|
||||
emptyPartnerUnit,
|
||||
} from "./gl-booking-form/ConsolidationPartnerPanel";
|
||||
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
|
||||
import { isPaymentCurrency, type PaymentCurrency } from "@edr/types";
|
||||
import { useEnabledCurrenciesQuery } from "@/hooks/useExchangeSettings";
|
||||
|
||||
/**
|
||||
* Container sizes offered on the parent-booking panel. Fixed rather than taken
|
||||
@@ -290,6 +292,10 @@ export default function GlCreateBookingForm() {
|
||||
const requestBulkLocked =
|
||||
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
|
||||
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
|
||||
// What an administrator has switched on, not a hardcoded pair — a currency turned off in
|
||||
// Exchange Settings disappears from the picker instead of 400ing on submit.
|
||||
const { data: enabledCurrencies } = useEnabledCurrenciesQuery();
|
||||
const importCurrencies = enabledCurrencies ?? ["ETB", "USD"];
|
||||
|
||||
// The expired booking a Rebook is copying from (its cargo seeds the form).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
@@ -344,7 +350,7 @@ export default function GlCreateBookingForm() {
|
||||
const [notes, setNotes] = useState("");
|
||||
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
||||
// deliberate (required before pricing). Everything else is forced to ETB.
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<PaymentCurrency | "">("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
@@ -570,8 +576,7 @@ export default function GlCreateBookingForm() {
|
||||
// Currency is the customer's choice on the request — seed it here; the
|
||||
// selector below is disabled while the request specifies one.
|
||||
if (
|
||||
bookingRequest.paymentCurrency === "USD" ||
|
||||
bookingRequest.paymentCurrency === "ETB"
|
||||
isPaymentCurrency(bookingRequest.paymentCurrency)
|
||||
) {
|
||||
setPaymentCurrency(bookingRequest.paymentCurrency);
|
||||
}
|
||||
@@ -1131,7 +1136,7 @@ export default function GlCreateBookingForm() {
|
||||
]);
|
||||
|
||||
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
|
||||
const effectiveCurrency: "USD" | "ETB" =
|
||||
const effectiveCurrency: PaymentCurrency =
|
||||
isImport && paymentCurrency ? paymentCurrency : "ETB";
|
||||
const currencyError =
|
||||
isImport && !paymentCurrency
|
||||
@@ -1227,8 +1232,7 @@ export default function GlCreateBookingForm() {
|
||||
// The partner's customer chose its own currency on its shipment request;
|
||||
// only a partner without a request falls back to this booking's currency.
|
||||
paymentCurrency:
|
||||
partnerRequest?.paymentCurrency === "USD" ||
|
||||
partnerRequest?.paymentCurrency === "ETB"
|
||||
isPaymentCurrency(partnerRequest?.paymentCurrency)
|
||||
? partnerRequest.paymentCurrency
|
||||
: effectiveCurrency,
|
||||
...(scheduledDate
|
||||
@@ -2325,14 +2329,14 @@ export default function GlCreateBookingForm() {
|
||||
{requestCurrencyLocked
|
||||
? "The customer chose the billing currency on the shipment request — it cannot be changed."
|
||||
: isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
? `Import shipments may be invoiced in ${importCurrencies.join(", ")}. Only ETB is paid online; the rest settle by bank transfer.`
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
value={isImport ? paymentCurrency : "ETB"}
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={!isImport || requestCurrencyLocked}
|
||||
allowUsd={isImport}
|
||||
allowed={isImport ? importCurrencies : ["ETB"]}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -32,3 +32,38 @@ export const useSetExchangeFallbackRate = () => {
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetDjfEnabled = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) => exchangeSettingsService.setDjfEnabled(enabled),
|
||||
onSuccess: (settings) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ENABLED_CURRENCIES_KEY });
|
||||
toast.success(
|
||||
settings.djfEnabled
|
||||
? t("exchangeSettings.djfEnabled", "Djiboutian Franc billing enabled")
|
||||
: t("exchangeSettings.djfDisabled", "Djiboutian Franc billing disabled"),
|
||||
);
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
const ENABLED_CURRENCIES_KEY = ["enabledCurrencies"];
|
||||
|
||||
/**
|
||||
* Currencies a booking may be billed in right now. Every currency picker reads this
|
||||
* instead of hardcoding a list, so switching one off in Exchange Settings removes it from
|
||||
* the forms rather than leaving a choice the API rejects.
|
||||
*/
|
||||
export const useEnabledCurrenciesQuery = () =>
|
||||
useQuery({
|
||||
queryKey: ENABLED_CURRENCIES_KEY,
|
||||
queryFn: () => exchangeSettingsService.enabledCurrencies(),
|
||||
// Changes only when an administrator flips a toggle.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const useUpdateManualPaymentSettings = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">>,
|
||||
) => manualPaymentSettingsService.update(patch),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Banknote, DollarSign, Receipt } from "lucide-react";
|
||||
import { Banknote, DollarSign, Landmark, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
@@ -53,10 +54,27 @@ const TABS = [
|
||||
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="USD" />,
|
||||
},
|
||||
{
|
||||
key: "manual-payments-djf",
|
||||
label: "Manual Payments (DJF)",
|
||||
icon: Landmark,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
manualCurrency: "DJF",
|
||||
subtitle:
|
||||
"Import and export invoices in DJF that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="DJF" />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/** Currency → the flag on the settings row. Mirrors the API's own TOGGLE_COLUMN map. */
|
||||
const MANUAL_TOGGLE = {
|
||||
ETB: "etbEnabled",
|
||||
USD: "usdEnabled",
|
||||
DJF: "djfEnabled",
|
||||
} as const satisfies Record<PaymentCurrency, string>;
|
||||
|
||||
export default function FinanceHubPage() {
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -64,9 +82,8 @@ export default function FinanceHubPage() {
|
||||
// A currency whose manual-payment channel is switched off has no tab at all
|
||||
// — the list would be empty and every confirmation refused.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const manualEnabled = (currency: "ETB" | "USD") =>
|
||||
!manualSettings ||
|
||||
(currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
|
||||
const manualEnabled = (currency: PaymentCurrency) =>
|
||||
!manualSettings || Boolean(manualSettings[MANUAL_TOGGLE[currency]]);
|
||||
|
||||
const visibleTabs = TABS.filter(
|
||||
(tab) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, type PaymentCurrency } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -274,7 +274,7 @@ function ConfirmCell({
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
currency: PaymentCurrency;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
@@ -296,9 +296,13 @@ export default function UsdPaymentsPanel({
|
||||
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const currencyEnabled = manualSettings
|
||||
? currency === "ETB"
|
||||
? manualSettings.etbEnabled
|
||||
: manualSettings.usdEnabled
|
||||
? Boolean(
|
||||
manualSettings[
|
||||
({ ETB: "etbEnabled", USD: "usdEnabled", DJF: "djfEnabled" } as const)[
|
||||
currency
|
||||
]
|
||||
],
|
||||
)
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react";
|
||||
|
||||
import {
|
||||
useExchangeSettingsQuery,
|
||||
useSetDjfEnabled,
|
||||
useSetExchangeFallbackRate,
|
||||
} from "@/hooks/useExchangeSettings";
|
||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||
@@ -47,6 +48,7 @@ const formatTime = (value: string | null) =>
|
||||
export default function ExchangeRateSettingsCard() {
|
||||
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
|
||||
const setRate = useSetExchangeFallbackRate();
|
||||
const setDjf = useSetDjfEnabled();
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
|
||||
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
||||
@@ -151,6 +153,29 @@ export default function ExchangeRateSettingsCard() {
|
||||
)}).`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4"
|
||||
disabled={isLoading || setDjf.isPending}
|
||||
checked={data?.djfEnabled ?? false}
|
||||
onChange={(e) => setDjf.mutate(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
Allow billing in Djiboutian Franc (DJF)
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
Adds DJF to the currency choice on new bookings and shipment
|
||||
requests. CBE quotes DJF in the same feed as USD, so no separate
|
||||
rate is needed. Turning it off stops new choices; bookings
|
||||
already priced in DJF keep their currency.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,11 +18,11 @@ import {
|
||||
useUpdateManualPaymentSettings,
|
||||
} from "@/hooks/useManualPaymentSettings";
|
||||
|
||||
type Currency = "ETB" | "USD";
|
||||
type Currency = PaymentCurrency;
|
||||
|
||||
const CURRENCIES: {
|
||||
code: Currency;
|
||||
field: "etbEnabled" | "usdEnabled";
|
||||
field: "etbEnabled" | "usdEnabled" | "djfEnabled";
|
||||
icon: typeof Banknote;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -42,6 +43,14 @@ const CURRENCIES: {
|
||||
description:
|
||||
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
|
||||
},
|
||||
{
|
||||
code: "DJF",
|
||||
field: "djfEnabled",
|
||||
icon: Landmark,
|
||||
title: "Franc (DJF) invoices",
|
||||
description:
|
||||
"DJF invoices settle on the Djibouti side. Switching this off leaves DJF customers with no way to be marked as paid.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -60,7 +69,9 @@ export default function ManualPaymentSettingsCard() {
|
||||
const { data, isLoading } = useManualPaymentSettingsQuery();
|
||||
const update = useUpdateManualPaymentSettings();
|
||||
|
||||
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
|
||||
const noneEnabled = Boolean(
|
||||
data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
@@ -25,6 +26,8 @@ export interface ExchangeSettings {
|
||||
fallbackSource: "AUTO" | "MANUAL";
|
||||
lastSyncedAt: string | null;
|
||||
updatedById: string | null;
|
||||
/** Whether Djiboutian Franc may be chosen as a billing currency. */
|
||||
djfEnabled: boolean;
|
||||
feed?: ExchangeFeedStatus;
|
||||
}
|
||||
|
||||
@@ -40,4 +43,24 @@ export const exchangeSettingsService = {
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setDjfEnabled: async (djfEnabled: boolean): Promise<ExchangeSettings> => {
|
||||
// Sent alone: the PATCH leaves an omitted field untouched, so this must not
|
||||
// re-submit the fallback rate and re-stamp it MANUAL.
|
||||
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||
djfEnabled,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Currencies a booking or contract may be billed in right now. Readable by customers
|
||||
* too, so the portal's picker offers exactly what the API will accept.
|
||||
*/
|
||||
enabledCurrencies: async (): Promise<PaymentCurrency[]> => {
|
||||
const response = await client.get<ApiResponse<{ currencies: PaymentCurrency[] }>>(
|
||||
`${BASE}/currencies`,
|
||||
);
|
||||
return unwrap(response.data).currencies;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
|
||||
export interface ManualPaymentSettings {
|
||||
etbEnabled: boolean;
|
||||
usdEnabled: boolean;
|
||||
djfEnabled: boolean;
|
||||
updatedById: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -25,7 +26,7 @@ export const manualPaymentSettingsService = {
|
||||
|
||||
/** Partial: an omitted currency keeps its current setting. */
|
||||
update: async (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">>,
|
||||
): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||
BASE,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Customer-management types for the freight backoffice.
|
||||
*
|
||||
@@ -420,7 +422,7 @@ export interface CustomerBooking {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
totalAmount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: PaymentCurrency;
|
||||
scheduledDate?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -469,7 +471,7 @@ export interface CustomerPayment {
|
||||
/** Booking reference the payment settles. */
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: PaymentCurrency;
|
||||
method: CustomerPaymentMethod;
|
||||
status: CustomerPaymentStatus;
|
||||
paidAt?: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { Freight, PaymentCurrency } from "@edr/types";
|
||||
|
||||
/**
|
||||
* What an invoice's `sourceId` points at, resolved server-side for display.
|
||||
@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
paymentMethods?: string;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
currency?: PaymentCurrency;
|
||||
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const KEY = ["enabled-currencies"] as const;
|
||||
|
||||
/**
|
||||
* Currencies a booking may be billed in right now.
|
||||
*
|
||||
* The picker used to hardcode ETB/USD, which meant a currency an administrator had not
|
||||
* enabled could still be chosen and only fail on submit. The endpoint is readable by
|
||||
* customers precisely so the form and the API agree.
|
||||
*
|
||||
* Falls back to ETB alone while loading — never to a guess that might not be accepted.
|
||||
*/
|
||||
export const useEnabledCurrencies = () =>
|
||||
useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: async (): Promise<PaymentCurrency[]> => {
|
||||
const response =
|
||||
await client.get<ApiResponse<{ currencies: PaymentCurrency[] }>>(
|
||||
"/exchange-settings/currencies",
|
||||
);
|
||||
return unwrap(response.data).currencies;
|
||||
},
|
||||
// Changes only when an administrator flips a toggle.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import { SelectField } from "./new-booking-form/shared";
|
||||
import { LocationPicker } from "./new-booking-form/LocationPicker";
|
||||
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
|
||||
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
|
||||
import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
|
||||
|
||||
const EDIT_SECTIONS = [
|
||||
"service",
|
||||
@@ -291,6 +292,9 @@ const DIRECTION_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function EditBookingPage() {
|
||||
// Offered currencies come from the API, so a currency an administrator has not
|
||||
// enabled is never presented as a choice that would 400 on submit.
|
||||
const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -686,7 +690,7 @@ export default function EditBookingPage() {
|
||||
{/* USD billing is import-only; export/intercity stay ETB. */}
|
||||
<PaymentCurrencyField
|
||||
control={form.control}
|
||||
allowUsd={operationType === "import"}
|
||||
allowed={operationType === "import" ? enabledCurrencies : ["ETB"]}
|
||||
/>
|
||||
|
||||
{(selectedService?.includesFirstMile ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { Banknote, Check, DollarSign } from "lucide-react";
|
||||
import { Banknote, Check, DollarSign, Landmark } from "lucide-react";
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import {
|
||||
PAYMENT_CURRENCY_OPTIONS,
|
||||
@@ -15,28 +15,31 @@ const CURRENCY_ICONS: Record<
|
||||
> = {
|
||||
USD: { icon: DollarSign, color: "#4F46E5" },
|
||||
ETB: { icon: Banknote, color: "#0A6F4D" },
|
||||
DJF: { icon: Landmark, color: "#B45309" },
|
||||
};
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
control,
|
||||
allowUsd = false,
|
||||
allowed = ["ETB"],
|
||||
}: {
|
||||
control: Control<BookingFormInputValues, any, BookingFormValues>;
|
||||
/**
|
||||
* Offer USD alongside ETB. Import shipments only — export and domestic
|
||||
* traffic is always invoiced in ETB.
|
||||
* Which currencies to offer. Export and domestic traffic is always invoiced in ETB, so
|
||||
* the caller narrows this to ETB there; on import it passes whatever an administrator
|
||||
* has enabled.
|
||||
*/
|
||||
allowUsd?: boolean;
|
||||
allowed?: readonly PaymentCurrency[];
|
||||
}) {
|
||||
const options = allowUsd
|
||||
? PAYMENT_CURRENCY_OPTIONS
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
|
||||
const options = PAYMENT_CURRENCY_OPTIONS.filter((o) =>
|
||||
allowed.includes(o.value),
|
||||
);
|
||||
const foreign = options.filter((o) => o.value !== "ETB").map((o) => o.value);
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||
{allowUsd
|
||||
? "Choose the currency for your freight quote and invoices. USD is paid by bank transfer, not online."
|
||||
{foreign.length > 0
|
||||
? `Choose the currency for your freight quote and invoices. ${foreign.join(" and ")} ${foreign.length > 1 ? "are" : "is"} paid by bank transfer, not online.`
|
||||
: "Choose the currency for your freight quote and invoices."}
|
||||
</Text>
|
||||
<Controller
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
PAYMENT_CURRENCIES as SHARED_PAYMENT_CURRENCIES,
|
||||
type PaymentCurrency as SharedPaymentCurrency,
|
||||
} from "@edr/types";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { DeepPartial, Path } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
@@ -73,11 +77,13 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
||||
|
||||
export type BookingDocuments = Record<string, File | File[] | null>;
|
||||
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||
// Re-exported from @edr/types: this list and the one the API validates against had already
|
||||
// drifted, and a form offering a currency the API rejects fails only on submit.
|
||||
export { PAYMENT_CURRENCIES } from "@edr/types";
|
||||
export type { PaymentCurrency } from "@edr/types";
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
value: PaymentCurrency;
|
||||
value: SharedPaymentCurrency;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
@@ -92,6 +98,12 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: "USD",
|
||||
description: "US Dollar — paid by bank transfer, slip sent to Finance.",
|
||||
},
|
||||
{
|
||||
value: "DJF",
|
||||
label: "DJF",
|
||||
description:
|
||||
"Djiboutian Franc — settled on the Djibouti side, in whole francs (no centimes).",
|
||||
},
|
||||
];
|
||||
|
||||
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
|
||||
@@ -108,7 +120,7 @@ export const bookingFormSchema = z
|
||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||
previousContractRef: z.string(),
|
||||
serviceTypeId: z.string("Select a service type."),
|
||||
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||
paymentCurrency: z.enum(SHARED_PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||
|
||||
firstMile: z
|
||||
.object({
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PaymentCurrencyField } from "./payment-currency-field";
|
||||
import { LocationPicker } from "./LocationPicker";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -31,6 +32,9 @@ export function Step2ServiceType({
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
}) {
|
||||
const serviceTypeId = form.watch("serviceTypeId");
|
||||
// Offered currencies come from the API, so a currency an administrator has not
|
||||
// enabled is never presented as a choice that would 400 on submit.
|
||||
const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
|
||||
const operationType = form.watch("operationType");
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === serviceTypeId,
|
||||
@@ -136,7 +140,7 @@ export function Step2ServiceType({
|
||||
{/* USD billing is import-only; export/intercity stay ETB. */}
|
||||
<PaymentCurrencyField
|
||||
control={form.control}
|
||||
allowUsd={operationType === "import"}
|
||||
allowed={operationType === "import" ? enabledCurrencies : ["ETB"]}
|
||||
/>
|
||||
|
||||
{showServiceSections && (
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
} from "./new-shipment-form/container-excel";
|
||||
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
|
||||
|
||||
type ShipmentForm = ReturnType<
|
||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||
@@ -1301,6 +1302,9 @@ function ScheduleStep({
|
||||
}) {
|
||||
const contractRouteId = form.watch("contractRouteId");
|
||||
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
||||
// Offered currencies come from the API, so a currency an administrator has not
|
||||
// enabled is never presented as a choice that would 400 on submit.
|
||||
const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
|
||||
|
||||
// Read the cargo entered in the previous step so the day list reflects what
|
||||
// can actually be shipped (matching wagons + open train capacity).
|
||||
@@ -1449,14 +1453,14 @@ function ScheduleStep({
|
||||
<StepLabel>Billing currency *</StepLabel>
|
||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||
{isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
? `Import shipments may be invoiced in ${enabledCurrencies.join(", ")}. Only ETB is paid online; the rest settle by bank transfer.`
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
value={field.value || ""}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
error={fieldState.error?.message}
|
||||
allowUsd={isImport}
|
||||
allowed={isImport ? enabledCurrencies : ["ETB"]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -21,10 +21,15 @@ import type { Freight } from "@edr/types";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
export default function NewShipmentRequestPage() {
|
||||
// Offered currencies come from the API, so a currency an administrator has not
|
||||
// enabled is never presented as a choice that would 400 on submit.
|
||||
const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
@@ -38,7 +43,7 @@ export default function NewShipmentRequestPage() {
|
||||
// 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" | "">("");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<PaymentCurrency | "">("");
|
||||
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
@@ -125,7 +130,7 @@ export default function NewShipmentRequestPage() {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
paymentCurrency:
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as PaymentCurrency),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -266,7 +271,7 @@ export default function NewShipmentRequestPage() {
|
||||
setCurrencyError(undefined);
|
||||
}}
|
||||
disabled={isIntercity || isExport}
|
||||
allowUsd={!isIntercity && !isExport}
|
||||
allowed={!isIntercity && !isExport ? enabledCurrencies : ["ETB"]}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,47 +1,58 @@
|
||||
import type { PaymentCurrency } from "@edr/types";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export interface CurrencySelectorProps {
|
||||
/** Selected currency code, or "" when none picked yet. */
|
||||
value: string;
|
||||
onChange: (currency: "USD" | "ETB") => void;
|
||||
onChange: (currency: PaymentCurrency) => void;
|
||||
disabled?: boolean;
|
||||
/** Validation error shown under the cards. */
|
||||
error?: string;
|
||||
/**
|
||||
* Offer USD alongside ETB. Import shipments only — export and domestic
|
||||
* traffic is invoiced in ETB, so the option stays hidden everywhere else.
|
||||
* USD is settled by bank transfer, never through the online gateway.
|
||||
* Which currencies to offer, in display order. ETB alone by default.
|
||||
*
|
||||
* Was a single `allowUsd` boolean; a list instead, because the caller now decides from
|
||||
* two independent things — the trade direction (export and domestic traffic is invoiced
|
||||
* in ETB) and what an administrator has switched on. A second boolean would have needed
|
||||
* a third the next time.
|
||||
*/
|
||||
allowUsd?: boolean;
|
||||
allowed?: readonly PaymentCurrency[];
|
||||
}
|
||||
|
||||
const ETB_OPTION = {
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
hint: "Pay online through the payment gateway",
|
||||
} as const;
|
||||
|
||||
const USD_OPTION = {
|
||||
code: "USD",
|
||||
symbol: "$",
|
||||
name: "US Dollar",
|
||||
hint: "Paid by bank transfer — send the slip to Finance",
|
||||
} as const;
|
||||
const OPTIONS: Record<PaymentCurrency, { code: PaymentCurrency; symbol: string; name: string; hint: string }> = {
|
||||
ETB: {
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
hint: "Pay online through the payment gateway",
|
||||
},
|
||||
USD: {
|
||||
code: "USD",
|
||||
symbol: "$",
|
||||
name: "US Dollar",
|
||||
hint: "Paid by bank transfer — send the slip to Finance",
|
||||
},
|
||||
DJF: {
|
||||
code: "DJF",
|
||||
symbol: "Fdj",
|
||||
name: "Djiboutian Franc",
|
||||
hint: "Djibouti-side settlement — no centimes, amounts are whole francs",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
|
||||
* is "" so a required choice never looks pre-made.
|
||||
* Card-style billing-currency picker. Renders unselected when `value` is ""
|
||||
* so a required choice never looks pre-made.
|
||||
*/
|
||||
export function CurrencySelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
allowUsd = false,
|
||||
allowed = ["ETB"],
|
||||
}: CurrencySelectorProps) {
|
||||
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
|
||||
const options = allowed.map((code) => OPTIONS[code]).filter(Boolean);
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user