mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1113 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -22,6 +22,8 @@ export interface FleetCardGridProps {
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||
onPurge?: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
@@ -35,6 +37,7 @@ const FleetCardGrid = ({
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onPurge,
|
||||
}: FleetCardGridProps) => {
|
||||
const presentation = resolveFleetCardPresentation(config);
|
||||
|
||||
@@ -183,6 +186,7 @@ const FleetCardGrid = ({
|
||||
layout="compact"
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
onPurge={onPurge}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
|
||||
import {
|
||||
Edit2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Users,
|
||||
MoreVertical,
|
||||
History,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -11,6 +19,8 @@ export interface FleetRecordActionsProps {
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||
onPurge?: (record: FleetRecord) => void;
|
||||
onAssignDriver?: (record: FleetRecord) => void;
|
||||
onHistory?: (record: FleetRecord) => void;
|
||||
onViewDetail?: (record: FleetRecord) => void;
|
||||
@@ -22,6 +32,7 @@ const FleetRecordActions = ({
|
||||
config,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onPurge,
|
||||
onAssignDriver,
|
||||
onHistory,
|
||||
onViewDetail,
|
||||
@@ -46,6 +57,7 @@ const FleetRecordActions = ({
|
||||
if (
|
||||
!onEdit &&
|
||||
!onRemove &&
|
||||
!onPurge &&
|
||||
!showDetail &&
|
||||
!showViewDetail &&
|
||||
!showHistory &&
|
||||
@@ -170,6 +182,15 @@ const FleetRecordActions = ({
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{onPurge ? (
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onPurge(record)}
|
||||
leftSection={<ShieldAlert size={14} strokeWidth={2} />}
|
||||
>
|
||||
Delete permanently
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -122,12 +122,16 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:locomotives:create",
|
||||
update: "edr_freight_app:locomotives:update",
|
||||
delete: "edr_freight_app:locomotives:delete",
|
||||
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||
hardDelete: "edr_freight_app:locomotives:hard_delete",
|
||||
},
|
||||
wagons: {
|
||||
view: "edr_freight_app:wagons:view",
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||
hardDelete: "edr_freight_app:wagons:hard_delete",
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
@@ -148,6 +152,8 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:routes:create",
|
||||
update: "edr_freight_app:routes:update",
|
||||
delete: "edr_freight_app:routes:delete",
|
||||
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||
hardDelete: "edr_freight_app:routes:hard_delete",
|
||||
},
|
||||
containers: {
|
||||
view: "edr_freight_app:containers:view",
|
||||
@@ -598,6 +604,19 @@ export function canFleetAction(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent-purge check for locomotives and wagons. Unlike
|
||||
* {@link canFleetAction} this does NOT fall back to the coarse fleet:manage
|
||||
* key — an irreversible delete needs its own grant, and the API guards these
|
||||
* endpoints the same way.
|
||||
*/
|
||||
export function canFleetHardDelete(
|
||||
user: AuthUser | null | undefined,
|
||||
resource: "locomotives" | "wagons" | "routes",
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS[resource].hardDelete);
|
||||
}
|
||||
|
||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
@@ -21,20 +21,23 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
ArrowUp,
|
||||
Boxes,
|
||||
Building2,
|
||||
Container,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
ChevronDown,
|
||||
Coins,
|
||||
Hash,
|
||||
ListOrdered,
|
||||
ListPlus,
|
||||
ListTree,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
@@ -58,9 +61,10 @@ import {
|
||||
useUpdateContractTemplate,
|
||||
} from "@/hooks/contract-templates/useContractTemplates";
|
||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||
|
||||
const BODY_HINT =
|
||||
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
|
||||
"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;
|
||||
@@ -105,6 +109,18 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
icon: CalendarRange,
|
||||
hint: "Year the contract is signed",
|
||||
},
|
||||
{
|
||||
token: "{{contractStartDate}}",
|
||||
label: "Start date",
|
||||
icon: CalendarClock,
|
||||
hint: "Date the contract's validity begins",
|
||||
},
|
||||
{
|
||||
token: "{{contractEndDate}}",
|
||||
label: "End date",
|
||||
icon: CalendarClock,
|
||||
hint: "Date the contract's validity ends",
|
||||
},
|
||||
];
|
||||
|
||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||
@@ -170,6 +186,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||
icon: Package,
|
||||
hint: "Description of the cargo",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.cargoTypeName}}",
|
||||
label: "Cargo type",
|
||||
icon: Package,
|
||||
hint: "Named commodity on its own, e.g. Coffee",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.containerType}}",
|
||||
label: "Container type",
|
||||
icon: Container,
|
||||
hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.cargoSummary}}",
|
||||
label: "Cargo summary",
|
||||
icon: Boxes,
|
||||
hint: "Every cargo line, e.g. Coffee (40ft) × 12",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.tradeDirection}}",
|
||||
label: "Trade direction",
|
||||
icon: ArrowLeftRight,
|
||||
hint: "IMPORT / EXPORT / DOMESTIC",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.freightType}}",
|
||||
label: "Freight type",
|
||||
icon: Boxes,
|
||||
hint: "CONTAINER or BULK",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.hazardousLabel}}",
|
||||
label: "Hazardous",
|
||||
icon: AlertTriangle,
|
||||
hint: "Declared hazard class + UN number, or No",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.totalWeightVgm}}",
|
||||
label: "Total weight",
|
||||
@@ -237,6 +289,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||
];
|
||||
|
||||
/**
|
||||
* Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
|
||||
* depth + bullets), which is what the contract renderer numbers and lays out.
|
||||
* Bold/colour/font would be dropped on save, so they are not offered —
|
||||
* an author never loses formatting they were allowed to apply.
|
||||
*/
|
||||
const QUILL_MODULES = {
|
||||
toolbar: [
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
[{ indent: "-1" }, { indent: "+1" }],
|
||||
["clean"],
|
||||
],
|
||||
};
|
||||
|
||||
const QUILL_FORMATS = ["list", "indent"];
|
||||
|
||||
const KNOWN_TOKENS = new Set<string>([
|
||||
...ALL_PLACEHOLDERS.map((p) => p.token),
|
||||
// Still filled by the renderer, just no longer offered as an insert button.
|
||||
@@ -282,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
|
||||
@@ -311,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: [],
|
||||
});
|
||||
@@ -326,31 +437,6 @@ function parseArticleBody(body: string): ParsedBody {
|
||||
return { clauses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the leading outline tokens in a body so every numbered clause line
|
||||
* carries its computed sequential number (stale numbers self-heal). Lines
|
||||
* without a number token and bullet lines pass through untouched.
|
||||
*/
|
||||
function renumberBody(body: string): string {
|
||||
const counters: number[] = [];
|
||||
return body
|
||||
.split("\n")
|
||||
.map((raw) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("- ")) return raw;
|
||||
const match = CLAUSE_NUMBER_RE.exec(line);
|
||||
let depth = matchDepth(match) ?? 1;
|
||||
depth = Math.min(depth, counters.length + 1);
|
||||
counters.splice(depth);
|
||||
while (counters.length < depth) counters.push(0);
|
||||
counters[depth - 1] += 1;
|
||||
if (!match) return raw;
|
||||
const number = counters.slice(0, depth).join(".");
|
||||
return `${number}. ${line.slice(match[0].length).trim()}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||
function HighlightedText({ text }: { text: string }) {
|
||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||
@@ -674,88 +760,46 @@ function ArticleEditorModal({
|
||||
}: ArticleEditorModalProps) {
|
||||
const [title, setTitle] = useState(initial.title);
|
||||
const [body, setBody] = useState(initial.body);
|
||||
// Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
|
||||
// stored body and let onChange convert edits back rather than re-deriving
|
||||
// HTML from `body` on every keystroke (which would fight the caret).
|
||||
const [html, setHtml] = useState(() => bodyToHtml(initial.body));
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const quillRef = useRef<ReactQuill>(null);
|
||||
// Placeholders drop into whichever field held the cursor last (body default).
|
||||
const lastFocused = useRef<"title" | "body">("body");
|
||||
|
||||
const insertAtCursor = (snippet: string) => {
|
||||
const isTitle = lastFocused.current === "title";
|
||||
const el = isTitle ? titleRef.current : bodyRef.current;
|
||||
const value = isTitle ? title : body;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? start;
|
||||
const next = value.slice(0, start) + snippet + value.slice(end);
|
||||
if (isTitle) setTitle(next);
|
||||
else setBody(next);
|
||||
// Refocus and place the caret right after the inserted snippet once the
|
||||
// controlled re-render has flushed.
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + snippet.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
/** Body is the source of truth for saving/preview; HTML is the editor view. */
|
||||
const applyHtml = (nextHtml: string) => {
|
||||
setHtml(nextHtml);
|
||||
setBody(htmlToBody(nextHtml));
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
|
||||
* below the one the caret is on. Clause lines get their outline number typed
|
||||
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
|
||||
* renumbered so the text always matches the preview.
|
||||
*/
|
||||
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
|
||||
const el = bodyRef.current;
|
||||
lastFocused.current = "body";
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
// Structured lines never split a sentence — insert after the caret's line.
|
||||
const lineEnd = body.indexOf("\n", caret);
|
||||
const insertAt = lineEnd === -1 ? body.length : lineEnd;
|
||||
const before = body.slice(0, insertAt);
|
||||
const after = body.slice(insertAt); // "" or starts with "\n"
|
||||
|
||||
let prefix: string;
|
||||
if (kind === "bullet") {
|
||||
prefix = "- ";
|
||||
} else {
|
||||
// New clause always starts a fresh top-level number. Sub-clause nests
|
||||
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
|
||||
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
|
||||
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
|
||||
const above = parseArticleBody(before);
|
||||
const lastDepth = above.paragraph
|
||||
? 1
|
||||
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
|
||||
const depth =
|
||||
kind === "sub"
|
||||
? lastDepth <= 1
|
||||
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
|
||||
: lastDepth
|
||||
: 1;
|
||||
// Digits are placeholders — renumberBody assigns the real value.
|
||||
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
|
||||
const insertAtCursor = (snippet: string) => {
|
||||
if (lastFocused.current === "title") {
|
||||
const el = titleRef.current;
|
||||
const start = el?.selectionStart ?? title.length;
|
||||
const end = el?.selectionEnd ?? start;
|
||||
setTitle(title.slice(0, start) + snippet + title.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + snippet.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
||||
const afterLines =
|
||||
after.length > 0 ? after.slice(1).split("\n") : [];
|
||||
const insertedIdx = beforeLines.length;
|
||||
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
|
||||
const next = kind === "bullet" ? joined : renumberBody(joined);
|
||||
setBody(next);
|
||||
|
||||
// Caret lands at the end of the inserted line, ready for typing.
|
||||
const caretTarget = next
|
||||
.split("\n")
|
||||
.slice(0, insertedIdx + 1)
|
||||
.join("\n").length;
|
||||
requestAnimationFrame(() => {
|
||||
const field = bodyRef.current;
|
||||
if (!field) return;
|
||||
field.focus();
|
||||
field.setSelectionRange(caretTarget, caretTarget);
|
||||
});
|
||||
// Quill tracks its own selection; insert there so the token lands where the
|
||||
// author was typing instead of at the end of the document.
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
const range = editor.getSelection(true);
|
||||
const at = range?.index ?? editor.getLength();
|
||||
editor.deleteText(at, range?.length ?? 0);
|
||||
editor.insertText(at, snippet, "user");
|
||||
editor.setSelection(at + snippet.length, 0);
|
||||
applyHtml(editor.root.innerHTML);
|
||||
};
|
||||
|
||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||
@@ -846,78 +890,24 @@ function ArticleEditorModal({
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Add structure
|
||||
Article body
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Tooltip
|
||||
label="New line with the next clause number typed for you (1., 2., 3., …)"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListOrdered size={13} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertStructuredLine("clause")}
|
||||
>
|
||||
New clause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListTree size={13} />}
|
||||
disabled={body.trim().length === 0}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertStructuredLine("sub")}
|
||||
>
|
||||
Sub-clause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="New line with a bullet (•) under the current clause"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListPlus size={13} />}
|
||||
disabled={body.trim().length === 0}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertStructuredLine("bullet")}
|
||||
>
|
||||
Bullet
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb={6}>
|
||||
{BODY_HINT}
|
||||
</Text>
|
||||
<Box onFocusCapture={() => (lastFocused.current = "body")}>
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={html}
|
||||
onChange={applyHtml}
|
||||
modules={QUILL_MODULES}
|
||||
formats={QUILL_FORMATS}
|
||||
placeholder="Write the article — each paragraph becomes a numbered clause."
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Textarea
|
||||
ref={bodyRef}
|
||||
label="Article body"
|
||||
description={BODY_HINT}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.currentTarget.value)}
|
||||
onFocus={() => (lastFocused.current = "body")}
|
||||
autosize
|
||||
minRows={12}
|
||||
maxRows={22}
|
||||
styles={{
|
||||
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
||||
}}
|
||||
required
|
||||
/>
|
||||
|
||||
{unknown.length > 0 && (
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||
|
||||
describe("article body ↔ Quill HTML", () => {
|
||||
it("round-trips clauses and bullets unchanged", () => {
|
||||
const body = [
|
||||
"Provide written instructions for each shipment.",
|
||||
"Prepare all necessary documents.",
|
||||
"- Commercial invoice",
|
||||
"- Packing list",
|
||||
"Pay 100% in advance.",
|
||||
].join("\n");
|
||||
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||
});
|
||||
|
||||
it("keeps a single paragraph a single paragraph", () => {
|
||||
const body = "The contract is valid once signed by both parties.";
|
||||
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||
});
|
||||
|
||||
it("preserves placeholders verbatim through a round trip", () => {
|
||||
const body = "Valid until August 31, {{contractYear}} for {{client.companyName}}.";
|
||||
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||
});
|
||||
|
||||
it("escapes and restores characters that are HTML-significant", () => {
|
||||
const body = "Rates < 100 & > 50 apply to the Client's cargo.";
|
||||
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||
});
|
||||
|
||||
it("maps Quill indent classes onto sub-clause depth", () => {
|
||||
const html =
|
||||
"<p>Top level clause.</p>" +
|
||||
'<p class="ql-indent-1">Nested one level.</p>' +
|
||||
'<p class="ql-indent-2">Nested two levels.</p>';
|
||||
expect(htmlToBody(html)).toBe(
|
||||
["Top level clause.", "1.1. Nested one level.", "1.1.1. Nested two levels."].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns Quill bullet lists into '- ' lines", () => {
|
||||
const html = "<p>Documents:</p><ul><li>Invoice</li><li>Waybill</li></ul>";
|
||||
expect(htmlToBody(html)).toBe("Documents:\n- Invoice\n- Waybill");
|
||||
});
|
||||
|
||||
it("treats an ordered list as clause lines, not bullets", () => {
|
||||
const html = "<ol><li>First clause.</li><li>Second clause.</li></ol>";
|
||||
expect(htmlToBody(html)).toBe("1. First clause.\n1. Second clause.");
|
||||
});
|
||||
|
||||
it("normalises the nbsp Quill inserts and drops empty blocks", () => {
|
||||
const html = "<p>Payment in advance.</p><p><br></p><p></p>";
|
||||
expect(htmlToBody(html)).toBe("Payment in advance.");
|
||||
});
|
||||
|
||||
it("returns an empty editor for an empty body", () => {
|
||||
expect(bodyToHtml("")).toBe("<p><br></p>");
|
||||
expect(htmlToBody("<p><br></p>")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Bridge between the Quill editor (HTML) and the stored article body, which is
|
||||
* the structural plain text the server parses into numbered clauses
|
||||
* (`parseArticleBody` in contract-article.util.ts): one clause per line, a
|
||||
* leading outline token ("2.", "2.1") for depth, and "- " for bullets.
|
||||
*
|
||||
* Quill owns presentation; the body format owns structure. Converting on the
|
||||
* way in and out keeps the renderer, the server-side renumbering, and the
|
||||
* generated PDF working exactly as before.
|
||||
*/
|
||||
|
||||
const ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(/[&<>]/g, (c) => ESCAPES[c]);
|
||||
|
||||
/**
|
||||
* Body text → Quill HTML. Clauses become `<p>` carrying their outline token so
|
||||
* the author sees the real numbering; bullets become a `<ul>` under the clause
|
||||
* they belong to.
|
||||
*/
|
||||
export function bodyToHtml(body: string): string {
|
||||
const lines = (body ?? "").split("\n").filter((l) => l.trim().length > 0);
|
||||
if (lines.length === 0) return "<p><br></p>";
|
||||
|
||||
const out: string[] = [];
|
||||
let inList = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("- ")) {
|
||||
if (!inList) {
|
||||
out.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
out.push(`<li>${escapeHtml(trimmed.slice(2).trim())}</li>`);
|
||||
continue;
|
||||
}
|
||||
if (inList) {
|
||||
out.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
out.push(`<p>${escapeHtml(trimmed)}</p>`);
|
||||
}
|
||||
if (inList) out.push("</ul>");
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quill HTML → body text. `<li>` inside a `<ul>` becomes a "- " bullet; every
|
||||
* other block becomes its own line. Quill's indent classes
|
||||
* (`ql-indent-1`, …) map onto sub-clause depth, so indenting in the toolbar
|
||||
* produces "1.1"-style nesting — the exact digits are placeholders, the server
|
||||
* renumbers them.
|
||||
*
|
||||
* Runs through DOMParser rather than regex: the input is real HTML from a
|
||||
* contenteditable, and entity handling (&, ) has to be right or the
|
||||
* text lands in the PDF mangled.
|
||||
*/
|
||||
export function htmlToBody(html: string): string {
|
||||
if (!html) return "";
|
||||
const doc = new DOMParser().parseFromString(
|
||||
`<div id="root">${html}</div>`,
|
||||
"text/html",
|
||||
);
|
||||
const root = doc.getElementById("root");
|
||||
if (!root) return "";
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
const textOf = (el: Element): string =>
|
||||
// is the nbsp Quill inserts for trailing spaces — plain space in the
|
||||
// stored body, otherwise it survives into the contract text.
|
||||
(el.textContent ?? "").replace(/ /g, " ").trim();
|
||||
|
||||
const indentOf = (el: Element): number => {
|
||||
const match = /ql-indent-(\d+)/.exec(el.className ?? "");
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
|
||||
const walk = (node: Element, insideList: boolean) => {
|
||||
for (const child of Array.from(node.children)) {
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
// <ol> is authored numbering; the body format numbers clauses itself,
|
||||
// so an ordered list is clause lines, not bullets.
|
||||
walk(child, tag === "ul");
|
||||
continue;
|
||||
}
|
||||
if (tag === "li") {
|
||||
const text = textOf(child);
|
||||
if (!text) continue;
|
||||
if (insideList) {
|
||||
lines.push(`- ${text}`);
|
||||
} else {
|
||||
const depth = indentOf(child) + 1;
|
||||
lines.push(`${Array.from({ length: depth }, () => "1").join(".")}. ${text}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tag === "p" || tag === "div") {
|
||||
const text = textOf(child);
|
||||
if (text) {
|
||||
const depth = indentOf(child);
|
||||
lines.push(
|
||||
depth > 0
|
||||
? `${Array.from({ length: depth + 1 }, () => "1").join(".")}. ${text}`
|
||||
: text,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Anything else (blockquote, heading, stray span): keep its text on a
|
||||
// line rather than dropping the author's words.
|
||||
const text = textOf(child);
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, false);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -222,10 +222,10 @@ const RuleEngineResourcePage = () => {
|
||||
// instead of mutating: the rate keeps its current value until an approver
|
||||
// applies the change. DRAFT rates still edit directly.
|
||||
const isRates = config?.slug === "rates";
|
||||
const [rateError, setRateError] = useState<string | null>(null);
|
||||
// No error modal here: the workflow falls back to a toast when no handler is
|
||||
// passed, which keeps failures visible without a dialog to dismiss.
|
||||
const rateChangeWorkflow = useRateChangeWorkflow(
|
||||
Boolean(isRates && canView),
|
||||
setRateError,
|
||||
);
|
||||
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
||||
/** rateId → its pending change, for the row badge. */
|
||||
@@ -265,8 +265,10 @@ const RuleEngineResourcePage = () => {
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
||||
useCargoLeafOptions(usesCargoTypeField);
|
||||
// No "None" on rates: a rate's container scope is either a real type or the
|
||||
// field is hidden entirely, so offering None only invites an unscoped rate.
|
||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
useContainerTypeOptions(false, usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
@@ -695,22 +697,6 @@ const RuleEngineResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={rateError != null}
|
||||
onClose={() => setRateError(null)}
|
||||
title="Cannot save rate change"
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="red">
|
||||
{rateError}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={() => setRateError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={priorityError != null}
|
||||
onClose={() => setPriorityError(null)}
|
||||
|
||||
@@ -24,18 +24,11 @@ function feedLabel(source: ExchangeRateSource | null): {
|
||||
switch (source) {
|
||||
case "live":
|
||||
return { live: true, text: "CBE reachable — using the live rate" };
|
||||
case "cache":
|
||||
return { live: true, text: "Using the rate cached from CBE" };
|
||||
case "stored":
|
||||
return {
|
||||
live: false,
|
||||
text: "CBE unreachable — using the fallback rate below",
|
||||
};
|
||||
case "default":
|
||||
return {
|
||||
live: false,
|
||||
text: "CBE unreachable and no rate stored — using the built-in default",
|
||||
};
|
||||
default:
|
||||
return { live: true, text: "No rate requested yet since the last restart" };
|
||||
}
|
||||
|
||||
@@ -1605,6 +1605,15 @@ export const api = {
|
||||
undefined,
|
||||
() => [["routes"]],
|
||||
),
|
||||
|
||||
/** Irreversible purge — refused while any train schedule uses the route. */
|
||||
purge: endpoint<string, void>(
|
||||
"routes",
|
||||
"purge",
|
||||
(id) => routesService.purge(id).then(() => undefined),
|
||||
undefined,
|
||||
() => [["routes"]],
|
||||
),
|
||||
},
|
||||
|
||||
stations: {
|
||||
@@ -2194,6 +2203,15 @@ export const api = {
|
||||
undefined,
|
||||
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||
),
|
||||
|
||||
/** Irreversible purge — locomotives and wagons only. */
|
||||
purge: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>(
|
||||
"fleet",
|
||||
"purge",
|
||||
({ slug, id }) => fleetService.purge(slug, id),
|
||||
undefined,
|
||||
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||
),
|
||||
},
|
||||
|
||||
truckTypes: {
|
||||
|
||||
@@ -5,8 +5,11 @@ import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||
|
||||
/** Where the rate the API last served came from. */
|
||||
export type ExchangeRateSource = "live" | "cache" | "stored" | "default";
|
||||
/**
|
||||
* Where the rate the API last served came from. `live` means CBE answered;
|
||||
* `stored` means it is failing and the fallback is in use.
|
||||
*/
|
||||
export type ExchangeRateSource = "live" | "stored";
|
||||
|
||||
/** Health of the CBE exchange-rate feed. */
|
||||
export interface ExchangeFeedStatus {
|
||||
|
||||
@@ -77,6 +77,21 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
||||
drivers: (id) => driversService.delete(id),
|
||||
};
|
||||
|
||||
/**
|
||||
* Permanent purge, only for the two slugs that expose it. Everything else stays
|
||||
* soft-delete/decommission only, so there is deliberately no entry here.
|
||||
*/
|
||||
const purgeHandlers: Partial<
|
||||
Record<FleetResourceSlug, (id: string) => Promise<unknown>>
|
||||
> = {
|
||||
locomotives: (id) => locomotivesService.purge(id),
|
||||
wagons: (id) => wagonService.purge(id),
|
||||
};
|
||||
|
||||
/** True when the slug supports an irreversible purge. */
|
||||
export const isFleetPurgeable = (slug: FleetResourceSlug) =>
|
||||
slug in purgeHandlers;
|
||||
|
||||
export const fleetService = {
|
||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
|
||||
@@ -89,4 +104,11 @@ export const fleetService = {
|
||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||
updateHandlers[slug](id, data),
|
||||
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
|
||||
purge: (slug: FleetResourceSlug, id: string) => {
|
||||
const handler = purgeHandlers[slug];
|
||||
if (!handler) {
|
||||
throw new Error(`Fleet resource "${slug}" cannot be permanently deleted`);
|
||||
}
|
||||
return handler(id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -85,4 +85,7 @@ export const locomotivesService = {
|
||||
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||
/** Irreversible purge — the API refuses it while any train references the loco. */
|
||||
purge: (id: string) =>
|
||||
apiClient.delete(`${URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)}/permanent`),
|
||||
};
|
||||
|
||||
@@ -101,6 +101,9 @@ export const routesService = {
|
||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
/** Irreversible purge — the API refuses it while any train schedule uses the route. */
|
||||
purge: (id: string) =>
|
||||
apiClient.delete(`${URL_CONSTANTS.ROUTES.BY_ID(id)}/permanent`),
|
||||
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
|
||||
getYards: async (): Promise<YardRef[]> => {
|
||||
const rows = await ruleEngineService.listAll("yards", { isActive: true });
|
||||
|
||||
@@ -123,6 +123,8 @@ export const wagonService = {
|
||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||
/** Irreversible purge — the API refuses it when the wagon has any history. */
|
||||
purge: (id: string) => apiClient.delete(`/wagons/${id}/permanent`),
|
||||
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
||||
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
||||
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
||||
|
||||
Reference in New Issue
Block a user