From 4e8d35a7fbfca0fe59793e0496ef42115d4368d7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 13 Jul 2026 11:38:23 +0000 Subject: [PATCH] Warehouse zone occupancy heatmap Visualize live per-zone occupancy on the warehouse detail page. --- ...120000000000-AddLastMileProofOfDelivery.ts | 34 ++++ .../dto/record-proof-of-delivery.dto.ts | 22 +++ .../last-mile/entities/last-mile.entity.ts | 18 ++ .../modules/last-mile/last-mile.controller.ts | 19 ++- .../src/modules/last-mile/last-mile.module.ts | 2 + .../modules/last-mile/last-mile.service.ts | 46 +++++ .../warehouse-inventory.controller.ts | 6 + .../warehouses/warehouse-inventory.service.ts | 81 +++++++++ .../operations/ProofOfDeliveryModal.tsx | 157 ++++++++++++++++++ .../warehouses/ZoneOccupancyHeatmap.tsx | 96 +++++++++++ .../src/components/warehouses/index.ts | 1 + .../backoffice/src/constants/URLS.ts | 5 + .../backoffice/src/hooks/useWarehouses.ts | 8 + .../src/pages/operations/LastMilePage.tsx | 19 +++ .../pages/warehouses/WarehouseDetailPage.tsx | 3 + .../src/services/last-mile.service.ts | 14 ++ .../src/services/warehouse.service.ts | 5 + .../backoffice/src/types/warehouse.ts | 15 ++ 18 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/ProofOfDeliveryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx diff --git a/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts b/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts new file mode 100644 index 000000000..ed5054e87 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Proof of delivery for EDR last-mile: recipient name, a captured signature + * (stored as a file), delivery photos (file ids), notes, and the capture time. + * Recorded when the driver completes the delivery. + */ +export class AddLastMileProofOfDelivery2120000000000 + implements MigrationInterface +{ + name = "AddLastMileProofOfDelivery2120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160), + ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid, + ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS pod_notes text, + ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS pod_recipient_name, + DROP COLUMN IF EXISTS pod_signature_file_id, + DROP COLUMN IF EXISTS pod_photo_file_ids, + DROP COLUMN IF EXISTS pod_notes, + DROP COLUMN IF EXISTS pod_captured_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts new file mode 100644 index 000000000..44bf2f9c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Proof of delivery captured by the EDR driver when a last-mile leg is + * completed. Sent as multipart/form-data — the recipient's signature (field + * `signature`) and proof photos (field `photos`) are uploaded alongside these + * text fields. + */ +export class RecordProofOfDeliveryDto { + @ApiProperty({ description: 'Name of the person who received the cargo.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + recipientName!: string; + + @ApiPropertyOptional({ description: 'Optional delivery notes.' }) + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 5dca86cc4..833911365 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -70,4 +70,22 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) vehicleAssignments?: LastMileVehicleAssignment[]; + + // ── Proof of delivery (captured by the EDR driver on completion) ────────── + @Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true }) + podRecipientName?: string | null; + + /** File id of the recipient's captured signature (PNG). */ + @Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true }) + podSignatureFileId?: string | null; + + /** File ids of the delivery proof photos. */ + @Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' }) + podPhotoFileIds!: string[]; + + @Column({ name: 'pod_notes', type: 'text', nullable: true }) + podNotes?: string | null; + + @Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true }) + podCapturedAt?: Date | null; } 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 207b71a25..abee4d53d 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 @@ -11,8 +11,11 @@ import { Patch, Post, Query, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -21,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 { SetDistancesDto } from './dto/set-distances.dto'; +import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; @@ -115,6 +119,19 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/proof-of-delivery') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' }) + async recordProofOfDelivery( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RecordProofOfDeliveryDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []); + } + @Post(':id/invoice') @BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice) @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e639e4dfd..289c2156f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { FilesModule } from '../files/files.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; @@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service'; VehiclesModule, DriversModule, NotificationsModule, + FilesModule, ], controllers: [LastMileController], providers: [LastMileRepository, LastMileService, LastMileInvoiceService], 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 15b8df3e7..999137e6e 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 @@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; +import { FilesService } from '../files/files.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @@ -47,6 +49,7 @@ export class LastMileService { private readonly dataSource: DataSource, private readonly history: FleetHistoryService, private readonly billing: BillingService, + private readonly filesService: FilesService, ) {} /** Attach real invoice info (number/status) to records so the UI can show an @@ -210,6 +213,49 @@ export class LastMileService { return record; } + /** + * Record proof of delivery (recipient signature + photos + notes) and complete + * the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt, + * vehicle release, history). + */ + async recordProofOfDelivery( + id: string, + dto: RecordProofOfDeliveryDto, + files: Express.Multer.File[], + ): Promise { + const existing = await this.findById(id); + + const signature = files.find((f) => f.fieldname === 'signature'); + const photos = files.filter((f) => f.fieldname === 'photos'); + + const signatureFileId = signature + ? ( + await this.filesService.upload({ + resourceId: id, + resource: 'last-mile', + code: 'pod-signature', + file: signature, + }) + ).id + : null; + const photoFileIds = photos.length + ? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id) + : []; + + await this.lastMileRepository.update(id, { + podRecipientName: dto.recipientName.trim(), + podSignatureFileId: signatureFileId, + podPhotoFileIds: photoFileIds, + podNotes: dto.notes?.trim() || null, + podCapturedAt: new Date(), + } as never); + + if (existing.status !== 'DELIVERED') { + return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto); + } + return this.findById(id); + } + async create(dto: CreateLastMileDto): Promise { const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 070a267a4..21f2663b6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -52,6 +52,12 @@ export class WarehouseInventoryController { return this.inventoryService.arrivalQueue(); } + @Get('zone-occupancy') + @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) + zoneOccupancy(@Query('yardId') yardId?: string) { + return this.inventoryService.zoneOccupancy(yardId); + } + @Post('auto-unload-arrived') @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) autoUnloadArrived() { 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 c4fc7316f..7c8edcce0 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 @@ -397,6 +397,87 @@ export class WarehouseInventoryService { * but has no customer truck assigned yet, nudge the customer to assign one — with * a deep-link to the booking's truck-assignment card. Fire-and-forget. */ + /** + * Live occupancy per zone: rated capacity vs the weight/items currently held + * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard + * occupancy heatmap. Optionally scoped to one yard. + */ + async zoneOccupancy(yardId?: string): Promise< + Array<{ + id: string; + name: string; + code: string; + type: string; + yardId: string; + capacityWeight: number | null; + capacityContainers: number | null; + usedWeight: number; + usedItems: number; + occupancyPct: number | null; + }> + > { + const rows: Array<{ + id: string; + name: string; + code: string; + type: string; + yardId: string; + capacityWeight: string | null; + capacityContainers: number | null; + usedWeight: number; + usedItems: number; + }> = await this.dataSource.query( + `SELECT z.id, + z.name, + z.code, + z.type, + z.yard_id AS "yardId", + z.capacity_weight AS "capacityWeight", + z.capacity_containers AS "capacityContainers", + COALESCE(SUM(inv.weight) FILTER ( + WHERE inv.deleted_at IS NULL + AND inv.status NOT IN ('DELIVERED', 'DISPATCHED') + ), 0)::float8 AS "usedWeight", + COALESCE(COUNT(inv.id) FILTER ( + WHERE inv.deleted_at IS NULL + AND inv.status NOT IN ('DELIVERED', 'DISPATCHED') + ), 0)::int AS "usedItems" + FROM freight.warehouse_zones z + LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id + WHERE z.is_active = true + AND z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.yard_id = $1) + GROUP BY z.id + ORDER BY z.name`, + [yardId ?? null], + ); + + return rows.map((r) => { + const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; + const byWeight = + capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; + const byItems = + r.capacityContainers && r.capacityContainers > 0 + ? (r.usedItems / r.capacityContainers) * 100 + : null; + // Prefer container-count occupancy (unit-consistent). Weight capacity is + // tonnes while inventory weight is kg, so weight% is only a rough fallback. + const pct = byItems ?? byWeight; + return { + id: r.id, + name: r.name, + code: r.code, + type: r.type, + yardId: r.yardId, + capacityWeight: capWeight, + capacityContainers: r.capacityContainers, + usedWeight: r.usedWeight, + usedItems: r.usedItems, + occupancyPct: pct == null ? null : Math.round(pct * 10) / 10, + }; + }); + } + /** * Recurring nudge: keep reminding self-haul IMPORT customers to assign a * collection truck while their goods are still in the warehouse diff --git a/apps/edr-freight-web/backoffice/src/components/operations/ProofOfDeliveryModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/ProofOfDeliveryModal.tsx new file mode 100644 index 000000000..7faae79d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/ProofOfDeliveryModal.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { + Button, + FileInput, + Group, + Modal, + Stack, + Text, + TextInput, + Textarea, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Camera, PenLine } from "lucide-react"; + +import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { lastMileService } from "@/services/last-mile.service"; +import { useToast } from "@/hooks/use-toast"; + +interface ProofOfDeliveryModalProps { + opened: boolean; + onClose: () => void; + lastMileId: string | null; + reference?: string | null; + /** Called after a successful capture so the caller can refetch. */ + onDone: () => void; +} + +/** + * Proof of delivery capture for an EDR last-mile leg: recipient name, a drawn + * signature, and proof photos. On confirm it uploads everything and completes + * the delivery (marks the leg DELIVERED). + */ +export function ProofOfDeliveryModal({ + opened, + onClose, + lastMileId, + reference, + onDone, +}: ProofOfDeliveryModalProps) { + const { toast } = useToast(); + const [recipient, setRecipient] = useState(""); + const [notes, setNotes] = useState(""); + const [signatureUrl, setSignatureUrl] = useState(null); + const [photos, setPhotos] = useState([]); + + const reset = () => { + setRecipient(""); + setNotes(""); + setSignatureUrl(null); + setPhotos([]); + }; + + const close = () => { + reset(); + onClose(); + }; + + const submit = useMutation({ + mutationFn: async () => { + if (!lastMileId) throw new Error("No delivery selected"); + const signature = signatureUrl + ? await (await fetch(signatureUrl)).blob() + : null; + return lastMileService.recordProofOfDelivery(lastMileId, { + recipientName: recipient.trim(), + notes: notes.trim() || undefined, + signature, + photos, + }); + }, + onSuccess: () => { + toast({ + title: "Proof of delivery recorded", + description: "The delivery has been completed.", + }); + reset(); + onDone(); + onClose(); + }, + onError: (e) => + toast({ + variant: "destructive", + title: "Could not record delivery", + description: e instanceof Error ? e.message : undefined, + }), + }); + + // Require a recipient plus at least one form of proof (signature or a photo). + const canSubmit = + recipient.trim().length > 0 && (Boolean(signatureUrl) || photos.length > 0); + + return ( + + + setRecipient(e.currentTarget.value)} + /> + +
+ + Recipient signature + + +
+ + } + accept="image/*" + multiple + clearable + value={photos} + onChange={setPhotos} + /> + +