mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge branch 'dev' into freight/feat/chat-app
This commit is contained in:
@@ -61,7 +61,6 @@ interface RefContainerType {
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer?: boolean;
|
||||
wagons_per_unit?: number;
|
||||
}
|
||||
interface RefContainerGroup {
|
||||
size: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -60,6 +62,7 @@ import type {
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
} from "@/types/customer";
|
||||
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -166,6 +169,13 @@ export default function CustomerDetailPage() {
|
||||
);
|
||||
const paidCurrency = payments[0]?.currency ?? "ETB";
|
||||
|
||||
// The company row is created on the wizard's first click, so a draft reaches
|
||||
// this page with a placeholder name/TIN. `stillOnboarding` drives the banner
|
||||
// and badge; `canReview` gates the approve/reject buttons and mirrors the
|
||||
// API's rule exactly, so no button is offered that the server would reject.
|
||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||
|
||||
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -273,11 +283,12 @@ export default function CustomerDetailPage() {
|
||||
<ProfileApprovalActions
|
||||
profileId={row.original.id}
|
||||
status={row.original.status}
|
||||
locked={!canReview}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[view],
|
||||
[view, canReview],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -602,7 +613,13 @@ export default function CustomerDetailPage() {
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
{stillOnboarding ? (
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding in progress
|
||||
</Badge>
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -631,6 +648,21 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
{stillOnboarding && (
|
||||
<Alert
|
||||
color="gray"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Hourglass size={18} />}
|
||||
title="This customer hasn't submitted their application yet"
|
||||
>
|
||||
They're still filling in the onboarding wizard, so the details
|
||||
below are an unfinished draft — the company name and TIN are
|
||||
placeholders until they reach those steps. Role profiles become
|
||||
reviewable once the application is submitted.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -642,10 +674,16 @@ export default function CustomerDetailPage() {
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending approval",
|
||||
value: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
// A draft's profiles are all `pending` by construction, which
|
||||
// would read as a review backlog that doesn't exist yet.
|
||||
label: stillOnboarding
|
||||
? "Awaiting submission"
|
||||
: "Pending approval",
|
||||
value: stillOnboarding
|
||||
? "—"
|
||||
: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
icon: IdCard,
|
||||
color: "yellow",
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
Mail,
|
||||
Phone,
|
||||
RefreshCw,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import { isOnboardingDraft } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -43,22 +45,39 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
||||
* review, so it excludes drafts — a company row exists from the onboarding
|
||||
* wizard's first click and would otherwise pad the review queue. Those drafts
|
||||
* get their own view instead of disappearing, so staff can still chase them.
|
||||
*/
|
||||
type CustomerView = "all" | "pending" | "onboarding" | "active";
|
||||
|
||||
const VIEW_FILTERS: Record<
|
||||
CustomerView,
|
||||
{ status?: CompanyStatus; onboardingCompleted?: boolean }
|
||||
> = {
|
||||
all: {},
|
||||
pending: { status: "pending", onboardingCompleted: true },
|
||||
onboarding: { onboardingCompleted: false },
|
||||
active: { status: "active" },
|
||||
};
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
|
||||
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
...VIEW_FILTERS[view],
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
|
||||
);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
@@ -114,6 +133,17 @@ export default function CustomersPage() {
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
// A draft's profiles are all `pending` by construction, so the
|
||||
// "N pending" review hint would be a lie until they submit.
|
||||
if (isOnboardingDraft(row.original)) {
|
||||
return (
|
||||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
@@ -206,6 +236,12 @@ export default function CustomersPage() {
|
||||
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
|
||||
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
|
||||
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
|
||||
{
|
||||
label: "Onboarding",
|
||||
value: stats?.onboarding ?? "—",
|
||||
icon: Hourglass,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
label: "Blacklisted",
|
||||
value: stats?.blacklisted ?? "—",
|
||||
@@ -243,14 +279,15 @@ export default function CustomersPage() {
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
|
||||
setView(v as CustomerView);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Onboarding", value: "onboarding" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
|
||||
import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns";
|
||||
import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
|
||||
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
|
||||
|
||||
@@ -40,6 +41,20 @@ export interface FleetResourceColumn {
|
||||
export interface FleetFormFieldDef extends FormFieldDef {
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
noneOption?: boolean;
|
||||
/**
|
||||
* Read-only field whose value is computed from the other fields rather than
|
||||
* typed. Rendered non-editable and recomputed on every change, so the stored
|
||||
* form value for this field is never trusted — the function is the source of
|
||||
* truth at both render and submit.
|
||||
*/
|
||||
derivedValue?: (values: Record<string, unknown>) => string;
|
||||
/**
|
||||
* Field can be emptied back to NULL. Empty values are normally dropped from
|
||||
* the payload (so a PATCH leaves them untouched); a clearable field instead
|
||||
* submits an explicit `null`, which is what actually unsets the column. Also
|
||||
* renders a clear button on a `select`.
|
||||
*/
|
||||
clearable?: boolean;
|
||||
/**
|
||||
* Field is owned by the Fayda identity — populated only by verification and
|
||||
* never hand-edited. Rendered disabled in the form.
|
||||
@@ -118,6 +133,7 @@ const WAGON_STATUS_OPTIONS = [
|
||||
|
||||
|
||||
|
||||
|
||||
export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{
|
||||
slug: "locomotives",
|
||||
@@ -261,16 +277,49 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "currentYard",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
|
||||
searchKeys: [
|
||||
"wagonNumber",
|
||||
"wagonTypeId",
|
||||
"trainId",
|
||||
"exportTrainNumber",
|
||||
"importTrainNumber",
|
||||
"status",
|
||||
"currentYardId",
|
||||
],
|
||||
columns: [
|
||||
// Tare weight and payload capacity are not wagon columns — they belong to the
|
||||
// wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages).
|
||||
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
|
||||
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
|
||||
// Unset on a wagon that is not on a run — renders as a dimmed dash.
|
||||
{ id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" },
|
||||
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
],
|
||||
formFields: [
|
||||
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
||||
// run until an operator picks an export run. The import run is fixed by
|
||||
// that choice, so it is derived rather than typed.
|
||||
{
|
||||
name: "exportTrainNumber",
|
||||
label: "Export train number",
|
||||
type: "select",
|
||||
description: "Odd — Ethiopia → Djibouti runs",
|
||||
placeholder: "e.g. 8001",
|
||||
options: EXPORT_TRAIN_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
name: "importTrainNumber",
|
||||
label: "Import train number",
|
||||
type: "text",
|
||||
description: "Even — Djibouti → Ethiopia runs",
|
||||
placeholder: "e.g. 8002",
|
||||
derivedValue: (values) => importRunFor(values.exportTrainNumber),
|
||||
// Follows the export run to NULL when that is cleared.
|
||||
clearable: true,
|
||||
},
|
||||
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
||||
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
@@ -278,6 +327,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "notes", label: "Notes", type: "textarea" },
|
||||
],
|
||||
emptyValues: {
|
||||
exportTrainNumber: "",
|
||||
importTrainNumber: "",
|
||||
wagonNumber: "",
|
||||
wagonTypeId: "",
|
||||
currentYardId: "",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react";
|
||||
|
||||
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
|
||||
/** Field labels for the diff — anything not listed falls back to the raw key. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
rateValue: "Rate",
|
||||
currency: "Currency",
|
||||
rateUnit: "Unit",
|
||||
appliesTo: "Applies to",
|
||||
trigger: "Trigger",
|
||||
tradeDirection: "Direction",
|
||||
containerTypeId: "Container type",
|
||||
cargoTypeId: "Cargo type",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const fmtValue = (field: string, value: unknown): string => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (field === "rateValue") {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||
}
|
||||
return String(value).replace(/_/g, " ");
|
||||
};
|
||||
|
||||
/** "Ocean freight · 40HC" — what rate this change targets. */
|
||||
const rateSummary = (r: RateChangeRequest): string => {
|
||||
const rate = (r.rate ?? {}) as Record<string, unknown>;
|
||||
const parts = [
|
||||
rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null,
|
||||
rate.appliesTo ? String(rate.appliesTo) : null,
|
||||
rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || "Rate";
|
||||
};
|
||||
|
||||
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
|
||||
const headline = (r: RateChangeRequest): string | null => {
|
||||
if (!("rateValue" in r.payload)) return null;
|
||||
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
|
||||
const before = fmtValue("rateValue", r.previousValues.rateValue);
|
||||
const after = fmtValue("rateValue", r.payload.rateValue);
|
||||
return `${before} → ${after}${currency ? ` ${currency}` : ""}`;
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
RateChangeRequest,
|
||||
unknown,
|
||||
{ id: string; decisionNote?: string }
|
||||
>;
|
||||
|
||||
interface RateApprovalsSectionProps {
|
||||
requests: RateChangeRequest[];
|
||||
/** Whether this user holds the rates approve permission. */
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending edits to LIVE rates. Each row is a before→after diff: the left value
|
||||
* is what pricing charges right now and keeps charging until someone approves.
|
||||
* Rendered above the rates table.
|
||||
*/
|
||||
const RateApprovalsSection = ({
|
||||
requests,
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
}: RateApprovalsSectionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" mb="md">
|
||||
<Group gap={8} mb={4}>
|
||||
<Clock size={16} />
|
||||
<Text fw={700}>Pending rate changes</Text>
|
||||
<Badge variant="light" color="yellow">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
Each rate below still charges its current value. Nothing changes until approved.
|
||||
</Text>
|
||||
|
||||
<Stack gap={8}>
|
||||
{requests.map((r) => {
|
||||
const isOpen = openId === r.id;
|
||||
const fields = Object.keys(r.payload);
|
||||
const summaryLine = headline(r);
|
||||
// Only the row being decided shows a spinner — the mutation's
|
||||
// isPending is shared across every row.
|
||||
const busy = decidingId === r.id;
|
||||
|
||||
return (
|
||||
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
update
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{rateSummary(r)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{summaryLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue("rateValue", r.previousValues.rateValue)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={700} c="edr-green">
|
||||
{fmtValue("rateValue", r.payload.rateValue)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide details" : "See all changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{canDecide ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={14} />}
|
||||
loading={busy && reject.isPending}
|
||||
disabled={busy && approve.isPending}
|
||||
onClick={() =>
|
||||
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={busy && approve.isPending}
|
||||
disabled={busy && reject.isPending}
|
||||
onClick={() =>
|
||||
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Approve & apply
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label="You need the rates approve permission to decide this">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Awaiting approver
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Collapse in={isOpen}>
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{fields.map((field) => (
|
||||
<Group key={field} gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field])}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field])}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{canDecide ? (
|
||||
<Textarea
|
||||
mt={4}
|
||||
size="xs"
|
||||
autosize
|
||||
minRows={2}
|
||||
label="Decision note (optional)"
|
||||
placeholder="Shown to the requester with your decision"
|
||||
value={notes[r.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateApprovalsSection;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import {
|
||||
canAccessRuleEngineResource,
|
||||
canApproveRuleEngineChange,
|
||||
} from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
@@ -11,14 +14,16 @@ import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Clock, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
usePriorityRuleWorkflow,
|
||||
useRateChangeWorkflow,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -51,6 +57,7 @@ import {
|
||||
getRuleEngineResource,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -145,6 +152,23 @@ const RuleEngineResourcePage = () => {
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
);
|
||||
|
||||
// A LIVE rate is what pricing charges, so editing one files a change request
|
||||
// instead of mutating: the rate keeps its current value until an approver
|
||||
// applies the change. DRAFT rates still edit directly.
|
||||
const isRates = config?.slug === "rates";
|
||||
const [rateError, setRateError] = useState<string | null>(null);
|
||||
const rateChangeWorkflow = useRateChangeWorkflow(
|
||||
Boolean(isRates && canView),
|
||||
setRateError,
|
||||
);
|
||||
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
||||
/** rateId → its pending change, for the row badge. */
|
||||
const pendingByRateId = useMemo(() => {
|
||||
const map = new Map<string, RateChangeRequest>();
|
||||
for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
|
||||
return map;
|
||||
}, [rateChangeWorkflow.pending.data]);
|
||||
|
||||
// Priority rules never mutate directly: changes are filed for approval and a
|
||||
// pending queue renders above the table. Validation errors (range collision,
|
||||
// gap, ceiling) surface in a modal so the text is impossible to miss.
|
||||
@@ -306,7 +330,27 @@ const RuleEngineResourcePage = () => {
|
||||
id: col.id,
|
||||
header: col.header,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
|
||||
cell: ({ row }) => {
|
||||
const cell = formatCell(row.original[col.accessorKey], col.format);
|
||||
// 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;
|
||||
const change = pendingByRateId.get(String(row.original.id));
|
||||
if (!change || change.payload.rateValue === undefined) return cell;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{cell}
|
||||
<Tooltip label="Awaiting approval — this rate still charges its current value">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Clock size={11} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{Number(change.payload.rateValue).toLocaleString()} pending
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
base.push({
|
||||
@@ -357,6 +401,8 @@ const RuleEngineResourcePage = () => {
|
||||
}, [
|
||||
canManage,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
submit,
|
||||
handleApproveRate,
|
||||
handleMoveOrder,
|
||||
@@ -400,6 +446,21 @@ const RuleEngineResourcePage = () => {
|
||||
currency: "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
};
|
||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||
// its current value until an approver applies it. DRAFT rates fall
|
||||
// through to the normal update below.
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (isPriorityRules) {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
@@ -483,6 +544,31 @@ const RuleEngineResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isRates ? (
|
||||
<RateApprovalsSection
|
||||
requests={rateChangeWorkflow.pending.data ?? []}
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={rateError != null}
|
||||
onClose={() => setRateError(null)}
|
||||
title="Cannot save rate change"
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="red">
|
||||
{rateError}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={() => setRateError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={priorityError != null}
|
||||
onClose={() => setPriorityError(null)}
|
||||
|
||||
@@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Max wagon count",
|
||||
type: "number",
|
||||
required: true,
|
||||
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
|
||||
description: "No upper limit — must be at least the min wagon count",
|
||||
},
|
||||
{ name: "scorePoints", label: "Score points", type: "number", required: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
@@ -477,6 +477,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
codeColumn("code"),
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
{ id: "country", header: "Country", accessorKey: "country" },
|
||||
{
|
||||
id: "hasFacility",
|
||||
header: "Facility",
|
||||
accessorKey: "hasFacility",
|
||||
format: "boolean",
|
||||
},
|
||||
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
@@ -489,6 +495,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
required: true,
|
||||
options: YARD_COUNTRIES,
|
||||
},
|
||||
{
|
||||
name: "hasFacility",
|
||||
label: "Has load/unload facility",
|
||||
type: "boolean",
|
||||
description:
|
||||
"This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
/**
|
||||
* Client mirror of the backend's contiguous-range rules for priority configs
|
||||
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
|
||||
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
|
||||
* range always starts at the lowest uncovered wagon count. The backend
|
||||
* re-validates on submit AND on approval; this only drives the form prefill.
|
||||
* currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
|
||||
* range always starts at the lowest uncovered wagon count. There is no upper
|
||||
* ceiling. The backend re-validates on submit AND on approval; this only
|
||||
* drives the form prefill.
|
||||
*/
|
||||
|
||||
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
|
||||
|
||||
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
|
||||
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
export interface PriorityRangeRule {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
|
||||
maxWagonCount?: unknown;
|
||||
}
|
||||
|
||||
const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
|
||||
|
||||
/**
|
||||
* Where the next range for `type` (+`currency`) must start, excluding
|
||||
* `excludeId` (the rule being edited). Null when the chain already covers
|
||||
* 1..cap — no further rule fits.
|
||||
* `excludeId` (the rule being edited). Null only when `type` is not yet a
|
||||
* known priority rule type — the chain itself is unbounded, so a next start
|
||||
* always exists.
|
||||
*/
|
||||
export function nextPriorityRangeStart(
|
||||
rules: PriorityRangeRule[],
|
||||
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
|
||||
currency: string | null | undefined,
|
||||
excludeId?: string,
|
||||
): number | null {
|
||||
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
|
||||
if (!cap) return null;
|
||||
if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
|
||||
|
||||
const scoped = rules
|
||||
.filter(
|
||||
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
|
||||
if (r.min > next) break; // gap before this rule — fill it first
|
||||
next = Math.max(next, r.max + 1);
|
||||
}
|
||||
return next > cap ? null : next;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export default function TrainBuilderDetailPage() {
|
||||
<AvailableWagonsPanel
|
||||
yardId={yard?.id ?? ""}
|
||||
yardLabel={yard?.label}
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
onAssign={(wagonIds) =>
|
||||
void withToast(
|
||||
|
||||
@@ -50,11 +50,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
// refilled) must not silently save as 0. Collect the numeric payload and
|
||||
// reject if any value is blank or NaN.
|
||||
const fields: (keyof TrainSchedulingGlobalRules)[] = [
|
||||
"maxTrainLengthMeters",
|
||||
"maxTrainWeightTons",
|
||||
"maxWagonsPerTrain",
|
||||
"max20ftContainerWeightTons",
|
||||
"max20ftPairWeightDiffTons",
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
@@ -98,32 +94,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
|
||||
<Card maw={720}>
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="Max train length (m)"
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max train weight (T)"
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
@@ -136,38 +106,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft container weight (T)"
|
||||
description="Each individual 20ft container gross weight limit"
|
||||
value={form.max20ftContainerWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft pair weight difference (T)"
|
||||
description="When two 20ft containers share a wagon, |weight1 − weight2| must not exceed this"
|
||||
value={form.max20ftPairWeightDiffTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user