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
This commit is contained in:
ghost2023
2026-09-04 11:53:39 +03:00
parent f451d0106d
commit 34c49e4c50
7 changed files with 177 additions and 104 deletions

View File

@@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["exchangeSettings"]; const QUERY_KEY = ["exchangeSettings"];
/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */
export const useExchangeSettingsQuery = () => export const useExchangeSettingsQuery = () =>
useQuery({ useQuery({
queryKey: QUERY_KEY, queryKey: QUERY_KEY,
queryFn: () => exchangeSettingsService.get(), queryFn: () => exchangeSettingsService.list(),
// Feed health is only interesting while it is being looked at. // Feed health is only interesting while it is being looked at.
staleTime: 30_000, staleTime: 30_000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
@@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => {
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
return useMutation({ return useMutation({
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), mutationFn: ({ currency, rate }: { currency: string; rate: number }) =>
exchangeSettingsService.setFallbackRate(currency, rate),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY }); queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success( toast.success(

View File

@@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => {
return useMutation({ return useMutation({
mutationFn: ( mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>, patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
>,
) => manualPaymentSettingsService.update(patch), ) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);

View File

@@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [
options: [ options: [
{ value: "ETB", label: "ETB" }, { value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" }, { value: "USD", label: "USD" },
{ value: "DJF", label: "DJF" },
], ],
}, },
{ {
@@ -235,8 +236,23 @@ export default function InvoicesPanel() {
const { data: exchangeSettings } = useExchangeSettingsQuery(); const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0; const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0; const usdCollected = summary?.USD ?? 0;
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate; const djfCollected = summary?.DJF ?? 0;
const etbFromUsd = rate ? usdCollected * rate : null; 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<Invoice>[] = useMemo( const columns: ColumnDef<Invoice>[] = useMemo(
() => [ () => [
@@ -356,8 +372,8 @@ export default function InvoicesPanel() {
items={[ items={[
{ {
label: "Total collected", label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only", hint: totalHint,
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"), value: formatMoney(totalEtb, "ETB"),
icon: CircleDollarSign, icon: CircleDollarSign,
color: "edr-green", color: "edr-green",
}, },
@@ -373,6 +389,12 @@ export default function InvoicesPanel() {
icon: Landmark, icon: Landmark,
color: "violet", color: "violet",
}, },
{
label: "Collected in DJF",
value: formatMoney(djfCollected, "DJF"),
icon: Landmark,
color: "orange",
},
]} ]}
/> />

View File

@@ -14,7 +14,10 @@ import {
useExchangeSettingsQuery, useExchangeSettingsQuery,
useSetExchangeFallbackRate, useSetExchangeFallbackRate,
} from "@/hooks/useExchangeSettings"; } from "@/hooks/useExchangeSettings";
import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; import type {
ExchangeRateSource,
ExchangeSetting,
} from "@/services/exchangeSettings.service";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
/** Feed health, phrased for an operator rather than a developer. */ /** Feed health, phrased for an operator rather than a developer. */
@@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): {
const formatTime = (value: string | null) => const formatTime = (value: string | null) =>
value ? formatDateTime(value) : "never"; value ? formatDateTime(value) : "never";
/** /** One currency's fallback row — its own draft, its own save. */
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. function ExchangeRateRow({
* The live CBE rate always wins; every successful fetch overwrites the stored setting,
* value, so it tracks the last known good rate on its own. Editing here is for disabled,
* a prolonged outage — the next successful CBE fetch replaces it. }: {
*/ setting: ExchangeSetting;
export default function ExchangeRateSettingsCard() { disabled: boolean;
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); }) {
const setRate = useSetExchangeFallbackRate(); const setRate = useSetExchangeFallbackRate();
const [draft, setDraft] = useState<string>(""); const [draft, setDraft] = useState<string>("");
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? "");
const parsed = Number(value); const parsed = Number(value);
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; const invalid = !Number.isFinite(parsed) || parsed <= 0;
const dirty = draft !== "" && parsed !== data?.fallbackRate; const dirty = draft !== "" && parsed !== setting.fallbackRate;
const feed = feedLabel(data?.feed?.source ?? null); const feed = feedLabel(setting.feed?.source ?? null);
const handleSave = async () => { const handleSave = async () => {
if (invalid) return; if (invalid) return;
await setRate.mutateAsync(parsed); await setRate.mutateAsync({ currency: setting.currency, rate: parsed });
setDraft(""); setDraft("");
}; };
return (
<div className="space-y-3 border-t pt-4 first:border-t-0 first:pt-0">
<div
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
feed.live
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
}`}
>
{feed.live ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="space-y-1">
<p className="font-medium">
{setting.currency} ETB {feed.text}
</p>
{setting.feed?.rate != null && (
<p>
Rate in use: {setting.feed.rate} ETB per {setting.currency}
</p>
)}
<p className="opacity-80">
Last successful update: {formatTime(setting.feed?.lastSuccessAt ?? null)}
</p>
{setting.feed?.lastError && (
<p className="opacity-80">Last error: {setting.feed.lastError}</p>
)}
</div>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={`fallback-rate-${setting.currency}`}
>
Fallback rate (ETB per {setting.currency})
</label>
<div className="flex items-center gap-2">
<Input
id={`fallback-rate-${setting.currency}`}
type="number"
step="0.0001"
min={0}
className="max-w-[220px]"
disabled={disabled}
value={value}
onChange={(e) => setDraft(e.target.value)}
/>
<Button
onClick={handleSave}
disabled={!dirty || invalid || setRate.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
</div>
{invalid && draft !== "" && (
<p className="text-sm text-red-600">Enter a rate greater than 0.</p>
)}
<p className="text-sm text-muted-foreground">
{setting.fallbackSource === "MANUAL"
? "Set manually. The next successful CBE update will replace it."
: `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`}
</p>
</div>
</div>
);
}
/**
* 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 ( return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<CardTitle>Exchange rate (USD ETB)</CardTitle> <CardTitle>Exchange rates ( ETB)</CardTitle>
<CardDescription> <CardDescription>
Rates come from the Commercial Bank of Ethiopia. The fallback Rates come from the Commercial Bank of Ethiopia. Each fallback
below is used only when CBE cannot be reached, and is refreshed below is used only when CBE cannot be reached for that currency,
automatically after every successful update. and is refreshed automatically after every successful update.
</CardDescription> </CardDescription>
</div> </div>
<Button <Button
@@ -88,69 +172,13 @@ export default function ExchangeRateSettingsCard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div {(data ?? []).map((setting) => (
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${ <ExchangeRateRow
feed.live key={setting.currency}
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100" setting={setting}
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100" disabled={isLoading}
}`} />
> ))}
{feed.live ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="space-y-1">
<p className="font-medium">{feed.text}</p>
{data?.feed?.rate != null && (
<p>Rate in use: {data.feed.rate} ETB per USD</p>
)}
<p className="opacity-80">
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
</p>
{data?.feed?.lastError && (
<p className="opacity-80">Last error: {data.feed.lastError}</p>
)}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="fallback-rate">
Fallback rate (ETB per USD)
</label>
<div className="flex items-center gap-2">
<Input
id="fallback-rate"
type="number"
step="0.0001"
min={1}
max={10000}
className="max-w-[220px]"
disabled={isLoading}
value={value}
onChange={(e) => setDraft(e.target.value)}
/>
<Button
onClick={handleSave}
disabled={!dirty || invalid || setRate.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
</div>
{invalid && draft !== "" && (
<p className="text-sm text-red-600">
Enter a rate between 1 and 10,000.
</p>
)}
<p className="text-sm text-muted-foreground">
{data?.fallbackSource === "MANUAL"
? "Set manually. The next successful CBE update will replace it."
: `Synced automatically from CBE (${formatTime(
data?.lastSyncedAt ?? null,
)}).`}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -17,11 +17,11 @@ import {
useUpdateManualPaymentSettings, useUpdateManualPaymentSettings,
} from "@/hooks/useManualPaymentSettings"; } from "@/hooks/useManualPaymentSettings";
type Currency = "ETB" | "USD"; type Currency = "ETB" | "USD" | "DJF";
const CURRENCIES: { const CURRENCIES: {
code: Currency; code: Currency;
field: "etbEnabled" | "usdEnabled"; field: "etbEnabled" | "usdEnabled" | "djfEnabled";
icon: typeof Banknote; icon: typeof Banknote;
title: string; title: string;
description: string; description: string;
@@ -42,6 +42,14 @@ const CURRENCIES: {
description: 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.", "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 { data, isLoading } = useManualPaymentSettingsQuery();
const update = useUpdateManualPaymentSettings(); const update = useUpdateManualPaymentSettings();
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled); const noneEnabled = Boolean(
data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
);
return ( return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
@@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p> <p>
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. Finance cannot settle any invoice by hand.
</p> </p>
</div> </div>

View File

@@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
*/ */
export type ExchangeRateSource = "live" | "stored"; 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 { export interface ExchangeFeedStatus {
rate: number | null; rate: number | null;
source: ExchangeRateSource | null; source: ExchangeRateSource | null;
@@ -19,25 +19,31 @@ export interface ExchangeFeedStatus {
lastError: string | null; lastError: string | null;
} }
export interface ExchangeSettings { /** One currency's X→ETB fallback settings — the API returns one per foreign currency. */
fallbackRate: number; export interface ExchangeSetting {
/** `AUTO` when synced from CBE, `MANUAL` when set here. */ currency: string;
fallbackSource: "AUTO" | "MANUAL"; fallbackRate: number | null;
/** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */
fallbackSource: "AUTO" | "MANUAL" | null;
lastSyncedAt: string | null; lastSyncedAt: string | null;
updatedById: string | null; updatedById: string | null;
feed?: ExchangeFeedStatus; feed?: ExchangeFeedStatus;
} }
export const exchangeSettingsService = { export const exchangeSettingsService = {
get: async (): Promise<ExchangeSettings> => { list: async (): Promise<ExchangeSetting[]> => {
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE); const response = await client.get<ApiResponse<ExchangeSetting[]>>(BASE);
return unwrap(response.data); return unwrap(response.data);
}, },
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => { setFallbackRate: async (
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, { currency: string,
fallbackRate, fallbackRate: number,
}); ): Promise<ExchangeSetting> => {
const response = await client.patch<ApiResponse<ExchangeSetting>>(
`${BASE}/${currency}`,
{ fallbackRate },
);
return unwrap(response.data); return unwrap(response.data);
}, },
}; };

View File

@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
export interface ManualPaymentSettings { export interface ManualPaymentSettings {
etbEnabled: boolean; etbEnabled: boolean;
usdEnabled: boolean; usdEnabled: boolean;
djfEnabled: boolean;
updatedById: string | null; updatedById: string | null;
updatedAt?: string; updatedAt?: string;
} }
@@ -25,7 +26,9 @@ export const manualPaymentSettingsService = {
/** Partial: an omitted currency keeps its current setting. */ /** Partial: an omitted currency keeps its current setting. */
update: async ( update: async (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>, patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
>,
): Promise<ManualPaymentSettings> => { ): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>( const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE, BASE,