mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
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
88 lines
2.8 KiB
TypeScript
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;
|