Merge pull request #1107 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-04 14:40:41 +03:00
committed by GitHub
25 changed files with 878 additions and 93 deletions

View File

@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
buildInitialValues(fields, initialRecord),
);
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, initialRecord));
setPosition(RULE_ENGINE_POSITION_END);
setFieldErrors({});
}
}, [open, fields, initialRecord]);
@@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
setFieldErrors((current) =>
current[name] ? { ...current, [name]: "" } : current,
);
setValues((current) => {
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
@@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const payload: Record<string, unknown> = {};
// Required selects that are empty block the submit and mark themselves,
// rather than posting an incomplete payload for the API to reject.
setFieldErrors({});
let blocked = false;
for (const field of visibleFields) {
// Derived fields always submit their computed value — never stale state.
@@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({
field.type === "select" &&
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
) {
// A required select left empty must not silently submit nothing — the
// API rejects the payload with a message that reads as if the admin
// skipped a field they never saw cleared (e.g. yards reset by a trade
// direction change). Surface it on the field instead.
if (!field.required) continue;
setFieldErrors((current) => ({
...current,
[field.name]: `${field.label} is required.`,
}));
blocked = true;
} else if (raw === "" || raw === undefined) {
if (!field.required) continue;
payload[field.name] = raw;
@@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({
payload.code = String(payload.code).toUpperCase();
}
if (blocked) return;
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
payload.insertAfterId = position;
}
@@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
// Native required blocks submit while a mandatory select is empty —
// without it the form posts and the API 400s (e.g. a container
// customs/lashing rate with no container type picked).
// Mantine's Select is not a native input, so `required` only marks it
// visually — handleSubmit is what actually blocks an empty one.
required={field.required}
error={fieldErrors[field.name] || undefined}
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({

View File

@@ -53,6 +53,10 @@ export const URL_CONSTANTS = {
NOTIFICATIONS: "/settings/notifications",
},
EXCHANGE_SETTINGS: {
BASE: "/exchange-settings",
},
DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,

View File

@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { exchangeSettingsService } from "@/services/exchangeSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["exchangeSettings"];
export const useExchangeSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => exchangeSettingsService.get(),
// Feed health is only interesting while it is being looked at.
staleTime: 30_000,
refetchOnWindowFocus: true,
});
export const useSetExchangeFallbackRate = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(
t("exchangeSettings.updated", "Fallback exchange rate updated"),
);
},
onError: handleError,
});
};

View File

@@ -33,6 +33,7 @@ import {
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { toast } from "sonner";
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
export default function SettingsPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -77,6 +78,8 @@ export default function SettingsPage() {
return (
<div className="p-6 space-y-6">
<ExchangeRateSettingsCard />
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle className="text-xl font-semibold">

View File

@@ -0,0 +1,163 @@
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 "cache":
return { live: true, text: "Using the rate cached from CBE" };
case "stored":
return {
live: false,
text: "CBE unreachable — using the fallback rate below",
};
case "default":
return {
live: false,
text: "CBE unreachable and no rate stored — using the built-in default",
};
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>
);
}

View File

@@ -0,0 +1,40 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
/** Where the rate the API last served came from. */
export type ExchangeRateSource = "live" | "cache" | "stored" | "default";
/** Health of the CBE exchange-rate feed. */
export interface ExchangeFeedStatus {
rate: number | null;
source: ExchangeRateSource | null;
lastSuccessAt: string | null;
lastError: string | null;
}
export interface ExchangeSettings {
fallbackRate: number;
/** `AUTO` when synced from CBE, `MANUAL` when set here. */
fallbackSource: "AUTO" | "MANUAL";
lastSyncedAt: string | null;
updatedById: string | null;
feed?: ExchangeFeedStatus;
}
export const exchangeSettingsService = {
get: async (): Promise<ExchangeSettings> => {
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
return unwrap(response.data);
},
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
fallbackRate,
});
return unwrap(response.data);
},
};