mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: `1px solid ${
|
||||
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
|
||||
}`,
|
||||
borderRadius: 12,
|
||||
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<PackageCheck size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference ?? booking.id}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
size="xs"
|
||||
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
|
||||
>
|
||||
{booking.loadingStatus}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.customer ?? "Unknown customer"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.weightTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportLoadingConfirmationPanel({
|
||||
scheduleId,
|
||||
items,
|
||||
isLoading,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
items: ImportLoadingBooking[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
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 (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading import bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No paid import bookings with wagons allocated on this schedule
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
Import bookings ({items.length})
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
{items.map((booking) => (
|
||||
<ImportLoadingBookingRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={selectedIds.includes(booking.id)}
|
||||
onToggle={() => toggle(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!selectedIds.length}
|
||||
loading={updateStatus.isPending}
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
|
||||
}
|
||||
>
|
||||
Mark loaded
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedIds.length}
|
||||
loading={updateStatus.isPending}
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
|
||||
}
|
||||
>
|
||||
Mark unloaded
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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" ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Import loading confirmation</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
|
||||
is tracking only — it does not block dispatch.
|
||||
</Text>
|
||||
<ImportLoadingConfirmationPanel
|
||||
scheduleId={scheduleId as string}
|
||||
items={importLoadingQuery.data?.items ?? []}
|
||||
isLoading={importLoadingQuery.isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
|
||||
Reference in New Issue
Block a user