mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
264 lines
9.5 KiB
TypeScript
264 lines
9.5 KiB
TypeScript
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>
|
||
);
|
||
}
|