feat(warehouses): allow deleting a warehouse

Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
This commit is contained in:
Hagernesh
2026-08-28 14:39:34 +00:00
parent 224d46402d
commit 8ef50f9aff
21 changed files with 1264 additions and 7 deletions

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backlog registration of full containers that were already sitting in a yard
* before the system knew about them. Such a row carries a true, backdated
* `arrived_at` for the record but accrues NO storage or demurrage — the
* operator decided these are not billable retroactively — so the flag exists
* to keep the fee engine off them.
*
* `company_id` / `company_name` carry the owner, since a backlog row has no
* booking to inherit one from. The name is free text for a company that is not
* a registered customer yet.
*/
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
name = 'WarehouseInventoryBacklogRegistration3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS backlog_registration,
DROP COLUMN IF EXISTS company_id,
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -0,0 +1,46 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { WarehousesService } from './warehouses.service';
/**
* Deleting a warehouse that still holds yards would orphan every zone and the
* inventory sitting in them, so remove() refuses instead of cascading.
*/
function makeService(warehouse: unknown) {
const warehousesRepository = {
findById: jest.fn().mockResolvedValue(warehouse),
softDelete: jest.fn().mockResolvedValue(undefined),
};
const service = Object.create(WarehousesService.prototype) as Record<string, unknown>;
service.warehousesRepository = warehousesRepository;
return { service: service as unknown as WarehousesService, warehousesRepository };
}
describe('WarehousesService.remove', () => {
it('soft-deletes a warehouse with no yards', async () => {
const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] });
await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true });
expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1');
});
it('refuses while yards remain', async () => {
const { service, warehousesRepository } = makeService({
id: 'w1',
code: 'GMP',
yards: [{ id: 'y1' }],
});
await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException);
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
});
it('404s on an unknown warehouse', async () => {
const { service, warehousesRepository } = makeService(null);
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,114 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
/**
* A loaded container that was already sitting in a yard before the system knew
* about it. It has no booking, so the owner is carried as a company reference
* or free text, and `arrivedAt` is the true historical arrival rather than now.
*/
export class RegisterBacklogContainerDto {
@ApiProperty({ description: 'ISO 6346 container number' })
@IsString()
@MaxLength(20)
containerNumber!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiProperty({ description: 'True historical arrival date — drives nothing billable.' })
@IsDateString()
arrivedAt!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' })
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(100)
sealNumber?: string;
@ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' })
@IsOptional()
@IsNumber()
@Min(0)
weight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
volume?: number;
/**
* ponytail: defaults to 0 when unknown, which is the honest value for a box
* nobody weighed. `containers.max_gross_weight` is a ceiling in
* cargoes.service, so set real figures here before this box is ever used for
* a new cargo assignment.
*/
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
tareWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
maxGrossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class BulkRegisterBacklogDto {
@ApiProperty({ type: [RegisterBacklogContainerDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => RegisterBacklogContainerDto)
containers!: RegisterBacklogContainerDto[];
}

View File

@@ -105,6 +105,22 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
goodsId?: string | null;
/**
* Registered as backlog: the box was already in the yard before the system
* knew about it. `arrivedAt` is the true, backdated arrival, but no storage
* or demurrage accrues — see WarehouseFeeService.previewForInventory.
*/
@Column({ name: 'backlog_registration', type: 'boolean', default: false })
backlogRegistration!: boolean;
/** Owner of a row with no booking to inherit one from. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
/** Owner as text — a company that is not a registered customer yet. */
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
companyName?: string | null;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
quantity!: number;

View File

@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
interface ItemAttributes {
arrivedAt: Date | null;
/** Backlog-registered box: real arrival on the record, but never billed. */
backlogRegistration: boolean;
gateClearedAt: Date | null;
releaseDate: Date | null;
freightType: string | null;
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
const [row] = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt",
inv.backlog_registration AS "backlogRegistration",
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
@@ -777,6 +780,13 @@ export class WarehouseFeeService {
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
// A backlog registration carries a backdated arrival so the record is
// honest about how long the box has sat, but it was never booked through
// EDR and is not billed for that history. No rule applies, so no preview —
// which also keeps it off the invoice, since invoicing reads this same list.
if (item.backlogRegistration) return [];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
// Truck detention is a per-truck charge, never a warehouse backlog row.
backlogRegistration: false,
gateClearedAt: null,
releaseDate: null,
freightType: leg.freightType ?? null,

View File

@@ -16,6 +16,10 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import {
BulkRegisterBacklogDto,
RegisterBacklogContainerDto,
} from './dto/register-backlog.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
@@ -143,6 +147,25 @@ export class WarehouseInventoryController {
return this.inventoryService.eligibleBookings(dir);
}
@Post('register-backlog')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({
summary: 'Register one loaded container already in the yard but never entered in the system',
})
registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.registerBacklogContainer(dto);
}
@Post('register-backlog-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' })
registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) {
const performedBy = actorLabel(user);
dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy));
return this.inventoryService.bulkRegisterBacklogContainers(dto);
}
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })

View File

@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import {
BulkRegisterBacklogDto,
RegisterBacklogContainerDto,
} from './dto/register-backlog.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -2863,6 +2867,136 @@ export class WarehouseInventoryService {
await this.lastMileService.acceptBooking(booking.reference);
}
/**
* Register a loaded container that is already physically in a yard but was
* never entered in the system. Unlike receive(), there is no booking, no
* truck entrance to record (nobody remembers the driver of a box that has sat
* for months) and the arrival is backdated to when it actually turned up.
*
* The row is flagged `backlogRegistration`, which keeps the fee engine off it
* entirely — see WarehouseFeeService.previewForInventory. Capacity is still
* charged, because the box does occupy the yard.
*/
async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise<WarehouseInventory> {
const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto));
const saved = await this.inventoryRepository.findById(id);
if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`);
return saved;
}
/** The write itself, so single and bulk share one transaction each. */
private async saveBacklogContainer(
manager: EntityManager,
dto: RegisterBacklogContainerDto,
): Promise<string> {
const containerNumber = dto.containerNumber.trim().toUpperCase();
const arrivedAt = new Date(dto.arrivedAt);
if (Number.isNaN(arrivedAt.getTime())) {
throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`);
}
if (arrivedAt.getTime() > Date.now()) {
throw new BadRequestException('Arrival date cannot be in the future');
}
{
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
const containerType = await manager.query(
`SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`,
[dto.containerTypeId],
);
if (containerType.length === 0) {
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
}
// container_number is UNIQUE — reuse the existing record rather than
// colliding, so a box seen before keeps one identity.
const containers = manager.getRepository(Container);
let container = await containers.findOne({ where: { containerNumber } });
if (container) {
const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({
where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) },
});
if (alreadyHeld) {
throw new BadRequestException(
`Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`,
);
}
} else {
container = await containers.save(
containers.create({
containerNumber,
containerTypeId: dto.containerTypeId,
sealNumber: dto.sealNumber?.trim() || null,
tareWeight: dto.tareWeight ?? 0,
maxGrossWeight: dto.maxGrossWeight ?? 0,
status: 'LOADED',
bookingId: null,
}),
);
}
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
this.assertCapacity('Warehouse', warehouse, weight, volume, 1);
this.assertCapacity('Yard', yard, weight, volume, 1);
this.assertCapacity('Zone', zone, weight, volume, 1);
const owner = dto.companyName?.trim() || null;
const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId: null,
containerId: container.id,
companyId: dto.companyId ?? null,
companyName: owner,
quantity: 1,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt,
backlogRegistration: true,
notes: this.buildReceiveNote({
grnNumber,
notes:
dto.notes?.trim() ||
`Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`,
}),
}),
);
await this.applyCapacityDelta(manager, dto, weight, volume, 1);
return saved.id;
}
}
/**
* Bulk backlog registration. All-or-nothing: one bad row rejects the sheet,
* so a half-registered yard can never happen.
*/
async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise<WarehouseInventory[]> {
const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase());
const seen = new Set<string>();
const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
if (repeated.length > 0) {
throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`);
}
const ids = await this.dataSource.transaction(async (manager) => {
const written: string[] = [];
for (const container of dto.containers) {
written.push(await this.saveBacklogContainer(manager, container));
}
return written;
});
return this.inventoryRepository.findAll({ where: { id: In(ids) } });
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
@@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service';
FREIGHT_PERMS.warehouseDashboard.view,
FREIGHT_PERMS.warehouses.create,
FREIGHT_PERMS.warehouses.update,
FREIGHT_PERMS.warehouses.delete,
FREIGHT_PERMS.warehouseYards.view,
FREIGHT_PERMS.warehouseYards.create,
])
@@ -79,6 +80,16 @@ export class WarehousesController {
return this.warehousesService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.warehouses.delete)
@ApiOperation({
summary: 'Delete warehouse',
description: 'Soft-deletes the warehouse. Refused while it still has yards.',
})
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.warehousesService.remove(id);
}
@Get(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
@ApiOperation({ summary: 'List yards within a warehouse' })

View File

@@ -108,6 +108,25 @@ export class WarehousesService {
return this.findById(id);
}
/**
* Soft-delete a warehouse. Yards (and therefore zones and inventory, which
* hang off a zone) are left alone — a warehouse holding them is refused
* rather than silently orphaning stock.
*/
async remove(id: string): Promise<{ id: string; deleted: true }> {
const existing = await this.findById(id);
if (existing.yards?.length) {
throw new ConflictException(
`Warehouse ${existing.code} still has ${existing.yards.length} yard(s). Delete them first.`,
);
}
await this.warehousesRepository.softDelete(id);
return { id, deleted: true };
}
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
private mapDbError(error: unknown): never {
if (error instanceof QueryFailedError) {

View File

@@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
@@ -708,6 +709,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="register-full-containers"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.receive}
>
<RegisterFullContainersPage />
</RequirePermission>
}
/>
<Route
path="loaded-inventory"
element={

View File

@@ -18,6 +18,7 @@ import {
Package,
PackageCheck,
PackageOpen,
PackagePlus,
Paperclip,
Receipt,
Stamp,
@@ -366,6 +367,12 @@ export const buildSidebarSections = (
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Register Full Containers",
href: "/dashboard/register-full-containers",
icon: <PackagePlus />,
permission: FREIGHT_PERMS.warehouseInventory.receive,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
import { Building2, Eye, MapPin, Package, Pencil, Trash2, Weight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
/** Omitted when the user lacks the delete permission. */
onDelete?: (warehouse: Warehouse) => void;
}
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
export function WarehouseCardView({ warehouses, onView, onEdit, onDelete }: WarehouseCardViewProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<Pencil size={16} />
</ActionIcon>
</Tooltip>
{onDelete ? (
<Tooltip label="Delete warehouse" withArrow>
<ActionIcon
variant="light"
color="red"
onClick={() => onDelete(warehouse)}
aria-label="Delete warehouse"
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Stack>
</Card>

View File

@@ -8,6 +8,7 @@ import {
Package,
Pencil,
Scale,
Trash2,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
/** Omitted when the user lacks the delete permission. */
onDelete?: (warehouse: Warehouse) => void;
}
const HEADER = bookingTable.headerCell;
@@ -63,7 +66,7 @@ function CapacityCell({
);
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
export function WarehouseTable({ warehouses, onView, onEdit, onDelete }: WarehouseTableProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -179,6 +182,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
{onDelete ? (
<ActionIcon variant="subtle" color="red" onClick={() => onDelete(row.original)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
) : null}
</Group>
),
},

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseFullContainerExcel } from "./full-container-excel";
function sheetFile(aoa: unknown[][]): File {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
return new File([buf], "backlog.xlsx");
}
const HEADERS = [
"Container Number",
"Container Size",
"Company",
"Arrival Date",
"Facility",
"Yard",
"Zone",
"Seal Number",
"Weight (Tons)",
"Notes",
];
const row = (num: string, arrived: string, weight: string | number = 24.5) => [
num,
"40",
"Acme PLC",
arrived,
"Gelan",
"A",
"1",
"SL1",
weight,
"",
];
describe("parseFullContainerExcel", () => {
it("parses a backlog sheet with past arrival dates", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("temu1234567", "2026-03-14"), row("MSCU7654321", "2025-11-02")]),
);
expect(result.errors).toEqual([]);
expect(result.rows).toHaveLength(2);
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
expect(result.rows[0].arrivedAt?.startsWith("2026-03-14")).toBe(true);
expect(result.rows[0].companyName).toBe("Acme PLC");
});
it("rejects a future arrival date — a backlog box arrived in the past", async () => {
const future = new Date();
future.setFullYear(future.getFullYear() + 1);
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("TEMU1234567", future.toISOString().slice(0, 10))]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("in the future"))).toBe(true);
});
it("accepts an arrival date of today", async () => {
const today = new Date().toISOString().slice(0, 10);
const result = await parseFullContainerExcel(sheetFile([HEADERS, row("TEMU1234567", today)]));
expect(result.errors).toEqual([]);
});
it("rejects an invalid container number and a negative weight", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("NOPE", "2026-03-14"), row("MSCU7654321", "2026-03-14", -3)]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("ISO container number"))).toBe(true);
expect(result.errors.some((e) => e.includes("0 or more"))).toBe(true);
});
it("rejects duplicate container numbers", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("TEMU1234567", "2026-03-14"), row("temu1234567", "2026-03-15")]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
import * as XLSX from "xlsx";
// Excel import for loaded containers already sitting in a yard but never
// entered in the system. One row per container. All-or-nothing — any bad row
// rejects the file with row-numbered errors, so a half-registered yard cannot
// happen.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ParsedFullContainerRow {
containerNumber: string;
containerSize: string;
companyName: string;
/** ISO instant; null when the cell was empty or unreadable. */
arrivedAt: string | null;
facility: string;
yard: string;
zone: string;
sealNumber: string;
weight: string;
notes: string;
}
export interface FullContainerExcelResult {
rows: ParsedFullContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerNumber"
| "containerSize"
| "companyName"
| "arrivedAt"
| "facility"
| "yard"
| "zone"
| "sealNumber"
| "weight"
| "notes";
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("seal")) return "sealNumber";
if (h.includes("size") || h.includes("type")) return "containerSize";
if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
if (h.includes("yard")) return "yard";
if (h.includes("zone")) return "zone";
if (h.includes("weight") || h.includes("vgm")) return "weight";
if (h.includes("note") || h.includes("remark")) return "notes";
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/**
* Excel dates arrive either as a serial number (raw cells) or as text.
* Returns an ISO instant, or null when the cell is empty/unparseable.
*/
function normalizeDate(raw: string): string | null {
const v = raw.trim();
if (!v) return null;
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
const serial = Number(v);
if (serial > 20000 && serial < 80000) {
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
}
}
const parsed = new Date(v);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/** Parse an uploaded workbook into one row per loaded container. */
export async function parseFullContainerExcel(file: File): Promise<FullContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find a "Container Number" column — download the template to see the expected format.',
],
};
}
const rows: ParsedFullContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
const startOfTomorrow = new Date();
startOfTomorrow.setHours(24, 0, 0, 0);
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const arrivedRaw = cell("arrivedAt");
const arrivedAt = normalizeDate(arrivedRaw);
if (arrivedRaw && !arrivedAt) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
}
// The whole point of a backlog is that it arrived in the past.
if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
}
const weightRaw = cell("weight");
if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
}
rows.push({
containerNumber,
containerSize: cell("containerSize"),
companyName: cell("companyName"),
arrivedAt,
facility: cell("facility"),
yard: cell("yard"),
zone: cell("zone"),
sealNumber: cell("sealNumber"),
weight: weightRaw,
notes: cell("notes"),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) {
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
}
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Download the import template with one filled sample row. */
export function downloadFullContainerTemplate() {
const headers = [
"Container Number",
"Container Size",
"Company",
"Arrival Date",
"Facility",
"Yard",
"Zone",
"Seal Number",
"Weight (Tons)",
"Notes",
];
const sample = [
"TEMU1234567",
"40",
"Acme Import PLC",
"2026-03-14",
"Gelan Multipurpose port",
"Yard A",
"Zone 1",
"SL482910",
24.5,
"Backlog — registered from yard tally sheet",
];
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
}

View File

@@ -707,6 +707,8 @@ export const URL_CONSTANTS = {
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
REGISTER_BACKLOG: "/warehouse-inventory/register-backlog",
REGISTER_BACKLOG_BULK: "/warehouse-inventory/register-backlog-bulk",
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
RECEIVED_EXPORT: "/warehouse-inventory/received-export",

View File

@@ -82,6 +82,14 @@ export function useUpdateWarehouse() {
});
}
export function useDeleteWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
// ── Yards ────────────────────────────────────────────────────────────────
export function useWarehouseYards(warehouseId?: string) {

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Autocomplete,
Badge,
Button,
Card,
Group,
Input,
List,
NumberInput,
ScrollArea,
SegmentedControl,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { Download, Upload } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
import {
downloadFullContainerTemplate,
parseFullContainerExcel,
type ParsedFullContainerRow,
} from "@/components/warehouses/full-container-excel";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { containerTypesService } from "@/services/container-types.service";
import { warehouseService } from "@/services/warehouse.service";
import type { RegisterBacklogContainerPayload } from "@/types/warehouse";
/** `YYYY-MM-DD` for today — the latest arrival a backlog box can claim. */
function todayForInput(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
/**
* Loaded containers that have been sitting in a yard since before the system
* knew about them. Registering one records its true arrival date without
* billing storage for the history — the server flags the row so the fee engine
* skips it entirely.
*/
export default function RegisterFullContainersPage() {
const { toast } = useToast();
const qc = useQueryClient();
const companies = useCompanyOptions();
const [mode, setMode] = useState<"single" | "bulk">("single");
// Location + owner, shared by both modes. In bulk they are the defaults that
// fill any blank cell in the sheet.
const [company, setCompany] = useState("");
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [arrivedAt, setArrivedAt] = useState(todayForInput());
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
// Single-container fields.
const [containerNumber, setContainerNumber] = useState("");
const [sealNumber, setSealNumber] = useState("");
const [weight, setWeight] = useState<number | string>("");
const [notes, setNotes] = useState("");
// Bulk fields.
const [file, setFile] = useState<File | null>(null);
const [rows, setRows] = useState<ParsedFullContainerRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: () => warehouseService.list({}),
});
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
const { data: containerTypes = [] } = useQuery({
queryKey: ["container-types-active"],
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouseId]);
useEffect(() => setZoneId(null), [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const containerTypeOptions = (containerTypes as any[]).map((ct) => ({
value: ct.id,
label: ct.label ? `${ct.label} (${ct.code})` : ct.code,
}));
const locationReady = Boolean(warehouseId && yardId && zoneId);
const basePayload = useMemo(
() => ({
warehouseId: warehouseId ?? "",
yardId: yardId ?? "",
zoneId: zoneId ?? "",
companyId: company ? companies.resolveId(company) : undefined,
companyName: company || undefined,
}),
[warehouseId, yardId, zoneId, company, companies],
);
const onSaved = (count: number) => {
toast({ title: `${count} container${count === 1 ? "" : "s"} registered` });
qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
};
const singleMutation = useMutation({
mutationFn: () =>
warehouseService.registerBacklogContainer({
...basePayload,
containerNumber: containerNumber.trim().toUpperCase(),
containerTypeId: containerTypeId ?? "",
arrivedAt: new Date(arrivedAt).toISOString(),
sealNumber: sealNumber.trim() || undefined,
weight: weight === "" ? undefined : Number(weight),
notes: notes.trim() || undefined,
}),
onSuccess: () => {
onSaved(1);
setContainerNumber("");
setSealNumber("");
setWeight("");
setNotes("");
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Could not register container",
description: error?.response?.data?.message || error?.message,
}),
});
// Row cell wins; the fields above the file fill the blanks.
const toPayload = (row: ParsedFullContainerRow): RegisterBacklogContainerPayload => ({
...basePayload,
companyName: row.companyName || basePayload.companyName,
companyId: row.companyName ? companies.resolveId(row.companyName) : basePayload.companyId,
containerNumber: row.containerNumber,
containerTypeId: containerTypeId ?? "",
arrivedAt: row.arrivedAt ?? new Date(arrivedAt).toISOString(),
sealNumber: row.sealNumber || undefined,
weight: row.weight === "" ? undefined : Number(row.weight),
notes: row.notes || undefined,
});
const bulkMutation = useMutation({
mutationFn: () => warehouseService.registerBacklogContainersBulk(rows.map(toPayload)),
onSuccess: (response) => {
onSaved(response.data?.length ?? rows.length);
setFile(null);
setRows([]);
setParseErrors([]);
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Bulk registration failed",
description: error?.response?.data?.message || error?.message,
}),
});
const handleFile = async (next: File | null) => {
setFile(next);
setRows([]);
setParseErrors([]);
if (!next) return;
const result = await parseFullContainerExcel(next);
setRows(result.rows);
setParseErrors(result.errors);
};
const singleReady =
locationReady && Boolean(containerTypeId) && containerNumber.trim().length > 0 && Boolean(arrivedAt);
const bulkReady = locationReady && Boolean(containerTypeId) && rows.length > 0;
return (
<PageContainer>
<PageHeader
title="Register Full Containers"
subtitle="Loaded containers already in the yard but not yet on the system"
/>
<Group mb="lg" justify="space-between">
<SegmentedControl
value={mode}
onChange={(v) => setMode(v as "single" | "bulk")}
data={[
{ label: "Single Container", value: "single" },
{ label: "Bulk Upload", value: "bulk" },
]}
/>
<Button
variant="subtle"
leftSection={<Download size={16} />}
onClick={() => downloadFullContainerTemplate()}
>
Download Template
</Button>
</Group>
<Alert color="blue" mb="lg" title="Backlog registrations are not billed">
The arrival date you enter is kept as the real record of how long the box has been here,
but no storage or demurrage accrues against it.
</Alert>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group grow align="flex-start">
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Container Type"
placeholder="Select container type"
value={containerTypeId}
onChange={setContainerTypeId}
data={containerTypeOptions}
searchable
required
/>
</Group>
<Group grow align="flex-start">
<Select
label="Warehouse"
placeholder="Select warehouse"
value={warehouseId}
onChange={setWarehouseId}
data={warehouseOptions}
searchable
required
/>
<Select
label="Yard"
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouseId}
searchable
required
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
required
/>
</Group>
<Input.Wrapper
label="Arrival Date"
description={
mode === "bulk"
? "Used for any row whose sheet cell is blank"
: "When the container actually arrived in the yard"
}
required
>
<input
type="date"
value={arrivedAt}
max={todayForInput()}
onChange={(e) => setArrivedAt(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
/>
</Input.Wrapper>
{mode === "single" ? (
<>
<Group grow align="flex-start">
<TextInput
label="Container Number"
placeholder="e.g., TEMU1234567"
value={containerNumber}
onChange={(e) => setContainerNumber(e.currentTarget.value)}
required
/>
<TextInput
label="Seal Number"
placeholder="Optional"
value={sealNumber}
onChange={(e) => setSealNumber(e.currentTarget.value)}
/>
<NumberInput
label="Weight (Tons)"
placeholder="Optional"
value={weight}
onChange={setWeight}
min={0}
decimalScale={3}
/>
</Group>
<Textarea
label="Notes"
placeholder="Where it came from, condition, anything worth recording"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end">
<Button
onClick={() => singleMutation.mutate()}
disabled={!singleReady}
loading={singleMutation.isPending}
>
Register Container
</Button>
</Group>
</>
) : (
<>
<Input.Wrapper label="Excel file" description="One row per container">
<input
type="file"
accept=".xlsx,.xls"
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
style={{ display: "block", padding: "8px 0" }}
/>
</Input.Wrapper>
{parseErrors.length > 0 && (
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was registered`}>
<ScrollArea.Autosize mah={200}>
<List size="sm">
{parseErrors.map((err) => (
<List.Item key={err}>{err}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Alert>
)}
{rows.length > 0 && (
<Stack gap="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Preview
</Text>
<Badge size="sm">{rows.length} containers</Badge>
{file && (
<Text size="xs" c="dimmed">
{file.name}
</Text>
)}
</Group>
<ScrollArea.Autosize mah={320}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th>Weight</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const payload = toPayload(row);
return (
<Table.Tr key={row.containerNumber}>
<Table.Td>{payload.containerNumber}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text size="sm">{payload.companyName || "—"}</Text>
{payload.companyName && !payload.companyId && (
<Badge size="xs" color="orange" variant="light">
New
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
{new Date(payload.arrivedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{payload.sealNumber ?? "—"}</Table.Td>
<Table.Td>{payload.weight ?? "—"}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Stack>
)}
<Group justify="flex-end">
<Button
leftSection={<Upload size={16} />}
onClick={() => bulkMutation.mutate()}
disabled={!bulkReady}
loading={bulkMutation.isPending}
>
Register {rows.length > 0 ? `${rows.length} containers` : ""}
</Button>
</Group>
</>
)}
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Plus } from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { PageContainer, PageHeader } from '@/components/page';
import {
CreateWarehouseModal,
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { useWarehouses } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import { useDeleteWarehouse, useWarehouses } from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
export default function WarehouseListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const remove = useDeleteWarehouse();
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouses.delete);
const [filter, setFilter] = useState<WarehouseFilter>({});
const [view, setView] = useState<WarehouseView>('table');
const [modalOpen, setModalOpen] = useState(false);
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
setModalOpen(true);
};
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
const handleDelete = (warehouse: Warehouse) => {
if (!window.confirm(`Delete warehouse ${warehouse.code}? Yards must be removed first.`)) return;
remove.mutate(warehouse.id, {
onSuccess: () => toast({ title: `Warehouse ${warehouse.code} deleted` }),
onError: (error) =>
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
});
};
const onDelete = canDelete ? handleDelete : undefined;
return (
<PageContainer>
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
) : (
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseTable
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseCardView
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
)}
<RuleEngineListFooter
pagination={controls.pagination}

View File

@@ -41,6 +41,7 @@ import type {
MoveInventoryPayload,
StoreInventoryPayload,
ReceiveInventoryPayload,
RegisterBacklogContainerPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
EligibleBooking,
@@ -257,6 +258,7 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
remove: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
// Yards list now returns the standard paginated envelope ({ items, meta }).
listFacilities: () =>
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
@@ -351,6 +353,20 @@ export const warehouseService = {
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
/** Register one loaded container already in the yard but never entered. */
registerBacklogContainer: (payload: RegisterBacklogContainerPayload) =>
apiClient.post<WarehouseInventoryItem>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG,
payload,
),
/** Bulk backlog registration — all-or-nothing on the server. */
registerBacklogContainersBulk: (containers: RegisterBacklogContainerPayload[]) =>
apiClient.post<WarehouseInventoryItem[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG_BULK,
{ containers },
),
receiveBulk: (payload: BulkReceivePayload) =>
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
bulkMarkInspected: (payload: BulkInspectPayload) =>

View File

@@ -1063,6 +1063,26 @@ export interface SaveZonePayload {
status?: WarehouseStatus;
}
/**
* A loaded container already sitting in a yard but never entered in the system.
* `arrivedAt` is the true historical arrival — the row is flagged as a backlog
* registration server-side and accrues no storage or demurrage.
*/
export interface RegisterBacklogContainerPayload {
containerNumber: string;
containerTypeId: string;
warehouseId: string;
yardId: string;
zoneId: string;
arrivedAt: string;
companyId?: string;
companyName?: string;
sealNumber?: string;
weight?: number;
volume?: number;
notes?: string;
}
export interface ReceiveInventoryPayload {
warehouseId: string;
yardId: string;