feat(warehouses): backoffice UI for container stack positions

Surfaces the physical stack/slot model in the staff app.

- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
  configured-vs-built-vs-occupied counts, and stack create/delete plus
  block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
  fillable level of each stack so the form cannot suggest a position
  the API will refuse
- move modal warns when a container is buried, lists the blockers, and
  disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
  both are exits from a stack
This commit is contained in:
Hagernesh
2026-08-29 06:37:53 +00:00
parent abfd55d43e
commit 312c1f1da3
22 changed files with 1074 additions and 27 deletions

View File

@@ -154,6 +154,14 @@ import type {
WarehouseYard,
WarehouseZone,
ZoneContentItem,
ZoneLayout,
ZoneSlotSummary,
WarehouseZoneStack,
WarehouseZoneSlot,
SaveStackPayload,
AvailableSlot,
ContainerAccessibility,
FindSlotPayload,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
@@ -1259,6 +1267,85 @@ export const api = {
({ zoneId }) => ["warehouse-zones", zoneId, "contents"],
),
zoneLayout: endpoint<{ zoneId: string }, ZoneLayout>(
"warehouse-zones",
"layout",
({ zoneId }) => warehouseService.zoneLayout(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zones", zoneId, "layout"],
),
zoneSlotSummary: endpoint<{ zoneId: string }, ZoneSlotSummary>(
"warehouse-zones",
"slot-summary",
({ zoneId }) => warehouseService.zoneSlotSummary(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zones", zoneId, "slot-summary"],
),
// ── Ground stacks and slots ────────────────────────────────────────────
listStacks: endpoint<{ zoneId: string }, WarehouseZoneStack[]>(
"warehouse-zone-stacks",
"list",
({ zoneId }) => warehouseService.listStacks(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zone-stacks", zoneId],
),
createStack: endpoint<
{ zoneId: string; payload: SaveStackPayload },
WarehouseZoneStack
>(
"warehouse-zone-stacks",
"create",
({ zoneId, payload }) =>
warehouseService.createStack(zoneId, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
updateStack: endpoint<
{ id: string; zoneId: string; payload: Partial<SaveStackPayload> },
WarehouseZoneStack
>(
"warehouse-zone-stacks",
"update",
({ id, payload }) => warehouseService.updateStack(id, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
deleteStack: endpoint<{ id: string; zoneId: string }, unknown>(
"warehouse-zone-stacks",
"delete",
({ id }) => warehouseService.removeStack(id).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
updateSlot: endpoint<
{ slotId: string; zoneId: string; payload: { status?: string; isActive?: boolean } },
WarehouseZoneSlot
>(
"warehouse-zone-stacks",
"update-slot",
({ slotId, payload }) => warehouseService.updateSlot(slotId, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
deleteZone: endpoint<{ id: string }, unknown>(
"warehouses",
"deleteZone",
@@ -1490,7 +1577,7 @@ export const api = {
"store",
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
reserve: endpoint<ReserveInventoryPayload, WarehouseInventoryItem>(
@@ -1529,6 +1616,36 @@ export const api = {
() => INVENTORY_INVALIDATIONS,
),
findAvailableSlot: endpoint<FindSlotPayload, AvailableSlot | null>(
"warehouse-inventory",
"find-slot",
(payload) => warehouseService.findAvailableSlot(payload).then((r) => r.data),
(payload) => ["warehouse-inventory", "find-slot", payload],
),
containerAccessibility: endpoint<{ id: string }, ContainerAccessibility>(
"warehouse-inventory",
"accessibility",
({ id }) => warehouseService.containerAccessibility(id).then((r) => r.data),
({ id }) => ["warehouse-inventory", id, "accessibility"],
),
assignSlot: endpoint<{ id: string; slotId?: string }, WarehouseInventoryItem>(
"warehouse-inventory",
"assign-slot",
({ id, slotId }) => warehouseService.assignSlot(id, slotId).then((r) => r.data),
undefined,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
releaseSlot: endpoint<{ id: string }, WarehouseInventoryItem>(
"warehouse-inventory",
"release-slot",
({ id }) => warehouseService.releaseSlot(id).then((r) => r.data),
undefined,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
move: endpoint<
{ id: string; payload: MoveInventoryPayload },
WarehouseInventoryItem
@@ -1538,7 +1655,7 @@ export const api = {
({ id, payload }) =>
warehouseService.move(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
markReadyForPickup: endpoint<string, WarehouseInventoryItem>(

View File

@@ -5,6 +5,14 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
ZoneLayout,
ZoneSlotSummary,
WarehouseZoneStack,
WarehouseZoneSlot,
SaveStackPayload,
AvailableSlot,
ContainerAccessibility,
FindSlotPayload,
TruckOnSite,
WarehouseOpsStats,
WarehouseThroughputPoint,
@@ -289,6 +297,37 @@ export const warehouseService = {
removeZone: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
zoneContents: (zoneId: string) =>
apiClient.get<ZoneContentItem[]>(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`),
zoneLayout: (zoneId: string) =>
apiClient.get<ZoneLayout>(URL_CONSTANTS.WAREHOUSE_ZONES.LAYOUT(zoneId)),
zoneSlotSummary: (zoneId: string) =>
apiClient.get<ZoneSlotSummary>(URL_CONSTANTS.WAREHOUSE_ZONES.SLOT_SUMMARY(zoneId)),
// ── Ground stacks and slots ────────────────────────────────────────────
listStacks: (zoneId: string) =>
apiClient.get<WarehouseZoneStack[]>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ZONE(zoneId)),
createStack: (zoneId: string, payload: SaveStackPayload) =>
apiClient.post<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BASE, {
...payload,
zoneId,
}),
updateStack: (id: string, payload: Partial<SaveStackPayload>) =>
apiClient.patch<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id), payload),
removeStack: (id: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id)),
updateSlot: (slotId: string, payload: { status?: string; isActive?: boolean }) =>
apiClient.patch<WarehouseZoneSlot>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.SLOT(slotId), payload),
// ── Physical placement ─────────────────────────────────────────────────
findAvailableSlot: (payload: FindSlotPayload) =>
apiClient.post<AvailableSlot | null>(URL_CONSTANTS.WAREHOUSE_INVENTORY.FIND_SLOT, payload),
assignSlot: (id: string, slotId?: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ASSIGN_SLOT(id), {
slotId,
}),
releaseSlot: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_SLOT(id), {}),
containerAccessibility: (id: string) =>
apiClient.get<ContainerAccessibility>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACCESSIBILITY(id)),
// ── Inventory ──────────────────────────────────────────────────────────
listInventory: (filter?: InventoryFilter) =>