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"];
/** 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(

View File

@@ -24,7 +24,9 @@ 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);

View File

@@ -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<Invoice>[] = 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",
},
]}
/>

View File

@@ -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<string>("");
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 (
<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 (
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Exchange rate (USD ETB)</CardTitle>
<CardTitle>Exchange rates ( ETB)</CardTitle>
<CardDescription>
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.
</CardDescription>
</div>
<Button
@@ -88,69 +172,13 @@ export default function ExchangeRateSettingsCard() {
</CardHeader>
<CardContent className="space-y-4">
<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">{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>
{(data ?? []).map((setting) => (
<ExchangeRateRow
key={setting.currency}
setting={setting}
disabled={isLoading}
/>
))}
</CardContent>
</Card>
);

View File

@@ -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 (
<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">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<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.
</p>
</div>

View File

@@ -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<ExchangeSettings> => {
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
list: async (): Promise<ExchangeSetting[]> => {
const response = await client.get<ApiResponse<ExchangeSetting[]>>(BASE);
return unwrap(response.data);
},
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
fallbackRate,
});
setFallbackRate: async (
currency: string,
fallbackRate: number,
): Promise<ExchangeSetting> => {
const response = await client.patch<ApiResponse<ExchangeSetting>>(
`${BASE}/${currency}`,
{ fallbackRate },
);
return unwrap(response.data);
},
};

View File

@@ -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<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE,