diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts new file mode 100644 index 000000000..ba980dfdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class TruckWarehouseGateTimeInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsDateString() + arrivedAt?: string | null; + + @IsOptional() + @IsDateString() + departedAt?: string | null; +} + +export class SetWarehouseGateTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckWarehouseGateTimeInput) + trucks!: TruckWarehouseGateTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8fa57cdc..d7e2ba1ff 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -24,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; +import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -144,6 +145,18 @@ export class LastMileController { return this.lastMileService.setDetentionTimes(id, dto.trucks); } + @Post(':id/warehouse-gate-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s warehouse gate arrival/departure times', + }) + async setWarehouseGateTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetWarehouseGateTimesDto, + ) { + return this.lastMileService.setWarehouseGateTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 6db55b66d..af6c920fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -913,6 +913,41 @@ export class LastMileService { return this.findById(id); } + async setWarehouseGateTimes( + id: string, + trucks: Array<{ + vehicleId: string; + arrivedAt?: string | null; + departedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Warehouse gate times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null; + const departed = t.departedAt ? new Date(t.departedAt) : null; + if (arrived && departed && departed.getTime() < arrived.getTime()) { + throw new BadRequestException( + 'A truck cannot depart before it arrived — check the warehouse gate times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { arrivedAt: arrived, departedAt: departed }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx new file mode 100644 index 000000000..6ab317271 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx @@ -0,0 +1,156 @@ +import { + Button, + Divider, + Group, + Modal, + Stack, + Table, + Text, +} from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { lastMileService, type LastMileRecord } from '@/services/last-mile.service'; + +interface WarehouseGateTimesModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + +export function WarehouseGateTimesModal({ opened, onClose, record }: WarehouseGateTimesModalProps) { + const { toast } = useToast(); + const qc = useQueryClient(); + const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + + interface TruckRow { + vehicleId: string; + label: string; + arrivedAt: Date | null; + departedAt: Date | null; + } + + const [rows, setRows] = useState>([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (assignments.length > 0) { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + arrivedAt: a.arrivedAt ? new Date(a.arrivedAt) : null, + departedAt: a.departedAt ? new Date(a.departedAt) : null, + })), + ); + } + }, [assignments, opened]); + + const updateMutation = useMutation({ + mutationFn: () => { + if (!id) return Promise.resolve(null); + return lastMileService.setWarehouseGateTimes(id, rows.map(r => ({ + vehicleId: r.vehicleId, + arrivedAt: r.arrivedAt?.toISOString() ?? null, + departedAt: r.departedAt?.toISOString() ?? null, + }))); + }, + onSuccess: () => { + toast({ + title: 'Warehouse gate times updated', + }); + qc.invalidateQueries({ queryKey: ['last-mile-record-import-trucks', id] }); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: 'destructive', + title: 'Failed to update warehouse gate times', + description: error?.response?.data?.message || error?.message, + }); + }, + onSettled: () => { + setSaving(false); + }, + }); + + const handleSave = async () => { + setSaving(true); + await updateMutation.mutateAsync(); + }; + + return ( + + + + Set arrival (gate-in) and departure (gate-out) times for each truck. + + + {/* @ts-ignore - DateTimePicker type inference issue with row state */} + + + + Plate + Arrived At (Gate-In) + Departed At (Gate-Out) + + + + {rows.map((row: TruckRow, idx: number) => ( + + + + {row.label} + + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, arrivedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, departedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + ))} + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index 84d103003..1686d0106 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -26,6 +26,7 @@ import ListControls from "@/components/common/ListControls"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal"; import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; +import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal"; import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options"; import { openPdfBlob } from "@/components/warehouses/pdf"; import { useListControls } from "@/hooks/useListControls"; @@ -119,6 +120,7 @@ function TruckRows({ group }: { group: BookingGroup }) { const [busy, setBusy] = useState(false); const [inspectId, setInspectId] = useState(null); const [detentionOpen, setDetentionOpen] = useState(false); + const [gateTimesOpen, setGateTimesOpen] = useState(false); const edrQuery = useQuery({ queryKey: ["booking-edr-trucks", group.bookingId], @@ -366,6 +368,13 @@ function TruckRows({ group }: { group: BookingGroup }) { > Detention times… + } + disabled={!lastMileId} + onClick={() => setGateTimesOpen(true)} + > + Warehouse gate times… + )} @@ -380,11 +389,18 @@ function TruckRows({ group }: { group: BookingGroup }) { inventoryId={inspectId} /> {isEdr && ( - setDetentionOpen(false)} - record={lastMileRecordQuery.data ?? null} - /> + <> + setDetentionOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + setGateTimesOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} )} diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 91e95ef2f..4f7f2904a 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -155,4 +155,13 @@ export const lastMileService = { returnedAt?: string | null; }>, ) => api.post(`${LM.BASE}/${id}/detention-times`, { trucks }), + /** Set warehouse gate arrival/departure times for each truck. */ + setWarehouseGateTimes: ( + id: string, + trucks: Array<{ + vehicleId: string; + arrivedAt?: string | null; + departedAt?: string | null; + }>, + ) => api.post(`${LM.BASE}/${id}/warehouse-gate-times`, { trucks }), };