From d4373dfbe4037fcfd7a4479990531e0b1c4d4674 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 28 Aug 2026 14:54:17 +0000 Subject: [PATCH] feat(warehouses): delete yards/zones and inspect zone contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yard and zone soft-delete, refused with 409 while a yard still has zones or a zone still holds inventory. warehouse_zones:delete was missing from the catalog — the role presets spread every zone key, so its absence crashes FreightPositionsSeeder at boot; a migration seeds it everywhere. Clicking a zone opens its contents as a datatable. Container identity comes from booking_container_units for booked cargo and from containers for backlog registrations; bulk cargo keeps its row with no container number rather than disappearing from the zone. --- ...000000000-WarehouseZoneDeletePermission.ts | 55 ++++++++ .../delete-yard-zone-guards.spec.ts | 80 +++++++++++ .../warehouses/warehouse-yards.controller.ts | 12 +- .../warehouses/warehouse-yards.service.ts | 18 +++ .../warehouses/warehouse-zones.controller.ts | 22 ++- .../warehouses/warehouse-zones.service.ts | 81 +++++++++++ .../src/seed/freight-permissions.registry.ts | 6 + .../warehouses/ZoneContentsModal.tsx | 112 +++++++++++++++ .../src/components/warehouses/index.ts | 1 + .../backoffice/src/lib/permissions.ts | 1 + .../pages/warehouses/WarehouseDetailPage.tsx | 131 ++++++++++++++---- .../backoffice/src/services/api.ts | 24 ++++ .../src/services/warehouse.service.ts | 5 + .../backoffice/src/types/warehouse.ts | 12 ++ 14 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx diff --git a/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts new file mode 100644 index 000000000..fa2bcf7ec --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts @@ -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:` 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 { + 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 { + 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, + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts new file mode 100644 index 000000000..bd3ce5b87 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts @@ -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; + 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; + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 7e658d9db..c51002a5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -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 { BookingStaff } from '../../common/booking-guards'; @@ -40,6 +40,16 @@ export class WarehouseYardsController { 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') @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 874de75db..b9e212ae1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -107,6 +107,24 @@ export class WarehouseYardsService { 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 { const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 04cbacd28..1a35307cd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -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 { BookingStaff } from '../../common/booking-guards'; @@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view, FREIGHT_PERMS.warehouseZones.update, + FREIGHT_PERMS.warehouseZones.delete, ]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -40,4 +41,23 @@ export class WarehouseZonesController { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { 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); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index 367a5a75e..dc02df6cb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,16 +1,33 @@ 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 { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseYardsService } from './warehouse-yards.service'; 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() export class WarehouseZonesService { constructor( private readonly zonesRepository: WarehouseZonesRepository, private readonly yardsService: WarehouseYardsService, + private readonly inventoryRepository: WarehouseInventoryRepository, + @InjectDataSource() private readonly dataSource: DataSource, ) {} findAll(): Promise { @@ -98,6 +115,70 @@ export class WarehouseZonesService { 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 { + 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 { const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ac2507aa5..bb83518df 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1272,6 +1272,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:warehouse_zones:update", "Update warehouse zone", ), + perm( + "f1c00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_zones:delete", + "Delete warehouse zone", + ), perm( "f1d00001-0001-4000-8000-000000000001", "edr_freight_app:warehouse_allocation_rules:view", @@ -2307,6 +2312,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:warehouse_zones:view", create: "edr_freight_app:warehouse_zones:create", update: "edr_freight_app:warehouse_zones:update", + delete: "edr_freight_app:warehouse_zones:delete", }, warehouseAllocationRules: { view: "edr_freight_app:warehouse_allocation_rules:view", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx new file mode 100644 index 000000000..24c618d3e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx @@ -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[] = [ + { + 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 ? ( + + {humanize(row.original.direction)} + + ) : ( + '—' + ), + }, + { + id: 'loadState', + header: 'Full / Empty', + cell: ({ row }) => + row.original.loadState ? ( + + {humanize(row.original.loadState)} + + ) : ( + '—' + ), + }, + { + 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 ( + + {zone ? `${zone.name} (${zone.code})` : 'Zone'} + + {items.length} item(s) in this zone + + + } + > + {isLoading ? ( + + + + ) : isError ? ( + + Failed to load zone contents. + + ) : ( + + )} + + ); +} + +export default ZoneContentsModal; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index dae0e1d7f..ab7591f61 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -9,6 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable'; export { CreateWarehouseModal } from './CreateWarehouseModal'; export { CreateYardModal } from './CreateYardModal'; export { CreateZoneModal } from './CreateZoneModal'; +export { ZoneContentsModal } from './ZoneContentsModal'; export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal'; export { WarehouseInfoCard } from './WarehouseInfoCard'; export { MoveInventoryModal } from './MoveInventoryModal'; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index b3d721eb5..38af63785 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -307,6 +307,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:warehouse_zones:view", create: "edr_freight_app:warehouse_zones:create", update: "edr_freight_app:warehouse_zones:update", + delete: "edr_freight_app:warehouse_zones:delete", }, warehouseAllocationRules: { view: "edr_freight_app:warehouse_allocation_rules:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index e3680fe73..0cd281ad3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ActionIcon, @@ -12,8 +12,8 @@ import { Tabs, Text, } from '@mantine/core'; -import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; +import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { KpiStrip, PageContainer, PageHeader } from '@/components/page'; @@ -23,16 +23,25 @@ import { InventoryWorkbench, WarehouseStatusBadge, WarehouseTypeBadge, + ZoneContentsModal, ZoneOccupancyHeatmap, formatCapacity, humanizeEnum, } 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 type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; export default function WarehouseDetailPage() { const { id } = useParams<{ id: string }>(); 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( api.warehouses.getById.queryOptions({ @@ -52,6 +61,7 @@ export default function WarehouseDetailPage() { const [zoneModalOpen, setZoneModalOpen] = useState(false); const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); + const [contentsZone, setContentsZone] = useState(null); const zonesQuery = useQuery( api.warehouses.listZones.queryOptions({ @@ -72,6 +82,44 @@ export default function WarehouseDetailPage() { [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[]>( () => [ { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, @@ -98,20 +146,33 @@ export default function WarehouseDetailPage() { header: 'Actions', meta: { headerClassName: 'text-right', cellClassName: 'text-right' }, cell: ({ row }) => ( - { - setEditingYard(row.original); - setYardModalOpen(true); - }} - > - - + + { + setEditingYard(row.original); + setYardModalOpen(true); + }} + > + + + {canDeleteYard ? ( + removeYard(row.original)} + > + + + ) : null} + ), }, ], - [], + [canDeleteYard, removeYard], ); const zoneColumns = useMemo[]>( @@ -140,20 +201,33 @@ export default function WarehouseDetailPage() { header: 'Actions', meta: { headerClassName: 'text-right', cellClassName: 'text-right' }, cell: ({ row }) => ( - { - setEditingZone(row.original); - setZoneModalOpen(true); - }} - > - - + e.stopPropagation()}> + { + setEditingZone(row.original); + setZoneModalOpen(true); + }} + > + + + {canDeleteZone ? ( + removeZone(row.original)} + > + + + ) : null} + ), }, ], - [], + [canDeleteZone, removeZone], ); if (isLoading) { @@ -309,6 +383,7 @@ export default function WarehouseDetailPage() { setContentsZone(zone)} status={ zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success' } @@ -346,6 +421,12 @@ export default function WarehouseDetailPage() { yard={editingYard} /> )} + setContentsZone(null)} + zone={contentsZone} + /> + {selectedYardId && ( [["warehouses"], ["warehouse-yards"]], ), + deleteYard: endpoint<{ id: string }, unknown>( + "warehouses", + "deleteYard", + ({ id }) => warehouseService.removeYard(id).then((r) => r.data), + undefined, + () => [["warehouses"], ["warehouse-yards"]], + ), + // ── Zones ────────────────────────────────────────────────────────────── listZones: endpoint<{ yardId: string }, WarehouseZone[]>( "warehouses", @@ -1243,6 +1252,21 @@ export const api = { () => [["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) ──────────────────────────────────────────────── listInventory: endpoint< { filter?: InventoryFilter }, diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 04cb95091..f0c894097 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -72,6 +72,7 @@ import type { WarehouseLoading, WarehouseYard, WarehouseZone, + ZoneContentItem, } from '@/types/warehouse'; export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; @@ -274,6 +275,7 @@ export const warehouseService = { getYard: (id: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)), updateYard: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload), + removeYard: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)), // ── Zones ────────────────────────────────────────────────────────────── listZones: (yardId: string) => @@ -284,6 +286,9 @@ export const warehouseService = { getZone: (id: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)), updateZone: (id: string, payload: Partial) => apiClient.patch(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(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`), // ── Inventory ────────────────────────────────────────────────────────── listInventory: (filter?: InventoryFilter) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 0974035a1..2b43e2d22 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -145,6 +145,18 @@ export interface WarehouseYard { 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 = [ 'PORT', 'DRY_PORT',