Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx

264 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
const [form, setForm] = useState<
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
>({});
useEffect(() => {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
// `numeric` columns come back from the API as strings (e.g. "250.00").
// Coerce every field to a real number so Mantine's controlled
// NumberInput edits cleanly (a string value fights the caret) and the
// default can be cleared and replaced.
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
setForm(numeric);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
// Run once on mount only. `toast` from useToast is a fresh function every
// render — listing it here re-fired the effect on every render, refetching
// the rules and overwriting whatever the user was typing (values snapped
// back to the saved defaults).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
// Every field must hold a real number — an empty box (cleared but not
// 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",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
const raw = form[key];
const num = raw === "" || raw == null ? NaN : Number(raw);
if (!Number.isFinite(num)) {
toast({
title: "All fields are required — fill every value before saving.",
variant: "destructive",
});
return;
}
payload[key] = num;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
} finally {
setSaving(false);
}
};
return (
<PageContainer>
<PageHeader
title="Train scheduling rules"
subtitle="Global limits applied when previewing and assigning bookings to trains."
/>
<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"
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"
allowDecimal
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowDecimal
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"
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"
allowDecimal
min={0}
disabled={loading}
/>
</Stack>
</Card>
<Card maw={720} mt="md">
<Stack gap="md">
<PageHeader
title="Booking windows"
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
/>
<DurationField
label="Import window lead"
description="The single booking day opens this long before departure"
value={form.importWindowLeadDays ?? ""}
nativeUnit="days"
onChange={(value) =>
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
disabled={loading}
/>
<DurationField
label="Export booking lead"
description="Export bookings are accepted first-come-first-serve starting this long before departure"
value={form.exportBookingLeadHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Window open hour (EAT)"
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<DurationField
label="Window duration"
description="How long the import booking window stays open"
value={form.windowDurationHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, windowDurationHours: value }))
}
min={1}
disabled={loading}
/>
<DurationField
label="Document review"
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
disabled={loading}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules
</Button>
</Group>
</Stack>
</Card>
</PageContainer>
);
}