Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-08-06 23:49:48 +00:00
122 changed files with 6618 additions and 547 deletions

View File

@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/audit-logs",
meta: {
title: "Audit Logs",
subtitle: "Request and entity-level activity recorded across the freight API",
},
},
{
prefix: "/dashboard/configuration/contract-validity-periods",
meta: {

View File

@@ -0,0 +1,369 @@
import { useEffect, useState } from "react";
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
NumberInput,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
lastMileRequestsService,
type LastMileRequest,
type LastMileRequestStatus,
} from "@/services/last-mile-requests.service";
const STATUS_META: Record<LastMileRequestStatus, { label: string; color: string }> = {
AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" },
SUBMITTED: { label: "Submitted", color: "yellow" },
APPROVED: { label: "Approved", color: "green" },
REJECTED: { label: "Rejected", color: "red" },
};
type StatusFilter = "ALL" | LastMileRequestStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "SUBMITTED", label: "Submitted" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
{ value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" },
{ value: "ALL", label: "All" },
];
const fmtDate = (iso?: string | null) =>
iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
export function LastMileRequestsPanel() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("SUBMITTED");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [approveTarget, setApproveTarget] = useState<LastMileRequest | null>(null);
const [rejectTarget, setRejectTarget] = useState<LastMileRequest | null>(null);
const [advanceAmount, setAdvanceAmount] = useState<number | string>("");
const [rejectReason, setRejectReason] = useState("");
const filter = {
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
};
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter),
queryFn: async () => (await lastMileRequestsService.list(filter)).data,
});
const rows = data?.data ?? [];
const meta = data?.meta;
const { data: freeTrucks } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount,
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
});
// Rule-based estimate for the approve dialog (estimated km × live last-mile
// rates). Prefills the advance once, without clobbering a typed value.
const { data: estimate } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""),
queryFn: async () =>
(await lastMileRequestsService.priceEstimate(approveTarget!.id)).data,
enabled: Boolean(approveTarget),
});
useEffect(() => {
if (approveTarget && estimate?.total != null && advanceAmount === "") {
setAdvanceAmount(estimate.total);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [estimate, approveTarget]);
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
const downloadContract = async (r: LastMileRequest) => {
try {
const { data: blob } = await lastMileRequestsService.contractDocument(r.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast({ title: "Contract PDF not available", variant: "destructive" });
}
};
const approve = useMutation({
mutationFn: () =>
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
onSuccess: () => {
void invalidate();
toast({ title: "Request approved" });
setApproveTarget(null);
setAdvanceAmount("");
},
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: "Approve failed", description, variant: "destructive" });
},
});
const reject = useMutation({
mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()),
onSuccess: () => {
void invalidate();
toast({ title: "Request rejected" });
setRejectTarget(null);
setRejectReason("");
},
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: "Reject failed", description, variant: "destructive" });
},
});
const columns: ColumnDef<LastMileRequest>[] = [
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={0}>
<Text size="sm" fw={600}>{r.booking?.reference ?? r.bookingId}</Text>
<Text size="xs" c="dimmed">{r.booking?.company?.name ?? "—"}</Text>
</Stack>
);
},
},
{
id: "containers",
header: () => <span>Requested Containers</span>,
cell: ({ row }) => {
const r = row.original;
const nums = r.requestedContainerNumbers;
return (
<Stack gap={0}>
<Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>
{r.requestedDeliveryDate && (
<Text size="xs" c="dimmed">Delivery: {r.requestedDeliveryDate}</Text>
)}
</Stack>
);
},
},
{
id: "submittedAt",
header: () => <span>Submitted</span>,
cell: ({ row }) => <Text size="sm">{fmtDate(row.original.submittedAt)}</Text>,
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge color={meta.color} variant="light" size="sm">
{meta.label}
</Badge>
);
},
},
{
id: "contract",
header: () => <span>LM Contract</span>,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "APPROVED") return <Text size="sm" c="dimmed"></Text>;
return (
<Stack gap={4} align="flex-start">
<Badge color={r.customerSignedAt ? "green" : "yellow"} variant="light" size="sm">
{r.customerSignedAt ? "Signed" : "Awaiting signature"}
</Badge>
<Button size="compact-xs" variant="subtle" onClick={() => void downloadContract(r)}>
Download PDF
</Button>
</Stack>
);
},
},
...(canApprove
? [
{
id: "actions",
header: () => <span>Actions</span>,
cell: ({ row }: { row: { original: LastMileRequest } }) => {
const r = row.original;
if (r.status !== "SUBMITTED") return null;
return (
<Group gap="xs">
<Button size="xs" variant="light" color="green" onClick={() => setApproveTarget(r)}>
Approve
</Button>
<Button size="xs" variant="light" color="red" onClick={() => setRejectTarget(r)}>
Reject
</Button>
</Group>
);
},
} as ColumnDef<LastMileRequest>,
]
: []),
];
return (
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free
</Text>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label}
</Button>
);
})}
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : "success"}
emptyMessage="No last-mile confirmation requests found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: meta?.totalPages ?? 1,
totalCount: meta?.total ?? 0,
}}
tableOptions={{
manualPagination: true,
pageCount: meta?.totalPages ?? 1,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: fp }) => (
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "requests" } }} />
)}
/>
</Stack>
<Modal
opened={Boolean(approveTarget)}
onClose={() => {
setApproveTarget(null);
setAdvanceAmount("");
}}
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
centered
>
<Stack gap="md">
{estimate?.total != null && (
<Stack gap={4}>
{estimate.lines.map((line) => (
<Text key={line.description} size="xs" c="dimmed">
{line.description} {line.amount.toLocaleString()}
</Text>
))}
<Text size="sm" fw={600}>
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
{estimate.estimatedKm != null
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
: ""}
</Text>
</Stack>
)}
<NumberInput
label="Advance amount"
placeholder="0.00"
required
min={0.01}
value={advanceAmount}
onChange={setAdvanceAmount}
/>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => {
setApproveTarget(null);
setAdvanceAmount("");
}}
>
Cancel
</Button>
<Button
disabled={!(Number(advanceAmount) > 0)}
loading={approve.isPending}
onClick={() => approve.mutate()}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={() => setRejectTarget(null)}
title={<Text fw={700}>Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}</Text>}
centered
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Why is this request being rejected?"
required
minRows={3}
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectTarget(null)}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectReason.trim()}
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Card>
);
}

View File

@@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({
{col.header}:
</Text>
<div style={{ textAlign: "right", flex: 1 }}>
{formatCell(displayValue, col.format)}
{formatCell(displayValue, col.format, record)}
</div>
</Group>
);

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, Plus, Trash2 } from "lucide-react";
import {
ActionIcon,
Modal,
Button,
TextInput,
@@ -41,6 +42,37 @@ type FormRow =
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
| { kind: "single"; field: FormFieldDef };
/** One editable distance tier of a tierList field (raw input strings). */
type TierRow = { minKm: string; maxKm: string; rateValue: string };
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" });
/**
* Validate a tier set before submit: every tier complete, ranges sane, no
* overlaps, and only the last tier open-ended. Returns the error message, or
* null when the set is valid.
*/
const validateTiers = (rows: TierRow[]): string | null => {
if (!rows.length) return "Add at least one tier.";
for (const row of rows) {
if (row.minKm === "" || row.rateValue === "") {
return "Every tier needs a From km and a Rate value.";
}
if (row.maxKm !== "" && Number(row.maxKm) <= Number(row.minKm)) {
return "Each tier's To km must be greater than its From km.";
}
}
const sorted = [...rows].sort((a, b) => Number(a.minKm) - Number(b.minKm));
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1];
if (prev.maxKm === "") return "Only the last tier can leave To km empty.";
if (Number(sorted[i].minKm) < Number(prev.maxKm)) {
return `Tiers overlap around ${sorted[i].minKm} km — each distance must fall in exactly one tier.`;
}
}
return null;
};
const isShortField = (field: FormFieldDef) =>
field.type === "text" ||
field.type === "number" ||
@@ -54,7 +86,7 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
while (index < fields.length) {
const field = fields[index];
if (field.type === "textarea" || field.type === "boolean") {
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
rows.push({ kind: "single", field });
index += 1;
continue;
@@ -85,6 +117,8 @@ const buildInitialValues = (
: record?.[field.name];
if (field.type === "multiselect") {
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
} else if (field.type === "tierList") {
values[field.name] = [emptyTier("0")];
} else if (raw !== undefined && raw !== null) {
if (field.type === "date" && typeof raw === "string") {
values[field.name] = raw.slice(0, 10);
@@ -93,6 +127,8 @@ const buildInitialValues = (
} else {
values[field.name] = raw;
}
} else if (field.defaultValue !== undefined) {
values[field.name] = field.defaultValue;
} else if (field.type === "boolean") {
values[field.name] = false;
} else if (field.type === "number") {
@@ -241,6 +277,19 @@ const RuleEngineFormDialog = ({
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
} else if (field.type === "tierList") {
const rows = (Array.isArray(raw) ? raw : []) as TierRow[];
const error = validateTiers(rows);
if (error) {
setFieldErrors((current) => ({ ...current, [field.name]: error }));
blocked = true;
} else {
payload[field.name] = rows.map((row) => ({
minKm: Number(row.minKm),
maxKm: row.maxKm === "" ? null : Number(row.maxKm),
rateValue: Number(row.rateValue),
}));
}
} else if (field.type === "number") {
if (raw === "" || raw === undefined) continue;
payload[field.name] = Number(raw);
@@ -313,6 +362,101 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "tierList") {
const rows = Array.isArray(values[field.name])
? (values[field.name] as TierRow[])
: [];
const setRows = (next: TierRow[]) => setField(field.name, next);
const setRow = (index: number, key: keyof TierRow, value: string) => {
if (value.trim().startsWith("-")) return;
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
};
return (
<Box key={field.name}>
<Text size="sm" fw={600} mb={2} c="var(--mantine-color-gray-8)">
{label}
</Text>
{field.description ? (
<Text size="xs" c="dimmed" mb={8}>
{field.description}
</Text>
) : null}
<Stack gap="xs">
{rows.map((row, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
<TextInput
label={index === 0 ? "From km" : undefined}
type="number"
min={0}
step="any"
placeholder="0"
value={row.minKm}
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "To km" : undefined}
type="number"
min={0}
step="any"
placeholder="No limit"
value={row.maxKm}
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "Rate value" : undefined}
type="number"
min={0}
step="any"
placeholder="Rate per km"
value={row.rateValue}
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
mb={2}
aria-label="Remove tier"
disabled={rows.length === 1}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Group justify="flex-start">
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
// The next tier naturally starts where the previous one ends.
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
>
Add tier
</Button>
</Group>
{fieldErrors[field.name] ? (
<Text size="xs" c="red">
{fieldErrors[field.name]}
</Text>
) : null}
</Stack>
</Box>
);
}
if (field.type === "multiselect") {
const options = field.optionsFromValues
? field.optionsFromValues(values)

View File

@@ -17,7 +17,13 @@ const extractLabel = (value: unknown): string | null => {
);
};
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
export const formatCell = (
value: unknown,
format?: ColumnFormat,
// The row the cell came from — currency amounts read their code off it so a
// last-mile rate priced in birr does not render as USD.
row?: Record<string, unknown>,
): ReactNode => {
if (value === null || value === undefined || value === "") {
return <Text size="sm" c="dimmed"></Text>;
}
@@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "currency") {
const num = Number(value);
const code = typeof row?.currency === "string" ? row.currency : "USD";
return (
<Text size="sm" fw={500}>
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
{Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`}
</Text>
);
}