From 380ba4c4c98368d936dd0d619cc4ef79f9020325 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 3 Sep 2026 22:27:00 +0300 Subject: [PATCH 1/2] Refactor code structure for improved readability and maintainability --- .../rate-change-requests.service.spec.ts | 51 +++++- .../services/rate-change-requests.service.ts | 35 ++++- .../rule-engine/services/rates.service.ts | 13 ++ .../train-scheduling.controller.ts | 21 +++ .../services/train-scheduling.service.ts | 148 ++++++++++++++++++ .../train-scheduling.module.ts | 2 + .../backoffice/src/constants/URLS.ts | 2 + .../pages/ruleEngine/RateApprovalsSection.tsx | 133 +++++++++------- .../ruleEngine/RuleEngineResourcePage.tsx | 19 ++- .../TrainScheduleV2DetailPage.tsx | 40 +++++ .../src/services/trainScheduling.service.ts | 9 ++ 11 files changed, 407 insertions(+), 66 deletions(-) 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 index f8617cd65..60121c2fd 100644 --- 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 @@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => { rate?: Rate; pending?: RateChangeRequest | null; applyThrows?: Error; + /** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */ + derived?: Partial; + previewThrows?: Error; } = {}) => { const rate = opts.rate ?? liveRate(); const saved: RateChangeRequest[] = []; @@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => { const rates = { findById: jest.fn(async () => rate), assertUpdateValid: jest.fn(async () => undefined), + // Stands in for buildUpdate: it resolves a patch into the full column + // set, including columns the form never posts (rateType and friends). + previewUpdate: jest.fn(async (_id: string, dto: Record) => { + if (opts.previewThrows) throw opts.previewThrows; + return { ...dto, ...(opts.derived ?? {}) } as Partial; + }), applyApprovedUpdate: jest.fn(async () => { if (opts.applyThrows) throw opts.applyThrows; return rate; @@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => { }); 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.'), - ); + // Resolving the patch IS the validation — buildUpdate throws on a bad + // unit, so previewUpdate surfaces it at submit time. + const { service } = build({ + previewThrows: 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/); }); + + it('shows the approver a bulk switch, which only exists as a derived column', async () => { + // The form posts intercityKind: BULK — never stored. The real edit lands + // on rateType (+ the cargo/container swap), so that is what the approver + // must see. Diffing the raw patch showed an empty change list. + const { service } = build({ + rate: liveRate({ + rateType: 'INTERCITY_CONTAINER', + appliesTo: 'INTERCITY', + containerTypeId: 'ct-1', + }), + derived: { + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + } as Partial, + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never, + }); + + expect(request.payload).toMatchObject({ + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + }); + expect(request.previousValues).toMatchObject({ + rateType: 'INTERCITY_CONTAINER', + containerTypeId: 'ct-1', + }); + }); }); describe('approve', () => { 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 index 8913c9ef9..68d2ccb2e 100644 --- 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 @@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** 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. */ +/** + * Persisted columns an approver is shown a before→after for. + * + * These are RESOLVED entity columns, not raw form fields: the diff runs + * against `RatesService.previewUpdate`, so a change the form expresses through + * a non-stored selector still shows up here as the column it actually moves + * (a flip to bulk lands on `rateType` + the container/cargo swap). + */ const DIFFABLE_FIELDS = [ 'rateValue', 'currency', @@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The container-vs-bulk shape of the rate. Missing here, switching a LIVE + // rate to bulk showed the approver an empty change list — the only column + // that records the kind is rateType, and the form never posts it directly. + 'rateType', + // Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping + // line diffed to nothing. + 'shippingLineCompanyId', // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', @@ -82,7 +96,11 @@ export class RateChangeRequestsService { ); } - const payload = this.changedFieldsOnly(rate, dto.update); + // Diff the RESOLVED columns, not the raw patch: the form's cargoKind / + // intercityKind selectors are never stored, so a bulk switch only shows up + // once the patch is resolved into the columns it moves. + const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto); + const payload = this.changedFieldsOnly(rate, resolved); if (Object.keys(payload).length === 0) { throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); } @@ -98,7 +116,9 @@ export class RateChangeRequestsService { ); } - await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + // previewUpdate above already ran the full validation (it IS buildUpdate), + // so re-validating here would only repeat it — and the trimmed payload is + // resolved columns, not a form patch, so it is not the right input for it. const request = await this.repo.save( this.repo.create({ @@ -186,13 +206,14 @@ export class RateChangeRequestsService { } /** - * Keep only fields the requester actually changed. A form posts every field - * back, so without this the diff would list untouched values as changes. + * Keep only columns the edit actually moves. `buildUpdate` returns a full + * resolved column set (it re-derives scope on every patch), so without this + * the diff would list every untouched column as a change. */ - private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + private changedFieldsOnly(rate: Rate, resolved: Partial): Record { const patch: Record = {}; for (const field of DIFFABLE_FIELDS) { - const proposed = (update as Record)[field]; + const proposed = (resolved as Record)[field]; if (proposed === undefined) continue; if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; patch[field] = proposed; 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 6677fcce5..a3361e526 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 @@ -753,6 +753,19 @@ export class RatesService { await this.buildUpdate(await this.findById(id), dto); } + /** + * The exact column changes applying this patch would make, without writing. + * + * A change request diffs against THIS rather than the raw patch: the form + * posts selectors that are never stored (`cargoKind`, `intercityKind`), and + * the real edit they encode lands on derived columns — flipping a rate to + * bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing + * the raw patch missed all of it, so the approver saw an empty change list. + */ + async previewUpdate(id: string, dto: UpdateRateDto): Promise> { + return 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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 4fc921ccb..1c7cd11ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -867,6 +867,27 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/wagons/export") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + }) + async scheduleWagonListExport( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = + await this.trainSchedulingService.scheduleWagonListWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + @Get("schedules/:id/export/load-list/document") @TrainSchedulingView() @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 4f416cdcf..03d75da18 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; +import { TabularExportService } from '../../exports/tabular-export.service'; + +/** One line of the schedule wagon-list export (raw SQL projection). */ +interface ScheduleWagonListRow { + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + containerNumber: string | null; + containerSizeFt: number | null; + loadType: string | null; + status: string | null; + bulkCargoDescription: string | null; + /** numeric columns arrive as strings from pg. */ + vgmTons: string | null; + originLabel: string | null; + destinationLabel: string | null; + bookingReference: string | null; + customerName: string | null; +} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -427,6 +446,9 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, + // Trailing + @Optional so the positional constructors in the existing specs + // keep working; production always resolves it from ExportsModule. + @Optional() private readonly tabularExport?: TabularExportService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -3747,6 +3769,132 @@ export class TrainSchedulingService { }; } + /** + * The schedule detail page's wagon-list Excel export. + * + * One row per container (a wagon carrying two boxes yields two rows, repeating + * the wagon number) so each container's own VGM is present and totals footable. + * Bulk wagons, having no containers, yield a single row carrying the bulk + * description and the allocated tonnage as the VGM figure. + * + * Only wagon slots that actually carry an allocation are listed — empty slots + * on the consist are omitted. + */ + async scheduleWagonListWorkbook( + scheduleId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.tabularExport) { + throw new BadRequestException('Tabular export service is unavailable'); + } + + // Row grain is the container item; the LEFT JOIN keeps bulk (and any + // container-less) allocation as one row. `booking_container_units` is joined + // on BOTH container number and its booking_container line — container + // numbers repeat across bookings, so number alone would multiply rows. + const rows: ScheduleWagonListRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + ci.container_number AS "containerNumber", + cit.size_ft AS "containerSizeFt", + a.load_type AS "loadType", + a.status AS "status", + bl.cargo_description AS "bulkCargoDescription", + COALESCE( + ci.gross_weight_tons, + bcu.vgm_tons, + bc.vgm_per_unit_tons, + a.allocated_weight_tons + ) AS "vgmTons", + COALESCE(by_.label, so.label) AS "originLabel", + COALESCE(ay.label, sd.label) AS "destinationLabel", + b.reference AS "bookingReference", + COALESCE( + slc.name, + CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, + c.name + ) AS "customerName" + FROM freight.train_schedules s + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations a + ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.bookings b ON b.id = a.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id + LEFT JOIN freight.booking_container bc + ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu + ON bcu.container_number = ci.container_number + AND bcu.booking_container_id = bc.id + AND bcu.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id + WHERE s.id = $1 AND s.deleted_at IS NULL + ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`, + [scheduleId], + ); + + // "number" is the printed line number of the sheet, not the wagon sequence — + // a two-container wagon occupies two lines, and the reader counts lines. + const sheetRows = rows.map((row, index) => ({ + number: index + 1, + wagonNumber: row.wagonNumber ?? '—', + containerNumber: + row.containerNumber ?? + (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), + vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), + originLabel: row.originLabel ?? '—', + destinationLabel: row.destinationLabel ?? '—', + customerName: row.customerName ?? '—', + })); + + const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${reference}`.slice(0, 31), + description: `Wagon list for train ${reference}`, + label: 'train-schedule:wagon-list', + kpis: [ + { label: 'Lines', value: sheetRows.length }, + { + label: 'Wagons', + value: new Set(rows.map((r) => r.sequenceNo)).size, + }, + { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'number', label: 'No.', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon', type: 'string' }, + { key: 'containerNumber', label: 'Container number', type: 'string' }, + { key: 'vgmTons', label: 'VGM', type: 'tons' }, + { key: 'originLabel', label: 'Origin', type: 'string' }, + { key: 'destinationLabel', label: 'Destination', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + ], + rows: sheetRows, + }); + + return { + filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, + buffer, + }; + } + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index dd5cc74bc..fdbc504b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; +import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, + ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 0dafb926f..ae972022e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -537,6 +537,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, EXPORT_LOAD_LIST_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/export/load-list/document`, + SCHEDULE_WAGONS_EXPORT: (id: string) => + `/train-scheduling/schedules/${id}/wagons/export`, INTERCITY_MARSHALLING_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/intercity/marshalling/document`, MARSHALLING_STOPS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx index a61376158..ce77041eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -27,8 +27,24 @@ const FIELD_LABELS: Record = { cargoTypeId: "Cargo type", originYardId: "Origin yard", destinationYardId: "Destination yard", + minKm: "From km", + maxKm: "To km", + baseLiters: "Base liters", + rateType: "Rate type", }; +/** + * A key the backend diffed but the UI has no label for still names a real + * change, so turn "baseLiters" into "Base liters" rather than hiding it. + */ +const labelFor = (field: string): string => + FIELD_LABELS[field] ?? + field + .replace(/([A-Z])/g, " $1") + .replace(/^./, (c) => c.toUpperCase()) + .replace(/\bId\b/, "") + .trim(); + const fmtDateTime = (iso: string) => new Date(iso).toLocaleString("en-GB", { day: "numeric", @@ -43,13 +59,16 @@ const fmtValue = ( value: unknown, labels?: Record, ): string => { - if (value === null || value === undefined || value === "") return "—"; + // "Not set" reads as a real before-state; a bare em dash on both sides of the + // arrow made a newly-set field look like no change at all. + if (value === null || value === undefined || value === "") return "Not set"; if (field === "rateValue") { const num = Number(value); return Number.isNaN(num) ? String(value) : num.toLocaleString(); } - // Yard ids are unreadable — an approver decides on the route, not a UUID. - if (field === "originYardId" || field === "destinationYardId") { + // Any id is unreadable — an approver decides on "Perishable → Truck", not on + // a pair of uuids. Covers yards, cargo types, container types and lines. + if (field.endsWith("Id")) { return labels?.[String(value)] ?? String(value); } return String(value).replace(/_/g, " "); @@ -66,13 +85,33 @@ const rateSummary = (r: RateChangeRequest): string => { 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}` : ""}`; +/** + * Every change in the request, as readable before→after pairs. The queue must + * be scannable without expanding: a cargo or direction change is just as much + * the point as a repricing, so it gets the same one-line treatment as the rate. + */ +const summaryRows = ( + r: RateChangeRequest, + labels?: Record, +): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => { + const currency = String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + ); + // Rate first — it is what most changes are about — then the rest in a stable + // order so the same edit always reads the same way. + const fields = Object.keys(r.payload).sort((a, b) => + a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b), + ); + return fields.map((field) => ({ + field, + label: labelFor(field), + before: fmtValue(field, r.previousValues[field], labels), + after: fmtValue(field, r.payload[field], labels), + suffix: field === "rateValue" && currency ? ` ${currency}` : "", + })); }; type Decide = UseMutationResult< @@ -87,8 +126,9 @@ interface RateApprovalsSectionProps { canDecide: boolean; approve: Decide; reject: Decide; - /** yardId → label, so a re-routed rate reads as yards, not UUIDs. */ - yardLabels?: Record; + /** id → label for every reference a diff can name (yards, cargo/container + * types, shipping lines), so a change reads as names, not UUIDs. */ + refLabels?: Record; } /** @@ -101,7 +141,7 @@ const RateApprovalsSection = ({ canDecide, approve, reject, - yardLabels, + refLabels, }: RateApprovalsSectionProps) => { const [openId, setOpenId] = useState(null); const [notes, setNotes] = useState>({}); @@ -127,7 +167,7 @@ const RateApprovalsSection = ({ {requests.map((r) => { const isOpen = openId === r.id; const fields = Object.keys(r.payload); - const summaryLine = headline(r); + const rows = summaryRows(r, refLabels); // Only the row being decided shows a spinner — the mutation's // isPending is shared across every row. const busy = decidingId === r.id; @@ -145,38 +185,36 @@ const RateApprovalsSection = ({ - {summaryLine ? ( - + {rows.map((row) => ( + + + {row.label} + - {fmtValue("rateValue", r.previousValues.rateValue)} + {row.before} - + - {fmtValue("rateValue", r.payload.rateValue)} - - - {String( - r.payload.currency ?? - r.previousValues.currency ?? - (r.rate as Record | undefined)?.currency ?? - "", - )} + {row.after} + {row.suffix} - ) : null} + ))} Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} {fields.length === 1 ? "field" : "fields"} changed - + {canDecide ? ( + + ) : null} @@ -218,24 +256,11 @@ const RateApprovalsSection = ({ - - {fields.map((field) => ( - - - {FIELD_LABELS[field] ?? field} - - - {fmtValue(field, r.previousValues[field], yardLabels)} - - - - {fmtValue(field, r.payload[field], yardLabels)} - - - ))} - {canDecide ? ( + {canDecide ? ( + + {/* The change itself is always visible above, so this panel + carries only what the approver adds. */}