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

fix
This commit is contained in:
marshal
2026-07-17 16:59:36 +03:00
committed by GitHub
12 changed files with 642 additions and 46 deletions

View File

@@ -176,6 +176,7 @@ const RuleEngineFormDialog = ({
) {
return false;
}
if (field.showIf && !field.showIf(values)) return false;
return true;
}),
[fields, values],
@@ -192,6 +193,22 @@ const RuleEngineFormDialog = ({
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
next.rateUnit = "";
}
// The legal yards depend on what the rate is for and which way it runs, so
// a leg picked under the old answer is no longer valid — clear it instead
// of submitting a pair the API will reject.
if (
(name === "appliesTo" || name === "tradeDirection") &&
"originYardId" in current
) {
next.originYardId = "";
next.destinationYardId = "";
}
// Intercity asks for a container type or a bulk cargo type, never both —
// switching kind drops whichever the other kind had filled in.
if (name === "intercityKind") {
next.containerTypeId = "";
next.cargoTypeId = "";
}
return next;
});
};

View File

@@ -155,6 +155,37 @@ export const useContainerTypeOptions = (
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/** A yard option that remembers its country, so callers can filter by leg. */
export interface YardOption {
label: string;
value: string;
country: string;
}
/**
* Active yards for the rate form's origin/destination pickers. The country
* rides along on each option because which yards are legal depends on the
* rate's direction (import starts in Djibouti, export starts in Ethiopia).
*/
export const useYardOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("yards", { activeOnly: true }),
queryFn: () => ruleEngineService.listAll<RuleEngineRecord>("yards"),
enabled,
select: (rows): YardOption[] =>
rows
.filter((row) => row.id && row.isActive !== false)
.map((row) => {
const label = String(row.label ?? "").trim();
const code = String(row.code ?? "").trim();
return {
label: label && code ? `${label} (${code})` : label || code || String(row.id),
value: String(row.id),
country: String(row.country ?? ""),
};
}),
});
/**
* Active wagon-type options for the cargo-type / container-type "Wagon type"
* picker. The FK the selection sets drives train-scheduling wagon resolution.

View File

@@ -41,6 +41,8 @@ import {
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
useYardOptions,
type YardOption,
usePriorityRuleWorkflow,
useRateChangeWorkflow,
useRateWorkflow,
@@ -72,6 +74,41 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
return undefined;
};
/**
* Which yards may sit at one end of the leg a base-freight rate prices.
*
* The railway sells three shapes and each pins the countries: an import lands
* at a Djibouti port and rails inland, an export is the reverse, and intercity
* stays inside Ethiopia. Narrowing the dropdown is what stops an import rate
* from being configured Ethiopia → Ethiopia — the API rejects that too, but
* the admin should never be offered it. Non-base-freight rates carry no leg,
* so they get nothing.
*/
const yardOptionsForLegEnd = (
yards: YardOption[],
values: Record<string, unknown>,
end: "origin" | "destination",
): { label: string; value: string }[] => {
const appliesTo = String(values.appliesTo ?? "");
let country: string | undefined;
if (appliesTo === "INTERCITY") {
country = "Ethiopia";
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
}
if (!country) return [];
return yards
.filter((yard) => yard.country === country)
.map(({ label, value }) => ({ label, value }));
};
const RuleEngineResourcePage = () => {
const { user } = useAuth();
const { resource: resourceSlug } = useParams<{ resource: string }>();
@@ -205,6 +242,11 @@ const RuleEngineResourcePage = () => {
useLiveRateOptions(usesLiveRateField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
// 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
@@ -279,9 +321,22 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
// Each end of the leg only offers yards in the country that end of the
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
yardOptionsForLegEnd(yardOptions ?? [], values, end),
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -709,7 +764,8 @@ const RuleEngineResourcePage = () => {
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading)
(usesWagonTypeField && wagonTypeOptionsLoading) ||
(usesYardField && yardOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}

View File

@@ -47,6 +47,13 @@ export interface FormFieldDef {
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
/**
* Show this field only when the predicate accepts the live form values — for
* visibility that depends on more than one field, which `showWhen` cannot
* express (the intercity container/bulk pickers hang off both `appliesTo`
* and `intercityKind`). Combines with showWhen/hideWhen: all must pass.
*/
showIf?: (values: Record<string, unknown>) => boolean;
/**
* Select options computed from other fields' current values. When set, the
* form resolves the option list at render time from the live form state
@@ -145,6 +152,21 @@ const RATE_TRIGGERS = [
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
];
/**
* Intercity runs inside Ethiopia and can carry either boxes or bulk, but the
* two price differently. The admin says which up front and the form then asks
* for the matching scope field — this choice is not stored on the rate itself;
* the API reads container-vs-bulk back off whichever scope field was filled.
*/
const INTERCITY_KINDS = [
{ label: "Container", value: "CONTAINER" },
{ label: "Bulk", value: "BULK" },
];
/** True when the rate being edited is base rail freight, which is priced per leg. */
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -544,6 +566,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
// Base freight is priced per leg, so the route is what tells two otherwise
// identical rates apart. Surcharges have no leg and render as "—".
{ id: "originYard", header: "From", accessorKey: "originYard", format: "entityLabel" },
{
id: "destinationYard",
header: "To",
accessorKey: "destinationYard",
format: "entityLabel",
},
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
@@ -577,23 +608,62 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
},
// ── Container type — Container & Intercity ───────────────────────────
// ── Cargo kind — Intercity only (import/export get it from appliesTo)
{
name: "intercityKind",
label: "Cargo type",
type: "select",
required: true,
options: INTERCITY_KINDS,
placeholder: "Is this rate for containers or bulk?",
description: "Intercity prices containers and bulk differently — pick which this covers.",
showWhen: { field: "appliesTo", equals: ["INTERCITY"] },
// Not a stored column: an existing rate records its kind in the rateType
// the API derived (INTERCITY_BULK / INTERCITY_CONTAINER).
getInitialValue: (record) =>
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
},
// ── Container type — Container freight, and container-kind intercity ──
{
name: "containerTypeId",
label: "Container type",
type: "select",
optional: true,
placeholder: "Select container type (optional)",
showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] },
showIf: (v) =>
v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
},
// ── Bulk cargo (leaf commodity) — Bulk & Intercity ──────────────────
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
optional: true,
placeholder: "Select bulk commodity (optional)",
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
showIf: (v) =>
v.appliesTo === "BULK" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
},
// ── The leg this rate prices — base freight only ──────────────────────
// Options are narrowed to the countries the direction allows (import
// starts in Djibouti, export in Ethiopia, intercity stays in Ethiopia);
// see RuleEngineResourcePage, which injects the yard lists.
{
name: "originYardId",
label: "Origin yard",
type: "select",
required: true,
placeholder: "Where the leg starts",
showIf: isBaseFreightRate,
},
{
name: "destinationYardId",
label: "Destination yard",
type: "select",
required: true,
placeholder: "Where the leg ends",
showIf: isBaseFreightRate,
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight

View File

@@ -52,7 +52,6 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -103,7 +102,6 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -976,18 +974,6 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
</Button>
) : null}
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Train size={16} />}
onClick={() => setAdjustConsistOpen(true)}
>
Adjust consist
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
@@ -1212,12 +1198,6 @@ export default function TrainScheduleV2DetailPage() {
/>
) : null}
<AdjustConsistModal
scheduleId={schedule.id}
opened={adjustConsistOpen}
onClose={() => setAdjustConsistOpen(false)}
/>
<BookingWindowSettingsModal
scheduleId={scheduleId ?? null}
opened={windowSettingsOpen}