mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
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:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user