Merge pull request #717 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-15 23:18:55 +03:00
committed by GitHub
53 changed files with 1524 additions and 39 deletions

View File

@@ -19,6 +19,7 @@ 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 { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => {
);
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table.
// pending queue renders above the table. Validation errors (range collision,
// gap, ceiling) surface in a modal so the text is impossible to miss.
const isPriorityRules = config?.slug === "priority-configs";
const [priorityError, setPriorityError] = useState<string | null>(null);
const priorityWorkflow = usePriorityRuleWorkflow(
Boolean(isPriorityRules && canView),
setPriorityError,
);
const editingId = editing?.id ? String(editing.id) : undefined;
@@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
// Full rule list backing the auto-filled "min wagon count": the next range
// always continues the chain for the selected type (per currency), so the
// form needs every existing rule, not the current page.
const { data: allPriorityRules } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(isPriorityRules && formOpen),
config?.orderConfig?.field,
);
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
if (isPriorityRules && field.name === "minWagonCount") {
return {
...field,
// Editing keeps the rule's own start (a lower gap never forces it to
// move); creating always continues the chain / fills the lowest gap.
computeValue: (values: Record<string, unknown>) =>
editing?.minWagonCount != null
? Number(editing.minWagonCount)
: nextPriorityRangeStart(
allPriorityRules ?? [],
String(values.type ?? ""),
!values.currency || values.currency === RULE_ENGINE_SELECT_NONE
? null
: String(values.currency),
editingId,
),
};
}
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return {
...field,
@@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => {
/>
) : null}
<Modal
opened={priorityError != null}
onClose={() => setPriorityError(null)}
title="Cannot save priority rule"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="red.8">
{priorityError}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPriorityError(null)}>
OK
</Button>
</Group>
</Stack>
</Modal>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">

View File

@@ -61,6 +61,13 @@ export interface FormFieldDef {
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
*/
getInitialValue?: (record: Record<string, unknown>) => unknown;
/**
* 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
* wagon count, which always continues the previous range for the selected
* type. Return null/undefined to leave the field empty (e.g. chain full).
*/
computeValue?: (values: Record<string, unknown>) => number | string | null;
}
export interface RuleEngineOrderConfig {
@@ -134,6 +141,7 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
];
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -155,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
return ["FLAT"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -356,8 +367,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
{
name: "minWagonCount",
label: "Min wagon count",
type: "number",
required: true,
disabled: true,
description: "Auto-filled — continues the previous range for the selected type",
},
{
name: "maxWagonCount",
label: "Max wagon count",
type: "number",
required: true,
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
},
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
],

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
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;
currency?: unknown;
minWagonCount?: unknown;
maxWagonCount?: unknown;
}
/**
* 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.
*/
export function nextPriorityRangeStart(
rules: PriorityRangeRule[],
type: string,
currency: string | null | undefined,
excludeId?: string,
): number | null {
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
if (!cap) return null;
const scoped = rules
.filter(
(r) =>
String(r.type ?? "") === type &&
(excludeId === undefined || String(r.id ?? "") !== excludeId) &&
(type !== "CURRENCY" ||
String(r.currency ?? "") === String(currency ?? "")),
)
.map((r) => ({
min: Number(r.minWagonCount ?? 0),
max: Number(r.maxWagonCount ?? 0),
}))
.sort((a, b) => a.min - b.min);
let next = 1;
for (const r of scoped) {
if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1);
}
return next > cap ? null : next;
}

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
@@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Hammer,
Pencil,
Ruler,
Search,
Train as TrainIcon,
@@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
@@ -52,6 +55,7 @@ export default function TrainBuilderListPage() {
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false);
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
const resetPage = useCallback(() => {
setPagination((prev) =>
@@ -239,6 +243,26 @@ export default function TrainBuilderListPage() {
</Badge>
),
},
{
id: "actions",
header: "",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Edit train ${row.original.code}`}
title="Edit name & train numbers"
onClick={(e) => {
// Row click navigates to the detail page — keep the edit local.
e.stopPropagation();
setEditTarget(row.original);
}}
>
<Pencil size={15} />
</ActionIcon>
),
},
];
}, []);
@@ -363,6 +387,8 @@ export default function TrainBuilderListPage() {
onClose={() => setBuildOpen(false)}
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
/>
<EditTrainDetailsModal train={editTarget} onClose={() => setEditTarget(null)} />
</PageContainer>
);
}