mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
changes
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
import type { PriorityRuleChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
|
||||
const ACTION_COLOR: Record<PriorityRuleChangeRequest["action"], string> = {
|
||||
CREATE: "teal",
|
||||
UPDATE: "blue",
|
||||
DELETE: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** "WAGON 1–5 · 30 pts" from a change payload / target rule. */
|
||||
const ruleSummary = (
|
||||
r: PriorityRuleChangeRequest,
|
||||
): string => {
|
||||
const source = (r.payload ?? r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||
const base = (r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||
const pick = (key: string) => source[key] ?? base[key];
|
||||
const type = pick("type");
|
||||
const currency = pick("currency");
|
||||
const min = pick("minWagonCount");
|
||||
const max = pick("maxWagonCount");
|
||||
const pts = pick("scorePoints");
|
||||
const parts = [
|
||||
type ? String(type) : null,
|
||||
currency ? String(currency) : null,
|
||||
min != null && max != null ? `${min}–${max} wagons` : null,
|
||||
pts != null ? `${pts} pts` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
PriorityRuleChangeRequest,
|
||||
unknown,
|
||||
{ id: string; decisionNote?: string }
|
||||
>;
|
||||
|
||||
interface PriorityRuleApprovalsSectionProps {
|
||||
requests: PriorityRuleChangeRequest[];
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending priority-rule change requests awaiting approval. Rendered above the
|
||||
* rules table on the priority-configs page; every rule change lands here first
|
||||
* and only an approval applies it.
|
||||
*/
|
||||
const PriorityRuleApprovalsSection = ({
|
||||
requests,
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
}: PriorityRuleApprovalsSectionProps) => {
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" mb="md">
|
||||
<Group gap={8} mb="sm">
|
||||
<Text fw={700}>Pending approvals</Text>
|
||||
<Badge variant="light" color="yellow">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={ACTION_COLOR[r.action]} radius="sm">
|
||||
{r.action.toLowerCase()}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{ruleSummary(r)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
{canDecide ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={14} />}
|
||||
loading={reject.isPending}
|
||||
onClick={() => reject.mutate({ id: r.id })}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: r.id })}
|
||||
>
|
||||
Approve & apply
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityRuleApprovalsSection;
|
||||
@@ -18,6 +18,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
usePriorityRuleWorkflow,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
);
|
||||
|
||||
// Priority rules never mutate directly: changes are filed for approval and a
|
||||
// pending queue renders above the table.
|
||||
const isPriorityRules = config?.slug === "priority-configs";
|
||||
const priorityWorkflow = usePriorityRuleWorkflow(
|
||||
Boolean(isPriorityRules && canView),
|
||||
);
|
||||
|
||||
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||
const usesContainerTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "containerTypeId"),
|
||||
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
|
||||
currency: "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
};
|
||||
} else if (config.slug === "priority-configs") {
|
||||
} else if (isPriorityRules) {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
// Approval workflow: file a change request instead of mutating directly.
|
||||
// On update, keep the target's existing label rather than a fresh stamp.
|
||||
if (editing?.id) {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{
|
||||
action: "UPDATE",
|
||||
priorityConfigId: String(editing.id),
|
||||
update: { ...values, label: String(editing.label ?? Date.now()) },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{ action: "CREATE", create: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (config.slug === "weight-limit-rules") {
|
||||
// Empty max capacity means "no ceiling" — send null explicitly so an
|
||||
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
||||
@@ -406,6 +443,15 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{isPriorityRules ? (
|
||||
<PriorityRuleApprovalsSection
|
||||
requests={priorityWorkflow.pending.data ?? []}
|
||||
canDecide={canManage}
|
||||
approve={priorityWorkflow.approve}
|
||||
reject={priorityWorkflow.reject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
fields={formFields}
|
||||
initialRecord={editing}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
isSubmitting={
|
||||
create.isPending || update.isPending || priorityWorkflow.submit.isPending
|
||||
}
|
||||
selectOptionsLoading={
|
||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will soft-delete the selected {config.label.toLowerCase()}{" "}
|
||||
record.
|
||||
{isPriorityRules
|
||||
? "This files a delete request for approval — the rule is removed once an approver confirms."
|
||||
: `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
loading={remove.isPending || priorityWorkflow.submit.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
if (isPriorityRules) {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{
|
||||
action: "DELETE",
|
||||
priorityConfigId: String(deleteTarget.id),
|
||||
},
|
||||
{ onSuccess: () => setDeleteTarget(null) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
remove.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{isPriorityRules ? "Request delete" : "Delete"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user