diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts new file mode 100644 index 000000000..3aa26d96e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 27282fc1d..d357c8e0c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 18f7c0f61..a80894ca8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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 { @@ -234,7 +295,7 @@ export class WarehouseInventoryService { private async validateLocation( manager: EntityManager, - dto: ReceiveWarehouseInventoryDto, + dto: Pick, ): 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, weightAdd: number, containerAdd: number, ): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx index abd787334..3ed723be1 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -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 ( - - - - - - {booking.reference} - {booking.isGovernment ? ( - }> - Government - - ) : null} - {booking.freightType} - {booking.schedulingStatus ? ( - {booking.schedulingStatus} - ) : null} - - {booking.customerLabel} - - {booking.originLabel} - - {booking.destinationLabel} - - - - {booking.serviceTypeLabel ? ( - - {booking.serviceTypeLabel} - {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} - - ) : null} - - - - ); -} +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([]); - const [selectedByBucket, setSelectedByBucket] = useState>({}); - 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 Loading operations queue…; - } - - if (!government.length && !commercial.length) { - return ( - - No PAID bookings ready to allocate. - - ); - } + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ +
+
+ +

{booking.reference}

+ {booking.isGovernment ? ( + + Government + + ) : null} +
+

+ + {booking.customerLabel} +

+
+
+ ); + }, + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ {booking.originLabel} + + {booking.destinationLabel} +
+
+ + {booking.tradeDirection} + + + {booking.freightType} + +
+
+ ); + }, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( +
+ + {row.original.schedulingStatus ? ( + + ) : null} +
+ ), + }, + { + id: "scheduled", + header: () => Scheduled, + cell: ({ row }) => ( + + + {row.original.scheduledDate} + + ), + }, + { + id: "priority", + header: () => Priority, + cell: ({ row }) => , + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {row.original.paymentCurrency}{" "} + {row.original.totalAmount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( + onAllocate([row.original.id])} + /> + ), + }, + ]; return ( - - {government.length > 0 ? ( - - - - Government priority - - Served first — not grouped by 3-hour window - - - - {govSelection.length} selected - - - - - {government.map((booking) => ( - toggleGov(booking.id)} - /> - ))} - - - ) : null} - - {commercial.length > 0 ? ( - - {commercial.map((bucket) => { - const selected = bucketSelection(bucket.key, bucket.bookings); - return ( - - - - - {bucket.label} - - {bucket.bookings.length} commercial booking - {bucket.bookings.length === 1 ? "" : "s"} - - - - {selected.length} selected - - - - - - - {bucket.bookings.map((booking) => ( - toggleBucket(bucket.key, booking.id)} - /> - ))} - - - - ); - })} - - ) : null} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index d052fad6f..dbbc87c52 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -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({ +