change price logic on the ,rule engine ui, auto generate the contrat

This commit is contained in:
marshal
2026-06-08 10:04:23 +03:00
parent 4993453993
commit 88df20be6b
76 changed files with 4907 additions and 225 deletions

View File

@@ -0,0 +1,354 @@
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="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;

View File

@@ -1,8 +1,10 @@
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import RuleEngineListFooter from "./RuleEngineListFooter";
import RuleEngineRecordActions from "./RuleEngineRecordActions";
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
import { formatCell } from "./ruleEngineFormat";
@@ -13,12 +15,10 @@ export interface RuleEngineCardGridProps {
status: "loading" | "error" | "success";
emptyMessage: string;
itemLabel: string;
pagination: {
pageIndex: number;
pageSize: number;
pageCount: number;
totalCount: number;
};
pagination: PaginationState;
pageCount: number;
totalCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
onEdit?: (record: RuleEngineRecord) => void;
onDelete?: (record: RuleEngineRecord) => void;
readOnly?: boolean;
@@ -71,6 +71,9 @@ const RuleEngineCardGrid = ({
emptyMessage,
itemLabel,
pagination,
pageCount,
totalCount,
onPaginationChange,
onEdit,
onDelete,
onViewChain,
@@ -249,19 +252,13 @@ const RuleEngineCardGrid = ({
})}
</SimpleGrid>
{pagination.pageCount > 1 && (
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Text size="sm" c="dimmed">
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
</Text>
<Pagination
value={pagination.pageIndex + 1}
total={pagination.pageCount}
size="sm"
radius="md"
/>
</Group>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel={itemLabel}
onPaginationChange={onPaginationChange}
/>
</Stack>
);
};

View File

@@ -20,6 +20,7 @@ import {
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
export interface RuleEngineFormDialogProps {
open: boolean;
@@ -30,6 +31,8 @@ export interface RuleEngineFormDialogProps {
initialRecord?: RuleEngineRecord | null;
isSubmitting: boolean;
selectOptionsLoading?: boolean;
positionOptions?: { label: string; value: string }[];
positionLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
}
@@ -146,15 +149,19 @@ const RuleEngineFormDialog = ({
initialRecord,
isSubmitting,
selectOptionsLoading = false,
positionOptions,
positionLoading = false,
onSubmit,
}: RuleEngineFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>(() =>
buildInitialValues(fields, initialRecord),
);
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, initialRecord));
setPosition(RULE_ENGINE_POSITION_END);
}
}, [open, fields, initialRecord]);
@@ -192,6 +199,10 @@ const RuleEngineFormDialog = ({
payload.code = String(payload.code).toUpperCase();
}
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
payload.insertAfterId = position;
}
onSubmit(payload);
};
@@ -315,6 +326,23 @@ const RuleEngineFormDialog = ({
<Stack gap="lg">
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
<Stack gap="md">
{!initialRecord && positionOptions ? (
<Select
label="Position"
description="New items are appended to the end by default."
value={position}
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
data={[
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
...positionOptions,
]}
searchable
disabled={positionLoading}
size="md"
radius="md"
styles={inputStyles}
/>
) : null}
{formRows.map((row) =>
row.kind === "pair" ? (
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">

View File

@@ -0,0 +1,73 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Group, Pagination, Select, Text } from "@mantine/core";
export interface RuleEngineListFooterProps {
pagination: PaginationState;
pageCount: number;
totalCount: number;
itemLabel: string;
onPaginationChange: OnChangeFn<PaginationState>;
}
const PAGE_SIZE_OPTIONS = ["5", "10", "25", "50"];
const RuleEngineListFooter = ({
pagination,
pageCount,
totalCount,
itemLabel,
onPaginationChange,
}: RuleEngineListFooterProps) => {
const { pageIndex, pageSize } = pagination;
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
const setPageIndex = (nextIndex: number) => {
onPaginationChange({ pageIndex: nextIndex, pageSize });
};
const setPageSize = (nextSize: number) => {
onPaginationChange({ pageIndex: 0, pageSize: nextSize });
};
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
p="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Group gap="md" align="center">
<Group gap="xs" align="center">
<Text size="sm" c="dimmed">
Rows per page
</Text>
<Select
value={String(pageSize)}
onChange={(value) => value && setPageSize(Number(value))}
data={PAGE_SIZE_OPTIONS}
size="xs"
w={70}
allowDeselect={false}
/>
</Group>
<Text size="sm" c="dimmed">
Showing {start}{end} of {totalCount} {itemLabel}
</Text>
</Group>
{pageCount > 1 && (
<Pagination
value={pageIndex + 1}
total={pageCount}
size="sm"
radius="md"
onChange={(page) => setPageIndex(page - 1)}
/>
)}
</Group>
);
};
export default RuleEngineListFooter;

View File

@@ -0,0 +1,60 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { RuleEngineOrderConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import { getOrderValue } from "./ruleEngineOrder.utils";
export interface RuleEngineOrderControlsProps {
record: RuleEngineRecord;
orderConfig: RuleEngineOrderConfig;
totalCount: number;
disabled?: boolean;
onMove: (id: string, direction: "up" | "down") => void;
}
const RuleEngineOrderControls = ({
record,
orderConfig,
totalCount,
disabled = false,
onMove,
}: RuleEngineOrderControlsProps) => {
const id = String(record.id);
const order = getOrderValue(record, orderConfig.field);
const canMoveUp = order > 1;
const canMoveDown = orderConfig.scopeField ? true : order < totalCount;
return (
<Group gap={4} wrap="nowrap">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
disabled={disabled || !canMoveUp}
onClick={() => onMove(id, "up")}
aria-label="Move up"
>
<ChevronUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
disabled={disabled || !canMoveDown}
onClick={() => onMove(id, "down")}
aria-label="Move down"
>
<ChevronDown size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
};
export default RuleEngineOrderControls;

View File

@@ -1,42 +1,50 @@
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { LayoutGrid, ListOrdered, Plus, Search, Table2 } from "lucide-react";
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
export interface RuleEngineToolbarProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder: string;
search?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
showSearch?: boolean;
onAdd?: () => void;
addLabel?: string;
onManageOrder?: () => void;
viewMode: RuleEngineViewMode;
onViewModeChange: (mode: RuleEngineViewMode) => void;
}
const RuleEngineToolbar = ({
search,
search = "",
onSearchChange,
searchPlaceholder,
searchPlaceholder = "Search…",
showSearch = true,
onAdd,
addLabel = "Add",
onManageOrder,
viewMode,
onViewModeChange,
}: RuleEngineToolbarProps) => (
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={18} />}
size="md"
radius="lg"
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
},
}}
/>
{showSearch && onSearchChange ? (
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={18} />}
size="md"
radius="lg"
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
},
}}
/>
) : (
<div style={{ flex: 1 }} />
)}
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
<SegmentedControl
@@ -72,6 +80,21 @@ const RuleEngineToolbar = ({
}}
/>
{onManageOrder ? (
<Button
onClick={onManageOrder}
leftSection={<ListOrdered size={18} />}
size="sm"
radius="lg"
variant="light"
color="gray"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
Manage order
</Button>
) : null}
{onAdd ? (
<Button
onClick={onAdd}

View File

@@ -0,0 +1,32 @@
import type { RuleEngineRecord, RuleEngineResourceSlug } from "@/types/rule-engine";
export const RULE_ENGINE_POSITION_END = "__end__";
export function getOrderItemLabel(
record: RuleEngineRecord,
slug: RuleEngineResourceSlug,
): string {
const code = String(record.code ?? "").trim();
switch (slug) {
case "cargo-types":
return String(record.cargoTypeName ?? (code || record.id));
case "container-types":
case "yards":
case "shipping-lines":
return String(record.label ?? (code || record.id));
case "service-types":
return String(record.serviceName ?? (code || record.id));
case "approval-rules":
return String(record.actionLabel ?? record.requiredRole ?? record.id);
default:
return String(record.label ?? record.code ?? record.id);
}
}
export function getOrderValue(
record: RuleEngineRecord,
field: "displayOrder" | "stepOrder",
): number {
const raw = record[field];
return typeof raw === "number" ? raw : Number(raw ?? 0);
}