mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
DragDropContext,
|
|
Draggable,
|
|
Droppable,
|
|
type DraggableProvided,
|
|
type DraggableStateSnapshot,
|
|
type DropResult,
|
|
} from "@hello-pangea/dnd";
|
|
import { GripVertical, Loader2 } from "lucide-react";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
|
|
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
|
|
|
import { getOrderItemLabel, getOrderValue } from "./ruleEngineOrder.utils";
|
|
|
|
interface OrderDraftItem {
|
|
id: string;
|
|
label: string;
|
|
code?: string;
|
|
order: number;
|
|
}
|
|
|
|
export interface ManageRuleEngineOrderDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
config: RuleEngineResourceConfig;
|
|
items: RuleEngineRecord[];
|
|
isLoading: boolean;
|
|
isSaving: boolean;
|
|
onSave: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) => void;
|
|
}
|
|
|
|
const toDraftItems = (
|
|
rows: RuleEngineRecord[],
|
|
config: RuleEngineResourceConfig,
|
|
): OrderDraftItem[] => {
|
|
const field = config.orderConfig!.field;
|
|
return [...rows]
|
|
.sort((a, b) => getOrderValue(a, field) - getOrderValue(b, field))
|
|
.map((row) => ({
|
|
id: String(row.id),
|
|
label: getOrderItemLabel(row, config.slug),
|
|
code: row.code ? String(row.code) : undefined,
|
|
order: getOrderValue(row, field),
|
|
}));
|
|
};
|
|
|
|
/** Reparent dragged row to body — fixes position:fixed inside Modal transforms. */
|
|
const PortalAwareRow = ({
|
|
snapshot,
|
|
children,
|
|
}: {
|
|
snapshot: DraggableStateSnapshot;
|
|
children: ReactNode;
|
|
}) => {
|
|
if (snapshot.isDragging) {
|
|
return createPortal(children, document.body);
|
|
}
|
|
return <>{children}</>;
|
|
};
|
|
|
|
const OrderRow = ({
|
|
item,
|
|
index,
|
|
dragProvided,
|
|
snapshot,
|
|
}: {
|
|
item: OrderDraftItem;
|
|
index: number;
|
|
dragProvided: DraggableProvided;
|
|
snapshot: DraggableStateSnapshot;
|
|
}) => (
|
|
<PortalAwareRow snapshot={snapshot}>
|
|
<Group
|
|
ref={dragProvided.innerRef}
|
|
{...dragProvided.draggableProps}
|
|
{...dragProvided.dragHandleProps}
|
|
gap="sm"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{
|
|
...dragProvided.draggableProps.style,
|
|
border: "1px solid var(--mantine-color-gray-3)",
|
|
borderRadius: "var(--mantine-radius-md)",
|
|
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
|
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
|
cursor: snapshot.isDragging ? "grabbing" : "grab",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
|
<GripVertical size={18} />
|
|
</Box>
|
|
<Badge variant="light" color="gray" size="sm">
|
|
{index + 1}
|
|
</Badge>
|
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
|
<Text size="sm" fw={500} truncate>
|
|
{item.label}
|
|
</Text>
|
|
{item.code ? (
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{item.code}
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
</Group>
|
|
</PortalAwareRow>
|
|
);
|
|
|
|
const ManageRuleEngineOrderDialog = ({
|
|
open,
|
|
onOpenChange,
|
|
config,
|
|
items,
|
|
isLoading,
|
|
isSaving,
|
|
onSave,
|
|
}: ManageRuleEngineOrderDialogProps) => {
|
|
const isScoped = config.orderConfig?.scopeField === "requiresDirectorApproval";
|
|
const [tab, setTab] = useState<"standard" | "director">("standard");
|
|
const [filter, setFilter] = useState("");
|
|
const [standardItems, setStandardItems] = useState<OrderDraftItem[]>([]);
|
|
const [directorItems, setDirectorItems] = useState<OrderDraftItem[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (isScoped) {
|
|
setStandardItems(
|
|
toDraftItems(
|
|
items.filter((row) => !row.requiresDirectorApproval),
|
|
config,
|
|
),
|
|
);
|
|
setDirectorItems(
|
|
toDraftItems(
|
|
items.filter((row) => row.requiresDirectorApproval),
|
|
config,
|
|
),
|
|
);
|
|
} else {
|
|
setStandardItems(toDraftItems(items, config));
|
|
}
|
|
setFilter("");
|
|
}, [open, items, config, isScoped]);
|
|
|
|
const activeItems = isScoped
|
|
? tab === "director"
|
|
? directorItems
|
|
: standardItems
|
|
: standardItems;
|
|
|
|
const setActiveItems = isScoped
|
|
? tab === "director"
|
|
? setDirectorItems
|
|
: setStandardItems
|
|
: setStandardItems;
|
|
|
|
const filteredItems = useMemo(() => {
|
|
const q = filter.trim().toLowerCase();
|
|
if (!q) return activeItems;
|
|
return activeItems.filter(
|
|
(item) =>
|
|
item.label.toLowerCase().includes(q) ||
|
|
(item.code?.toLowerCase().includes(q) ?? false),
|
|
);
|
|
}, [activeItems, filter]);
|
|
|
|
const droppableId = isScoped
|
|
? `rule-engine-order-${tab}`
|
|
: "rule-engine-order-list";
|
|
|
|
const onDragEnd = (result: DropResult) => {
|
|
if (!result.destination || filter.trim()) return;
|
|
const sourceIndex = result.source.index;
|
|
const destIndex = result.destination.index;
|
|
if (sourceIndex === destIndex) return;
|
|
|
|
setActiveItems((prev) => {
|
|
const next = [...prev];
|
|
const [removed] = next.splice(sourceIndex, 1);
|
|
next.splice(destIndex, 0, removed!);
|
|
return next.map((item, index) => ({ ...item, order: index + 1 }));
|
|
});
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (isScoped) {
|
|
onSave({
|
|
ids: (tab === "director" ? directorItems : standardItems).map((item) => item.id),
|
|
requiresDirectorApproval: tab === "director",
|
|
});
|
|
return;
|
|
}
|
|
onSave({ ids: standardItems.map((item) => item.id) });
|
|
};
|
|
|
|
const renderList = (listItems: OrderDraftItem[]) => (
|
|
<Droppable droppableId={droppableId}>
|
|
{(provided) => (
|
|
<Stack
|
|
gap="xs"
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
style={{ minHeight: 120 }}
|
|
>
|
|
{listItems.length === 0 ? (
|
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
|
No items to reorder.
|
|
</Text>
|
|
) : (
|
|
listItems.map((item, index) => (
|
|
<Draggable
|
|
key={item.id}
|
|
draggableId={item.id}
|
|
index={index}
|
|
isDragDisabled={Boolean(filter.trim())}
|
|
>
|
|
{(dragProvided, snapshot) => (
|
|
<OrderRow
|
|
item={item}
|
|
index={index}
|
|
dragProvided={dragProvided}
|
|
snapshot={snapshot}
|
|
/>
|
|
)}
|
|
</Draggable>
|
|
))
|
|
)}
|
|
{provided.placeholder}
|
|
</Stack>
|
|
)}
|
|
</Droppable>
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
opened={open}
|
|
onClose={() => onOpenChange(false)}
|
|
title={`Manage order · ${config.label}`}
|
|
centered
|
|
size="lg"
|
|
radius="lg"
|
|
transitionProps={{ duration: 0, transition: "fade" }}
|
|
styles={{
|
|
content: {
|
|
transform: "none",
|
|
overflow: "visible",
|
|
},
|
|
body: {
|
|
overflow: "visible",
|
|
},
|
|
}}
|
|
>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
Drag items anywhere in the list to set display order. Changes apply when you save.
|
|
</Text>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="xl">
|
|
<Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
|
|
</Group>
|
|
) : isScoped ? (
|
|
<Tabs
|
|
value={tab}
|
|
onChange={(value) => setTab((value as "standard" | "director") ?? "standard")}
|
|
>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="standard">Standard chain ({standardItems.length})</Tabs.Tab>
|
|
<Tabs.Tab value="director">Director chain ({directorItems.length})</Tabs.Tab>
|
|
</Tabs.List>
|
|
<Tabs.Panel value="standard" pt="md">
|
|
<Stack gap="md">
|
|
<TextInput
|
|
placeholder="Filter items…"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
|
/>
|
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
|
{renderList(filteredItems)}
|
|
</Box>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
<Tabs.Panel value="director" pt="md">
|
|
<Stack gap="md">
|
|
<TextInput
|
|
placeholder="Filter items…"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
|
/>
|
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
|
{renderList(filteredItems)}
|
|
</Box>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
) : (
|
|
<>
|
|
<TextInput
|
|
placeholder="Filter items…"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
|
/>
|
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
|
{renderList(filteredItems)}
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{filter.trim() ? (
|
|
<Text size="xs" c="dimmed">
|
|
Clear the filter to drag and reorder items.
|
|
</Text>
|
|
) : null}
|
|
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
onClick={handleSave}
|
|
disabled={isLoading || isSaving}
|
|
leftSection={
|
|
isSaving ? (
|
|
<Loader2 size={16} style={{ animation: "spin 1s linear infinite" }} />
|
|
) : undefined
|
|
}
|
|
>
|
|
{isSaving ? "Saving…" : "Save order"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</DragDropContext>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default ManageRuleEngineOrderDialog;
|