feat: add toggle to DJF

This commit is contained in:
ghost2023
2026-09-06 16:35:14 +03:00
parent aa96bb4086
commit eab36a2526
21 changed files with 337 additions and 41 deletions

View File

@@ -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>

View File

@@ -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,
},

View File

@@ -63,6 +63,7 @@ export const URL_CONSTANTS = {
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
CURRENCIES: "/payment-settings/manual/currencies",
},
AUDIT_LOGS: {

View File

@@ -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"),
);

View File

@@ -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.`

View File

@@ -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&apos;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&apos;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" />

View File

@@ -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>>(

View File

@@ -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,
};
}

View File

@@ -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("");

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>