mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08: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,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 { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
|
||||
@@ -486,6 +486,10 @@ export const URL_CONSTANTS = {
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
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_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||
UNLOAD_BOOKING: (bookingId: string) =>
|
||||
@@ -617,6 +621,7 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/last-mile",
|
||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||
},
|
||||
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -57,6 +57,7 @@ import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
|
||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||
@@ -546,6 +547,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
||||
const LastMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
@@ -1031,6 +1033,12 @@ const LastMilePage = () => {
|
||||
const handleAdvanceStatus = (record: LastMileRecord) => {
|
||||
const next = NEXT_STATUS[record.status];
|
||||
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(
|
||||
{ id: record.id, data: { status: next } },
|
||||
{
|
||||
@@ -2080,6 +2088,17 @@ const LastMilePage = () => {
|
||||
onClose={() => setDetentionRecord(null)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
ZoneOccupancyHeatmap,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
@@ -298,6 +299,8 @@ export default function WarehouseDetailPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
|
||||
@@ -108,6 +108,20 @@ export const lastMileService = {
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||
generateInvoice: (id: string) =>
|
||||
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). */
|
||||
generateTruckDetentionInvoice: (id: string) =>
|
||||
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 type {
|
||||
ZoneOccupancy,
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
AllocationRule,
|
||||
@@ -365,6 +366,10 @@ export const warehouseService = {
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
arrivalQueue: () =>
|
||||
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
|
||||
zoneOccupancy: (yardId?: string) =>
|
||||
apiClient.get<ZoneOccupancy[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
|
||||
),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
|
||||
@@ -1092,3 +1092,18 @@ export interface InventoryInquiryFilter {
|
||||
zoneId?: string;
|
||||
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