mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +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 })
|
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||||
goodsId?: string | null;
|
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 })
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|||||||
|
|
||||||
interface ItemAttributes {
|
interface ItemAttributes {
|
||||||
arrivedAt: Date | null;
|
arrivedAt: Date | null;
|
||||||
|
/** Backlog-registered box: real arrival on the record, but never billed. */
|
||||||
|
backlogRegistration: boolean;
|
||||||
gateClearedAt: Date | null;
|
gateClearedAt: Date | null;
|
||||||
releaseDate: Date | null;
|
releaseDate: Date | null;
|
||||||
freightType: string | null;
|
freightType: string | null;
|
||||||
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
|
|||||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.arrived_at AS "arrivedAt",
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
|
inv.backlog_registration AS "backlogRegistration",
|
||||||
inv.gate_cleared_at AS "gateClearedAt",
|
inv.gate_cleared_at AS "gateClearedAt",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.quantity AS "inventoryQuantity",
|
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. */
|
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||||
const item = await this.loadItem(inventoryId);
|
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 rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
|
|||||||
trucks.map(async (t) => {
|
trucks.map(async (t) => {
|
||||||
const item: ItemAttributes = {
|
const item: ItemAttributes = {
|
||||||
arrivedAt: null,
|
arrivedAt: null,
|
||||||
|
// Truck detention is a per-truck charge, never a warehouse backlog row.
|
||||||
|
backlogRegistration: false,
|
||||||
gateClearedAt: null,
|
gateClearedAt: null,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
freightType: leg.freightType ?? null,
|
freightType: leg.freightType ?? null,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
|||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-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 { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||||
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
@@ -143,6 +147,25 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.eligibleBookings(dir);
|
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')
|
@Post('receive-bulk')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
@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 { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-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 { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
@@ -2863,6 +2867,136 @@ export class WarehouseInventoryService {
|
|||||||
await this.lastMileService.acceptBooking(booking.reference);
|
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> {
|
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
@@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
FREIGHT_PERMS.warehouseDashboard.view,
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
FREIGHT_PERMS.warehouses.create,
|
FREIGHT_PERMS.warehouses.create,
|
||||||
FREIGHT_PERMS.warehouses.update,
|
FREIGHT_PERMS.warehouses.update,
|
||||||
|
FREIGHT_PERMS.warehouses.delete,
|
||||||
FREIGHT_PERMS.warehouseYards.view,
|
FREIGHT_PERMS.warehouseYards.view,
|
||||||
FREIGHT_PERMS.warehouseYards.create,
|
FREIGHT_PERMS.warehouseYards.create,
|
||||||
])
|
])
|
||||||
@@ -79,6 +80,16 @@ export class WarehousesController {
|
|||||||
return this.warehousesService.update(id, dto);
|
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')
|
@Get(':warehouseId/yards')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||||
|
|||||||
@@ -108,6 +108,25 @@ export class WarehousesService {
|
|||||||
return this.findById(id);
|
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. */
|
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||||
private mapDbError(error: unknown): never {
|
private mapDbError(error: unknown): never {
|
||||||
if (error instanceof QueryFailedError) {
|
if (error instanceof QueryFailedError) {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
|
|||||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||||
|
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||||
@@ -708,6 +709,16 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="register-full-containers"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.warehouseInventory.receive}
|
||||||
|
>
|
||||||
|
<RegisterFullContainersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="loaded-inventory"
|
path="loaded-inventory"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
|
PackagePlus,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
Stamp,
|
Stamp,
|
||||||
@@ -366,6 +367,12 @@ export const buildSidebarSections = (
|
|||||||
icon: <Container />,
|
icon: <Container />,
|
||||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Register Full Containers",
|
||||||
|
href: "/dashboard/register-full-containers",
|
||||||
|
icon: <PackagePlus />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.receive,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
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';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
|
|||||||
warehouses: Warehouse[];
|
warehouses: Warehouse[];
|
||||||
onView: (warehouse: Warehouse) => void;
|
onView: (warehouse: Warehouse) => void;
|
||||||
onEdit: (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(
|
const { data: stations } = useQuery(
|
||||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||||
);
|
);
|
||||||
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
Scale,
|
Scale,
|
||||||
|
Trash2,
|
||||||
Warehouse as WarehouseIcon,
|
Warehouse as WarehouseIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
|
|||||||
warehouses: Warehouse[];
|
warehouses: Warehouse[];
|
||||||
onView: (warehouse: Warehouse) => void;
|
onView: (warehouse: Warehouse) => void;
|
||||||
onEdit: (warehouse: Warehouse) => void;
|
onEdit: (warehouse: Warehouse) => void;
|
||||||
|
/** Omitted when the user lacks the delete permission. */
|
||||||
|
onDelete?: (warehouse: Warehouse) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADER = bookingTable.headerCell;
|
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(
|
const { data: stations } = useQuery(
|
||||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
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">
|
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
{onDelete ? (
|
||||||
|
<ActionIcon variant="subtle" color="red" onClick={() => onDelete(row.original)} title="Delete">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
</Group>
|
</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?direction=${direction}`
|
||||||
: `/warehouse-inventory/eligible-bookings`,
|
: `/warehouse-inventory/eligible-bookings`,
|
||||||
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
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",
|
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||||
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||||
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
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 ────────────────────────────────────────────────────────────────
|
// ── Yards ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useWarehouseYards(warehouseId?: string) {
|
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 { useDebouncedValue } from '@mantine/hooks';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useAuth } from '@/auth/useAuth';
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
import {
|
||||||
CreateWarehouseModal,
|
CreateWarehouseModal,
|
||||||
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
|
|||||||
// despite the ruleEngine path.
|
// despite the ruleEngine path.
|
||||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||||
import { useListControls } from '@/hooks/useListControls';
|
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';
|
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function WarehouseListPage() {
|
export default function WarehouseListPage() {
|
||||||
const navigate = useNavigate();
|
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 [filter, setFilter] = useState<WarehouseFilter>({});
|
||||||
const [view, setView] = useState<WarehouseView>('table');
|
const [view, setView] = useState<WarehouseView>('table');
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{view === 'table' ? (
|
{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
|
<RuleEngineListFooter
|
||||||
pagination={controls.pagination}
|
pagination={controls.pagination}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
StoreInventoryPayload,
|
StoreInventoryPayload,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
|
RegisterBacklogContainerPayload,
|
||||||
ReleaseOrderPayload,
|
ReleaseOrderPayload,
|
||||||
DeliverInventoryPayload,
|
DeliverInventoryPayload,
|
||||||
EligibleBooking,
|
EligibleBooking,
|
||||||
@@ -257,6 +258,7 @@ export const warehouseService = {
|
|||||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
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 }).
|
// Yards list now returns the standard paginated envelope ({ items, meta }).
|
||||||
listFacilities: () =>
|
listFacilities: () =>
|
||||||
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
||||||
@@ -351,6 +353,20 @@ export const warehouseService = {
|
|||||||
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
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) =>
|
receiveBulk: (payload: BulkReceivePayload) =>
|
||||||
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
||||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||||
|
|||||||
@@ -1063,6 +1063,26 @@ export interface SaveZonePayload {
|
|||||||
status?: WarehouseStatus;
|
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 {
|
export interface ReceiveInventoryPayload {
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
yardId: string;
|
yardId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user