From 8ef50f9affeaf6aaf1c2f0e8d274f9176fada2c7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 28 Aug 2026 14:39:34 +0000 Subject: [PATCH] feat(warehouses): allow deleting a warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...0-WarehouseInventoryBacklogRegistration.ts | 34 ++ .../warehouses/delete-warehouse-guard.spec.ts | 46 ++ .../warehouses/dto/register-backlog.dto.ts | 114 +++++ .../entities/warehouse-inventory.entity.ts | 16 + .../warehouses/warehouse-fee.service.ts | 12 + .../warehouse-inventory.controller.ts | 23 + .../warehouses/warehouse-inventory.service.ts | 134 ++++++ .../warehouses/warehouses.controller.ts | 13 +- .../modules/warehouses/warehouses.service.ts | 19 + apps/edr-freight-web/backoffice/src/App.tsx | 11 + .../components/layout/sidebar-sections.tsx | 7 + .../warehouses/WarehouseCardView.tsx | 18 +- .../components/warehouses/WarehouseTable.tsx | 10 +- .../warehouses/full-container-excel.test.ts | 87 ++++ .../warehouses/full-container-excel.ts | 206 ++++++++ .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/hooks/useWarehouses.ts | 8 + .../warehouses/RegisterFullContainersPage.tsx | 442 ++++++++++++++++++ .../pages/warehouses/WarehouseListPage.tsx | 33 +- .../src/services/warehouse.service.ts | 16 + .../backoffice/src/types/warehouse.ts | 20 + 21 files changed, 1264 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/RegisterFullContainersPage.tsx diff --git a/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts new file mode 100644 index 000000000..ba4ba7cdb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts new file mode 100644 index 000000000..c87f59886 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts new file mode 100644 index 000000000..0c8ecde4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index b2c4ca3c4..f437188c7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 576933c01..c59cd0617 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { 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 { 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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 19d3b4b7a..da055a6c6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 126998bae..efdfa4cd7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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 { + 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 { + const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase()); + const seen = new Set(); + 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 { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; await this.assertExportBookingPaid(dto.bookingId, bookingDirection); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 275afadf6..6a2a51b4e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 140d9f6b4..dd209dec5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -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) { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a2af15786..539f2ee98 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Register Full Containers", + href: "/dashboard/register-full-containers", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.receive, + }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx index c6baed7ca..ceb616e45 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx @@ -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 + {onDelete ? ( + + onDelete(warehouse)} + aria-label="Delete warehouse" + > + + + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 82a44f557..7dac2d8a1 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -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 onEdit(row.original)} title="Edit"> + {onDelete ? ( + onDelete(row.original)} title="Delete"> + + + ) : null} ), }, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts new file mode 100644 index 000000000..107bf0f75 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.test.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts new file mode 100644 index 000000000..f94057383 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts @@ -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 { + 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(sheet, { header: 1, raw: false, defval: "" }); + + let headerRowIdx = -1; + let columns: Array = []; + 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(); + 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"); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8603444d2..f56e4b90d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index bf19542b4..6ed13636a 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -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) { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/RegisterFullContainersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/RegisterFullContainersPage.tsx new file mode 100644 index 000000000..2a63bfe7e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/RegisterFullContainersPage.tsx @@ -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(null); + const [yardId, setYardId] = useState(null); + const [zoneId, setZoneId] = useState(null); + const [arrivedAt, setArrivedAt] = useState(todayForInput()); + const [containerTypeId, setContainerTypeId] = useState(null); + + // Single-container fields. + const [containerNumber, setContainerNumber] = useState(""); + const [sealNumber, setSealNumber] = useState(""); + const [weight, setWeight] = useState(""); + const [notes, setNotes] = useState(""); + + // Bulk fields. + const [file, setFile] = useState(null); + const [rows, setRows] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + + 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 ( + + + + + setMode(v as "single" | "bulk")} + data={[ + { label: "Single Container", value: "single" }, + { label: "Bulk Upload", value: "bulk" }, + ]} + /> + + + + + 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. + + + + + + + + + + + + setArrivedAt(e.target.value)} + style={{ + padding: "8px", + borderRadius: "4px", + border: "1px solid #ced4da", + width: "100%", + }} + /> + + + {mode === "single" ? ( + <> + + setContainerNumber(e.currentTarget.value)} + required + /> + setSealNumber(e.currentTarget.value)} + /> + + + +