Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx
Marshal 3a1a08b1e1 add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
2026-07-17 23:25:53 +00:00

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>
);
}