mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
feat(warehouses): delete yards/zones and inspect zone contents
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.
This commit is contained in:
@@ -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 { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ZoneContentsModal } from './ZoneContentsModal';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
const [contentsZone, setContentsZone] = useState<WarehouseZone | null>(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<ColumnDef<WarehouseYard>[]>(
|
||||
() => [
|
||||
{ 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 }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<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>[]>(
|
||||
@@ -140,20 +201,33 @@ export default function WarehouseDetailPage() {
|
||||
header: 'Actions',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<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) {
|
||||
@@ -309,6 +383,7 @@ export default function WarehouseDetailPage() {
|
||||
<DataTable
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
onRowClick={(zone) => setContentsZone(zone)}
|
||||
status={
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
@@ -346,6 +421,12 @@ export default function WarehouseDetailPage() {
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
<ZoneContentsModal
|
||||
opened={Boolean(contentsZone)}
|
||||
onClose={() => setContentsZone(null)}
|
||||
zone={contentsZone}
|
||||
/>
|
||||
|
||||
{selectedYardId && (
|
||||
<CreateZoneModal
|
||||
opened={zoneModalOpen}
|
||||
|
||||
@@ -153,6 +153,7 @@ import type {
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
} from "@/types/warehouse";
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
@@ -1211,6 +1212,14 @@ export const api = {
|
||||
() => [["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 },
|
||||
|
||||
@@ -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<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
||||
apiClient.patch<WarehouseYard>(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<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
listInventory: (filter?: InventoryFilter) =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user