Add migration to widen window_duration_hours precision and update related components for duration handling

This commit is contained in:
Marshal
2026-07-03 16:37:09 +00:00
parent 907ff2138b
commit a5c46505e3
8 changed files with 274 additions and 30 deletions

View File

@@ -2,6 +2,7 @@ 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";
@@ -19,14 +20,30 @@ export default function TrainSchedulingGlobalRulesPage() {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
// `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);
}
})();
}, [toast]);
// 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
@@ -88,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -98,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -107,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -120,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
max20ftContainerWeightTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0.001}
disabled={loading}
/>
@@ -133,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
max20ftPairWeightDiffTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0}
disabled={loading}
/>
@@ -145,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
title="Booking windows"
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
/>
<NumberInput
label="Import window lead (days)"
description="The single booking day opens this many days before departure"
<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}
/>
<NumberInput
label="Export booking lead (hours)"
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
<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 }))
}
@@ -172,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<NumberInput
label="Window duration (hours)"
<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={0.25}
max={12}
step={0.25}
min={1}
disabled={loading}
/>
<NumberInput
label="Document review (minutes)"
<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}
/>
<NumberInput
label="Payment window (minutes)"
<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}
/>
<NumberInput
label="Reopen delay (minutes)"
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
<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 }))
}