feat(warehouses): allow deleting a warehouse

Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
This commit is contained in:
Hagernesh
2026-08-28 14:39:34 +00:00
parent 224d46402d
commit 8ef50f9aff
21 changed files with 1264 additions and 7 deletions

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Autocomplete,
Badge,
Button,
Card,
Group,
Input,
List,
NumberInput,
ScrollArea,
SegmentedControl,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { Download, Upload } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
import {
downloadFullContainerTemplate,
parseFullContainerExcel,
type ParsedFullContainerRow,
} from "@/components/warehouses/full-container-excel";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { containerTypesService } from "@/services/container-types.service";
import { warehouseService } from "@/services/warehouse.service";
import type { RegisterBacklogContainerPayload } from "@/types/warehouse";
/** `YYYY-MM-DD` for today — the latest arrival a backlog box can claim. */
function todayForInput(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
/**
* Loaded containers that have been sitting in a yard since before the system
* knew about them. Registering one records its true arrival date without
* billing storage for the history — the server flags the row so the fee engine
* skips it entirely.
*/
export default function RegisterFullContainersPage() {
const { toast } = useToast();
const qc = useQueryClient();
const companies = useCompanyOptions();
const [mode, setMode] = useState<"single" | "bulk">("single");
// Location + owner, shared by both modes. In bulk they are the defaults that
// fill any blank cell in the sheet.
const [company, setCompany] = useState("");
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [arrivedAt, setArrivedAt] = useState(todayForInput());
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
// Single-container fields.
const [containerNumber, setContainerNumber] = useState("");
const [sealNumber, setSealNumber] = useState("");
const [weight, setWeight] = useState<number | string>("");
const [notes, setNotes] = useState("");
// Bulk fields.
const [file, setFile] = useState<File | null>(null);
const [rows, setRows] = useState<ParsedFullContainerRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: () => warehouseService.list({}),
});
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
const { data: containerTypes = [] } = useQuery({
queryKey: ["container-types-active"],
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouseId]);
useEffect(() => setZoneId(null), [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const containerTypeOptions = (containerTypes as any[]).map((ct) => ({
value: ct.id,
label: ct.label ? `${ct.label} (${ct.code})` : ct.code,
}));
const locationReady = Boolean(warehouseId && yardId && zoneId);
const basePayload = useMemo(
() => ({
warehouseId: warehouseId ?? "",
yardId: yardId ?? "",
zoneId: zoneId ?? "",
companyId: company ? companies.resolveId(company) : undefined,
companyName: company || undefined,
}),
[warehouseId, yardId, zoneId, company, companies],
);
const onSaved = (count: number) => {
toast({ title: `${count} container${count === 1 ? "" : "s"} registered` });
qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
};
const singleMutation = useMutation({
mutationFn: () =>
warehouseService.registerBacklogContainer({
...basePayload,
containerNumber: containerNumber.trim().toUpperCase(),
containerTypeId: containerTypeId ?? "",
arrivedAt: new Date(arrivedAt).toISOString(),
sealNumber: sealNumber.trim() || undefined,
weight: weight === "" ? undefined : Number(weight),
notes: notes.trim() || undefined,
}),
onSuccess: () => {
onSaved(1);
setContainerNumber("");
setSealNumber("");
setWeight("");
setNotes("");
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Could not register container",
description: error?.response?.data?.message || error?.message,
}),
});
// Row cell wins; the fields above the file fill the blanks.
const toPayload = (row: ParsedFullContainerRow): RegisterBacklogContainerPayload => ({
...basePayload,
companyName: row.companyName || basePayload.companyName,
companyId: row.companyName ? companies.resolveId(row.companyName) : basePayload.companyId,
containerNumber: row.containerNumber,
containerTypeId: containerTypeId ?? "",
arrivedAt: row.arrivedAt ?? new Date(arrivedAt).toISOString(),
sealNumber: row.sealNumber || undefined,
weight: row.weight === "" ? undefined : Number(row.weight),
notes: row.notes || undefined,
});
const bulkMutation = useMutation({
mutationFn: () => warehouseService.registerBacklogContainersBulk(rows.map(toPayload)),
onSuccess: (response) => {
onSaved(response.data?.length ?? rows.length);
setFile(null);
setRows([]);
setParseErrors([]);
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Bulk registration failed",
description: error?.response?.data?.message || error?.message,
}),
});
const handleFile = async (next: File | null) => {
setFile(next);
setRows([]);
setParseErrors([]);
if (!next) return;
const result = await parseFullContainerExcel(next);
setRows(result.rows);
setParseErrors(result.errors);
};
const singleReady =
locationReady && Boolean(containerTypeId) && containerNumber.trim().length > 0 && Boolean(arrivedAt);
const bulkReady = locationReady && Boolean(containerTypeId) && rows.length > 0;
return (
<PageContainer>
<PageHeader
title="Register Full Containers"
subtitle="Loaded containers already in the yard but not yet on the system"
/>
<Group mb="lg" justify="space-between">
<SegmentedControl
value={mode}
onChange={(v) => setMode(v as "single" | "bulk")}
data={[
{ label: "Single Container", value: "single" },
{ label: "Bulk Upload", value: "bulk" },
]}
/>
<Button
variant="subtle"
leftSection={<Download size={16} />}
onClick={() => downloadFullContainerTemplate()}
>
Download Template
</Button>
</Group>
<Alert color="blue" mb="lg" title="Backlog registrations are not billed">
The arrival date you enter is kept as the real record of how long the box has been here,
but no storage or demurrage accrues against it.
</Alert>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group grow align="flex-start">
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Container Type"
placeholder="Select container type"
value={containerTypeId}
onChange={setContainerTypeId}
data={containerTypeOptions}
searchable
required
/>
</Group>
<Group grow align="flex-start">
<Select
label="Warehouse"
placeholder="Select warehouse"
value={warehouseId}
onChange={setWarehouseId}
data={warehouseOptions}
searchable
required
/>
<Select
label="Yard"
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouseId}
searchable
required
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
required
/>
</Group>
<Input.Wrapper
label="Arrival Date"
description={
mode === "bulk"
? "Used for any row whose sheet cell is blank"
: "When the container actually arrived in the yard"
}
required
>
<input
type="date"
value={arrivedAt}
max={todayForInput()}
onChange={(e) => setArrivedAt(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
/>
</Input.Wrapper>
{mode === "single" ? (
<>
<Group grow align="flex-start">
<TextInput
label="Container Number"
placeholder="e.g., TEMU1234567"
value={containerNumber}
onChange={(e) => setContainerNumber(e.currentTarget.value)}
required
/>
<TextInput
label="Seal Number"
placeholder="Optional"
value={sealNumber}
onChange={(e) => setSealNumber(e.currentTarget.value)}
/>
<NumberInput
label="Weight (Tons)"
placeholder="Optional"
value={weight}
onChange={setWeight}
min={0}
decimalScale={3}
/>
</Group>
<Textarea
label="Notes"
placeholder="Where it came from, condition, anything worth recording"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end">
<Button
onClick={() => singleMutation.mutate()}
disabled={!singleReady}
loading={singleMutation.isPending}
>
Register Container
</Button>
</Group>
</>
) : (
<>
<Input.Wrapper label="Excel file" description="One row per container">
<input
type="file"
accept=".xlsx,.xls"
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
style={{ display: "block", padding: "8px 0" }}
/>
</Input.Wrapper>
{parseErrors.length > 0 && (
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was registered`}>
<ScrollArea.Autosize mah={200}>
<List size="sm">
{parseErrors.map((err) => (
<List.Item key={err}>{err}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Alert>
)}
{rows.length > 0 && (
<Stack gap="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Preview
</Text>
<Badge size="sm">{rows.length} containers</Badge>
{file && (
<Text size="xs" c="dimmed">
{file.name}
</Text>
)}
</Group>
<ScrollArea.Autosize mah={320}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th>Weight</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const payload = toPayload(row);
return (
<Table.Tr key={row.containerNumber}>
<Table.Td>{payload.containerNumber}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text size="sm">{payload.companyName || "—"}</Text>
{payload.companyName && !payload.companyId && (
<Badge size="xs" color="orange" variant="light">
New
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
{new Date(payload.arrivedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{payload.sealNumber ?? "—"}</Table.Td>
<Table.Td>{payload.weight ?? "—"}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Stack>
)}
<Group justify="flex-end">
<Button
leftSection={<Upload size={16} />}
onClick={() => bulkMutation.mutate()}
disabled={!bulkReady}
loading={bulkMutation.isPending}
>
Register {rows.length > 0 ? `${rows.length} containers` : ""}
</Button>
</Group>
</>
)}
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Plus } from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { PageContainer, PageHeader } from '@/components/page';
import {
CreateWarehouseModal,
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { useWarehouses } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import { useDeleteWarehouse, useWarehouses } from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
export default function WarehouseListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const remove = useDeleteWarehouse();
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouses.delete);
const [filter, setFilter] = useState<WarehouseFilter>({});
const [view, setView] = useState<WarehouseView>('table');
const [modalOpen, setModalOpen] = useState(false);
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
setModalOpen(true);
};
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
const handleDelete = (warehouse: Warehouse) => {
if (!window.confirm(`Delete warehouse ${warehouse.code}? Yards must be removed first.`)) return;
remove.mutate(warehouse.id, {
onSuccess: () => toast({ title: `Warehouse ${warehouse.code} deleted` }),
onError: (error) =>
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
});
};
const onDelete = canDelete ? handleDelete : undefined;
return (
<PageContainer>
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
) : (
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseTable
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseCardView
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
)}
<RuleEngineListFooter
pagination={controls.pagination}