mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Warehouse zone occupancy heatmap
Visualize live per-zone occupancy on the warehouse detail page.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
|
|||||||
|
|
||||||
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||||
vehicleAssignments?: LastMileVehicleAssignment[];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} 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 { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
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 { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.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 { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
@@ -115,6 +119,19 @@ export class LastMileController {
|
|||||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
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')
|
@Post(':id/invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
||||||
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
|
|||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
FilesModule,
|
||||||
],
|
],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
|||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
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 { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
@@ -47,6 +49,7 @@ export class LastMileService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Attach real invoice info (number/status) to records so the UI can show an
|
/** Attach real invoice info (number/status) to records so the UI can show an
|
||||||
@@ -210,6 +213,49 @@ export class LastMileService {
|
|||||||
return record;
|
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<LastMile> {
|
||||||
|
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<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
const record = await this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.arrivalQueue();
|
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')
|
@Post('auto-unload-arrived')
|
||||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||||
autoUnloadArrived() {
|
autoUnloadArrived() {
|
||||||
|
|||||||
@@ -397,6 +397,87 @@ export class WarehouseInventoryService {
|
|||||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
* 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.
|
* 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
|
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
|
||||||
* collection truck while their goods are still in the warehouse
|
* collection truck while their goods are still in the warehouse
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [photos, setPhotos] = useState<File[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={`Record delivery${reference ? ` — ${reference}` : ""}`}
|
||||||
|
size="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Received by"
|
||||||
|
placeholder="Recipient's name"
|
||||||
|
required
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Recipient signature
|
||||||
|
</Text>
|
||||||
|
<ContractSignaturePad onChange={setSignatureUrl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label="Proof photos"
|
||||||
|
placeholder="Attach delivery photo(s)"
|
||||||
|
leftSection={<Camera size={16} />}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
value={photos}
|
||||||
|
onChange={setPhotos}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Optional delivery notes"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Provide a signature or at least one photo. Confirming completes the delivery.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={close} disabled={submit.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
leftSection={<PenLine size={16} />}
|
||||||
|
loading={submit.isPending}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => submit.mutate()}
|
||||||
|
>
|
||||||
|
Confirm delivery
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Badge, Card, Group, Loader, Progress, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import { LayoutGrid } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useZoneOccupancy } from '@/hooks/useWarehouses';
|
||||||
|
import type { ZoneOccupancy } from '@/types/warehouse';
|
||||||
|
|
||||||
|
/** Green < 60%, amber 60–85%, red > 85%. */
|
||||||
|
function tone(pct: number | null): { color: string; label: string } {
|
||||||
|
if (pct == null) return { color: 'gray', label: 'No capacity set' };
|
||||||
|
if (pct > 85) return { color: 'red', label: 'Full' };
|
||||||
|
if (pct >= 60) return { color: 'orange', label: 'Filling' };
|
||||||
|
return { color: 'teal', label: 'Space' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityLabel(z: ZoneOccupancy): string {
|
||||||
|
if (z.capacityContainers && z.capacityContainers > 0) {
|
||||||
|
return `${z.usedItems} / ${z.capacityContainers} items`;
|
||||||
|
}
|
||||||
|
if (z.capacityWeight && z.capacityWeight > 0) {
|
||||||
|
return `${z.usedItems} item(s) · ${z.usedWeight.toLocaleString()} kg`;
|
||||||
|
}
|
||||||
|
return `${z.usedItems} item(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoneOccupancyHeatmapProps {
|
||||||
|
/** Scope to one yard; omit for all zones. */
|
||||||
|
yardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
|
||||||
|
* container-count based (unit-consistent); weight is shown as context only.
|
||||||
|
*/
|
||||||
|
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||||
|
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zones.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
No active zones to show occupancy for.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<LayoutGrid size={16} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Zone occupancy
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
({zones.length} zone{zones.length !== 1 ? 's' : ''})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||||
|
{zones.map((z) => {
|
||||||
|
const t = tone(z.occupancyPct);
|
||||||
|
const pct = z.occupancyPct ?? 0;
|
||||||
|
return (
|
||||||
|
<Card key={z.id} withBorder radius="md" padding="sm">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||||
|
<Text fw={600} size="sm" truncate title={z.name}>
|
||||||
|
{z.name}
|
||||||
|
</Text>
|
||||||
|
<Badge color={t.color} variant="light" size="sm">
|
||||||
|
{z.occupancyPct == null ? '—' : `${Math.round(pct)}%`}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Progress value={Math.min(pct, 100)} color={t.color} size="lg" radius="sm" />
|
||||||
|
<Group justify="space-between" gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{capacityLabel(z)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c={t.color === 'gray' ? 'dimmed' : t.color}>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,3 +29,4 @@ export { VisualEmptyState } from './VisualEmptyState';
|
|||||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||||
export { InspectionReportModal } from './InspectionReportModal';
|
export { InspectionReportModal } from './InspectionReportModal';
|
||||||
export { FeePreviewModal } from './FeePreviewModal';
|
export { FeePreviewModal } from './FeePreviewModal';
|
||||||
|
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||||
|
|||||||
@@ -486,6 +486,10 @@ export const URL_CONSTANTS = {
|
|||||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||||
RESERVE: "/warehouse-inventory/reserve",
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
|
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||||
|
yardId
|
||||||
|
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||||
|
: "/warehouse-inventory/zone-occupancy",
|
||||||
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||||
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||||
UNLOAD_BOOKING: (bookingId: string) =>
|
UNLOAD_BOOKING: (bookingId: string) =>
|
||||||
@@ -617,6 +621,7 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/last-mile",
|
BASE: "/last-mile",
|
||||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||||
|
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||||
},
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ export function useAllWarehouseZones() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live per-zone occupancy for the heatmap (optionally scoped to one yard). */
|
||||||
|
export function useZoneOccupancy(yardId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
||||||
|
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateZone() {
|
export function useCreateZone() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { vehiclesService } from "@/services/vehicles.service";
|
|||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
|
|
||||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||||
@@ -546,6 +547,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
|||||||
const LastMilePage = () => {
|
const LastMilePage = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
@@ -1031,6 +1033,12 @@ const LastMilePage = () => {
|
|||||||
const handleAdvanceStatus = (record: LastMileRecord) => {
|
const handleAdvanceStatus = (record: LastMileRecord) => {
|
||||||
const next = NEXT_STATUS[record.status];
|
const next = NEXT_STATUS[record.status];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
|
// Completing a delivery requires proof of delivery — open the capture modal
|
||||||
|
// instead of advancing straight to DELIVERED.
|
||||||
|
if (next === "DELIVERED") {
|
||||||
|
setPodRecord(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: record.id, data: { status: next } },
|
{ id: record.id, data: { status: next } },
|
||||||
{
|
{
|
||||||
@@ -2080,6 +2088,17 @@ const LastMilePage = () => {
|
|||||||
onClose={() => setDetentionRecord(null)}
|
onClose={() => setDetentionRecord(null)}
|
||||||
record={detentionRecord}
|
record={detentionRecord}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProofOfDeliveryModal
|
||||||
|
opened={Boolean(podRecord)}
|
||||||
|
onClose={() => setPodRecord(null)}
|
||||||
|
lastMileId={podRecord?.id ?? null}
|
||||||
|
reference={podRecord?.booking?.reference ?? null}
|
||||||
|
onDone={() => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
InventoryWorkbench,
|
InventoryWorkbench,
|
||||||
WarehouseStatusBadge,
|
WarehouseStatusBadge,
|
||||||
WarehouseTypeBadge,
|
WarehouseTypeBadge,
|
||||||
|
ZoneOccupancyHeatmap,
|
||||||
formatCapacity,
|
formatCapacity,
|
||||||
humanizeEnum,
|
humanizeEnum,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
@@ -298,6 +299,8 @@ export default function WarehouseDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
|
||||||
|
|
||||||
{!selectedYardId ? (
|
{!selectedYardId ? (
|
||||||
<Text c="dimmed" ta="center" py="lg">
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
Select a yard to view its zones.
|
Select a yard to view its zones.
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ export const lastMileService = {
|
|||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||||
generateInvoice: (id: string) =>
|
generateInvoice: (id: string) =>
|
||||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
||||||
|
/** Record proof of delivery (recipient signature + photos) and complete the leg. */
|
||||||
|
recordProofOfDelivery: (
|
||||||
|
id: string,
|
||||||
|
payload: { recipientName: string; notes?: string; signature?: Blob | null; photos?: File[] },
|
||||||
|
) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("recipientName", payload.recipientName);
|
||||||
|
if (payload.notes) form.append("notes", payload.notes);
|
||||||
|
if (payload.signature) form.append("signature", payload.signature, "signature.png");
|
||||||
|
(payload.photos ?? []).forEach((photo) => form.append("photos", photo));
|
||||||
|
return api.post<LastMileRecord>(LM.PROOF_OF_DELIVERY(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
},
|
||||||
/** Generate a truck-detention invoice (per truck per day after the grace window). */
|
/** Generate a truck-detention invoice (per truck per day after the grace window). */
|
||||||
generateTruckDetentionInvoice: (id: string) =>
|
generateTruckDetentionInvoice: (id: string) =>
|
||||||
api.post<{ id: string; invoiceNumber?: string } | null>(
|
api.post<{ id: string; invoiceNumber?: string } | null>(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api as apiClient } from '../auth/http';
|
|||||||
|
|
||||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
import type {
|
import type {
|
||||||
|
ZoneOccupancy,
|
||||||
AllocationCriteria,
|
AllocationCriteria,
|
||||||
AllocationPreviewResult,
|
AllocationPreviewResult,
|
||||||
AllocationRule,
|
AllocationRule,
|
||||||
@@ -365,6 +366,10 @@ export const warehouseService = {
|
|||||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||||
arrivalQueue: () =>
|
arrivalQueue: () =>
|
||||||
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
|
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
|
||||||
|
zoneOccupancy: (yardId?: string) =>
|
||||||
|
apiClient.get<ZoneOccupancy[]>(
|
||||||
|
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
|
||||||
|
),
|
||||||
autoUnloadArrived: () =>
|
autoUnloadArrived: () =>
|
||||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||||
autoLoadReady: () =>
|
autoLoadReady: () =>
|
||||||
|
|||||||
@@ -1092,3 +1092,18 @@ export interface InventoryInquiryFilter {
|
|||||||
zoneId?: string;
|
zoneId?: string;
|
||||||
status?: InventoryStatus;
|
status?: InventoryStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live occupancy of a single warehouse zone (heatmap data). */
|
||||||
|
export interface ZoneOccupancy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
yardId: string;
|
||||||
|
capacityWeight: number | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
usedWeight: number;
|
||||||
|
usedItems: number;
|
||||||
|
/** 0–100+, container-count based (weight is a rough fallback). Null if no capacity set. */
|
||||||
|
occupancyPct: number | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user