Merge pull request #1019 from Tria-plc/edrmiles

feat: add warehouse gate times editor modal for import trucks
This commit is contained in:
Hagernesh Tadesse
2026-07-30 13:06:00 +03:00
committed by GitHub
6 changed files with 256 additions and 5 deletions

View File

@@ -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[];
}

View File

@@ -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())

View File

@@ -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<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(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -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>
);
}

View File

@@ -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<string | null>(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
</Menu.Item>
<Menu.Item
leftSection={<FileText size={14} />}
disabled={!lastMileId}
onClick={() => setGateTimesOpen(true)}
>
Warehouse gate times
</Menu.Item>
</>
)}
</Menu.Dropdown>
@@ -380,11 +389,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
inventoryId={inspectId}
/>
{isEdr && (
<TruckDetentionModal
opened={detentionOpen}
onClose={() => setDetentionOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
<>
<TruckDetentionModal
opened={detentionOpen}
onClose={() => setDetentionOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
<WarehouseGateTimesModal
opened={gateTimesOpen}
onClose={() => setGateTimesOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
</>
)}
</>
)}

View File

@@ -155,4 +155,13 @@ export const lastMileService = {
returnedAt?: string | null;
}>,
) => 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 }),
};