Files
edr-platform/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx
2026-06-22 08:11:11 +00:00

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;