Merge pull request #760 from Tria-plc/freight/feature/user_management_UI

Freight/feature/user management UI
This commit is contained in:
yaschalew10
2026-07-17 13:30:34 +03:00
committed by GitHub
4 changed files with 78 additions and 53 deletions

View File

@@ -263,6 +263,14 @@ const FleetFormDialog = ({
return map; return map;
}, [fields]); }, [fields]);
// Emptying one of these means "unset the column", so it submits an explicit
// null instead of being dropped from the payload like other empty fields.
const clearableByName = useMemo(() => {
const map: Record<string, boolean> = {};
fields.forEach((f) => (map[f.name] = Boolean(f.clearable)));
return map;
}, [fields]);
const handleSubmit = () => { const handleSubmit = () => {
// Hard gate: a driver record cannot be saved until its identity is verified // Hard gate: a driver record cannot be saved until its identity is verified
// with Fayda. Mirrored server-side in DriversService. // with Fayda. Mirrored server-side in DriversService.
@@ -280,8 +288,8 @@ const FleetFormDialog = ({
const payload = Object.fromEntries( const payload = Object.fromEntries(
Object.entries(submitted) Object.entries(submitted)
.map(([key, value]) => { .map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "") if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, undefined]; return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") { if (fieldTypeByName[key] === "number") {
const num = Number(value); const num = Number(value);
return [key, Number.isNaN(num) ? undefined : num]; return [key, Number.isNaN(num) ? undefined : num];
@@ -357,6 +365,7 @@ const FleetFormDialog = ({
} }
error={error} error={error}
searchable searchable
clearable={field.clearable}
disabled={selectOptionsLoading || isDisabled} disabled={selectOptionsLoading || isDisabled}
rightSection={ rightSection={
selectOptionsLoading ? ( selectOptionsLoading ? (

View File

@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service"; import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns";
const parseError = (error: unknown, fallback: string) => { const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) { if (isAxiosError(error)) {
@@ -26,10 +27,6 @@ const parseError = (error: unknown, fallback: string) => {
return fallback; return fallback;
}; };
// Run-number parity carries the trade direction: odd = export, even = import.
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/** /**
* Step one of the Train Builder: pick the yard it is being assembled in and * Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by * couple at least two locomotives from that yard. The train code is assigned by
@@ -59,6 +56,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
setLocomotiveIds([]); setLocomotiveIds([]);
}, [yardId]); }, [yardId]);
// The import run is fixed by the export run, so it tracks it rather than
// being entered by hand (and clears back to empty when the export is cleared).
useEffect(() => {
setImportTrainNumber(importRunFor(exportTrainNumber));
}, [exportTrainNumber]);
useEffect(() => { useEffect(() => {
if (!opened) { if (!opened) {
setExportTrainNumber(""); setExportTrainNumber("");
@@ -78,9 +81,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}); });
return; return;
} }
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) { // Both numbers come from the fixed run pairs, so parity cannot be wrong —
// only "nothing picked" is reachable here.
if (!exportTrainNumber || !importTrainNumber) {
toast({ toast({
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)", title: "Pick an export train number (e.g. 8001) — the import run follows it",
variant: "destructive", variant: "destructive",
}); });
return; return;
@@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100} maxLength={100}
/> />
<Group grow> <Group grow>
<TextInput <Select
label="Export train number" label="Export train number"
description="Odd — Ethiopia → Djibouti runs" description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001" placeholder="e.g. 8001"
value={exportTrainNumber} data={EXPORT_TRAIN_OPTIONS}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)} value={exportTrainNumber || null}
maxLength={20} onChange={(value) => setExportTrainNumber(value ?? "")}
error={ searchable
exportTrainNumber && !isOddNumber(exportTrainNumber) clearable
? "Must be numeric and odd"
: undefined
}
/> />
{/* Fixed by the export run — derived, never typed. */}
<TextInput <TextInput
label="Import train number" label="Import train number"
description="Even — Djibouti → Ethiopia runs" description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002" placeholder="e.g. 8002"
value={importTrainNumber} value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)} readOnly
maxLength={20} variant="filled"
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
/> />
</Group> </Group>
<Select <Select

View File

@@ -0,0 +1,35 @@
/**
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing
* an export number fully determines the import one.
*
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
* the convention stays correct here.
*
* Mirrors RUN_WAGONS/IMPORT_RUN in the API's SeedWagonRunNumbers migration —
* keep the two in sync when runs are added or retired.
*/
export const TRAIN_RUN_PAIRS: Record<string, string> = {
"8001": "8002",
"8101": "8102",
"8201": "8202",
"8301": "8302",
"8401": "8402",
"8501": "8502",
"8601": "8602",
"8701": "8702",
"8801": "8802",
"8901": "8902",
"9001": "9002",
};
/** Selectable export runs, in run order. */
export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
label: run,
value: run,
}));
/** The import run implied by an export run; empty string when unset/unknown. */
export const importRunFor = (exportRun: unknown): string =>
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; 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 { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers"; import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
@@ -47,6 +48,13 @@ export interface FleetFormFieldDef extends FormFieldDef {
* truth at both render and submit. * truth at both render and submit.
*/ */
derivedValue?: (values: Record<string, unknown>) => string; 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 * Field is owned by the Fayda identity — populated only by verification and
* never hand-edited. Rendered disabled in the form. * never hand-edited. Rendered disabled in the form.
@@ -123,33 +131,6 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Detained", value: Freight.WagonStatus.Detained }, { label: "Detained", value: Freight.WagonStatus.Detained },
]; ];
/**
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing
* an export number fully determines the import one.
*
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
* the convention stays correct here.
*/
const TRAIN_RUN_PAIRS: Record<string, string> = {
"8001": "8002",
"8101": "8102",
"8201": "8202",
"8301": "8302",
"8401": "8402",
"8501": "8502",
"8601": "8602",
"8701": "8702",
"8801": "8802",
"8901": "8902",
"9001": "9002",
};
const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
label: run,
value: run,
}));
@@ -327,6 +308,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
description: "Odd — Ethiopia → Djibouti runs", description: "Odd — Ethiopia → Djibouti runs",
placeholder: "e.g. 8001", placeholder: "e.g. 8001",
options: EXPORT_TRAIN_OPTIONS, options: EXPORT_TRAIN_OPTIONS,
clearable: true,
}, },
{ {
name: "importTrainNumber", name: "importTrainNumber",
@@ -334,8 +316,9 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
type: "text", type: "text",
description: "Even — Djibouti → Ethiopia runs", description: "Even — Djibouti → Ethiopia runs",
placeholder: "e.g. 8002", placeholder: "e.g. 8002",
derivedValue: (values) => derivedValue: (values) => importRunFor(values.exportTrainNumber),
TRAIN_RUN_PAIRS[String(values.exportTrainNumber ?? "")] ?? "", // Follows the export run to NULL when that is cleared.
clearable: true,
}, },
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },