Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/SlotPicker.tsx
Hagernesh 312c1f1da3 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
2026-08-29 06:39:29 +00:00

88 lines
2.8 KiB
TypeScript

import { useMemo } from 'react';
import { Select, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { ZoneLayout } from '@/types/warehouse';
interface SlotPickerProps {
zoneId: string;
value: string;
onChange: (slotId: string) => void;
label?: string;
disabled?: boolean;
}
/**
* The stack levels a container may actually be put on right now.
*
* Only the next fillable level of each stack is offered: a box cannot stand on
* level 2 while level 1 is empty, so listing level 3 of an empty stack would
* only produce a rejected request. The server enforces the same rule — this
* mirrors it so the operator never sees a 400 for a position the form offered.
*/
export function fillableLevels(layout: ZoneLayout | undefined) {
if (!layout) return [];
return layout.stacks
.filter((stack) => stack.isActive && stack.status === 'ACTIVE')
.flatMap((stack) => {
const occupied = stack.slots
.filter((slot) => slot.effectiveStatus === 'OCCUPIED')
.map((slot) => slot.level);
const top = occupied.length > 0 ? Math.max(...occupied) : 0;
if (top >= stack.maxStackHeight) return [];
const next = stack.slots.find(
(slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
);
if (!next) return [];
return [
{
value: next.slotId,
label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
},
];
});
}
export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
const { data, isLoading } = useQuery(
api.warehouses.zoneLayout.queryOptions({
input: { zoneId },
enabled: Boolean(zoneId),
}),
);
const options = useMemo(() => fillableLevels(data), [data]);
// A zone with no stacks configured keeps plain zone-level placement — showing
// an empty picker there would imply a choice that does not exist.
if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
return (
<Select
label={label}
description={
options.length === 0 && !isLoading ? (
<Text size="xs" c="orange">
Every stack in this zone is full or blocked the item will be stored at zone level.
</Text>
) : (
'Leave blank to take the lowest free level automatically.'
)
}
placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
searchable
clearable
disabled={disabled || isLoading || options.length === 0}
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
/>
);
}
export default SlotPicker;