mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user