mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
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:
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user