From d7d9db9c3a82103fed228b9b12b06ef316d09837 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 14:27:00 +0000 Subject: [PATCH] changes --- .../priority-configs.controller.ts | 18 ++ .../services/priority-configs.range.spec.ts | 229 ++++++++++++++++++ .../services/priority-configs.service.ts | 100 +++++++- .../contracts/ContractActionsToolbar.tsx | 6 +- .../contracts/ContractApprovalStepsCard.tsx | 54 ++++- .../ruleEngine/RuleEngineFormDialog.tsx | 9 +- .../src/hooks/rule-engine/useRuleEngine.ts | 25 +- .../ruleEngine/RuleEngineResourcePage.tsx | 54 ++++- .../src/pages/ruleEngine/config/resources.ts | 24 +- .../src/pages/ruleEngine/priorityRuleRange.ts | 60 +++++ 10 files changed, 554 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 99eaabf3f..6424f013e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; @@ -24,6 +25,23 @@ export class PriorityConfigsController { return this.service.findAll(query); } + // Static route — must stay above `:id` (Express matches in declaration order). + @Get('next-range') + @RuleEngineView('priority-configs') + @ApiOperation({ + summary: + "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + }) + nextRange( + @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + @Query('currency') currency?: string, + ) { + if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) { + throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS'); + } + return this.service.nextRange(type, currency ?? null); + } + @Get(':id') @RuleEngineView('priority-configs') @ApiOperation({ summary: 'Get a priority config by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts new file mode 100644 index 000000000..53cb7ed83 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -0,0 +1,229 @@ +import { BadRequestException } from '@nestjs/common'; + +import { PriorityConfig } from '../entities/priority-config.entity'; +import { PriorityConfigsService } from './priority-configs.service'; + +/** + * Contiguous-range rules for priority configs: per type (per currency for + * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. Caps: WAGON 50, + * CURRENCY 35, CUSTOMS 15. + */ +describe('PriorityConfigsService range validation', () => { + const rule = ( + type: PriorityConfig['type'], + min: number, + max: number, + currency: string | null = null, + id = `${type}-${min}-${max}-${currency ?? 'none'}`, + ): PriorityConfig => + ({ + id, + type, + label: `${min}-${max}`, + currency, + minWagonCount: min, + maxWagonCount: max, + }) as PriorityConfig; + + const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => { + const repository = { + findAll: jest.fn(async ({ where }: { where: { type: string } }) => + rules.filter((r) => r.type === where.type), + ), + findById: jest.fn(async (id: string) => + rules.find((r) => r.id === id) ?? null, + ), + }; + return new PriorityConfigsService( + repository as never, + undefined as never, // DisplayOrderService — unused by range validation + ); + }; + + const attempt = ( + svc: PriorityConfigsService, + input: Partial[0]>, + ) => + svc.assertNoRangeCollision({ + type: 'WAGON', + minWagonCount: 1, + maxWagonCount: 5, + ...input, + }); + + it('accepts the first WAGON range starting at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a first range that does not start at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects an exact duplicate (1–5 vs 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a partial overlap (4–7 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 4, + maxWagonCount: 7, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 8, + maxWagonCount: 9, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('accepts the contiguous continuation (6–10 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 6, + maxWagonCount: 10, + }), + ).resolves.toBeUndefined(); + }); + + it('after deleting a middle rule, the next range must fill the lowest gap', async () => { + // Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6. + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }), + ).rejects.toThrow(/must start at 6/); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }), + ).rejects.toThrow(/overlaps existing rule/); + }); + + it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([]), { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 36, + }), + ).rejects.toThrow(/may not exceed 35/); + await expect( + attempt(serviceWith([]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 16, + }), + ).rejects.toThrow(/may not exceed 15/); + }); + + it('rejects any new rule once the chain covers the full range', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 50)]), { + minWagonCount: 51, + maxWagonCount: 51, + }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 1, + }), + ).rejects.toThrow(/already cover the full 1–15 range/); + }); + + it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { + const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]); + // ETB has no rules yet → starts at 1. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'ETB', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).resolves.toBeUndefined(); + // USD must continue at 6. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('excludes the rule being edited from its own contiguity check', async () => { + const existing = rule('WAGON', 6, 10, null, 'editing-me'); + const svc = serviceWith([rule('WAGON', 1, 5), existing]); + // Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed. + await expect( + attempt(svc, { + minWagonCount: 6, + maxWagonCount: 12, + excludeId: 'editing-me', + }), + ).resolves.toBeUndefined(); + }); + + it('lets an upper rule keep its start while a lower gap exists', async () => { + // Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap + // must not block editing an upper rule's points or max. + const upper = rule('WAGON', 11, 20, null, 'upper'); + const svc = serviceWith([rule('WAGON', 1, 5), upper]); + await expect( + attempt(svc, { + minWagonCount: 11, + maxWagonCount: 25, + excludeId: 'upper', + }), + ).resolves.toBeUndefined(); + // But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6. + await expect( + attempt(svc, { + minWagonCount: 30, + maxWagonCount: 35, + excludeId: 'upper', + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('reports the next-range prefill for the form', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ + nextMin: 6, + maxCap: 50, + }); + await expect( + serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), + ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ + nextMin: 1, + maxCap: 35, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 9aaf06985..ff3711e42 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,6 +10,31 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; +/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ +export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +/** + * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range + * must start. Null when the chain is already complete up to the type's cap. + */ +function nextRangeStart( + rules: Pick[], +): number | null { + const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; + const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); + let next = 1; + for (const r of sorted) { + if (r.minWagonCount > next) break; // gap before this rule — fill it + next = Math.max(next, r.maxWagonCount + 1); + } + if (cap != null && next > cap) return null; + return next; +} + @Injectable() export class PriorityConfigsService { constructor( @@ -73,10 +98,13 @@ export class PriorityConfigsService { } /** - * No two rules of the same type (and, for CURRENCY rules, the same currency) - * may cover overlapping wagon-count ranges — a booking must match at most one - * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial - * overlap (1–5 vs 4–7). Ranges are inclusive on both ends. + * Range rules per type (and, for CURRENCY rules, per currency): + * - ranges never overlap — a booking matches at most one rule per type; + * - ranges are contiguous from 1: a new range must START at the lowest + * wagon count not yet covered (after 1–5 the next is 6–…; deleting a + * middle rule opens a gap and the next create must fill it first); + * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { type: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @@ -90,13 +118,49 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const siblings = await this.repository.findAll({ - where: { type: input.type }, - }); - const clash = siblings.find( + const cap = RANGE_CAPS[input.type]; + if (input.maxWagonCount > cap) { + throw new BadRequestException( + `${input.type} ranges may not exceed ${cap} — ` + + `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, + ); + } + + const siblings = ( + await this.repository.findAll({ where: { type: input.type } }) + ).filter( (s) => s.id !== input.excludeId && - (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) && + (input.type !== 'CURRENCY' || + (s.currency ?? null) === (input.currency ?? null)), + ); + + const expectedStart = nextRangeStart(siblings); + // An edited rule may always KEEP its current start (so a gap lower in the + // chain never blocks editing an upper rule's points/max) — or move down to + // fill that lowest gap. + const currentStart = input.excludeId + ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null + : null; + if (expectedStart == null && currentStart == null) { + throw new BadRequestException( + `${input.type} rules already cover the full 1–${cap} range — ` + + 'delete or shrink an existing rule first.', + ); + } + if ( + input.minWagonCount !== expectedStart && + input.minWagonCount !== currentStart + ) { + throw new BadRequestException( + `The next ${input.type} range must start at ${expectedStart} ` + + `(ranges are contiguous — no gaps, no overlaps). ` + + `You entered ${input.minWagonCount}–${input.maxWagonCount}.`, + ); + } + + const clash = siblings.find( + (s) => input.minWagonCount <= s.maxWagonCount && input.maxWagonCount >= s.minWagonCount, ); @@ -109,6 +173,24 @@ export class PriorityConfigsService { } } + /** + * Where the next range for a type/currency must start, and the type's + * ceiling — feeds the create form so the min field is auto-filled and + * locked. `nextMin` is null when the chain already covers 1..cap. + */ + async nextRange( + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + currency?: string | null, + ): Promise<{ nextMin: number | null; maxCap: number }> { + const siblings = ( + await this.repository.findAll({ where: { type } }) + ).filter( + (s) => + type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), + ); + return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + } + async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index a7187638e..0aeb624b3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, + FileCheck, FilePen, FileSignature, MessageSquareWarning, RefreshCw, ShieldCheck, - Sparkles, XCircle, Zap, } from "lucide-react"; @@ -180,7 +180,7 @@ export function ContractActionsToolbar({ documentGenerated ? ( ) : ( - + ) } loading={mutations.generateContract.isPending} @@ -196,7 +196,7 @@ export function ContractActionsToolbar({ fullWidth variant="light" color="orange" - leftSection={} + leftSection={} loading={mutations.generateContract.isPending} onClick={() => mutations.generateContract.mutate()} > diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 044c729ee..eed6c9e3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({ const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); + const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const [rejectStepRow, setRejectStepRow] = useState(null); @@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + // Approvers must review the GENERATED contract document before approving. If + // it has not been generated yet, block the approval and tell staff to generate + // it first (via "Generate contract" in Staff actions) — mirrors the server + // guard so the user sees a clear reason, not a generic failure toast. + const documentGenerated = Boolean(contract.contractGeneratedAt); + const openApprove = (step: Freight.IContractApprovalStep) => { + if (contract.status === "PENDING_APPROVAL" && !documentGenerated) { + setNeedsGenerateOpen(true); + return; + } setPendingStep(step); setConfirmOpen(true); }; @@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({ + setNeedsGenerateOpen(false)} + title={ + + + Generate the contract first + + } + radius="md" + centered + > + + + The contract document for{" "} + + {contract.reference} + {" "} + has not been generated yet. Approvers must review the generated + document before it can be approved. + + + Use{" "} + + Generate contract + {" "} + in the Staff actions panel — edit the articles first if needed — then + return here to approve. + + + + + + + = {}; for (const field of visibleFields) { - const raw = values[field.name]; + // Derived fields always submit their computed value — never stale state. + const raw = field.computeValue + ? (field.computeValue(values) ?? "") + : values[field.name]; if (field.type === "multiselect") { // Always the full replacement list — the API syncs the relation to it. payload[field.name] = Array.isArray(raw) ? raw : []; @@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({ } const isNumber = field.type === "number"; + const computed = field.computeValue ? field.computeValue(values) : undefined; return ( { const next = e.currentTarget.value; if (isNumber && next.trim().startsWith("-")) return; diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index b3a6c49fd..5ae7bac72 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { /** * Priority-rule approval workflow. Every create/update/delete of a priority * config is SUBMITTED as a change request; an approver applies or rejects it. - * Error toasts surface the backend message so range-collision rejections - * ("1–5 overlaps existing rule …") reach the user verbatim. + * Backend messages (range collision, gap, ceiling) surface verbatim — through + * `onErrorMessage` (the page shows them in a modal) or a toast as fallback. */ -export const usePriorityRuleWorkflow = (enabled: boolean) => { +export const usePriorityRuleWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { const qc = useQueryClient(); const backendMessage = (err: unknown, fallback: string) => { @@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { return msg || fallback; }; + const showError = (err: unknown, fallback: string) => { + const message = backendMessage(err, fallback); + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + const pending = useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"), @@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, }); await invalidateRuleEngineList(qc, "priority-configs"); + // The full order-list backs the auto-filled min field — keep it fresh too. + await qc.invalidateQueries({ + queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"), + }); }; const submit = useMutation({ @@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change submitted for approval — the team has been notified"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to submit change")), + onError: (err) => showError(err, "Failed to submit change"), }); const approve = useMutation({ @@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change approved and applied"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to approve change")), + onError: (err) => showError(err, "Failed to approve change"), }); const reject = useMutation({ @@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change rejected"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to reject change")), + onError: (err) => showError(err, "Failed to reject change"), }); return { pending, submit, approve, reject }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 95c4c22ac..af8b65a50 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -19,6 +19,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 { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; @@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => { ); // Priority rules never mutate directly: changes are filed for approval and a - // pending queue renders above the table. + // pending queue renders above the table. Validation errors (range collision, + // gap, ceiling) surface in a modal so the text is impossible to miss. const isPriorityRules = config?.slug === "priority-configs"; + const [priorityError, setPriorityError] = useState(null); const priorityWorkflow = usePriorityRuleWorkflow( Boolean(isPriorityRules && canView), + setPriorityError, ); const editingId = editing?.id ? String(editing.id) : undefined; @@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => { const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = useWagonTypeOptions(usesWagonTypeField); + // Full rule list backing the auto-filled "min wagon count": the next range + // always continues the chain for the selected type (per currency), so the + // form needs every existing rule, not the current page. + const { data: allPriorityRules } = useRuleEngineOrderList( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + Boolean(isPriorityRules && formOpen), + config?.orderConfig?.field, + ); + const formFields = useMemo(() => { if (!config) return []; return config.formFields.map((field) => { + if (isPriorityRules && field.name === "minWagonCount") { + return { + ...field, + // Editing keeps the rule's own start (a lower gap never forces it to + // move); creating always continues the chain / fills the lowest gap. + computeValue: (values: Record) => + editing?.minWagonCount != null + ? Number(editing.minWagonCount) + : nextPriorityRangeStart( + allPriorityRules ?? [], + String(values.type ?? ""), + !values.currency || values.currency === RULE_ENGINE_SELECT_NONE + ? null + : String(values.currency), + editingId, + ), + }; + } if (config.slug === "cargo-types" && field.name === "parentGroupId") { return { ...field, @@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => { } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]); const rows = data?.items ?? []; const meta = data?.meta; @@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => { /> ) : null} + setPriorityError(null)} + title="Cannot save priority rule" + centered + size="md" + > + + + {priorityError} + + + + + + + diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index bc17fb47a..4423b4f93 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -61,6 +61,13 @@ export interface FormFieldDef { * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; + /** + * Fully derived field: its value is computed from the live form values on + * every render and the input is locked. Used for the priority-rule min + * wagon count, which always continues the previous range for the selected + * type. Return null/undefined to leave the field empty (e.g. chain full). + */ + computeValue?: (values: Record) => number | string | null; } export interface RuleEngineOrderConfig { @@ -356,8 +363,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select a currency", hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] }, }, - { name: "minWagonCount", label: "Min wagon count", type: "number", required: true }, - { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true }, + { + name: "minWagonCount", + label: "Min wagon count", + type: "number", + required: true, + disabled: true, + description: "Auto-filled — continues the previous range for the selected type", + }, + { + name: "maxWagonCount", + label: "Max wagon count", + type: "number", + required: true, + description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts new file mode 100644 index 000000000..f387fc8dc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -0,0 +1,60 @@ +/** + * Client mirror of the backend's contiguous-range rules for priority configs + * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per + * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. The backend + * re-validates on submit AND on approval; this only drives the form prefill. + */ + +export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; + +/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ +export const PRIORITY_RANGE_CAPS: Record = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +export interface PriorityRangeRule { + id?: unknown; + type?: unknown; + currency?: unknown; + minWagonCount?: unknown; + maxWagonCount?: unknown; +} + +/** + * Where the next range for `type` (+`currency`) must start, excluding + * `excludeId` (the rule being edited). Null when the chain already covers + * 1..cap — no further rule fits. + */ +export function nextPriorityRangeStart( + rules: PriorityRangeRule[], + type: string, + currency: string | null | undefined, + excludeId?: string, +): number | null { + const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; + if (!cap) return null; + + const scoped = rules + .filter( + (r) => + String(r.type ?? "") === type && + (excludeId === undefined || String(r.id ?? "") !== excludeId) && + (type !== "CURRENCY" || + String(r.currency ?? "") === String(currency ?? "")), + ) + .map((r) => ({ + min: Number(r.minWagonCount ?? 0), + max: Number(r.maxWagonCount ?? 0), + })) + .sort((a, b) => a.min - b.min); + + let next = 1; + for (const r of scoped) { + if (r.min > next) break; // gap before this rule — fill it first + next = Math.max(next, r.max + 1); + } + return next > cap ? null : next; +}