mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
feat: add toggle to DJF
This commit is contained in:
@@ -65,6 +65,7 @@ import {
|
||||
type ConsolidationCandidate,
|
||||
} from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { usePaymentCurrenciesQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
@@ -346,6 +347,9 @@ export default function GlCreateBookingForm() {
|
||||
// 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" | "DJF" | "">("");
|
||||
const { data: offeredCurrencies } = usePaymentCurrenciesQuery();
|
||||
// Undefined while the read is in flight — assume on, the switch is rarely off.
|
||||
const djfOffered = offeredCurrencies ? offeredCurrencies.includes("DJF") : true;
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
@@ -2359,7 +2363,9 @@ export default function GlCreateBookingForm() {
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={!isImport || requestCurrencyLocked}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
// A currency switched off in Configuration → Payments is not
|
||||
// offered, but one the customer already chose stays visible.
|
||||
allowDjf={isImport && (djfOffered || paymentCurrency === "DJF")}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -612,7 +612,7 @@ export const buildSidebarSections = (
|
||||
permission: FREIGHT_PERMS.settings.operationsStandards.view,
|
||||
},
|
||||
{
|
||||
label: "Manual payments",
|
||||
label: "Payments",
|
||||
href: "/dashboard/configuration/manual-payments",
|
||||
permission: FREIGHT_PERMS.settings.manualPayment.view,
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
MANUAL_PAYMENT_SETTINGS: {
|
||||
BASE: "/payment-settings/manual",
|
||||
CURRENCIES: "/payment-settings/manual/currencies",
|
||||
},
|
||||
|
||||
AUDIT_LOGS: {
|
||||
|
||||
@@ -9,6 +9,18 @@ import {
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
|
||||
export const PAYMENT_CURRENCIES_KEY = ["paymentCurrencies"];
|
||||
|
||||
/**
|
||||
* Currencies a booking may be billed in right now. Open read, so forms can
|
||||
* hide a switched-off currency without needing the settings permission.
|
||||
*/
|
||||
export const usePaymentCurrenciesQuery = () =>
|
||||
useQuery({
|
||||
queryKey: PAYMENT_CURRENCIES_KEY,
|
||||
queryFn: () => manualPaymentSettingsService.currencies(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
export const useManualPaymentSettingsQuery = () =>
|
||||
useQuery({
|
||||
@@ -25,13 +37,18 @@ export const useUpdateManualPaymentSettings = () => {
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
Pick<
|
||||
ManualPaymentSettings,
|
||||
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
|
||||
>
|
||||
>,
|
||||
) => manualPaymentSettingsService.update(patch),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
||||
// The Manual Payments worklist only lists enabled currencies.
|
||||
// The Manual Payments worklist only lists enabled currencies, and the
|
||||
// booking forms only offer currencies that are switched on.
|
||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||
queryClient.invalidateQueries({ queryKey: PAYMENT_CURRENCIES_KEY });
|
||||
toast.success(
|
||||
t("manualPaymentSettings.updated", "Manual payment settings updated"),
|
||||
);
|
||||
|
||||
@@ -291,8 +291,8 @@ export default function UsdPaymentsPanel({
|
||||
const { user } = useAuth();
|
||||
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
// Manual settlement is switched on per currency in Configuration →
|
||||
// Payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const currencyEnabled = manualSettings
|
||||
@@ -549,7 +549,7 @@ export default function UsdPaymentsPanel({
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Payments.`
|
||||
: controls.activeCount > 0
|
||||
? "No invoices match these filters."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
|
||||
import { AlertTriangle, Banknote, Coins, Landmark } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
@@ -53,7 +53,8 @@ const CURRENCIES: {
|
||||
];
|
||||
|
||||
/**
|
||||
* Switches the manual (offline) payment channel on or off per currency.
|
||||
* Two levels of switch: whether DJF is accepted as a payment currency at all,
|
||||
* and whether the manual (offline) channel is open, per currency.
|
||||
*
|
||||
* Off means gone, not greyed out: the Manual Payments worklist lists only
|
||||
* enabled currencies, and the API refuses a confirmation in a disabled one —
|
||||
@@ -75,11 +76,12 @@ export default function ManualPaymentSettingsCard() {
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Manual payments</CardTitle>
|
||||
<CardTitle>Payments</CardTitle>
|
||||
<CardDescription>
|
||||
Whether Finance staff may mark invoices as paid by hand, from
|
||||
Invoices → Manual Payments. Each currency is switched separately.
|
||||
Confirming still requires the payment slip and the booking's pay
|
||||
Which currencies customers may be billed in, and whether Finance
|
||||
staff may mark invoices as paid by hand from Invoices → Manual
|
||||
Payments. Each currency is switched separately. Confirming a manual
|
||||
payment still requires the payment slip and the booking's pay
|
||||
window to be open.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -95,6 +97,38 @@ export default function ManualPaymentSettingsCard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-[86px] w-full rounded-md" />
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="font-medium">Accept Djibouti Franc (DJF)</p>
|
||||
<Badge variant={data.djfPaymentsEnabled ? "default" : "secondary"}>
|
||||
{data.djfPaymentsEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Whether DJF is offered as a billing currency on new bookings and
|
||||
shipment requests, and whether DJF invoices can be paid online
|
||||
(Waafi / CAC Bank). Switching this off stops new DJF business —
|
||||
invoices already in DJF stay settleable by hand below.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={data.djfPaymentsEnabled}
|
||||
disabled={!canManage || update.isPending}
|
||||
aria-label="Accept DJF as a payment currency"
|
||||
onCheckedChange={(checked) =>
|
||||
update.mutate({ djfPaymentsEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="pt-2 text-sm font-medium">Manual (offline) settlement</p>
|
||||
|
||||
{isLoading || !data
|
||||
? CURRENCIES.map((c) => (
|
||||
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface ManualPaymentSettings {
|
||||
etbEnabled: boolean;
|
||||
usdEnabled: boolean;
|
||||
djfEnabled: boolean;
|
||||
/**
|
||||
* Wider than `djfEnabled`: whether DJF is accepted as a payment currency at
|
||||
* all — offered on the booking forms and payable online. Off leaves existing
|
||||
* DJF invoices settleable by hand.
|
||||
*/
|
||||
djfPaymentsEnabled: boolean;
|
||||
updatedById: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -24,10 +30,21 @@ export const manualPaymentSettingsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Currencies customers may be billed and pay in — open read, no permission. */
|
||||
currencies: async (): Promise<string[]> => {
|
||||
const response = await client.get<ApiResponse<string[]>>(
|
||||
URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.CURRENCIES,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Partial: an omitted currency keeps its current setting. */
|
||||
update: async (
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
Pick<
|
||||
ManualPaymentSettings,
|
||||
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
|
||||
>
|
||||
>,
|
||||
): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
/**
|
||||
* Currencies a booking may be billed and paid in right now — DJF drops out
|
||||
* when staff switch it off in Configuration → Payments. One open GET, so it
|
||||
* is fetched here rather than through a service file of its own.
|
||||
*/
|
||||
export const PAYMENT_CURRENCIES_KEY = ["payment-currencies"] as const;
|
||||
|
||||
export function usePaymentCurrencies() {
|
||||
const { data } = useQuery({
|
||||
queryKey: PAYMENT_CURRENCIES_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ApiResponse<string[]>>(
|
||||
"/api/payment-settings/manual/currencies",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
currencies: data,
|
||||
/** In flight or failed → assume on; the switch is off only by exception. */
|
||||
djfEnabled: data ? data.includes("DJF") : true,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
@@ -207,13 +208,21 @@ export function PaymentMethodModal({
|
||||
/** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */
|
||||
bill?: InvoicePaymentFlow["bill"];
|
||||
}) {
|
||||
// DJF switched off in Configuration → Payments closes its online rails; the
|
||||
// API refuses the charge too, so offering them would only fail at the gate.
|
||||
const { djfEnabled } = usePaymentCurrencies();
|
||||
const currencyOff =
|
||||
!djfEnabled && currency?.trim().toUpperCase() === "DJF";
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
providersForCurrency(currency).filter(
|
||||
(p) =>
|
||||
(otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)),
|
||||
),
|
||||
[currency, otp, bill],
|
||||
currencyOff
|
||||
? []
|
||||
: providersForCurrency(currency).filter(
|
||||
(p) =>
|
||||
(otp || !isOtpMethod(p.method)) &&
|
||||
(bill || !isBillMethod(p.method)),
|
||||
),
|
||||
[currency, otp, bill, currencyOff],
|
||||
);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
|
||||
const [mobile, setMobile] = useState("");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type BookingFormValues,
|
||||
type PaymentCurrency,
|
||||
} from "./schema";
|
||||
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||
import { OptionFieldError, StepLabel } from "./shared";
|
||||
|
||||
const CURRENCY_ICONS: Record<
|
||||
@@ -29,10 +30,14 @@ export function PaymentCurrencyField({
|
||||
*/
|
||||
allowUsd?: boolean;
|
||||
}) {
|
||||
// DJF is offered wherever USD is — both are import-shipment-only currencies.
|
||||
const options = allowUsd
|
||||
? PAYMENT_CURRENCY_OPTIONS
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
|
||||
// DJF is offered wherever USD is — both are import-shipment-only currencies
|
||||
// — and only while staff keep it switched on in Configuration → Payments.
|
||||
const { djfEnabled } = usePaymentCurrencies();
|
||||
const options = (
|
||||
allowUsd
|
||||
? PAYMENT_CURRENCY_OPTIONS
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
|
||||
).filter((o) => o.value !== "DJF" || djfEnabled);
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
ExportTrainPicker,
|
||||
OperationDatePicker,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
contractsService,
|
||||
@@ -1318,6 +1320,7 @@ function ScheduleStep({
|
||||
}) {
|
||||
const contractRouteId = form.watch("contractRouteId");
|
||||
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
||||
const { djfEnabled } = usePaymentCurrencies();
|
||||
|
||||
// Read the cargo entered in the previous step so the day list reflects what
|
||||
// can actually be shipped (matching wagons + open train capacity).
|
||||
@@ -1474,7 +1477,8 @@ function ScheduleStep({
|
||||
onChange={(v) => field.onChange(v)}
|
||||
error={fieldState.error?.message}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
// Hidden once staff switch DJF off, unless it is already picked.
|
||||
allowDjf={isImport && (djfEnabled || field.value === "DJF")}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CurrencySelector } from "@edr/ui-common";
|
||||
|
||||
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
|
||||
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -39,6 +41,7 @@ export default function NewShipmentRequestPage() {
|
||||
// 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 { djfEnabled } = usePaymentCurrencies();
|
||||
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
@@ -267,7 +270,7 @@ export default function NewShipmentRequestPage() {
|
||||
}}
|
||||
disabled={isIntercity || isExport}
|
||||
allowUsd={!isIntercity && !isExport}
|
||||
allowDjf={!isIntercity && !isExport}
|
||||
allowDjf={!isIntercity && !isExport && djfEnabled}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user