mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Added ShippingLineBookingCompletionController and associated service to handle the completion of shipping line bookings. - Introduced a new module for booking completion to maintain module separation and avoid cyclic dependencies. - Updated the train scheduling global rules to set default desk hours to 24 hours. - Modified existing services and entities to accommodate the new booking completion logic. - Enhanced the front-end components to support the new booking completion flow, including updates to the booking detail and bookings pages. - Implemented validation and error handling for booking completion, ensuring that only approved bookings can be completed. - Added migration to set default desk hours in the database.
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Box,
|
||
Divider,
|
||
Group,
|
||
Loader,
|
||
NumberInput,
|
||
Select,
|
||
Stack,
|
||
Switch,
|
||
Text,
|
||
} from "@mantine/core";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { Info, Moon, Sun } from "lucide-react";
|
||
|
||
import DurationField from "@/components/trainScheduling/DurationField";
|
||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||
|
||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||
const DEFAULTS = {
|
||
windowOpenHour: 8,
|
||
// Equal to open ⇒ 24-hour desk (the default).
|
||
windowCloseHour: 8,
|
||
windowDurationHours: 3,
|
||
docReviewMinutes: 30,
|
||
paymentWindowMinutes: 60,
|
||
importWindowLeadDays: 3,
|
||
exportBookingLeadHours: 24,
|
||
};
|
||
|
||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||
function hourLabel(hour: number): string {
|
||
const period = hour < 12 ? "AM" : "PM";
|
||
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||
return `${h12}:00 ${period}`;
|
||
}
|
||
|
||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||
value: String(h),
|
||
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||
}));
|
||
|
||
export interface WindowFormState {
|
||
windowOpenHour: number;
|
||
windowCloseHour: number;
|
||
windowDurationHours: number | "";
|
||
docReviewMinutes: number | "";
|
||
paymentWindowMinutes: number | "";
|
||
importWindowLeadDays: number | "";
|
||
exportBookingLeadHours: number | "";
|
||
/** Blank = close exactly at departure. */
|
||
closeOffsetMinutes: number | "";
|
||
}
|
||
|
||
/**
|
||
* Builds the create payload from form state, or returns an error message when a
|
||
* required field was left blank. The close offset is direction-scoped: only the
|
||
* offset matching this schedule's direction is sent, since the other is never read.
|
||
*/
|
||
export function buildWindowRulePayload(
|
||
form: WindowFormState,
|
||
isExport: boolean,
|
||
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
|
||
const duration = Number(form.windowDurationHours);
|
||
const doc = Number(form.docReviewMinutes);
|
||
const pay = Number(form.paymentWindowMinutes);
|
||
const lead = Number(form.importWindowLeadDays);
|
||
const exportLead = Number(form.exportBookingLeadHours);
|
||
|
||
const leadInvalid = isExport
|
||
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
|
||
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||
if (
|
||
form.windowDurationHours === "" ||
|
||
form.docReviewMinutes === "" ||
|
||
form.paymentWindowMinutes === "" ||
|
||
!Number.isFinite(duration) ||
|
||
!Number.isFinite(doc) ||
|
||
!Number.isFinite(pay) ||
|
||
leadInvalid
|
||
) {
|
||
return { error: "Fill every booking-window field, or turn the toggle off" };
|
||
}
|
||
|
||
// Blank offset = close at departure. Sent as null (not omitted) so it wins
|
||
// over a non-null global offset.
|
||
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
|
||
|
||
return {
|
||
payload: {
|
||
windowOpenHour: form.windowOpenHour,
|
||
windowCloseHour: form.windowCloseHour,
|
||
windowDurationHours: duration,
|
||
docReviewMinutes: doc,
|
||
paymentWindowMinutes: pay,
|
||
...(isExport
|
||
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
|
||
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
|
||
},
|
||
};
|
||
}
|
||
|
||
export interface CreateScheduleWindowFieldsProps {
|
||
/** Direction of the selected route — picks lead/offset semantics. */
|
||
isExport: boolean;
|
||
form: WindowFormState | null;
|
||
onChange: (next: WindowFormState) => void;
|
||
}
|
||
|
||
/**
|
||
* Booking-window settings for a schedule being created. Prefills from the live
|
||
* global rules (so the fields show what the schedule WOULD inherit), then lets
|
||
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
|
||
* the booking-close offset.
|
||
*/
|
||
export default function CreateScheduleWindowFields({
|
||
isExport,
|
||
form,
|
||
onChange,
|
||
}: CreateScheduleWindowFieldsProps) {
|
||
const rulesQuery = useQuery({
|
||
queryKey: ["train-scheduling", "global-rules"],
|
||
queryFn: () => trainSchedulingService.getGlobalRules(),
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
|
||
// Seed once from the global rules, so the toggle opens on the values this
|
||
// schedule would otherwise inherit rather than on hardcoded guesses.
|
||
const [seeded, setSeeded] = useState(false);
|
||
useEffect(() => {
|
||
if (seeded || form != null) return;
|
||
const r = rulesQuery.data;
|
||
if (!r && rulesQuery.isLoading) return;
|
||
const num = (v: unknown, fallback: number) => {
|
||
const n = v == null || v === "" ? NaN : Number(v);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
};
|
||
const offset = isExport
|
||
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
|
||
?.exportCloseOffsetMinutes
|
||
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
|
||
?.importCloseOffsetMinutes;
|
||
onChange({
|
||
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
|
||
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
|
||
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
|
||
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
|
||
paymentWindowMinutes: num(
|
||
isExport
|
||
? (r as { exportPaymentWindowMinutes?: number } | undefined)
|
||
?.exportPaymentWindowMinutes
|
||
: r?.paymentWindowMinutes,
|
||
DEFAULTS.paymentWindowMinutes,
|
||
),
|
||
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
|
||
exportBookingLeadHours: num(
|
||
r?.exportBookingLeadHours,
|
||
DEFAULTS.exportBookingLeadHours,
|
||
),
|
||
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
|
||
});
|
||
setSeeded(true);
|
||
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
|
||
|
||
const set = (patch: Partial<WindowFormState>) => {
|
||
if (form) onChange({ ...form, ...patch });
|
||
};
|
||
|
||
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
|
||
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
|
||
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
|
||
|
||
const reopenSummary = useMemo(() => {
|
||
if (!form) return "";
|
||
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
|
||
const h = Math.floor(total / 60);
|
||
const m = total % 60;
|
||
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
|
||
return parts.length ? parts.join(" ") : "0m";
|
||
}, [form]);
|
||
|
||
if (!form) {
|
||
return (
|
||
<Group justify="center" py="md">
|
||
<Loader size="sm" />
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Stack gap="lg">
|
||
{isExport ? (
|
||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||
Export schedules use a single first-come-first-served window: it opens the
|
||
export lead time before departure — shifted to the next desk opening if that
|
||
lands outside desk hours — and stays open until it closes. Cycle timing below
|
||
doesn't apply.
|
||
</Alert>
|
||
) : (
|
||
<Alert variant="light" color="orange" icon={<Info size={16} />}>
|
||
These settings apply to this train only, and can be set only for the FIRST
|
||
train on a route and departure day. Later trains that day join its booking
|
||
group and share the same window.
|
||
</Alert>
|
||
)}
|
||
|
||
{/* ── Daily desk hours ─────────────────────────────────────────── */}
|
||
<Box>
|
||
<Group justify="space-between" align="center" mb={6}>
|
||
<Text size="sm" fw={600}>
|
||
Daily desk hours (EAT)
|
||
</Text>
|
||
{is24h ? (
|
||
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
|
||
24-hour desk
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
|
||
{hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)}
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
<Group grow align="flex-start">
|
||
<Select
|
||
label="Opens"
|
||
data={HOUR_OPTIONS}
|
||
value={String(form.windowOpenHour)}
|
||
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
|
||
allowDeselect={false}
|
||
comboboxProps={{ withinPortal: true }}
|
||
/>
|
||
<Select
|
||
label="Closes"
|
||
data={HOUR_OPTIONS}
|
||
value={String(form.windowCloseHour)}
|
||
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
|
||
allowDeselect={false}
|
||
comboboxProps={{ withinPortal: true }}
|
||
/>
|
||
</Group>
|
||
{isOvernight && !is24h ? (
|
||
<Text size="xs" c="dimmed" mt={4}>
|
||
Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight,
|
||
closing {form.windowCloseHour}:00 the next morning.
|
||
</Text>
|
||
) : null}
|
||
<Switch
|
||
mt="sm"
|
||
size="sm"
|
||
color="grape"
|
||
label="Run 24 hours a day (never pause overnight)"
|
||
checked={is24h}
|
||
onChange={(e) =>
|
||
set({
|
||
// On → close == open (24h desk). Off → restore a normal ~9h day.
|
||
windowCloseHour: e.currentTarget.checked
|
||
? form.windowOpenHour
|
||
: Math.min(23, form.windowOpenHour + 9),
|
||
})
|
||
}
|
||
/>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Cycle timing ─────────────────────────────────────────────── */}
|
||
<Box>
|
||
<Text size="sm" fw={600} mb={6}>
|
||
Cycle timing
|
||
</Text>
|
||
<Stack gap="sm">
|
||
<DurationField
|
||
label="Window duration"
|
||
description="How long each booking cycle stays open before it closes for review"
|
||
value={form.windowDurationHours}
|
||
nativeUnit="hours"
|
||
onChange={(v) => set({ windowDurationHours: v })}
|
||
min={0.0166}
|
||
disabled={isExport}
|
||
/>
|
||
<Group grow align="flex-start">
|
||
<DurationField
|
||
label="Document review"
|
||
description="Staff time to accept documents after the window closes"
|
||
value={form.docReviewMinutes}
|
||
nativeUnit="minutes"
|
||
onChange={(v) => set({ docReviewMinutes: v })}
|
||
min={0}
|
||
disabled={isExport}
|
||
/>
|
||
<DurationField
|
||
label="Payment window"
|
||
description="Time a selected customer has to pay"
|
||
value={form.paymentWindowMinutes}
|
||
nativeUnit="minutes"
|
||
onChange={(v) => set({ paymentWindowMinutes: v })}
|
||
min={1}
|
||
/>
|
||
</Group>
|
||
{!isExport ? (
|
||
<Text size="xs" c="dimmed">
|
||
Reopen gap after each cycle = document review + payment ={" "}
|
||
<b>{reopenSummary}</b>.
|
||
</Text>
|
||
) : null}
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||
{isExport ? (
|
||
<NumberInput
|
||
label="Export booking lead (hours)"
|
||
description="How many hours before departure the export booking window opens"
|
||
value={form.exportBookingLeadHours}
|
||
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
|
||
min={1}
|
||
clampBehavior="none"
|
||
allowNegative={false}
|
||
allowDecimal={false}
|
||
/>
|
||
) : (
|
||
<NumberInput
|
||
label="Window lead (days)"
|
||
description="How many days before departure the booking window starts"
|
||
value={form.importWindowLeadDays}
|
||
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
|
||
min={0}
|
||
clampBehavior="none"
|
||
allowNegative={false}
|
||
allowDecimal={false}
|
||
/>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
{/* ── Booking close offset ─────────────────────────────────────── */}
|
||
<Box>
|
||
<Text size="sm" fw={600} mb={2}>
|
||
Booking close offset
|
||
</Text>
|
||
<Text size="xs" c="dimmed" mb={8}>
|
||
How long before departure this 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.
|
||
</Text>
|
||
<DurationField
|
||
label={isExport ? "Export close offset" : "Import close offset"}
|
||
description={
|
||
isExport
|
||
? "This export booking window closes this long before departure (blank = at departure)"
|
||
: "This import booking window closes this long before departure (blank = at departure)"
|
||
}
|
||
value={form.closeOffsetMinutes}
|
||
nativeUnit="minutes"
|
||
onChange={(v) => set({ closeOffsetMinutes: v })}
|
||
min={0}
|
||
/>
|
||
</Box>
|
||
</Stack>
|
||
);
|
||
}
|