mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
FileSignature,
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
import AuditLogsPage from "./pages/audit/AuditLogsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import {
|
||||
@@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
icon: <History />,
|
||||
permission: FREIGHT_PERMS.audit.view,
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
@@ -639,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||
// The ET hub's rows open the shipment clearance detail at this URL.
|
||||
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
|
||||
];
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
@@ -1595,6 +1605,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit-logs"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
|
||||
<AuditLogsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
element={
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,15 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
ROOT: ["last-mile-requests"] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["last-mile-requests", "list", filter ?? {}] as const,
|
||||
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||
priceEstimate: (id: string) =>
|
||||
["last-mile-requests", "price-estimate", id] as const,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
ROOT: ["rule-engine"] as const,
|
||||
list: (
|
||||
|
||||
@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
|
||||
SUMMARY: "/payments/summary",
|
||||
},
|
||||
|
||||
AUDIT: {
|
||||
LOGS: "/audit/logs",
|
||||
},
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
@@ -703,6 +707,16 @@ export const URL_CONSTANTS = {
|
||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BASE: "/last-mile-requests",
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
BASE: "/drivers",
|
||||
BY_ID: (id: string) => `/drivers/${id}`,
|
||||
|
||||
@@ -116,6 +116,9 @@ export const FREIGHT_PERMS = {
|
||||
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||
requestView: "edr_freight_app:last_mile:request_view",
|
||||
requestReview: "edr_freight_app:last_mile:request_review",
|
||||
requestApprove: "edr_freight_app:last_mile:request_approve",
|
||||
},
|
||||
locomotives: {
|
||||
view: "edr_freight_app:locomotives:view",
|
||||
@@ -276,6 +279,9 @@ export const FREIGHT_PERMS = {
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
view: "edr_freight_app:audit:view",
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
view: "edr_freight_app:staff:roles:view",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
AuditLogRow,
|
||||
AuditQueryMethod,
|
||||
AuditUser,
|
||||
LocalizedText,
|
||||
} from "@/services/audit.service";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "Created",
|
||||
UPDATE: "Updated",
|
||||
DELETE: "Deleted",
|
||||
INSERT_CHILD: "Linked child",
|
||||
DELETE_CHILD: "Unlinked child",
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "edr-green",
|
||||
UPDATE: "yellow",
|
||||
DELETE: "red",
|
||||
INSERT_CHILD: "indigo",
|
||||
DELETE_CHILD: "gray",
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
|
||||
// a plain string or IAM's { am, en } — never render either directly.
|
||||
// "undefined undefined" is the producer's own broken template when no user
|
||||
// was attached at all (unauthenticated/customer flows, e.g. Fayda
|
||||
// verification) — filtered out here rather than shown as raw garbage.
|
||||
function localize(value: LocalizedText | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === "object") return value.en ?? value.am ?? undefined;
|
||||
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUser(user: AuditUser | null | undefined): string {
|
||||
return localize(user?.name) ?? user?.id ?? "—";
|
||||
}
|
||||
|
||||
function summarize(row: AuditLogRow): string {
|
||||
if (row.changes?.length) {
|
||||
return row.changes
|
||||
.slice(0, 2)
|
||||
.map((c) => c.field)
|
||||
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
|
||||
}
|
||||
if (row.payload) {
|
||||
return (
|
||||
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
const tableHeader =
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
|
||||
const filter = {
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
take: pagination.pageSize,
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.audit.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.count ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<AuditLogRow>[] = [
|
||||
{
|
||||
id: "time",
|
||||
header: () => <span className={tableHeader}>Time</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className={tableHeader}>Action</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "entity",
|
||||
header: () => <span className={tableHeader}>Entity</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{row.original.entityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
header: () => <span className={tableHeader}>User</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{formatUser(row.original.auditLog?.user)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: () => <span className={tableHeader}>Summary</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{summarize(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
subtitle="Request and entity-level activity recorded across the freight API."
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ import { bookingsService } from "@/services/bookings.service";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
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 { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
|
||||
import {
|
||||
LAST_MILE_STATUSES,
|
||||
type LastMileApiStatus,
|
||||
@@ -543,6 +546,9 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
||||
const LastMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
|
||||
const [view, setView] = useState<"legs" | "requests">("legs");
|
||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -1590,6 +1596,30 @@ const LastMilePage = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
{canViewRequests && (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "legs" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("legs")}
|
||||
>
|
||||
Deliveries
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "requests" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("requests")}
|
||||
>
|
||||
Requests
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{view === "requests" && canViewRequests ? (
|
||||
<LastMileRequestsPanel />
|
||||
) : (
|
||||
<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%">
|
||||
@@ -1672,6 +1702,7 @@ const LastMilePage = () => {
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
||||
<Modal
|
||||
|
||||
@@ -306,7 +306,27 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const formFields = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return config.formFields.map((field) => {
|
||||
// Last-mile bands (both modes): creating uses the multi-row tier list (one
|
||||
// rate per tier, each tier carrying its own rate value); editing an
|
||||
// existing band row keeps the single From/To/value fields (a rate row IS
|
||||
// one band).
|
||||
const bandFields = config.formFields.filter((field) => {
|
||||
if (config.slug !== "rates") return true;
|
||||
if (field.type === "tierList") return !editing;
|
||||
if (editing) return true;
|
||||
// On create the tier rows carry From/To/value — drop the single fields,
|
||||
// including the last-mile "Rate value" (the non-last-mile one keeps its
|
||||
// own showIf).
|
||||
if (
|
||||
field.name === "rateValue" &&
|
||||
field.showWhen?.field === "appliesTo" &&
|
||||
field.showWhen.equals.includes("LAST_MILE")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return field.name !== "minKm" && field.name !== "maxKm";
|
||||
});
|
||||
return bandFields.map((field) => {
|
||||
if (isPriorityRules && field.name === "minWagonCount") {
|
||||
return {
|
||||
...field,
|
||||
@@ -474,7 +494,7 @@ const RuleEngineResourcePage = () => {
|
||||
header: col.header,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const cell = formatCell(row.original[col.accessorKey], col.format);
|
||||
const cell = formatCell(row.original[col.accessorKey], col.format, row.original);
|
||||
// On the rate column, show the proposed value under the live one — the
|
||||
// live value stays the headline because it is what still gets charged.
|
||||
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
||||
@@ -584,10 +604,26 @@ const RuleEngineResourcePage = () => {
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
const isSurcharge = values.appliesTo === "OTHER";
|
||||
// Last mile: the form's calculation mode picks the unit (bulk = per
|
||||
// ton·km, container = per km + distance band) and the currency stays as
|
||||
// chosen (birr or dollar). Everything else remains USD-only.
|
||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||
const { lastMileMode, ...rest } = values;
|
||||
payload = {
|
||||
...values,
|
||||
currency: "USD",
|
||||
...rest,
|
||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
...(isLastMile
|
||||
? {
|
||||
// Empty "To km" means an open-ended band — send null so an
|
||||
// edit can clear a previously-set ceiling. On create the tier
|
||||
// spread below overrides the band fields per tier.
|
||||
maxKm: values.maxKm ?? null,
|
||||
...(lastMileMode === "BULK"
|
||||
? { rateUnit: "PER_TON_KM", containerTypeId: undefined }
|
||||
: { rateUnit: "PER_KM" }),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||
// its current value until an approver applies it. DRAFT rates fall
|
||||
@@ -604,6 +640,31 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Container-mode create: the tier list becomes one rate row per tier,
|
||||
// created sequentially so an overlap/duplicate rejection stops the batch
|
||||
// with its own toast instead of half-failing in parallel.
|
||||
const tiers = (
|
||||
payload as {
|
||||
tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>;
|
||||
}
|
||||
).tiers;
|
||||
if (!editing?.id && Array.isArray(tiers)) {
|
||||
const { tiers: _omitted, ...base } = payload as Record<string, unknown>;
|
||||
void _omitted;
|
||||
void (async () => {
|
||||
try {
|
||||
for (const tier of tiers) {
|
||||
await create.mutateAsync({ ...base, ...tier });
|
||||
}
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
} catch {
|
||||
// The create mutation already toasted the failure; keep the dialog
|
||||
// open so the admin can fix the tier set and retry.
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
} else if (isPriorityRules) {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ColumnFormat =
|
||||
| "entityLabel"
|
||||
| "rateLabel";
|
||||
|
||||
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio";
|
||||
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
|
||||
|
||||
export interface ResourceColumn {
|
||||
id: string;
|
||||
@@ -70,6 +70,8 @@ export interface FormFieldDef {
|
||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||
*/
|
||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
||||
/** Pre-selected value on create (no record yet) — e.g. last-mile currency = ETB. */
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Fully derived field: its value is computed from the live form values on
|
||||
* every render and the input is locked. Used for the priority-rule min
|
||||
@@ -267,8 +269,10 @@ const unitsForShape = (
|
||||
case "INTERCITY":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||
case "FIRST_MILE":
|
||||
case "LAST_MILE":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||
case "LAST_MILE":
|
||||
// PER_KM = container mode (distance-banded), PER_TON_KM = bulk mode.
|
||||
return ["PER_KM", "PER_TON_KM", "PER_CONTAINER", "PER_TON", "FLAT"];
|
||||
default:
|
||||
return ["FLAT"];
|
||||
}
|
||||
@@ -295,8 +299,8 @@ export const rateUnitOptions = (
|
||||
};
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "ETB (Birr)", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
];
|
||||
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
@@ -846,6 +850,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
// Container last-mile distance bands; blank for every other rate shape.
|
||||
{ id: "minKm", header: "From km", accessorKey: "minKm", format: "number" },
|
||||
{ id: "maxKm", header: "To km", accessorKey: "maxKm", format: "number" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
@@ -958,6 +965,83 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
getInitialValue: (record) =>
|
||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
// ── Last mile — two calculation modes ─────────────────────────────────
|
||||
// Bulk bills per ton per km (price = tons × km × rate); Container bills
|
||||
// per km, banded by distance range with one rate row per container type
|
||||
// per band (price = km × rate × quantity).
|
||||
{
|
||||
name: "lastMileMode",
|
||||
label: "Calculation mode",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Bulk (per ton per km)", value: "BULK" },
|
||||
{ label: "Container (per km, distance-banded)", value: "CONTAINER" },
|
||||
],
|
||||
description:
|
||||
"Bulk: price = tons × km × rate. Container: price = km × band rate × quantity, one rate per container type per distance band.",
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
// Not a stored column: the mode is recorded in the unit the API keeps.
|
||||
getInitialValue: (record) =>
|
||||
record.rateUnit === "PER_TON_KM" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this band prices",
|
||||
description: "20ft and 40ft price differently — one rate per type per band.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "minKm",
|
||||
label: "From km",
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "0",
|
||||
description: "Band start (inclusive). Use 0 for the first band.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" &&
|
||||
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||
},
|
||||
{
|
||||
name: "maxKm",
|
||||
label: "To km",
|
||||
type: "number",
|
||||
optional: true,
|
||||
placeholder: "Leave empty for no upper limit",
|
||||
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" &&
|
||||
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||
},
|
||||
{
|
||||
name: "currency",
|
||||
label: "Currency",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: CURRENCIES,
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
// Birr is the norm for domestic trucking; USD stays selectable.
|
||||
defaultValue: "ETB",
|
||||
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
||||
},
|
||||
// ── Distance tiers (create only — the page swaps this for the single
|
||||
// From/To/value fields when editing an existing band row). Each tier
|
||||
// becomes its own rate row, so every band keeps edit/delete/approval. ──
|
||||
{
|
||||
name: "tiers",
|
||||
label: "Distance tiers",
|
||||
type: "tierList",
|
||||
required: true,
|
||||
description:
|
||||
"One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" &&
|
||||
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||
},
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
{
|
||||
@@ -1002,10 +1086,29 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Where the leg ends",
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{
|
||||
name: "rateValue",
|
||||
label: "Rate value",
|
||||
type: "number",
|
||||
required: true,
|
||||
suffix: "USD",
|
||||
showIf: (v) => v.appliesTo !== "LAST_MILE",
|
||||
},
|
||||
// Last-mile rates carry their own currency (birr or dollar) and the
|
||||
// value is a per-km / per-ton·km price, so no hardcoded USD suffix.
|
||||
{
|
||||
name: "rateValue",
|
||||
label: "Rate value",
|
||||
type: "number",
|
||||
required: true,
|
||||
description:
|
||||
"Container mode: price per km for this band. Bulk mode: price per ton per km.",
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
},
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
// is always per excess ton, so the unit field is hidden for it — the API
|
||||
// forces PER_TON regardless.
|
||||
// forces PER_TON regardless. Last mile derives its unit from the
|
||||
// calculation mode instead.
|
||||
{
|
||||
name: "rateUnit",
|
||||
label: "Rate unit",
|
||||
@@ -1014,7 +1117,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description:
|
||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
showIf: (v) =>
|
||||
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -163,6 +163,11 @@ import {
|
||||
type SaveLocomotivePayload,
|
||||
} from "./locomotives.service";
|
||||
import { overviewService } from "./overview.service";
|
||||
import {
|
||||
auditService,
|
||||
type AuditLogListFilter,
|
||||
type PaginatedAuditLogs,
|
||||
} from "./audit.service";
|
||||
import { reportsService } from "./reports.service";
|
||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||
import {
|
||||
@@ -2136,6 +2141,15 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
audit: {
|
||||
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
|
||||
"audit",
|
||||
"list",
|
||||
({ filter }) => auditService.list(filter),
|
||||
({ filter }) => ["audit", "list", filter ?? {}],
|
||||
),
|
||||
},
|
||||
|
||||
signatures: {
|
||||
mySignature: endpoint<void, SavedSignature | null>(
|
||||
"me",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const A = URL_CONSTANTS.AUDIT;
|
||||
|
||||
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
|
||||
// local-packages/FRONTEND_GUIDE.md.
|
||||
export type AuditQueryMethod =
|
||||
| "INSERT"
|
||||
| "UPDATE"
|
||||
| "DELETE"
|
||||
| "INSERT_CHILD"
|
||||
| "DELETE_CHILD";
|
||||
|
||||
export interface AuditFieldChange {
|
||||
field: string;
|
||||
from: unknown;
|
||||
to: unknown;
|
||||
}
|
||||
|
||||
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
|
||||
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
|
||||
// entity (auditLog.user, payload) can come back as either a plain string or
|
||||
// this shape; both `name` fields below reflect that.
|
||||
export type LocalizedText = string | { am?: string; en?: string };
|
||||
|
||||
// The vendored interceptor's own broken template produces a plain string
|
||||
// ("undefined undefined") when no user was attached at all (unauthenticated/
|
||||
// customer flows) — that's the non-bilingual string case for `name` here.
|
||||
export interface AuditUser {
|
||||
id?: string;
|
||||
name?: LocalizedText;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuditLogRow {
|
||||
id?: string;
|
||||
createdAt: string;
|
||||
deletedAt?: string | null;
|
||||
entityName: string;
|
||||
queryMethod: AuditQueryMethod;
|
||||
changes?: AuditFieldChange[] | null;
|
||||
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
|
||||
auditLog?: { id?: string; user?: AuditUser | null };
|
||||
}
|
||||
|
||||
export interface AuditLogListFilter {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedAuditLogs {
|
||||
items: AuditLogRow[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const auditService = {
|
||||
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
|
||||
const params: Record<string, number | undefined> = {
|
||||
skip: filter?.skip,
|
||||
take: filter?.take,
|
||||
};
|
||||
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
|
||||
const data = unwrap(response.data) as PaginatedAuditLogs;
|
||||
return { items: data.items ?? [], count: data.count ?? 0 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { api } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export const LAST_MILE_REQUEST_STATUSES = [
|
||||
'AWAITING_CONFIRMATION',
|
||||
'SUBMITTED',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
export type LastMileRequestStatus = (typeof LAST_MILE_REQUEST_STATUSES)[number];
|
||||
|
||||
export interface LastMileRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
companyId?: string;
|
||||
company?: { id: string; name?: string } | null;
|
||||
} | null;
|
||||
trainScheduleId: string;
|
||||
status: LastMileRequestStatus;
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
reminderSentAt?: string | null;
|
||||
submittedByUserId?: string | null;
|
||||
submittedAt?: string | null;
|
||||
reviewedByStaffId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
resultingLastMileId?: string | null;
|
||||
requestedDeliveryDate?: string | null;
|
||||
customerSignedAt?: string | null;
|
||||
signerDisplayName?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LastMileRequestListResponse {
|
||||
data: LastMileRequest[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** Rule-based estimate for the approve dialog — all nulls when no rule covers the job. */
|
||||
export interface LastMilePriceEstimate {
|
||||
estimatedKm: number | null;
|
||||
mode: 'BULK' | 'CONTAINER' | null;
|
||||
currency: string | null;
|
||||
total: number | null;
|
||||
lines: Array<{ description: string; amount: number }>;
|
||||
}
|
||||
|
||||
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||
|
||||
export const lastMileRequestsService = {
|
||||
list: (params?: { status?: LastMileRequestStatus; bookingId?: string; page?: number; pageSize?: number }) =>
|
||||
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||
priceEstimate: (id: string) => api.get<LastMilePriceEstimate>(LMR.PRICE_ESTIMATE(id)),
|
||||
approve: (id: string, advanceAmount: number) =>
|
||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||
reject: (id: string, reason: string) =>
|
||||
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
||||
contractDocument: (id: string) =>
|
||||
api.get<Blob>(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }),
|
||||
};
|
||||
Reference in New Issue
Block a user