mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 01:33:39 +00:00
add permanent purge functionality for wagons and routes
- Implemented a method in to permanently delete wagons without history. - Added corresponding permissions for hard delete actions in . - Updated the UI components to include purge actions, ensuring they are only available to users with the appropriate permissions. - Created modals for confirming permanent deletions in and . - Enhanced API services to handle purge requests for locomotives, wagons, and routes. - Added tests for the purge functionality in both and services to ensure proper behavior and error handling.
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
canFleetAction,
|
||||
canFleetHardDelete,
|
||||
hasPermission,
|
||||
FREIGHT_PERMS,
|
||||
} from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -31,6 +36,7 @@ import {
|
||||
type FleetResourceSlug,
|
||||
} from "@/pages/fleet/config/resources";
|
||||
import {
|
||||
isFleetPurgeable,
|
||||
isFleetServerPaginated,
|
||||
type FleetListFilters,
|
||||
type FleetRecord,
|
||||
@@ -49,6 +55,12 @@ const FleetResourcePage = () => {
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
// Irreversible purge: only locomotives/wagons expose it, and it needs its own
|
||||
// grant — the coarse fleet:manage key deliberately does not unlock it.
|
||||
const canPurge =
|
||||
isFleetPurgeable(slug) &&
|
||||
(slug === "locomotives" || slug === "wagons") &&
|
||||
canFleetHardDelete(user, slug);
|
||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||
// (raise a request, fulfill one, or see the cross-yard history).
|
||||
const canTransfer =
|
||||
@@ -71,6 +83,10 @@ const FleetResourcePage = () => {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
const [purgeTarget, setPurgeTarget] = useState<FleetRecord | null>(null);
|
||||
// Typing the record's own code is the confirmation — a purge cannot be undone,
|
||||
// so a single misplaced click must not be enough to trigger it.
|
||||
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
@@ -142,6 +158,7 @@ const FleetResourcePage = () => {
|
||||
const create = useMutation(api.fleet.create.mutationOptions());
|
||||
const update = useMutation(api.fleet.update.mutationOptions());
|
||||
const remove = useMutation(api.fleet.remove.mutationOptions());
|
||||
const purge = useMutation(api.fleet.purge.mutationOptions());
|
||||
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||
api.wagonTypes.list.queryOptions(),
|
||||
@@ -385,6 +402,7 @@ const FleetResourcePage = () => {
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||
onHistory={setHistoryTarget}
|
||||
/>
|
||||
@@ -446,6 +464,37 @@ const FleetResourcePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** The code the operator must retype to confirm a purge. */
|
||||
const purgeFields = purgeTarget
|
||||
? (purgeTarget as unknown as Record<string, unknown>)
|
||||
: null;
|
||||
const purgeLabel = purgeFields
|
||||
? String(purgeFields.wagonNumber ?? purgeFields.code ?? "")
|
||||
: "";
|
||||
|
||||
const closePurge = () => {
|
||||
setPurgeTarget(null);
|
||||
setPurgeConfirmText("");
|
||||
};
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (!purgeTarget || !("id" in purgeTarget)) return;
|
||||
try {
|
||||
await purge.mutateAsync({ slug, id: String(purgeTarget.id) });
|
||||
toast({ title: `${config.entityLabel} permanently deleted` });
|
||||
closePurge();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
"Permanent delete failed";
|
||||
toast({
|
||||
title: "Permanent delete failed",
|
||||
description: String(message),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignDriver = async () => {
|
||||
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
||||
try {
|
||||
@@ -673,6 +722,7 @@ const FleetResourcePage = () => {
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -718,6 +768,48 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(purgeTarget)}
|
||||
onClose={closePurge}
|
||||
title={<Text fw={600}>Delete permanently</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This permanently removes{" "}
|
||||
<Text span fw={700}>
|
||||
{purgeLabel || `this ${config.entityLabel.toLowerCase()}`}
|
||||
</Text>{" "}
|
||||
from the database. It cannot be undone.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Only unused records can be purged — if it has any history or is still
|
||||
referenced, the request is refused and you should use{" "}
|
||||
{(config.removeActionLabel ?? "Delete").toLowerCase()} instead.
|
||||
</Text>
|
||||
<TextInput
|
||||
label={`Type ${purgeLabel} to confirm`}
|
||||
placeholder={purgeLabel}
|
||||
value={purgeConfirmText}
|
||||
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closePurge}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={purge.isPending}
|
||||
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||
onClick={handlePurge}
|
||||
>
|
||||
Delete permanently
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(assigningDriver)}
|
||||
onClose={() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Plus,
|
||||
Route as RouteIcon,
|
||||
Trash2,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -38,7 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
formatRouteLabel,
|
||||
@@ -168,6 +170,14 @@ export default function RoutesPage() {
|
||||
const canCreate = canFleetAction(user, "routes", "create");
|
||||
const canUpdate = canFleetAction(user, "routes", "update");
|
||||
const canDelete = canFleetAction(user, "routes", "delete");
|
||||
// Irreversible purge needs its own grant — the coarse fleet:manage key that
|
||||
// canFleetAction accepts deliberately does not unlock it.
|
||||
const canPurge = canFleetHardDelete(user, "routes");
|
||||
// Both destructive actions confirm first: deactivate is recoverable but still
|
||||
// changes what operations can book, and a purge cannot be undone at all.
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
|
||||
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
|
||||
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||
|
||||
const routesQuery = useQuery({
|
||||
...api.routes.listPaged.queryOptions({
|
||||
@@ -202,6 +212,7 @@ export default function RoutesPage() {
|
||||
const createMutation = useMutation(api.routes.create.mutationOptions());
|
||||
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
||||
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
||||
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
|
||||
|
||||
// Narrowing the result set can strand the user on a page that no longer
|
||||
// exists (search down to 3 rows while on page 5 → empty table).
|
||||
@@ -353,15 +364,40 @@ export default function RoutesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (route: RouteRecord) => {
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateTarget) return;
|
||||
try {
|
||||
await deactivateMutation.mutateAsync(route.id);
|
||||
await deactivateMutation.mutateAsync(deactivateTarget.id);
|
||||
toast({ title: "Route marked stop working" });
|
||||
setDeactivateTarget(null);
|
||||
} catch {
|
||||
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const closePurge = () => {
|
||||
setPurgeTarget(null);
|
||||
setPurgeConfirmText("");
|
||||
};
|
||||
|
||||
/** The label the operator must retype to confirm an irreversible purge. */
|
||||
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (!purgeTarget) return;
|
||||
try {
|
||||
await purgeMutation.mutateAsync(purgeTarget.id);
|
||||
toast({ title: "Route permanently deleted" });
|
||||
closePurge();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Permanent delete failed",
|
||||
description: normalizeRouteError(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
||||
@@ -465,17 +501,29 @@ export default function RoutesPage() {
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
onClick={() => setDeactivateTarget(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<Tooltip label="Delete permanently">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={purgeMutation.isPending}
|
||||
onClick={() => setPurgeTarget(row.original)}
|
||||
>
|
||||
<ShieldAlert size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [deactivateMutation.isPending, canUpdate, canDelete]);
|
||||
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -696,6 +744,78 @@ export default function RoutesPage() {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(deactivateTarget)}
|
||||
onClose={() => setDeactivateTarget(null)}
|
||||
title={<Text fw={600}>Mark stop working</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Stop new operations on{" "}
|
||||
<Text span fw={700}>
|
||||
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
|
||||
</Text>
|
||||
? The route keeps its history and can no longer be booked.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={deactivateMutation.isPending}
|
||||
onClick={handleDeactivate}
|
||||
>
|
||||
Mark stop working
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(purgeTarget)}
|
||||
onClose={closePurge}
|
||||
title={<Text fw={600}>Delete permanently</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This permanently removes{" "}
|
||||
<Text span fw={700}>
|
||||
{purgeLabel}
|
||||
</Text>{" "}
|
||||
and its stops from the database. It cannot be undone.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Only unused routes can be purged — if any train schedule still
|
||||
references it, the request is refused and you should mark it stop
|
||||
working instead.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Type the route to confirm"
|
||||
placeholder={purgeLabel}
|
||||
value={purgeConfirmText}
|
||||
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closePurge}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={purgeMutation.isPending}
|
||||
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||
onClick={handlePurge}
|
||||
>
|
||||
Delete permanently
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(viewing)}
|
||||
onClose={() => setViewing(null)}
|
||||
|
||||
Reference in New Issue
Block a user