diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 3b27c7213..ac6b71c89 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -2033,6 +2033,22 @@ export class WarehouseInventoryService {
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
+
+ if (item.bookingId) {
+ const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
+ await this.dataSource.query(
+ `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
+ FROM freight.bookings
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [item.bookingId],
+ );
+ const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
+ if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) {
+ throw new BadRequestException(
+ 'Customer must approve delivery (sign the handover) before the exit paper can be generated',
+ );
+ }
+ }
}
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx
new file mode 100644
index 000000000..ab1d907e6
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx
@@ -0,0 +1,172 @@
+import { useMemo, useState } from "react";
+import { PackageCheck } from "lucide-react";
+import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+import { api } from "@/services/api";
+import type {
+ ImportLoadingBooking,
+ ImportLoadingBookingsResponse,
+ LoadingStatus,
+} from "@/types/trainScheduling";
+
+function ImportLoadingBookingRow({
+ booking,
+ selected,
+ onToggle,
+}: {
+ booking: ImportLoadingBooking;
+ selected: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ {booking.reference ?? booking.id}
+
+
+ {booking.loadingStatus}
+
+
+
+ {booking.customer ?? "Unknown customer"}
+
+
+ {booking.weightTons}T
+
+
+
+ );
+}
+
+export function ImportLoadingConfirmationPanel({
+ scheduleId,
+ items,
+ isLoading,
+}: {
+ scheduleId: string;
+ items: ImportLoadingBooking[];
+ isLoading?: boolean;
+}) {
+ const [selectedIds, setSelectedIds] = useState([]);
+ const queryClient = useQueryClient();
+
+ const updateStatus = useMutation<
+ ImportLoadingBookingsResponse,
+ Error,
+ { id: string; bookingIds: string[]; loadingStatus: LoadingStatus }
+ >({
+ ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(),
+ onSuccess: () => {
+ setSelectedIds([]);
+ queryClient.invalidateQueries({
+ queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }),
+ });
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Could not update loading status");
+ },
+ });
+
+ const toggle = (id: string) => {
+ setSelectedIds((prev) =>
+ prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
+ );
+ };
+
+ const allIds = useMemo(() => items.map((b) => b.id), [items]);
+
+ if (isLoading) {
+ return (
+
+
+
+ Loading import bookings…
+
+
+ );
+ }
+
+ if (!items.length) {
+ return (
+
+
+ No paid import bookings with wagons allocated on this schedule
+
+
+ );
+ }
+
+ return (
+
+
+
+ Import bookings ({items.length})
+
+
+
+
+
+
+
+
+ {items.map((booking) => (
+ toggle(booking.id)}
+ />
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index 9319e6a01..c02eb2c39 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -42,6 +42,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
+import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() {
},
});
+ const importLoadingQuery = useQuery(
+ api.trainScheduling.importLoadingBookings.queryOptions({
+ input: { id: scheduleId ?? "" },
+ enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
+ }),
+ );
+
const eligibleFilters = useMemo(
() =>
schedule
@@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
+ {schedule?.direction === "IMPORT" ? (
+
+
+ Import loading confirmation
+
+ Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
+ is tracking only — it does not block dispatch.
+
+
+
+
+ ) : null}
+
{gatepassApplies ? (