Warehouse zone occupancy heatmap

Visualize live per-zone occupancy on the warehouse detail page.
This commit is contained in:
Hagernesh
2026-07-13 11:38:23 +00:00
parent 1034756bab
commit 4e8d35a7fb
18 changed files with 550 additions and 1 deletions

View File

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

View File

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

View File

@@ -29,3 +29,4 @@ export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';