From 34c49e4c50dbdb25f529671d6c658de71c450eea Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:53:39 +0300 Subject: [PATCH] feat(freight-backoffice): rework settings pages for the per-currency API exchangeSettings.service/hook/card follow the backend's new shape: a single ExchangeSettings object becomes a list, get() becomes list(), and setFallbackRate(rate) becomes setFallbackRate(currency, rate). ExchangeRateSettingsCard now renders one row per currency instead of one hardcoded USD->ETB form. manualPaymentSettings.service/hook/card add djfEnabled alongside etb/usdEnabled, matching the backend's new column. InvoicesPage's collected-summary KPI strip adds a DJF tile and folds its ETB-equivalent into the existing 'Total collected' conversion, reading each currency's rate from the now-list-shaped settings query. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../src/hooks/useExchangeSettings.ts | 6 +- .../src/hooks/useManualPaymentSettings.ts | 4 +- .../src/pages/invoices/InvoicesPage.tsx | 30 ++- .../settings/ExchangeRateSettingsCard.tsx | 190 ++++++++++-------- .../settings/ManualPaymentSettingsCard.tsx | 18 +- .../src/services/exchangeSettings.service.ts | 28 ++- .../services/manualPaymentSettings.service.ts | 5 +- 7 files changed, 177 insertions(+), 104 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts index d5fca3c6e..b7c26c152 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; const QUERY_KEY = ["exchangeSettings"]; +/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */ export const useExchangeSettingsQuery = () => useQuery({ queryKey: QUERY_KEY, - queryFn: () => exchangeSettingsService.get(), + queryFn: () => exchangeSettingsService.list(), // Feed health is only interesting while it is being looked at. staleTime: 30_000, refetchOnWindowFocus: true, @@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => { const { handleError } = useErrorHandler(t); return useMutation({ - mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), + mutationFn: ({ currency, rate }: { currency: string; rate: number }) => + exchangeSettingsService.setFallbackRate(currency, rate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); toast.success( diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts index b49f33376..cbb5f5996 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts @@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => { return useMutation({ mutationFn: ( - patch: Partial>, + patch: Partial< + Pick + >, ) => manualPaymentSettingsService.update(patch), onSuccess: (data) => { queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 2b319de30..de8d46ce3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [ options: [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ], }, { @@ -235,8 +236,23 @@ export default function InvoicesPanel() { const { data: exchangeSettings } = useExchangeSettingsQuery(); const etbCollected = summary?.ETB ?? 0; const usdCollected = summary?.USD ?? 0; - const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate; - const etbFromUsd = rate ? usdCollected * rate : null; + const djfCollected = summary?.DJF ?? 0; + const rateFor = (currency: string) => { + const setting = exchangeSettings?.find((s) => s.currency === currency); + return setting?.feed?.rate ?? setting?.fallbackRate ?? null; + }; + const usdRate = rateFor("USD"); + const djfRate = rateFor("DJF"); + const etbFromUsd = usdRate ? usdCollected * usdRate : null; + const etbFromDjf = djfRate ? djfCollected * djfRate : null; + const totalEtb = etbCollected + (etbFromUsd ?? 0) + (etbFromDjf ?? 0); + const totalHint = [ + "ETB", + etbFromUsd !== null ? "USD" : null, + etbFromDjf !== null ? "DJF" : null, + ] + .filter(Boolean) + .join(" + "); const columns: ColumnDef[] = useMemo( () => [ @@ -356,8 +372,8 @@ export default function InvoicesPanel() { items={[ { label: "Total collected", - hint: etbFromUsd !== null ? "ETB + USD" : "ETB only", - value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"), + hint: totalHint, + value: formatMoney(totalEtb, "ETB"), icon: CircleDollarSign, color: "edr-green", }, @@ -373,6 +389,12 @@ export default function InvoicesPanel() { icon: Landmark, color: "violet", }, + { + label: "Collected in DJF", + value: formatMoney(djfCollected, "DJF"), + icon: Landmark, + color: "orange", + }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx index da01b51ce..beee6c2a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -14,7 +14,10 @@ import { useExchangeSettingsQuery, useSetExchangeFallbackRate, } from "@/hooks/useExchangeSettings"; -import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; +import type { + ExchangeRateSource, + ExchangeSetting, +} from "@/services/exchangeSettings.service"; import { formatDateTime } from "@/lib/format"; /** Feed health, phrased for an operator rather than a developer. */ @@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): { const formatTime = (value: string | null) => value ? formatDateTime(value) : "never"; -/** - * USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. - * The live CBE rate always wins; every successful fetch overwrites the stored - * value, so it tracks the last known good rate on its own. Editing here is for - * a prolonged outage — the next successful CBE fetch replaces it. - */ -export default function ExchangeRateSettingsCard() { - const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); +/** One currency's fallback row — its own draft, its own save. */ +function ExchangeRateRow({ + setting, + disabled, +}: { + setting: ExchangeSetting; + disabled: boolean; +}) { const setRate = useSetExchangeFallbackRate(); const [draft, setDraft] = useState(""); - const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); + const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? ""); const parsed = Number(value); - const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; - const dirty = draft !== "" && parsed !== data?.fallbackRate; + const invalid = !Number.isFinite(parsed) || parsed <= 0; + const dirty = draft !== "" && parsed !== setting.fallbackRate; - const feed = feedLabel(data?.feed?.source ?? null); + const feed = feedLabel(setting.feed?.source ?? null); const handleSave = async () => { if (invalid) return; - await setRate.mutateAsync(parsed); + await setRate.mutateAsync({ currency: setting.currency, rate: parsed }); setDraft(""); }; + return ( +
+
+ {feed.live ? ( + + ) : ( + + )} +
+

+ {setting.currency} → ETB — {feed.text} +

+ {setting.feed?.rate != null && ( +

+ Rate in use: {setting.feed.rate} ETB per {setting.currency} +

+ )} +

+ Last successful update: {formatTime(setting.feed?.lastSuccessAt ?? null)} +

+ {setting.feed?.lastError && ( +

Last error: {setting.feed.lastError}

+ )} +
+
+ +
+ +
+ setDraft(e.target.value)} + /> + +
+ {invalid && draft !== "" && ( +

Enter a rate greater than 0.

+ )} +

+ {setting.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`} +

+
+
+ ); +} + +/** + * X→ETB fallback used when the CBE exchange-rate endpoint is unreachable for + * that currency — one row per foreign currency (USD, DJF). The live CBE rate + * always wins; every successful fetch overwrites the stored value, so it + * tracks the last known good rate on its own. Editing here is for a + * prolonged outage — the next successful CBE fetch replaces it. + */ +export default function ExchangeRateSettingsCard() { + const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); + return (
- Exchange rate (USD → ETB) + Exchange rates (→ ETB) - Rates come from the Commercial Bank of Ethiopia. The fallback - below is used only when CBE cannot be reached, and is refreshed - automatically after every successful update. + Rates come from the Commercial Bank of Ethiopia. Each fallback + below is used only when CBE cannot be reached for that currency, + and is refreshed automatically after every successful update.
-
- {invalid && draft !== "" && ( -

- Enter a rate between 1 and 10,000. -

- )} -

- {data?.fallbackSource === "MANUAL" - ? "Set manually. The next successful CBE update will replace it." - : `Synced automatically from CBE (${formatTime( - data?.lastSyncedAt ?? null, - )}).`} -

- + {(data ?? []).map((setting) => ( + + ))}
); diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx index 3fe5c1e1e..d7386259a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx @@ -17,11 +17,11 @@ import { useUpdateManualPaymentSettings, } from "@/hooks/useManualPaymentSettings"; -type Currency = "ETB" | "USD"; +type Currency = "ETB" | "USD" | "DJF"; const CURRENCIES: { code: Currency; - field: "etbEnabled" | "usdEnabled"; + field: "etbEnabled" | "usdEnabled" | "djfEnabled"; icon: typeof Banknote; title: string; description: string; @@ -42,6 +42,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: "Djibouti Franc (DJF) invoices", + description: + "DJF invoices can be paid online (Waafi / CAC Bank) or by bank transfer. Switch this off if Finance should stop accepting DJF payments by hand.", + }, ]; /** @@ -60,7 +68,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 ( @@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {

- Both currencies are off — the Manual Payments list is empty and + Every currency is off — the Manual Payments list is empty and Finance cannot settle any invoice by hand.

diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts index 37b8dd254..d0591b775 100644 --- a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE; */ export type ExchangeRateSource = "live" | "stored"; -/** Health of the CBE exchange-rate feed. */ +/** Health of the CBE exchange-rate feed for one currency. */ export interface ExchangeFeedStatus { rate: number | null; source: ExchangeRateSource | null; @@ -19,25 +19,31 @@ export interface ExchangeFeedStatus { lastError: string | null; } -export interface ExchangeSettings { - fallbackRate: number; - /** `AUTO` when synced from CBE, `MANUAL` when set here. */ - fallbackSource: "AUTO" | "MANUAL"; +/** One currency's X→ETB fallback settings — the API returns one per foreign currency. */ +export interface ExchangeSetting { + currency: string; + fallbackRate: number | null; + /** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */ + fallbackSource: "AUTO" | "MANUAL" | null; lastSyncedAt: string | null; updatedById: string | null; feed?: ExchangeFeedStatus; } export const exchangeSettingsService = { - get: async (): Promise => { - const response = await client.get>(BASE); + list: async (): Promise => { + const response = await client.get>(BASE); return unwrap(response.data); }, - setFallbackRate: async (fallbackRate: number): Promise => { - const response = await client.patch>(BASE, { - fallbackRate, - }); + setFallbackRate: async ( + currency: string, + fallbackRate: number, + ): Promise => { + const response = await client.patch>( + `${BASE}/${currency}`, + { fallbackRate }, + ); return unwrap(response.data); }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts index 6710730cb..ffe5281ec 100644 --- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts @@ -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,9 @@ export const manualPaymentSettingsService = { /** Partial: an omitted currency keeps its current setting. */ update: async ( - patch: Partial>, + patch: Partial< + Pick + >, ): Promise => { const response = await client.patch>( BASE,