mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string {
|
||||
return code.split("_")[0];
|
||||
}
|
||||
|
||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
||||
// the second one — never the suffix.
|
||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
return code.split("_")[1] === "BULK";
|
||||
}
|
||||
|
||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
||||
// "client clears its own customs" choice.
|
||||
function customsVariant(code: ContractTemplate["code"]): boolean | null {
|
||||
if (code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (code.endsWith("_CUSTOMS")) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
@@ -66,12 +77,12 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
: (templates ?? []).map((template) => (
|
||||
<TemplateCard
|
||||
key={template.code}
|
||||
@@ -104,6 +115,7 @@ function TemplateCard({
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
const customs = customsVariant(template.code);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -139,13 +151,29 @@ function TemplateCard({
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{customs !== null && (
|
||||
<Tooltip
|
||||
label={
|
||||
customs
|
||||
? "Used when the contract has customs clearing enabled"
|
||||
: "Used when the client handles its own customs clearing"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
|
||||
{customs ? "With customs" : "No customs"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Name + description */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -18,16 +20,23 @@ import {
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
|
||||
import type {
|
||||
EmptyContainerReturn,
|
||||
EmptyContainerReturnStatus,
|
||||
} from "@/types/importOperations";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
@@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
|
||||
COMPLETED: "Completed",
|
||||
};
|
||||
|
||||
// Fixed series colors (colors follow the entity, never the rank) — pair
|
||||
// validated for CVD separation + surface contrast.
|
||||
const RETURNED_BY_SERIES = [
|
||||
{ key: "edr", label: "EDR Last Mile", color: "#0d9488" },
|
||||
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
|
||||
];
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
@@ -186,10 +202,47 @@ export default function ContainerReturnsPage() {
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
const filteredReturnedContainers = useMemo(() => {
|
||||
if (filterType === "all") return returnedContainers;
|
||||
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase());
|
||||
}, [returnedContainers, filterType]);
|
||||
let rows = returnedContainers as EmptyContainerReturn[];
|
||||
if (filterType !== "all") {
|
||||
rows = rows.filter((ret) => ret.returnedBy === filterType.toUpperCase());
|
||||
}
|
||||
if (statusFilter) {
|
||||
rows = rows.filter((ret) => ret.status === statusFilter);
|
||||
}
|
||||
return rows;
|
||||
}, [returnedContainers, filterType, statusFilter]);
|
||||
|
||||
const returnedControls = useListControls(filteredReturnedContainers, {
|
||||
dateKey: "returnDate",
|
||||
searchValue: (ret) =>
|
||||
`${ret.containerNumber} ${ret.facility ?? ""} ${ret.yard ?? ""} ${ret.condition ?? ""}`,
|
||||
});
|
||||
|
||||
// Charts read the filtered set, so the controls above drive them too.
|
||||
const returnsPerDay = useMemo(() => {
|
||||
const byDay = new Map<string, { date: string; edr: number; customer: number }>();
|
||||
for (const ret of returnedControls.filteredRows) {
|
||||
const day = toDayString(ret.returnDate);
|
||||
if (!day) continue;
|
||||
const entry = byDay.get(day) ?? { date: day, edr: 0, customer: 0 };
|
||||
if (ret.returnedBy === "CUSTOMER") entry.customer += 1;
|
||||
else entry.edr += 1;
|
||||
byDay.set(day, entry);
|
||||
}
|
||||
return [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [returnedControls.filteredRows]);
|
||||
|
||||
const returnsByStatus = useMemo(
|
||||
() =>
|
||||
RETURN_STATUS_ORDER.map((status) => ({
|
||||
label: RETURN_STATUS_LABEL[status],
|
||||
value: returnedControls.filteredRows.filter((ret) => ret.status === status).length,
|
||||
})),
|
||||
[returnedControls.filteredRows],
|
||||
);
|
||||
|
||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||
const filteredGroups = useMemo(() => {
|
||||
@@ -271,6 +324,103 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const returnedColumns: ColumnDef<EmptyContainerReturn>[] = [
|
||||
{
|
||||
id: "containerNumber",
|
||||
header: "Container Number",
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.containerNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bookingRef",
|
||||
header: "Booking Ref",
|
||||
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||
},
|
||||
{
|
||||
id: "returnedBy",
|
||||
header: "Returned By",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnedBy ? (
|
||||
<Badge size="sm" color={row.original.returnedBy === "EDR" ? "edr-green" : "orange"}>
|
||||
{row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "returnDate",
|
||||
header: "Returned Date",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
|
||||
},
|
||||
{
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.facility || "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard || "—",
|
||||
},
|
||||
{
|
||||
id: "condition",
|
||||
header: "Condition",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" lineClamp={2}>
|
||||
{row.original.condition || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm">
|
||||
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) => {
|
||||
const ret = row.original;
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setHistoryRow(ret)}
|
||||
title="View status history"
|
||||
>
|
||||
<History size={14} />
|
||||
</ActionIcon>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Done
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||
|
||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||
@@ -305,73 +455,45 @@ export default function ContainerReturnsPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{filteredReturnedContainers.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} mb="xs">Returned Containers</Text>
|
||||
<Table.ScrollContainer minWidth={1000} mb="lg">
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Returned Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredReturnedContainers.map((ret: any) => {
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Table.Tr key={ret.id}>
|
||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{ret.returnedBy ? (
|
||||
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
|
||||
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryRow(ret)} title="View status history">
|
||||
<History size={14} />
|
||||
</ActionIcon>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">Done</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</>
|
||||
{returnedContainers.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Returned Containers</Text>
|
||||
<ListControls
|
||||
search={returnedControls.search}
|
||||
onSearchChange={returnedControls.setSearch}
|
||||
searchPlaceholder="Search container, facility, condition…"
|
||||
dateFrom={returnedControls.dateFrom}
|
||||
onDateFromChange={returnedControls.setDateFrom}
|
||||
dateTo={returnedControls.dateTo}
|
||||
onDateToChange={returnedControls.setDateTo}
|
||||
dateLabel="Returned"
|
||||
hasFilters={returnedControls.hasFilters || Boolean(statusFilter)}
|
||||
onReset={() => {
|
||||
returnedControls.reset();
|
||||
setStatusFilter(null);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
data={RETURN_STATUS_ORDER.map((status) => ({
|
||||
value: status,
|
||||
label: RETURN_STATUS_LABEL[status],
|
||||
}))}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</ListControls>
|
||||
<DataTable
|
||||
columns={returnedColumns}
|
||||
data={returnedControls.pagedRows}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...returnedControls.tableProps}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
@@ -471,6 +593,26 @@ export default function ContainerReturnsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="lg">
|
||||
<OverviewStackedBarChart
|
||||
title="Returns per day by truck type"
|
||||
data={returnsPerDay}
|
||||
series={RETURNED_BY_SERIES}
|
||||
formatXLabel={(value) =>
|
||||
new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short" })
|
||||
}
|
||||
emptyMessage="No returns in this range"
|
||||
/>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Returns by status"
|
||||
data={returnsByStatus}
|
||||
valueLabel="Containers"
|
||||
emptyMessage="No returns in this range"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<ContainerReturnModal
|
||||
opened={returnModalOpen}
|
||||
onClose={() => setReturnModalOpen(false)}
|
||||
|
||||
@@ -11,12 +11,18 @@ export interface ContractTemplateArticle {
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
||||
// border, and so has a single template.
|
||||
code:
|
||||
| "IMPORT_BULK"
|
||||
| "EXPORT_BULK"
|
||||
| "IMPORT_BULK_CUSTOMS"
|
||||
| "IMPORT_BULK_NO_CUSTOMS"
|
||||
| "EXPORT_BULK_CUSTOMS"
|
||||
| "EXPORT_BULK_NO_CUSTOMS"
|
||||
| "INTERCITY_BULK"
|
||||
| "IMPORT_CONTAINER"
|
||||
| "EXPORT_CONTAINER"
|
||||
| "IMPORT_CONTAINER_CUSTOMS"
|
||||
| "IMPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "INTERCITY_CONTAINER";
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user