mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 06:33:39 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user