Move within warehouses

This commit is contained in:
hagiye
2026-06-16 12:46:02 +03:00
parent f5d6d8e7da
commit 63f177e6d0
11 changed files with 412 additions and 213 deletions

View File

@@ -0,0 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class MoveWarehouseInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
}

View File

@@ -3,6 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { MoveWarehouseInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@@ -36,6 +37,12 @@ export class WarehouseInventoryController {
return this.inventoryService.receive(dto);
}
@Post(':id/move')
@ApiOperation({ summary: 'Move inventory to another warehouse location' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveWarehouseInventoryDto) {
return this.inventoryService.move(id, dto);
}
@Patch(':id/inspect')
@ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' })
inspect(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,6 +3,7 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { MoveWarehouseInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -120,6 +121,66 @@ export class WarehouseInventoryService {
return this.findById(id);
}
async move(id: string, dto: MoveWarehouseInventoryDto): Promise<WarehouseInventory> {
const movedId = await this.dataSource.transaction(async (manager) => {
const item = await manager.getRepository(WarehouseInventory).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!item) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (
item.warehouseId === dto.warehouseId &&
item.yardId === dto.yardId &&
item.zoneId === dto.zoneId
) {
throw new BadRequestException('Destination location is the same as current location');
}
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
const weight = Number(item.weight) || 0;
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
if (item.warehouseId !== dto.warehouseId) {
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
}
if (item.yardId !== dto.yardId) {
this.assertCapacity('Yard', yard, weight, containerCount);
}
this.assertCapacity('Zone', zone, weight, containerCount);
await this.applyCapacityDelta(
manager,
{
warehouseId: item.warehouseId,
yardId: item.yardId,
zoneId: item.zoneId,
},
-weight,
-containerCount,
);
await this.applyCapacityDelta(manager, dto, weight, containerCount);
item.warehouseId = dto.warehouseId;
item.yardId = dto.yardId;
item.zoneId = dto.zoneId;
if (dto.remarks?.trim()) {
const existingNotes = item.notes?.trim();
item.notes = existingNotes
? `${existingNotes}\nMove: ${dto.remarks.trim()}`
: `Move: ${dto.remarks.trim()}`;
}
const saved = await manager.getRepository(WarehouseInventory).save(item);
return saved.id;
});
return this.findById(movedId);
}
// ── Status transitions ─────────────────────────────────────────────────
async inspect(id: string): Promise<WarehouseInventory> {
@@ -234,7 +295,7 @@ export class WarehouseInventoryService {
private async validateLocation(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
dto: Pick<ReceiveWarehouseInventoryDto, 'warehouseId' | 'yardId' | 'zoneId'>,
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
if (!warehouse) {
@@ -306,7 +367,7 @@ export class WarehouseInventoryService {
private async applyCapacityDelta(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
dto: Pick<ReceiveWarehouseInventoryDto, 'warehouseId' | 'yardId' | 'zoneId'>,
weightAdd: number,
containerAdd: number,
): Promise<void> {

View File

@@ -1,77 +1,16 @@
import { useMemo, useState } from "react";
import { ArrowRight, Building2, Package } from "lucide-react";
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Calendar, Package, User } from "lucide-react";
import { Group, Text } from "@mantine/core";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
function BookingQueueRow({
booking,
selected,
disabled,
onToggle,
}: {
booking: BookingListRow;
selected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
<Package size={14} />
<Text fw={600} size="sm">{booking.reference}</Text>
{booking.isGovernment ? (
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
Government
</Badge>
) : null}
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
<Group gap={6}>
<Text size="xs">{booking.originLabel}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destinationLabel}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore} />
{booking.serviceTypeLabel ? (
<Text size="xs" c="dimmed">
{booking.serviceTypeLabel}
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
import { cn } from "@/lib/utils";
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
export function OperationsBookingQueue({
bookings,
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
isLoading?: boolean;
onAllocate: (bookingIds: string[]) => void;
}) {
const { government, commercial } = useMemo(
() => groupBookingsForOperationsQueue(bookings),
[bookings],
const navigate = useNavigate();
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
window.setTimeout(() => {
suppressRowClickRef.current = false;
}, 400);
}, []);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
navigate(`/dashboard/booking-requests/${row.id}`);
},
[navigate],
);
const [govSelected, setGovSelected] = useState<string[]>([]);
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
const allocatable = (row: BookingListRow) =>
row.status === "PAID" &&
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
const govSelection = govSelected.length
? govSelected
: government.filter(allocatable).map((b) => b.id);
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
const existing = selectedByBucket[bucketKey];
if (existing) return existing;
return bucketBookings.filter(allocatable).map((b) => b.id);
};
const toggleGov = (bookingId: string) => {
setGovSelected((prev) => {
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
return base.includes(bookingId)
? base.filter((id) => id !== bookingId)
: [...base, bookingId];
});
};
const toggleBucket = (bucketKey: string, bookingId: string) => {
setSelectedByBucket((prev) => {
const current = prev[bucketKey] ?? [];
const next = current.includes(bookingId)
? current.filter((id) => id !== bookingId)
: [...current, bookingId];
return { ...prev, [bucketKey]: next };
});
};
if (isLoading) {
return <Text size="sm" c="dimmed">Loading operations queue</Text>;
}
if (!government.length && !commercial.length) {
return (
<Text size="sm" c="dimmed">
No PAID bookings ready to allocate.
</Text>
);
}
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Package className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<Group gap={6} wrap="nowrap">
<p className="truncate font-medium text-foreground">{booking.reference}</p>
{booking.isGovernment ? (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
Government
</Badge>
) : null}
</Group>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{booking.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
{booking.tradeDirection}
</Badge>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{booking.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="space-y-1 py-1">
<BookingStatusBadge status={row.original.status} />
{row.original.schedulingStatus ? (
<SchedulingStatusBadge status={row.original.schedulingStatus} />
) : null}
</div>
),
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.scheduledDate}
</span>
),
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,
cell: ({ row }) => <BookingPriorityBadge score={row.original.priorityScore} />,
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{row.original.paymentCurrency}{" "}
{row.original.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
),
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
onAllocateBooking={() => onAllocate([row.original.id])}
/>
),
},
];
return (
<Stack gap="lg">
{government.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Title order={5}>Government priority</Title>
<Text size="xs" c="dimmed">
Served first not grouped by 3-hour window
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{govSelection.length} selected</Badge>
<Button
size="compact-sm"
color="violet"
disabled={!govSelection.length}
onClick={() => onAllocate(govSelection)}
>
Allocate
</Button>
</Group>
</Group>
<Stack gap="sm">
{government.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={govSelection.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleGov(booking.id)}
/>
))}
</Stack>
</Paper>
) : null}
{commercial.length > 0 ? (
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
{commercial.map((bucket) => {
const selected = bucketSelection(bucket.key, bucket.bookings);
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">{bucket.label}</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} commercial booking
{bucket.bookings.length === 1 ? "" : "s"}
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();
onAllocate(selected);
}}
>
Allocate
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={selected.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleBucket(bucket.key, booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : null}
</Stack>
<DataTable
columns={columns}
data={bookings}
status={isLoading ? "loading" : "success"}
emptyMessage="No PAID bookings ready to load."
onRowClick={handleRowClick}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import { ArrowLeftRight, ClipboardCheck, PackageCheck } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -9,6 +9,7 @@ interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
onMove?: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
}
@@ -23,6 +24,7 @@ export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
onMove,
busyId,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
@@ -85,6 +87,17 @@ export function WarehouseInventoryTable({
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<ArrowLeftRight size={14} />}
disabled={!onMove || busy}
loading={busy}
onClick={() => onMove?.(item)}
>
Move
</Button>
<Button
size="compact-xs"
variant="light"

View File

@@ -248,5 +248,6 @@ export const URL_CONSTANTS = {
INQUIRY: '/warehouse-inventory/inquiry',
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
},
};

View File

@@ -4,6 +4,7 @@ import { warehouseService } from '@/services/warehouse.service';
import type {
InventoryFilter,
InventoryInquiryFilter,
MoveInventoryPayload,
ReceiveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -153,6 +154,18 @@ export function useMarkReadyForLoading() {
});
}
export function useMoveInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: MoveInventoryPayload }) =>
warehouseService.moveInventory(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
return useQuery({
queryKey: warehouseKeys.inquiry(filter),

View File

@@ -79,9 +79,8 @@ export default function BookingRequestsPage() {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
assignedToSchedule: "false",
sortBy: "isGovernment",
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
};
@@ -362,7 +361,7 @@ export default function BookingRequestsPage() {
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="ready">Ready to Load</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>

View File

@@ -1,5 +1,19 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Button,
Card,
Center,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
@@ -14,6 +28,7 @@ import { extractErrorMessage } from '@/components/warehouses/options';
import {
useInspectInventory,
useMarkReadyForLoading,
useMoveInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
@@ -26,6 +41,13 @@ export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>({});
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [moveDraft, setMoveDraft] = useState<{
warehouseId?: string;
yardId?: string;
zoneId?: string;
remarks?: string;
}>({});
const [busyId, setBusyId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 300);
@@ -37,10 +59,13 @@ export default function WarehouseInventoryPage() {
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
const moveYardsQuery = useWarehouseYards(moveDraft.warehouseId);
const moveZonesQuery = useWarehouseZones(moveDraft.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const moveMutation = useMoveInventory();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
@@ -54,6 +79,29 @@ export default function WarehouseInventoryPage() {
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const moveYardOptions = useMemo(
() => (moveYardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[moveYardsQuery.data],
);
const moveZoneOptions = useMemo(
() => (moveZonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[moveZonesQuery.data],
);
const openMoveModal = (item: WarehouseInventoryItem) => {
setMoveItem(item);
setMoveDraft({
warehouseId: item.warehouseId,
yardId: item.yardId,
zoneId: item.zoneId,
remarks: '',
});
};
const closeMoveModal = () => {
setMoveItem(null);
setMoveDraft({});
};
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
@@ -79,6 +127,28 @@ export default function WarehouseInventoryPage() {
}
};
const handleMove = async () => {
if (!moveItem || !moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId) return;
setBusyId(moveItem.id);
try {
await moveMutation.mutateAsync({
id: moveItem.id,
payload: {
warehouseId: moveDraft.warehouseId,
yardId: moveDraft.yardId,
zoneId: moveDraft.zoneId,
remarks: moveDraft.remarks?.trim() || undefined,
},
});
toast({ title: 'Inventory moved' });
closeMoveModal();
} catch (error) {
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
@@ -156,6 +226,7 @@ export default function WarehouseInventoryPage() {
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onMove={openMoveModal}
busyId={busyId}
/>
)}
@@ -164,6 +235,70 @@ export default function WarehouseInventoryPage() {
</Stack>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
<Modal opened={Boolean(moveItem)} onClose={closeMoveModal} title="Move inventory" size="lg" centered>
<Stack gap="md">
<Select
label="Destination warehouse"
placeholder="Select warehouse"
required
searchable
data={warehouseOptions}
value={moveDraft.warehouseId ?? null}
onChange={(value) =>
setMoveDraft((draft) => ({
...draft,
warehouseId: value ?? undefined,
yardId: undefined,
zoneId: undefined,
}))
}
/>
<Select
label="Destination yard"
placeholder="Select yard"
required
searchable
disabled={!moveDraft.warehouseId}
data={moveYardOptions}
value={moveDraft.yardId ?? null}
onChange={(value) =>
setMoveDraft((draft) => ({ ...draft, yardId: value ?? undefined, zoneId: undefined }))
}
/>
<Select
label="Destination zone"
placeholder="Select zone"
required
searchable
disabled={!moveDraft.yardId}
data={moveZoneOptions}
value={moveDraft.zoneId ?? null}
onChange={(value) => setMoveDraft((draft) => ({ ...draft, zoneId: value ?? undefined }))}
/>
<Textarea
label="Remarks"
placeholder="Reason for the move"
minRows={3}
value={moveDraft.remarks ?? ''}
onChange={(event) =>
setMoveDraft((draft) => ({ ...draft, remarks: event.currentTarget.value }))
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMoveModal}>
Cancel
</Button>
<Button
color="green"
loading={moveMutation.isPending}
disabled={!moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId}
onClick={handleMove}
>
Move inventory
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -5,6 +5,7 @@ import type {
InventoryFilter,
InventoryInquiryFilter,
InventoryInquiryResult,
MoveInventoryPayload,
ReceiveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -62,6 +63,8 @@ export const warehouseService = {
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECT(id)),
markReadyForLoading: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
moveInventory: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
listReadyForLoading: (filter?: InventoryFilter) =>
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
params: cleanParams(filter ?? {}),

View File

@@ -162,6 +162,13 @@ export interface ReceiveInventoryPayload {
notes?: string;
}
export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
remarks?: string;
}
export interface WarehouseFilter {
search?: string;
type?: WarehouseType;