mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
- Updated ContractDocumentViewModelBuilder to include cargoTypeName, containerType, and cargoSummary in the schedule. - Modified contract dynamic template tests to validate the new cargo fields. - Enhanced contract renderer service tests to reflect changes in cargo data structure. - Updated contract view model interface to include new cargo-related fields. - Improved dynamic template rendering to display cargo type and container type. - Refactored exchange settings controller and service to streamline error handling and feed status management. - Introduced article HTML conversion functions to support Quill editor integration for structured article editing. - Added tests for article HTML conversion to ensure correct round-trip processing of clauses and bullets.
157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/shared/common/ui/card";
|
|
import { Input } from "@/shared/common/ui/input";
|
|
import { Button } from "@/shared/common/ui/button";
|
|
import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react";
|
|
|
|
import {
|
|
useExchangeSettingsQuery,
|
|
useSetExchangeFallbackRate,
|
|
} from "@/hooks/useExchangeSettings";
|
|
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
|
|
|
/** Feed health, phrased for an operator rather than a developer. */
|
|
function feedLabel(source: ExchangeRateSource | null): {
|
|
live: boolean;
|
|
text: string;
|
|
} {
|
|
switch (source) {
|
|
case "live":
|
|
return { live: true, text: "CBE reachable — using the live rate" };
|
|
case "stored":
|
|
return {
|
|
live: false,
|
|
text: "CBE unreachable — using the fallback rate below",
|
|
};
|
|
default:
|
|
return { live: true, text: "No rate requested yet since the last restart" };
|
|
}
|
|
}
|
|
|
|
const formatTime = (value: string | null) =>
|
|
value ? new Date(value).toLocaleString() : "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();
|
|
const setRate = useSetExchangeFallbackRate();
|
|
const [draft, setDraft] = useState<string>("");
|
|
|
|
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
|
const parsed = Number(value);
|
|
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000;
|
|
const dirty = draft !== "" && parsed !== data?.fallbackRate;
|
|
|
|
const feed = feedLabel(data?.feed?.source ?? null);
|
|
|
|
const handleSave = async () => {
|
|
if (invalid) return;
|
|
await setRate.mutateAsync(parsed);
|
|
setDraft("");
|
|
};
|
|
|
|
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>
|
|
<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.
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|