mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat: add warehouse gate times editor modal for import trucks
Enable per-truck editing of warehouse gate arrival/departure times (arrivedAt/departedAt) via modal on Import Trucks page. Accessible via row action menu for EDR-haulage trucks. Includes backend endpoint POST /last-mile/:id/warehouse-gate-times and frontend modal with DateTimePicker inputs. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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[];
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
|||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
import { SetDetentionTimesDto } from './dto/set-detention-times.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 { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -144,6 +145,18 @@ export class LastMileController {
|
|||||||
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
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')
|
@Post(':id/proof-of-delivery')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -913,6 +913,41 @@ export class LastMileService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setWarehouseGateTimes(
|
||||||
|
id: string,
|
||||||
|
trucks: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
departedAt?: string | null;
|
||||||
|
}>,
|
||||||
|
): Promise<LastMile> {
|
||||||
|
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(
|
async setDistances(
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
|||||||
@@ -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<LastMileRecord['vehicleAssignments']>[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<Array<TruckRow>>([]);
|
||||||
|
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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Warehouse Gate Times" size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Set arrival (gate-in) and departure (gate-out) times for each truck.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* @ts-ignore - DateTimePicker type inference issue with row state */}
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Plate</Table.Th>
|
||||||
|
<Table.Th>Arrived At (Gate-In)</Table.Th>
|
||||||
|
<Table.Th>Departed At (Gate-Out)</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row: TruckRow, idx: number) => (
|
||||||
|
<Table.Tr key={row.vehicleId}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.label}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<DateTimePicker
|
||||||
|
placeholder="Select arrival time"
|
||||||
|
value={(row.arrivedAt as unknown) as Date | null}
|
||||||
|
onChange={(date) => {
|
||||||
|
const newRows = [...rows];
|
||||||
|
newRows[idx] = { ...row, arrivedAt: date };
|
||||||
|
setRows(newRows);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<DateTimePicker
|
||||||
|
placeholder="Select departure time"
|
||||||
|
value={(row.departedAt as unknown) as Date | null}
|
||||||
|
onChange={(date) => {
|
||||||
|
const newRows = [...rows];
|
||||||
|
newRows[idx] = { ...row, departedAt: date };
|
||||||
|
setRows(newRows);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} loading={saving}>
|
||||||
|
Save Times
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import ListControls from "@/components/common/ListControls";
|
|||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
|
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import { useListControls } from "@/hooks/useListControls";
|
||||||
@@ -119,6 +120,7 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||||
|
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
||||||
|
|
||||||
const edrQuery = useQuery({
|
const edrQuery = useQuery({
|
||||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||||
@@ -366,6 +368,13 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
>
|
>
|
||||||
Detention times…
|
Detention times…
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
disabled={!lastMileId}
|
||||||
|
onClick={() => setGateTimesOpen(true)}
|
||||||
|
>
|
||||||
|
Warehouse gate times…
|
||||||
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
@@ -380,11 +389,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
inventoryId={inspectId}
|
inventoryId={inspectId}
|
||||||
/>
|
/>
|
||||||
{isEdr && (
|
{isEdr && (
|
||||||
<TruckDetentionModal
|
<>
|
||||||
opened={detentionOpen}
|
<TruckDetentionModal
|
||||||
onClose={() => setDetentionOpen(false)}
|
opened={detentionOpen}
|
||||||
record={lastMileRecordQuery.data ?? null}
|
onClose={() => setDetentionOpen(false)}
|
||||||
/>
|
record={lastMileRecordQuery.data ?? null}
|
||||||
|
/>
|
||||||
|
<WarehouseGateTimesModal
|
||||||
|
opened={gateTimesOpen}
|
||||||
|
onClose={() => setGateTimesOpen(false)}
|
||||||
|
record={lastMileRecordQuery.data ?? null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,4 +155,13 @@ export const lastMileService = {
|
|||||||
returnedAt?: string | null;
|
returnedAt?: string | null;
|
||||||
}>,
|
}>,
|
||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
|
) => api.post<LastMileRecord>(`${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<LastMileRecord>(`${LM.BASE}/${id}/warehouse-gate-times`, { trucks }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user