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:
Marshal
2026-08-04 14:07:16 +00:00
parent 8cf49aa1cc
commit 5d68a3f7b7
22 changed files with 806 additions and 20 deletions

View File

@@ -64,7 +64,7 @@ import type { ContractTemplateArticle } from "@/services/contract-templates.serv
import { bodyToHtml, htmlToBody } from "./article-html";
const BODY_HINT =
"Each paragraph becomes a numbered clause (1., 2., …) — use Indent to nest it as a sub-clause (1.1, 1.1.1). The bullet list makes • points under the clause above. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
"Enter starts a new line. Use the numbered list for clauses and Tab (or Indent) to nest — levels number 1. → a. → i. like a word processor. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
interface ArticleDraft {
id?: string;
@@ -350,6 +350,49 @@ function matchDepth(match: RegExpExecArray | null): number | null {
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
/** 1 → "a", 2 → "b", … 27 → "aa". Mirrors the API's `toAlpha`. */
function toAlpha(n: number): string {
let out = "";
let value = n;
while (value > 0) {
const rem = (value - 1) % 26;
out = String.fromCharCode(97 + rem) + out;
value = Math.floor((value - 1) / 26);
}
return out || "a";
}
const ROMAN: Array<[number, string]> = [
[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"],
[100, "c"], [90, "xc"], [50, "l"], [40, "xl"],
[10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
];
/** 1 → "i", 4 → "iv". Mirrors the API's `toRoman`. */
function toRoman(n: number): string {
let value = n;
let out = "";
for (const [amount, numeral] of ROMAN) {
while (value >= amount) {
out += numeral;
value -= amount;
}
}
return out || "i";
}
/**
* Outline marker for a clause at its own level, cycling 1. → a. → i. by depth.
* Mirrors `clauseMarker` in the API's contract-article.util.ts — the preview
* must match the generated document exactly.
*/
function clauseMarker(counter: number, depth: number): string {
const style = (depth - 1) % 3;
if (style === 1) return toAlpha(counter);
if (style === 2) return toRoman(counter);
return String(counter);
}
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
@@ -379,7 +422,7 @@ function parseArticleBody(body: string): ParsedBody {
counters[depth - 1] += 1;
clauses.push({
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
number: counters.slice(0, depth).join("."),
number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});

View File

@@ -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={() => {

View File

@@ -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)}