Merge branch 'dev' into fixes

This commit is contained in:
ghost2023
2026-08-04 17:38:13 +03:00
38 changed files with 1531 additions and 230 deletions

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

@@ -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 */}

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

@@ -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)}