mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Move within warehouses
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user