add booking request functionality for GENERAL customs contracts

- Create migration for booking_requests table with necessary fields and indexes.
- Implement BookingRequestRepository for database operations related to booking requests.
- Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests.
- Create DTOs for creating booking requests and reviewing them.
- Define BookingRequest entity to map to the booking_requests table.
- Add UI components for managing shipment requests, including detail and list pages.
- Implement OperationDatePicker component for selecting available shipment days.
This commit is contained in:
Marshal
2026-06-29 09:30:44 +00:00
parent aeb5e0046e
commit 0f7cac2b68
46 changed files with 2665 additions and 992 deletions

View File

@@ -1,9 +1,11 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Button,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
@@ -19,9 +21,13 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
@@ -49,12 +55,34 @@ export function ContractActionsToolbar({
const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false);
const [validityDays, setValidityDays] = useState<number | string>(365);
const [validityDays, setValidityDays] = useState<string | null>(null);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Admin-configured validity durations (days) for the accept dialog. Staff can
// only pick one of these — no free-typing. Read-only setting, fetched once.
const { data: validitySetting, isLoading: validityLoading } = useQuery({
...api.dropdownSettings.getByCode.queryOptions({
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
}),
retry: false,
});
const validityOptions = useMemo(
() =>
[...(validitySetting?.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
// Default the selection to the first configured option when the dialog opens.
useEffect(() => {
if (acceptOpen && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}
@@ -189,22 +217,48 @@ export function ContractActionsToolbar({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Set the contract validity window, then start the approval chain.
Pick the contract validity window, then start the approval chain.
</Text>
<NumberInput
label="Validity (days)"
min={1}
value={validityDays}
onChange={setValidityDays}
/>
{validityOptions.length > 0 ? (
<Select
label="Validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under "}
{!validityLoading && (
<Anchor
href="/dashboard/dropdown-settings"
onClick={(e) => {
e.preventDefault();
navigate("/dashboard/dropdown-settings");
}}
>
Dropdown Settings
</Anchor>
)}
{!validityLoading && "."}
</Text>
)}
<Button
color="edr-green"
loading={mutations.staffAccept.isPending}
onClick={() =>
mutations.staffAccept.mutate(Number(validityDays) || 365, {
disabled={!validityDays}
onClick={() => {
const days = Number(validityDays);
if (!days) return;
mutations.staffAccept.mutate(days, {
onSuccess: () => setAcceptOpen(false),
})
}
});
}}
>
Accept
</Button>

View File

@@ -1,5 +1,10 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import {
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
@@ -22,6 +27,7 @@ import {
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Container as ContainerIcon,
FileText,
@@ -32,10 +38,13 @@ import {
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
import {
useContractCapacity,
useContractDetail,
@@ -74,16 +83,61 @@ function emptyUnit(): UnitDraft {
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
// When GL accepts a shipment request, the form opens with ?requestId=… so it
// can prefill the requested quantities/date and mark the request accepted on
// success.
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const { data: capacity = [] } = useContractCapacity(id);
const mutations = useContractMutations(id ?? "");
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
const [prefilled, setPrefilled] = useState(false);
// Prefill once from an accepted shipment request: size/qty container lines
// (one blank unit per requested container) + bulk + route + notes. GL still
// enters per-unit container numbers + sets the binding shipment date.
useEffect(() => {
if (!bookingRequest || prefilled) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
})),
);
} else if (lines.bulk) {
setBulkLines([
{
cargoTypeId: lines.bulk.cargoTypeId ?? "",
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
itemCount: lines.bulk.itemCount ?? "",
hazardousQuantity: lines.bulk.hazardousQuantity ?? "",
},
]);
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
@@ -146,6 +200,60 @@ export default function GlCreateBookingForm() {
[contract, quantities],
);
// The route this shipment ships on (for the cargo-aware day list). For a
// single-route contract there's exactly one; for GENERAL multi-route, the
// selected route (defaults to the first).
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
// Cargo-aware availability query: only days where a train has remaining
// capacity AND enough matching-type wagons for the entered cargo. Null until
// the cargo is entered (so the Schedule section stays empty first).
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
return null;
if (isContainer) {
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || 0),
0,
);
if (tons <= 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.trainScheduling.availableDaysForCargo.queryOptions({
input: cargoQuery ?? {
freightType: "BULK" as const,
},
}),
enabled: cargoQuery !== null,
});
if (isLoading) {
return (
<PageContainer>
@@ -265,8 +373,20 @@ export default function GlCreateBookingForm() {
}
mutations.createBooking.mutate(payload, {
onSuccess: (booking) =>
navigate(`/dashboard/bookings/${booking.id}/milestones`),
onSuccess: async (booking) => {
if (requestId) {
// GENERAL+customs accept flow: mark the request accepted + link the
// booking, then hand off to the per-booking clearance review.
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal — the booking exists; the request link can be retried.
}
navigate(`/dashboard/clearance/${booking.id}`);
} else {
navigate(`/dashboard/bookings/${booking.id}/milestones`);
}
},
});
};
@@ -312,38 +432,60 @@ export default function GlCreateBookingForm() {
</Group>
</Alert>
)}
<SectionCard icon={FileText} title="Schedule">
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<TextInput
label="Scheduled date"
type="date"
description="Binding shipment day"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.currentTarget.value)}
required
/>
</Grid.Col>
{needsRouteSelect && (
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
</Grid.Col>
)}
</Grid>
{bookingRequest ? (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<FileText size={16} />}
title="From shipment request"
>
Booking on behalf of the customer for request{" "}
<b>{bookingRequest.reference}</b>.
{bookingRequest.scheduledDate ? (
<>
{" "}
Customer requested{" "}
<b>
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}
</b>{" "}
set the binding shipment date below.
</>
) : null}
</Alert>
) : null}
<SectionCard icon={FileText} title="Route">
{needsRouteSelect ? (
<Select
label="Contract route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
) : (
<Text size="sm" c="dimmed">
{selectedRoute
? `${selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "Origin"}${
selectedRoute.destinationYard?.label ??
selectedRoute.destinationYard?.code ??
"Destination"
}`
: "This contract's only route."}
</Text>
)}
</SectionCard>
{isContainer ? (
@@ -610,6 +752,40 @@ export default function GlCreateBookingForm() {
</SectionCard>
)}
<SectionCard icon={FileText} title="Schedule">
{cargoQuery === null ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter the cargo details first available shipment days depend on
the wagons the cargo needs.
</Alert>
) : (
<>
{bookingRequest?.scheduledDate ? (
<Text size="xs" c="dimmed" mb="xs">
Customer requested{" "}
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}{" "}
pick the binding shipment day below.
</Text>
) : null}
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</>
)}
</SectionCard>
<SectionCard icon={FileText} title="Notes">
<Textarea
placeholder="Internal GL notes (optional)"