mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
263 lines
9.7 KiB
TypeScript
263 lines
9.7 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";
|
|
|
|
/**
|
|
* Coerce an API rules object into editable form state: `numeric` columns arrive
|
|
* as strings ("250.00") and nullable offsets arrive as null — map both to a real
|
|
* number, or "" for blank/null so a NumberInput edits cleanly and a cleared
|
|
* offset stays blank.
|
|
*/
|
|
function toFormState(
|
|
rules: TrainSchedulingGlobalRules,
|
|
): Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> {
|
|
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;
|
|
}
|
|
return numeric;
|
|
}
|
|
|
|
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();
|
|
setForm(toFormState(rules));
|
|
} 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)[] = [
|
|
"maxWagonsPerTrain",
|
|
"importWindowLeadDays",
|
|
"exportBookingLeadHours",
|
|
"windowOpenHour",
|
|
"windowCloseHour",
|
|
"windowDurationHours",
|
|
"docReviewMinutes",
|
|
"paymentWindowMinutes",
|
|
];
|
|
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;
|
|
}
|
|
|
|
// Close offsets are optional: a blank box means "no offset" (window closes at
|
|
// departure) and is sent as 0, which the API stores as null. A filled box is
|
|
// sent as its minute value.
|
|
const offsetFields: (keyof TrainSchedulingGlobalRules)[] = [
|
|
"importCloseOffsetMinutes",
|
|
"exportCloseOffsetMinutes",
|
|
];
|
|
for (const key of offsetFields) {
|
|
const raw = form[key];
|
|
const num = raw === "" || raw == null ? 0 : Number(raw);
|
|
payload[key] = Number.isFinite(num) ? num : 0;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const updated = await trainSchedulingService.updateGlobalRules(payload);
|
|
setForm(toFormState(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 wagons per train"
|
|
value={form.maxWagonsPerTrain ?? ""}
|
|
onChange={(value) =>
|
|
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
|
}
|
|
clampBehavior="none"
|
|
allowNegative={false}
|
|
allowDecimal
|
|
min={1}
|
|
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 booking desk opens each day (e.g. 8 = 08:00)"
|
|
value={form.windowOpenHour ?? ""}
|
|
onChange={(value) =>
|
|
setForm((current) => ({ ...current, windowOpenHour: value }))
|
|
}
|
|
clampBehavior="none"
|
|
allowNegative={false}
|
|
allowDecimal
|
|
min={0}
|
|
max={23}
|
|
disabled={loading}
|
|
/>
|
|
<NumberInput
|
|
label="Window close hour (EAT)"
|
|
description="Local hour the booking desk shuts each day; a not-yet-full window resumes next morning at the open hour. Set equal to the open hour for a 24-hour desk."
|
|
value={form.windowCloseHour ?? ""}
|
|
onChange={(value) =>
|
|
setForm((current) => ({ ...current, windowCloseHour: value }))
|
|
}
|
|
clampBehavior="none"
|
|
allowNegative={false}
|
|
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}
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Card maw={720} mt="md">
|
|
<Stack gap="md">
|
|
<PageHeader
|
|
title="Booking close offset"
|
|
subtitle="How long before departure a schedule stops accepting bookings. e.g. a 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to close exactly at departure. Set separately for import and export."
|
|
/>
|
|
<DurationField
|
|
label="Import close offset"
|
|
description="Import/domestic booking windows close this long before departure (blank = at departure)"
|
|
value={form.importCloseOffsetMinutes ?? ""}
|
|
nativeUnit="minutes"
|
|
onChange={(value) =>
|
|
setForm((current) => ({ ...current, importCloseOffsetMinutes: value }))
|
|
}
|
|
min={0}
|
|
disabled={loading}
|
|
/>
|
|
<DurationField
|
|
label="Export close offset"
|
|
description="Export booking windows close this long before departure (blank = at departure)"
|
|
value={form.exportCloseOffsetMinutes ?? ""}
|
|
nativeUnit="minutes"
|
|
onChange={(value) =>
|
|
setForm((current) => ({ ...current, exportCloseOffsetMinutes: value }))
|
|
}
|
|
min={0}
|
|
disabled={loading}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
|
Save rules
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|