feat(warehouses): track physical container stack and slot positions

Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.

- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
  stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
  unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
  accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
This commit is contained in:
Hagernesh
2026-08-28 16:07:50 +00:00
parent 9de4e9e238
commit a6ea1a48ac
28 changed files with 2313 additions and 13 deletions

View File

@@ -4,12 +4,19 @@ import { DataTable, type ColumnDef } from '@edr/ui-common';
import { formatDateTime, humanize } from '@/lib/format';
import { api } from '@/services/api';
import type { WarehouseZone, ZoneContentItem } from '@/types/warehouse';
import type { ZoneContentItem } from '@/types/warehouse';
/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */
export interface ZoneRef {
id: string;
name: string;
code: string;
}
interface ZoneContentsModalProps {
opened: boolean;
onClose: () => void;
zone: WarehouseZone | null;
zone: ZoneRef | null;
}
const columns: ColumnDef<ZoneContentItem>[] = [

View File

@@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string {
interface ZoneOccupancyHeatmapProps {
/** Scope to one yard; omit for all zones. */
yardId?: string;
/** Pass to make each tile open that zone; omitted leaves the tiles inert. */
onZoneClick?: (zone: ZoneOccupancy) => void;
}
/**
* 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) {
export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) {
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
if (isLoading) {
@@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
const t = tone(z.occupancyPct);
const pct = z.occupancyPct ?? 0;
return (
<Card key={z.id} withBorder radius="md" padding="sm">
<Card
key={z.id}
withBorder
radius="md"
padding="sm"
onClick={onZoneClick ? () => onZoneClick(z) : undefined}
style={onZoneClick ? { cursor: 'pointer' } : undefined}
title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
>
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text fw={600} size="sm" truncate title={z.name}>

View File

@@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
export { CreateWarehouseModal } from './CreateWarehouseModal';
export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ZoneContentsModal } from './ZoneContentsModal';
export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';

View File

@@ -24,6 +24,7 @@ import {
WarehouseStatusBadge,
WarehouseTypeBadge,
ZoneContentsModal,
type ZoneRef,
ZoneOccupancyHeatmap,
formatCapacity,
humanizeEnum,
@@ -61,7 +62,7 @@ export default function WarehouseDetailPage() {
const [zoneModalOpen, setZoneModalOpen] = useState(false);
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
const [contentsZone, setContentsZone] = useState<WarehouseZone | null>(null);
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
@@ -373,7 +374,10 @@ export default function WarehouseDetailPage() {
</Button>
</Group>
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
<ZoneOccupancyHeatmap
yardId={selectedYardId ?? undefined}
onZoneClick={(zone) => setContentsZone(zone)}
/>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">