From 801872c1065b7597a0b16e2be238fddbef134892 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 10:15:26 +0000 Subject: [PATCH] fix rate edit --- .../src/common/rule-engine-guards.ts | 11 + .../2300000000000-CreateRateChangeRequests.ts | 47 ++++ .../rate-change-requests.controller.ts | 58 ++++ .../dto/rate-change-request.dto.ts | 28 ++ .../entities/rate-change-request.entity.ts | 54 ++++ .../modules/rule-engine/rule-engine.module.ts | 6 + .../rate-change-requests.service.spec.ts | 213 +++++++++++++++ .../services/rate-change-requests.service.ts | 241 +++++++++++++++++ .../rule-engine/services/rates.service.ts | 58 +++- ...pdate-train-scheduling-global-rules.dto.ts | 28 -- .../train-scheduling.service.ts | 29 +- .../src/seed/freight-permissions.registry.ts | 17 ++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../src/hooks/rule-engine/useRuleEngine.ts | 66 +++++ .../backoffice/src/lib/permissions.ts | 15 ++ .../pages/ruleEngine/RateApprovalsSection.tsx | 247 ++++++++++++++++++ .../ruleEngine/RuleEngineResourcePage.tsx | 92 ++++++- .../TrainSchedulingGlobalRulesPage.tsx | 62 ----- .../services/ruleEngine/ruleEngine.service.ts | 62 +++++ .../backoffice/src/types/trainScheduling.ts | 4 - 20 files changed, 1227 insertions(+), 112 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts new file mode 100644 index 000000000..9972ab06a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { isSuperAdmin } from '../../../common/freight-permission.util'; +import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { RateChangeStatus } from '../entities/rate-change-request.entity'; +import { RateChangeRequestsService } from '../services/rate-change-requests.service'; + +/** + * Edits to LIVE rates. Staff with `manage` propose (submit); only holders of + * `approve` decide. Until a change is approved the live rate keeps its current + * value, so pricing never moves on an unapproved edit. + */ +@ApiTags('rate-change-requests') +@Controller('rate-change-requests') +@ApiBearerAuth() +export class RateChangeRequestsController { + constructor(private readonly service: RateChangeRequestsService) {} + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) + submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rate change requests, optionally by status' }) + list(@Query('status') status?: RateChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Approve a rate change and put it into effect' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); + } + + @Post(':id/reject') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts new file mode 100644 index 000000000..6c5dc3fbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +import { UpdateRateDto } from './update-rate.dto'; + +export class SubmitRateChangeDto { + @ApiProperty({ description: 'The LIVE rate to reprice' }) + @IsUUID() + rateId!: string; + + @ApiProperty({ + description: + 'Proposed field changes. The live rate keeps its current values until this is approved.', + type: UpdateRateDto, + }) + @ValidateNested() + @Type(() => UpdateRateDto) + update!: UpdateRateDto; +} + +export class DecideRateChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts new file mode 100644 index 000000000..00a1c0ba3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Rate } from './rate.entity'; + +export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed edit to a LIVE rate, awaiting approval. + * + * A LIVE rate is what pricing actually charges, so it is never mutated in + * place: the edit is filed here and the live row keeps its old value until an + * approver applies it. `payload` holds only the changed fields (an + * UpdateRateDto patch), `rateId` the rate being repriced. + * + * DRAFT rates are not covered — nothing prices off a draft, so those still + * edit directly and reach LIVE through the existing submit/approve flow. + */ +@Entity({ schema: 'freight', name: 'rate_change_requests' }) +@Index(['status']) +export class RateChangeRequest extends BaseEntity { + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate, { nullable: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate | null; + + /** Proposed field changes — an UpdateRateDto patch, changed keys only. */ + @Column({ name: 'payload', type: 'jsonb' }) + payload!: Record; + + /** + * The rate's values at submit time, for the approver's before→after diff. + * Snapshotted because the live row can move on between submit and decision. + */ + @Column({ name: 'previous_values', type: 'jsonb' }) + previousValues!: Record; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: RateChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 7edcf0bbf..39a5450ef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; +import { RateChangeRequestsController } from './controllers/rate-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -17,6 +18,7 @@ import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; +import { RateChangeRequest } from './entities/rate-change-request.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; @@ -49,6 +51,7 @@ import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; +import { RateChangeRequestsService } from './services/rate-change-requests.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; @@ -72,6 +75,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerType, PriorityConfig, PriorityRuleChangeRequest, + RateChangeRequest, ServiceType, WeightLimitRule, Yard, @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesController, PriorityConfigsController, PriorityRuleChangeRequestsController, + RateChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -121,6 +126,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesService, PriorityConfigsService, PriorityRuleChangeRequestsService, + RateChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts new file mode 100644 index 000000000..6c3adce66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; + +import { RateChangeRequest } from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RateChangeRequestsService } from './rate-change-requests.service'; + +/** + * The guarantee under test: editing a LIVE rate never moves the live value. + * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only + * approval applies it, and only then through RatesService (so every rate rule + * is re-checked against the state at approval time). + */ +describe('RateChangeRequestsService', () => { + const liveRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-1', + status: 'LIVE', + rateType: 'OCEAN_FREIGHT', + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + currency: 'USD', + // Postgres numeric comes back as a string — the no-op check must cope. + rateValue: '100.0000' as unknown as number, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: null, + proposedByStaffId: 'staff-1', + ...overrides, + }) as unknown as Rate; + + const build = (opts: { + rate?: Rate; + pending?: RateChangeRequest | null; + applyThrows?: Error; + } = {}) => { + const rate = opts.rate ?? liveRate(); + const saved: RateChangeRequest[] = []; + + const repo = { + findOne: jest.fn(async ({ where }: { where: Record }) => { + if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null; + return saved.find((r) => r.id === where.id) ?? opts.pending ?? null; + }), + create: jest.fn((data: Partial) => ({ id: 'req-1', ...data })), + save: jest.fn(async (entity: RateChangeRequest) => { + saved.push(entity); + return entity; + }), + find: jest.fn(async () => saved), + }; + + const rates = { + findById: jest.fn(async () => rate), + assertUpdateValid: jest.fn(async () => undefined), + applyApprovedUpdate: jest.fn(async () => { + if (opts.applyThrows) throw opts.applyThrows; + return rate; + }), + }; + + const inbox = { notify: jest.fn(async () => undefined) }; + + const service = new RateChangeRequestsService( + repo as never, + rates as never, + inbox as never, + ); + // `pending` is the very object approve/reject mutate — assert on it, not a copy. + return { service, repo, rates, inbox, pending: opts.pending }; + }; + + describe('submit', () => { + it('files a pending request instead of touching the live rate', async () => { + const { service, rates } = build(); + + const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }); + + expect(request.status).toBe('PENDING'); + expect(request.payload).toEqual({ rateValue: 200 }); + // The old value is snapshotted for the approver's diff... + expect(request.previousValues).toEqual({ rateValue: '100.0000' }); + // ...and nothing wrote to the rate itself. + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('keeps only the fields that actually changed', async () => { + const { service } = build(); + + // A form posts every field back; only rateValue differs from the live rate. + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 200, + currency: 'USD', + rateUnit: 'PER_CONTAINER', + appliesTo: 'CONTAINER', + }, + }); + + expect(request.payload).toEqual({ rateValue: 200 }); + }); + + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { + const { service } = build(); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }), + ).rejects.toThrow(/Nothing changed/); + }); + + it('refuses a rate that is not LIVE — those edit directly', async () => { + const { service } = build({ rate: liveRate({ status: 'DRAFT' }) }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(BadRequestException); + }); + + it('refuses a second pending change for the same rate', async () => { + const { service } = build({ + pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest, + }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(ConflictException); + }); + + it('validates up front so the requester hears about a bad patch, not the approver', async () => { + const { service, rates } = build(); + rates.assertUpdateValid.mockRejectedValueOnce( + new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + ); + await expect( + service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), + ).rejects.toThrow(/not valid for this rate/); + }); + }); + + describe('approve', () => { + const pendingRequest = (): RateChangeRequest => + ({ + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + }) as unknown as RateChangeRequest; + + it('applies the change through RatesService and marks it approved', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + + const decided = await service.approve('req-1', 'approver-1', 'Agreed'); + + expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 }); + expect(decided.status).toBe('APPROVED'); + expect(decided.decidedByUserId).toBe('approver-1'); + expect(decided.decisionNote).toBe('Agreed'); + }); + + it('blocks the requester from approving their own change', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('lets a super admin self-approve', async () => { + const { service } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({ + status: 'APPROVED', + }); + }); + + it('stays PENDING when applying now fails — never marks a change that did not land', async () => { + const { service, pending, repo } = build({ + pending: pendingRequest(), + applyThrows: new ConflictException('A rate for this exact combination already exists.'), + }); + + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/); + // Apply runs first, so a failure leaves the request untouched and re-decidable. + expect(pending!.status).toBe('PENDING'); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('refuses to decide an already-decided request', async () => { + const { service } = build({ + pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest, + }); + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException); + }); + }); + + describe('reject', () => { + it('never touches the rate — it simply keeps its current value', async () => { + const { service, rates } = build({ + pending: { + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + } as unknown as RateChangeRequest, + }); + + const decided = await service.reject('req-1', 'approver-1', 'Too steep'); + + expect(decided.status).toBe('REJECTED'); + expect(decided.decisionNote).toBe('Too steep'); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts new file mode 100644 index 000000000..357c67f95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -0,0 +1,241 @@ +import { NotificationAudience, NotificationType } from '@edr/types'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { + RateChangeRequest, + RateChangeStatus, +} from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RatesService } from './rates.service'; + +/** Backoffice page where both the queue and the rates live. */ +const RATES_LINK = '/dashboard/rules/rates'; + +/** Fields a change request may carry — anything else in the patch is ignored. */ +const DIFFABLE_FIELDS = [ + 'rateValue', + 'currency', + 'rateUnit', + 'appliesTo', + 'trigger', + 'tradeDirection', + 'containerTypeId', + 'cargoTypeId', +] as const; + +/** + * Approval workflow for edits to LIVE rates. + * + * A LIVE rate is what pricing charges right now, so it is never edited in + * place. The edit is filed here as a PENDING request and the live row keeps + * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 + * waits. Approval replays the edit through RatesService, so every rule + * (unit validity, pattern uniqueness) is re-checked against whatever is true + * at approval time, not at submit time. + */ +@Injectable() +export class RateChangeRequestsService { + private readonly logger = new Logger(RateChangeRequestsService.name); + + constructor( + @InjectRepository(RateChangeRequest) + private readonly repo: Repository, + private readonly rates: RatesService, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * File an edit against a LIVE rate. Validated up front so the requester + * hears about a bad unit or a pattern clash immediately rather than the + * approver hitting it days later. + */ + async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { + const rate = await this.rates.findById(dto.rateId); + if (rate.status !== 'LIVE') { + throw new BadRequestException( + `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, + ); + } + + const payload = this.changedFieldsOnly(rate, dto.update); + if (Object.keys(payload).length === 0) { + throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); + } + + // One pending edit per rate: two racing requests would both validate, then + // the second would silently overwrite the first on approval. + const inFlight = await this.repo.findOne({ + where: { rateId: dto.rateId, status: 'PENDING' }, + }); + if (inFlight) { + throw new ConflictException( + 'This rate already has a change awaiting approval. Have it approved or rejected first.', + ); + } + + await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + + const request = await this.repo.save( + this.repo.create({ + rateId: dto.rateId, + payload, + previousValues: this.snapshot(rate, payload), + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Rate change submitted', + `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, + request, + ); + return request; + } + + async list(status?: RateChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { rate: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Approve and apply. The live mutation runs FIRST — if it now fails (someone + * created a clashing rate since submit), the request stays PENDING and the + * approver sees the real error instead of a request marked approved that + * never landed. + */ + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + canSelfApprove = false, + ): Promise { + const request = await this.findPending(id); + + // Separation of duties: the requester cannot approve their own repricing — + // except super admins, who have full backoffice authority. + if (!canSelfApprove && userId && userId === request.requestedByUserId) { + throw new ForbiddenException('You cannot approve a rate change you submitted'); + } + + await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change approved', + `The rate change was approved and is now live.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** Reject — the live rate is never touched, so it simply keeps its value. */ + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change rejected', + `The rate change was rejected — the rate keeps its current value.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Keep only fields the requester actually changed. A form posts every field + * back, so without this the diff would list untouched values as changes. + */ + private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + const patch: Record = {}; + for (const field of DIFFABLE_FIELDS) { + const proposed = (update as Record)[field]; + if (proposed === undefined) continue; + if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; + patch[field] = proposed; + } + return patch; + } + + /** The live values the patch would overwrite — the "before" side of the diff. */ + private snapshot(rate: Rate, payload: Record): Record { + const before: Record = {}; + for (const field of Object.keys(payload)) { + before[field] = (rate as unknown as Record)[field] ?? null; + } + return before; + } + + /** + * rateValue arrives as a string from Postgres `numeric` but as a number from + * the form, so 100 and "100.0000" must compare equal or every submit would + * look like a change. + */ + private sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { + return numA === numB; + } + return String(a) === String(b); + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); + if (!request) throw new NotFoundException(`Rate change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); + } + return request; + } + + /** Fire-and-forget — a notification failure never blocks the workflow. */ + private notifyTeam(title: string, body: string, request: RateChangeRequest): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RATES_LINK, + data: { rateChangeRequestId: request.id, rateId: request.rateId }, + }) + .catch((err) => + this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 3cd17cf6e..b9a5e7873 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -119,12 +119,62 @@ export class RatesService { }); } - /** Update a DRAFT rate. */ + /** + * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit + * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. + */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT rates can be updated'); + throw new BadRequestException( + existing.status === 'LIVE' + ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' + : 'Only DRAFT rates can be updated', + ); } + return this.applyUpdate(existing, dto); + } + + /** + * Apply an approved change request to a LIVE rate. Same validation as a + * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching + * here has already been through approval. Only ever called by + * RateChangeRequestsService.approve. + */ + async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'LIVE') { + throw new BadRequestException( + `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, + ); + } + return this.applyUpdate(existing, dto); + } + + /** + * Validate a proposed patch against a rate without writing anything — lets a + * change request be refused at submit time instead of surprising the + * approver. Throws exactly what applying it would throw. + */ + async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { + await this.buildUpdate(await this.findById(id), dto); + } + + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { + const updates = await this.buildUpdate(existing, dto); + const updated = await this.repository.update(existing.id, updates); + if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); + return updated; + } + + /** + * The shared edit body: re-derives rateType, re-validates the unit against + * the (possibly changed) shape, and guards pattern uniqueness. Status is + * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart + * from the uniqueness read, so it doubles as the dry-run validator. + */ + private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { + const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; @@ -179,9 +229,7 @@ export class RatesService { updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - const updated = await this.repository.update(id, updates); - if (!updated) throw new NotFoundException(`Rate ${id} not found`); - return updated; + return updates; } /** Submit a DRAFT rate for CEO approval. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2948b874d..c5e6fa65f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -3,20 +3,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { - @ApiPropertyOptional({ example: 760 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainLengthMeters?: number; - - @ApiPropertyOptional({ example: 3500 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainWeightTons?: number; - @ApiPropertyOptional({ example: 53 }) @IsOptional() @Type(() => Number) @@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ example: 30 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0.001) - max20ftContainerWeightTons?: number; - - @ApiPropertyOptional({ example: 10 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0) - max20ftPairWeightDiffTons?: number; - @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) @IsOptional() @Type(() => Number) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index a08b12378..0d9bf63c9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -600,7 +600,24 @@ export class TrainSchedulingService { } async getTrainSchedulingGlobalRules() { - return this.loadGlobalRulesRow(); + return this.toPublicGlobalRules(await this.loadGlobalRulesRow()); + } + + /** + * Train length/weight and 20ft weight caps are engine-internal (wagon + * planning still reads them off the row); they are no longer exposed or + * editable through the global-rules endpoints. + */ + private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { + if (!row) return row; + const { + maxTrainLengthMeters: _len, + maxTrainWeightTons: _wt, + max20ftContainerWeightTons: _cw, + max20ftPairWeightDiffTons: _pd, + ...pub + } = row; + return pub; } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { @@ -608,15 +625,7 @@ export class TrainSchedulingService { if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } - if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; - if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; - if (dto.max20ftContainerWeightTons != null) { - row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; - } - if (dto.max20ftPairWeightDiffTons != null) { - row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; - } if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; @@ -654,7 +663,7 @@ export class TrainSchedulingService { await this.restampPendingWindows(); } - return saved; + return this.toPublicGlobalRules(saved); } /** diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6e4ec81b1..d3b5bbb00 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record> = { + rates: 'b2000001-0001-4000-8000-000000000017', +}; + +export type RuleEngineApprovableSlug = 'rates'; + export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ...(approveId + ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + : []), ]; }, ); @@ -389,6 +404,8 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + approve: (slug: RuleEngineApprovableSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { manage: 'edr_freight_app:allocation:manage', diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 164094cd3..160e0a61d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -167,6 +167,7 @@ export const QUERY_KEYS = { orderList: (resource: RuleEngineResourceSlug | string) => ["rule-engine", "order-list", resource] as const, priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const, + rateChanges: ["rule-engine", "rate-changes"] as const, }, OVERVIEW: { 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 5ae7bac72..d0b34cb5b 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 @@ -7,6 +7,7 @@ import { ruleEngineService, type RuleEngineListParams, type SubmitPriorityRuleChangePayload, + type SubmitRateChangePayload, } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { @@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = ( return { pending, submit, approve, reject }; }; +/** + * Approval workflow for edits to LIVE rates. The live rate keeps its current + * value until a change is approved, so the rates list is invalidated on every + * outcome — including reject, which restores the row's "no pending" state. + */ +export const useRateChangeWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { + const qc = useQueryClient(); + + const showError = (err: unknown, fallback: string) => { + const raw = (err as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback; + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + + const pending = useQuery({ + queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges, + queryFn: () => ruleEngineService.listRateChanges("PENDING"), + enabled, + }); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges }); + await invalidateRuleEngineList(qc, "rates"); + }; + + const submit = useMutation({ + mutationFn: (payload: SubmitRateChangePayload) => + ruleEngineService.submitRateChange(payload), + onSuccess: async () => { + toast.success( + "Change submitted for approval — the rate keeps its current value until approved", + ); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to submit rate change"), + }); + + const approve = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.approveRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change approved — the new rate is now live"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to approve rate change"), + }); + + const reject = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.rejectRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change rejected — the rate keeps its current value"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to reject rate change"), + }); + + return { pending, submit, approve, reject }; +}; + export const useRateWorkflow = () => { const qc = useQueryClient(); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 9bf9aab6e..98f998bc0 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string { return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`; } +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Only resources with an approval workflow have it. + */ +export function ruleEngineApproveKey(slug: "rates"): string { + return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`; +} + +export function canApproveRuleEngineChange( + user: AuthUser | null | undefined, + slug: "rates", +): boolean { + return hasPermission(user, ruleEngineApproveKey(slug)); +} + export function canAccessRuleEngineResource( user: AuthUser | null | undefined, slug: RuleEngineResourceSlug, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx new file mode 100644 index 000000000..6156762a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Group, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import type { UseMutationResult } from "@tanstack/react-query"; +import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react"; + +import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service"; + +/** Field labels for the diff — anything not listed falls back to the raw key. */ +const FIELD_LABELS: Record = { + rateValue: "Rate", + currency: "Currency", + rateUnit: "Unit", + appliesTo: "Applies to", + trigger: "Trigger", + tradeDirection: "Direction", + containerTypeId: "Container type", + cargoTypeId: "Cargo type", +}; + +const fmtDateTime = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +const fmtValue = (field: string, value: unknown): string => { + if (value === null || value === undefined || value === "") return "—"; + if (field === "rateValue") { + const num = Number(value); + return Number.isNaN(num) ? String(value) : num.toLocaleString(); + } + return String(value).replace(/_/g, " "); +}; + +/** "Ocean freight · 40HC" — what rate this change targets. */ +const rateSummary = (r: RateChangeRequest): string => { + const rate = (r.rate ?? {}) as Record; + const parts = [ + rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null, + rate.appliesTo ? String(rate.appliesTo) : null, + rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null, + ].filter(Boolean); + return parts.join(" · ") || "Rate"; +}; + +/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ +const headline = (r: RateChangeRequest): string | null => { + if (!("rateValue" in r.payload)) return null; + const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); + const before = fmtValue("rateValue", r.previousValues.rateValue); + const after = fmtValue("rateValue", r.payload.rateValue); + return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +}; + +type Decide = UseMutationResult< + RateChangeRequest, + unknown, + { id: string; decisionNote?: string } +>; + +interface RateApprovalsSectionProps { + requests: RateChangeRequest[]; + /** Whether this user holds the rates approve permission. */ + canDecide: boolean; + approve: Decide; + reject: Decide; +} + +/** + * Pending edits to LIVE rates. Each row is a before→after diff: the left value + * is what pricing charges right now and keeps charging until someone approves. + * Rendered above the rates table. + */ +const RateApprovalsSection = ({ + requests, + canDecide, + approve, + reject, +}: RateApprovalsSectionProps) => { + const [openId, setOpenId] = useState(null); + const [notes, setNotes] = useState>({}); + + if (requests.length === 0) return null; + + const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; + + return ( + + + + Pending rate changes + + {requests.length} + + + + Each rate below still charges its current value. Nothing changes until approved. + + + + {requests.map((r) => { + const isOpen = openId === r.id; + const fields = Object.keys(r.payload); + const summaryLine = headline(r); + // Only the row being decided shows a spinner — the mutation's + // isPending is shared across every row. + const busy = decidingId === r.id; + + return ( + + + + + + update + + + {rateSummary(r)} + + + + {summaryLine ? ( + + + {fmtValue("rateValue", r.previousValues.rateValue)} + + + + {fmtValue("rateValue", r.payload.rateValue)} + + + {String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + )} + + + ) : null} + + + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + + + + + + {canDecide ? ( + + + + + ) : ( + + + Awaiting approver + + + )} + + + + + {fields.map((field) => ( + + + {FIELD_LABELS[field] ?? field} + + + {fmtValue(field, r.previousValues[field])} + + + + {fmtValue(field, r.payload[field])} + + + ))} + {canDecide ? ( +