mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge pull request #1447 from Tria-plc/credit-invoice
feat(warehouses): delete yards/zones and inspect zone contents
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
|
||||||
|
* warehouse and yard delete permissions, which already exist.
|
||||||
|
*
|
||||||
|
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
|
||||||
|
* into the warehouse positions, so the moment the key is added to the registry
|
||||||
|
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
|
||||||
|
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
|
||||||
|
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
|
||||||
|
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
|
||||||
|
*
|
||||||
|
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
|
||||||
|
* lands on the same id. Skips silently when the freight application row is
|
||||||
|
* absent, since there is nothing to attach to.
|
||||||
|
*/
|
||||||
|
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
|
||||||
|
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
|
||||||
|
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||||
|
SELECT $2::uuid,
|
||||||
|
$1::varchar,
|
||||||
|
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
|
||||||
|
a.id
|
||||||
|
FROM iam.application a
|
||||||
|
WHERE a.key = 'edr_freight_app'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||||
|
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants go first, or the delete trips the position/role permission foreign
|
||||||
|
* keys — a half-removed permission is worse than one left in place.
|
||||||
|
*/
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.position_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||||
|
WarehouseZoneDeletePermission3810000000000.KEY,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-deleting a parent would leave its children pointing at a row every
|
||||||
|
* joining query drops, so both removes refuse while children exist.
|
||||||
|
*/
|
||||||
|
function makeYardsService(yard: unknown) {
|
||||||
|
const yardsRepository = {
|
||||||
|
findById: jest.fn().mockResolvedValue(yard),
|
||||||
|
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = Object.create(WarehouseYardsService.prototype) as Record<string, unknown>;
|
||||||
|
service.yardsRepository = yardsRepository;
|
||||||
|
return { service: service as unknown as WarehouseYardsService, yardsRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeZonesService(zone: unknown, heldInventory: number) {
|
||||||
|
const zonesRepository = {
|
||||||
|
findById: jest.fn().mockResolvedValue(zone),
|
||||||
|
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const inventoryRepository = {
|
||||||
|
findAndCount: jest.fn().mockResolvedValue([[], heldInventory]),
|
||||||
|
};
|
||||||
|
const service = Object.create(WarehouseZonesService.prototype) as Record<string, unknown>;
|
||||||
|
service.zonesRepository = zonesRepository;
|
||||||
|
service.inventoryRepository = inventoryRepository;
|
||||||
|
return { service: service as unknown as WarehouseZonesService, zonesRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WarehouseYardsService.remove', () => {
|
||||||
|
it('soft-deletes a yard with no zones', async () => {
|
||||||
|
const { service, yardsRepository } = makeYardsService({ id: 'y1', code: 'CY-A', zones: [] });
|
||||||
|
|
||||||
|
await expect(service.remove('y1')).resolves.toEqual({ id: 'y1', deleted: true });
|
||||||
|
expect(yardsRepository.softDelete).toHaveBeenCalledWith('y1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while zones remain', async () => {
|
||||||
|
const { service, yardsRepository } = makeYardsService({
|
||||||
|
id: 'y1',
|
||||||
|
code: 'CY-A',
|
||||||
|
zones: [{ id: 'z1' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.remove('y1')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(yardsRepository.softDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s on an unknown yard', async () => {
|
||||||
|
const { service } = makeYardsService(null);
|
||||||
|
|
||||||
|
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WarehouseZonesService.remove', () => {
|
||||||
|
it('soft-deletes an empty zone', async () => {
|
||||||
|
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0);
|
||||||
|
|
||||||
|
await expect(service.remove('z1')).resolves.toEqual({ id: 'z1', deleted: true });
|
||||||
|
expect(zonesRepository.softDelete).toHaveBeenCalledWith('z1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while inventory sits in it', async () => {
|
||||||
|
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 16);
|
||||||
|
|
||||||
|
await expect(service.remove('z1')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(zonesRepository.softDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s on an unknown zone', async () => {
|
||||||
|
const { service } = makeZonesService(null, 0);
|
||||||
|
|
||||||
|
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } 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';
|
||||||
@@ -40,6 +40,16 @@ export class WarehouseYardsController {
|
|||||||
return this.yardsService.update(id, dto);
|
return this.yardsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseYards.delete)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete warehouse yard',
|
||||||
|
description: 'Soft-deletes the yard. Refused while it still has zones.',
|
||||||
|
})
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.yardsService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':yardId/zones')
|
@Get(':yardId/zones')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||||
@ApiOperation({ summary: 'List zones within a yard' })
|
@ApiOperation({ summary: 'List zones within a yard' })
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ export class WarehouseYardsService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a yard. Zones (and the inventory sitting in them) are left
|
||||||
|
* alone — a yard still holding zones is refused rather than orphaning stock.
|
||||||
|
*/
|
||||||
|
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (existing.zones?.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.yardsRepository.softDelete(id);
|
||||||
|
|
||||||
|
return { id, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch } 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';
|
||||||
@@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
FREIGHT_PERMS.warehouseZones.view,
|
FREIGHT_PERMS.warehouseZones.view,
|
||||||
FREIGHT_PERMS.warehouseInventory.view,
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
FREIGHT_PERMS.warehouseZones.update,
|
FREIGHT_PERMS.warehouseZones.update,
|
||||||
|
FREIGHT_PERMS.warehouseZones.delete,
|
||||||
])
|
])
|
||||||
export class WarehouseZonesController {
|
export class WarehouseZonesController {
|
||||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
@@ -40,4 +41,23 @@ export class WarehouseZonesController {
|
|||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||||
return this.zonesService.update(id, dto);
|
return this.zonesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/contents')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'What is currently stored in a zone',
|
||||||
|
description: 'A row per container — booked units and backlog-registered containers alike.',
|
||||||
|
})
|
||||||
|
contents(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.contents(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete warehouse zone',
|
||||||
|
description: 'Soft-deletes the zone. Refused while inventory still sits in it.',
|
||||||
|
})
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.remove(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||||
|
|
||||||
|
/** One container (or one bulk lot) currently sitting in a zone. */
|
||||||
|
export interface ZoneContentItem {
|
||||||
|
inventoryId: string;
|
||||||
|
containerNumber: string | null;
|
||||||
|
unloadedAt: string | null;
|
||||||
|
containerType: string | null;
|
||||||
|
direction: 'IMPORT' | 'EXPORT' | null;
|
||||||
|
loadState: string | null;
|
||||||
|
status: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseZonesService {
|
export class WarehouseZonesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly zonesRepository: WarehouseZonesRepository,
|
private readonly zonesRepository: WarehouseZonesRepository,
|
||||||
private readonly yardsService: WarehouseYardsService,
|
private readonly yardsService: WarehouseYardsService,
|
||||||
|
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<WarehouseZone[]> {
|
findAll(): Promise<WarehouseZone[]> {
|
||||||
@@ -98,6 +115,70 @@ export class WarehouseZonesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is physically sitting in one zone, a row per container.
|
||||||
|
*
|
||||||
|
* Container identity has two sources and neither covers the other: booked
|
||||||
|
* cargo carries its units on `booking_container_units`, while a backlog
|
||||||
|
* registration has no booking and links `warehouse_inventory.container_id`
|
||||||
|
* straight to a `containers` row. Bulk cargo has neither, so it comes back
|
||||||
|
* with a null container number rather than being dropped from its zone.
|
||||||
|
*
|
||||||
|
* Full/empty likewise: `containers.status` when there is a container row,
|
||||||
|
* otherwise a returned unit is the empty one.
|
||||||
|
*/
|
||||||
|
async contents(zoneId: string): Promise<ZoneContentItem[]> {
|
||||||
|
await this.findById(zoneId);
|
||||||
|
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT i.id AS "inventoryId",
|
||||||
|
COALESCE(c.container_number, bcu.container_number) AS "containerNumber",
|
||||||
|
i.unloaded_at AS "unloadedAt",
|
||||||
|
COALESCE(ct_direct.label, ct_booked.label, bc.container_size) AS "containerType",
|
||||||
|
b.trade_direction AS "direction",
|
||||||
|
CASE
|
||||||
|
WHEN c.status IS NOT NULL THEN c.status
|
||||||
|
WHEN bcu.is_return THEN 'EMPTY'
|
||||||
|
WHEN bcu.container_number IS NOT NULL THEN 'FULL'
|
||||||
|
ELSE NULL
|
||||||
|
END AS "loadState",
|
||||||
|
i.status AS "status",
|
||||||
|
b.reference AS "bookingReference"
|
||||||
|
FROM freight.warehouse_inventory i
|
||||||
|
LEFT JOIN freight.containers c ON c.id = i.container_id AND c.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct_direct ON ct_direct.id = c.container_type_id
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = i.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.booking_container bc ON bc.booking_id = b.id AND bc.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct_booked ON ct_booked.id = bc.container_type_id
|
||||||
|
LEFT JOIN freight.booking_container_units bcu
|
||||||
|
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||||
|
WHERE i.zone_id = $1 AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.unloaded_at DESC NULLS LAST,
|
||||||
|
COALESCE(c.container_number, bcu.container_number)`,
|
||||||
|
[zoneId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a zone. Inventory points at a zone, so a zone still holding
|
||||||
|
* stock is refused — soft-deleting it would leave those rows pointing at a
|
||||||
|
* location every zone-joining query drops.
|
||||||
|
*/
|
||||||
|
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
const [, held] = await this.inventoryRepository.findAndCount({ where: { zoneId: id } });
|
||||||
|
|
||||||
|
if (held > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Zone ${existing.code} still holds ${held} inventory item(s). Move them out first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.zonesRepository.softDelete(id);
|
||||||
|
|
||||||
|
return { id, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||||
|
|
||||||
|
|||||||
@@ -1272,6 +1272,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:warehouse_zones:update",
|
"edr_freight_app:warehouse_zones:update",
|
||||||
"Update warehouse zone",
|
"Update warehouse zone",
|
||||||
),
|
),
|
||||||
|
perm(
|
||||||
|
"f1c00001-0001-4000-8000-000000000004",
|
||||||
|
"edr_freight_app:warehouse_zones:delete",
|
||||||
|
"Delete warehouse zone",
|
||||||
|
),
|
||||||
perm(
|
perm(
|
||||||
"f1d00001-0001-4000-8000-000000000001",
|
"f1d00001-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:warehouse_allocation_rules:view",
|
"edr_freight_app:warehouse_allocation_rules:view",
|
||||||
@@ -2307,6 +2312,7 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:warehouse_zones:view",
|
view: "edr_freight_app:warehouse_zones:view",
|
||||||
create: "edr_freight_app:warehouse_zones:create",
|
create: "edr_freight_app:warehouse_zones:create",
|
||||||
update: "edr_freight_app:warehouse_zones:update",
|
update: "edr_freight_app:warehouse_zones:update",
|
||||||
|
delete: "edr_freight_app:warehouse_zones:delete",
|
||||||
},
|
},
|
||||||
warehouseAllocationRules: {
|
warehouseAllocationRules: {
|
||||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Badge, Group, Loader, Modal, Text } from '@mantine/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import { formatDateTime, humanize } from '@/lib/format';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import type { WarehouseZone, ZoneContentItem } from '@/types/warehouse';
|
||||||
|
|
||||||
|
interface ZoneContentsModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
zone: WarehouseZone | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<ZoneContentItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'containerNumber',
|
||||||
|
header: 'Container No.',
|
||||||
|
// Bulk cargo has no container of its own — it still occupies the zone.
|
||||||
|
cell: ({ row }) => row.original.containerNumber ?? 'Bulk cargo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unloadedAt',
|
||||||
|
header: 'Unloaded',
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.unloadedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'containerType',
|
||||||
|
header: 'Type',
|
||||||
|
cell: ({ row }) => row.original.containerType ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'direction',
|
||||||
|
header: 'Import / Export',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.direction ? (
|
||||||
|
<Badge color={row.original.direction === 'IMPORT' ? 'blue' : 'teal'} variant="light">
|
||||||
|
{humanize(row.original.direction)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loadState',
|
||||||
|
header: 'Full / Empty',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.loadState ? (
|
||||||
|
<Badge color={row.original.loadState === 'EMPTY' ? 'gray' : 'green'} variant="light">
|
||||||
|
{humanize(row.original.loadState)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bookingReference',
|
||||||
|
header: 'Booking',
|
||||||
|
cell: ({ row }) => row.original.bookingReference ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => humanize(row.original.status),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ZoneContentsModal({ opened, onClose, zone }: ZoneContentsModalProps) {
|
||||||
|
const { data, isLoading, isError } = useQuery(
|
||||||
|
api.warehouses.zoneContents.queryOptions({
|
||||||
|
input: { zoneId: zone?.id ?? '' },
|
||||||
|
enabled: opened && Boolean(zone?.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size="xl"
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={700}>{zone ? `${zone.name} (${zone.code})` : 'Zone'}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{items.length} item(s) in this zone
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : isError ? (
|
||||||
|
<Text c="red" ta="center" py="xl">
|
||||||
|
Failed to load zone contents.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
status="success"
|
||||||
|
emptyMessage="This zone is empty."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoneContentsModal;
|
||||||
@@ -9,6 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
|||||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||||
export { CreateYardModal } from './CreateYardModal';
|
export { CreateYardModal } from './CreateYardModal';
|
||||||
export { CreateZoneModal } from './CreateZoneModal';
|
export { CreateZoneModal } from './CreateZoneModal';
|
||||||
|
export { ZoneContentsModal } from './ZoneContentsModal';
|
||||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:warehouse_zones:view",
|
view: "edr_freight_app:warehouse_zones:view",
|
||||||
create: "edr_freight_app:warehouse_zones:create",
|
create: "edr_freight_app:warehouse_zones:create",
|
||||||
update: "edr_freight_app:warehouse_zones:update",
|
update: "edr_freight_app:warehouse_zones:update",
|
||||||
|
delete: "edr_freight_app:warehouse_zones:delete",
|
||||||
},
|
},
|
||||||
warehouseAllocationRules: {
|
warehouseAllocationRules: {
|
||||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -23,16 +23,25 @@ import {
|
|||||||
InventoryWorkbench,
|
InventoryWorkbench,
|
||||||
WarehouseStatusBadge,
|
WarehouseStatusBadge,
|
||||||
WarehouseTypeBadge,
|
WarehouseTypeBadge,
|
||||||
|
ZoneContentsModal,
|
||||||
ZoneOccupancyHeatmap,
|
ZoneOccupancyHeatmap,
|
||||||
formatCapacity,
|
formatCapacity,
|
||||||
humanizeEnum,
|
humanizeEnum,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
|
import { useAuth } from '@/auth/useAuth';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function WarehouseDetailPage() {
|
export default function WarehouseDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canDeleteYard = hasPermission(user, FREIGHT_PERMS.warehouseYards.delete);
|
||||||
|
const canDeleteZone = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||||
|
|
||||||
const { data: warehouse, isLoading } = useQuery(
|
const { data: warehouse, isLoading } = useQuery(
|
||||||
api.warehouses.getById.queryOptions({
|
api.warehouses.getById.queryOptions({
|
||||||
@@ -52,6 +61,7 @@ export default function WarehouseDetailPage() {
|
|||||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||||
|
const [contentsZone, setContentsZone] = useState<WarehouseZone | null>(null);
|
||||||
|
|
||||||
const zonesQuery = useQuery(
|
const zonesQuery = useQuery(
|
||||||
api.warehouses.listZones.queryOptions({
|
api.warehouses.listZones.queryOptions({
|
||||||
@@ -72,6 +82,44 @@ export default function WarehouseDetailPage() {
|
|||||||
[yards],
|
[yards],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteYard = useMutation(api.warehouses.deleteYard.mutationOptions());
|
||||||
|
const deleteZone = useMutation(api.warehouses.deleteZone.mutationOptions());
|
||||||
|
|
||||||
|
// The API refuses a yard that still has zones (and a zone that still holds
|
||||||
|
// inventory) with a 409 — surface that message rather than a bare failure.
|
||||||
|
const removeYard = useCallback(
|
||||||
|
(yard: WarehouseYard) => {
|
||||||
|
if (!window.confirm(`Delete yard ${yard.code}? Its zones must be removed first.`)) return;
|
||||||
|
deleteYard.mutate(
|
||||||
|
{ id: yard.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast({ title: `Yard ${yard.code} deleted` }),
|
||||||
|
onError: (error) =>
|
||||||
|
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[deleteYard, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeZone = useCallback(
|
||||||
|
(zone: WarehouseZone) => {
|
||||||
|
if (!window.confirm(`Delete zone ${zone.code}? It must be empty first.`)) return;
|
||||||
|
deleteZone.mutate(
|
||||||
|
{ id: zone.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: `Zone ${zone.code} deleted` });
|
||||||
|
void zonesQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[deleteZone, toast, zonesQuery],
|
||||||
|
);
|
||||||
|
|
||||||
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
|
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
|
||||||
() => [
|
() => [
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||||
@@ -98,20 +146,33 @@ export default function WarehouseDetailPage() {
|
|||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
onClick={() => {
|
color="gray"
|
||||||
setEditingYard(row.original);
|
title="Edit"
|
||||||
setYardModalOpen(true);
|
onClick={() => {
|
||||||
}}
|
setEditingYard(row.original);
|
||||||
>
|
setYardModalOpen(true);
|
||||||
<Pencil size={16} />
|
}}
|
||||||
</ActionIcon>
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
{canDeleteYard ? (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => removeYard(row.original)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[canDeleteYard, removeYard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
|
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
|
||||||
@@ -140,20 +201,33 @@ export default function WarehouseDetailPage() {
|
|||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
onClick={() => {
|
color="gray"
|
||||||
setEditingZone(row.original);
|
title="Edit"
|
||||||
setZoneModalOpen(true);
|
onClick={() => {
|
||||||
}}
|
setEditingZone(row.original);
|
||||||
>
|
setZoneModalOpen(true);
|
||||||
<Pencil size={16} />
|
}}
|
||||||
</ActionIcon>
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
{canDeleteZone ? (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => removeZone(row.original)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[canDeleteZone, removeZone],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -309,6 +383,7 @@ export default function WarehouseDetailPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={zoneColumns}
|
columns={zoneColumns}
|
||||||
data={zonesQuery.data ?? []}
|
data={zonesQuery.data ?? []}
|
||||||
|
onRowClick={(zone) => setContentsZone(zone)}
|
||||||
status={
|
status={
|
||||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||||
}
|
}
|
||||||
@@ -346,6 +421,12 @@ export default function WarehouseDetailPage() {
|
|||||||
yard={editingYard}
|
yard={editingYard}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ZoneContentsModal
|
||||||
|
opened={Boolean(contentsZone)}
|
||||||
|
onClose={() => setContentsZone(null)}
|
||||||
|
zone={contentsZone}
|
||||||
|
/>
|
||||||
|
|
||||||
{selectedYardId && (
|
{selectedYardId && (
|
||||||
<CreateZoneModal
|
<CreateZoneModal
|
||||||
opened={zoneModalOpen}
|
opened={zoneModalOpen}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ import type {
|
|||||||
WarehouseLoading,
|
WarehouseLoading,
|
||||||
WarehouseYard,
|
WarehouseYard,
|
||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
|
ZoneContentItem,
|
||||||
} from "@/types/warehouse";
|
} from "@/types/warehouse";
|
||||||
import { endpoint } from "@/utils/endpoint";
|
import { endpoint } from "@/utils/endpoint";
|
||||||
import {
|
import {
|
||||||
@@ -1211,6 +1212,14 @@ export const api = {
|
|||||||
() => [["warehouses"], ["warehouse-yards"]],
|
() => [["warehouses"], ["warehouse-yards"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
deleteYard: endpoint<{ id: string }, unknown>(
|
||||||
|
"warehouses",
|
||||||
|
"deleteYard",
|
||||||
|
({ id }) => warehouseService.removeYard(id).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["warehouses"], ["warehouse-yards"]],
|
||||||
|
),
|
||||||
|
|
||||||
// ── Zones ──────────────────────────────────────────────────────────────
|
// ── Zones ──────────────────────────────────────────────────────────────
|
||||||
listZones: endpoint<{ yardId: string }, WarehouseZone[]>(
|
listZones: endpoint<{ yardId: string }, WarehouseZone[]>(
|
||||||
"warehouses",
|
"warehouses",
|
||||||
@@ -1243,6 +1252,21 @@ export const api = {
|
|||||||
() => [["warehouse-yards"]],
|
() => [["warehouse-yards"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
zoneContents: endpoint<{ zoneId: string }, ZoneContentItem[]>(
|
||||||
|
"warehouse-zones",
|
||||||
|
"contents",
|
||||||
|
({ zoneId }) => warehouseService.zoneContents(zoneId).then((r) => r.data),
|
||||||
|
({ zoneId }) => ["warehouse-zones", zoneId, "contents"],
|
||||||
|
),
|
||||||
|
|
||||||
|
deleteZone: endpoint<{ id: string }, unknown>(
|
||||||
|
"warehouses",
|
||||||
|
"deleteZone",
|
||||||
|
({ id }) => warehouseService.removeZone(id).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["warehouses"], ["warehouse-yards"]],
|
||||||
|
),
|
||||||
|
|
||||||
// ── Inventory (queries) ────────────────────────────────────────────────
|
// ── Inventory (queries) ────────────────────────────────────────────────
|
||||||
listInventory: endpoint<
|
listInventory: endpoint<
|
||||||
{ filter?: InventoryFilter },
|
{ filter?: InventoryFilter },
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import type {
|
|||||||
WarehouseLoading,
|
WarehouseLoading,
|
||||||
WarehouseYard,
|
WarehouseYard,
|
||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
|
ZoneContentItem,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
@@ -274,6 +275,7 @@ export const warehouseService = {
|
|||||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||||
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
||||||
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
|
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
|
||||||
|
removeYard: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||||
|
|
||||||
// ── Zones ──────────────────────────────────────────────────────────────
|
// ── Zones ──────────────────────────────────────────────────────────────
|
||||||
listZones: (yardId: string) =>
|
listZones: (yardId: string) =>
|
||||||
@@ -284,6 +286,9 @@ export const warehouseService = {
|
|||||||
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||||
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
||||||
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
|
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
|
||||||
|
removeZone: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||||
|
zoneContents: (zoneId: string) =>
|
||||||
|
apiClient.get<ZoneContentItem[]>(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`),
|
||||||
|
|
||||||
// ── Inventory ──────────────────────────────────────────────────────────
|
// ── Inventory ──────────────────────────────────────────────────────────
|
||||||
listInventory: (filter?: InventoryFilter) =>
|
listInventory: (filter?: InventoryFilter) =>
|
||||||
|
|||||||
@@ -145,6 +145,18 @@ export interface WarehouseYard {
|
|||||||
zones?: WarehouseZone[];
|
zones?: WarehouseZone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One container (or bulk lot) currently sitting in a zone. */
|
||||||
|
export interface ZoneContentItem {
|
||||||
|
inventoryId: string;
|
||||||
|
containerNumber: string | null;
|
||||||
|
unloadedAt: string | null;
|
||||||
|
containerType: string | null;
|
||||||
|
direction: 'IMPORT' | 'EXPORT' | null;
|
||||||
|
loadState: string | null;
|
||||||
|
status: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const FACILITY_TYPES = [
|
export const FACILITY_TYPES = [
|
||||||
'PORT',
|
'PORT',
|
||||||
'DRY_PORT',
|
'DRY_PORT',
|
||||||
|
|||||||
Reference in New Issue
Block a user