From 16a7aebaddc9dfc48179049260c71348e5e2c59d Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Fri, 24 Jul 2026 14:01:21 +0300 Subject: [PATCH 1/6] update migration to before deployment in freight api --- .github/workflows/deploy.yml | 7 +++--- apps/edr-freight-api/Dockerfile | 4 ++++ apps/edr-freight-api/package.json | 1 + .../src/config/database.config.ts | 15 +++++++----- apps/edr-freight-api/src/data-source.ts | 24 ++++++------------- apps/edr-freight-api/src/scripts/migrate.ts | 24 +++++++++++++++++++ 6 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/migrate.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f0beccd20..c648fec09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -144,15 +144,16 @@ jobs: run: ./scripts/deploy/create-npmrc.sh - name: Resolve env file path for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | case "${{ matrix.service }}" in + freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;; passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; esac - name: Build migration image for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker build \ @@ -163,7 +164,7 @@ jobs: . - name: Run migrations for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 984e622db..e2c4137cc 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -31,6 +31,10 @@ COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +FROM deployer AS migration +WORKDIR /deploy +CMD ["node", "dist/scripts/migrate.js"] + FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat ENV NODE_ENV=production diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bca74475e..8ecd815ed 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,6 +36,7 @@ "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "migration:run": "node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index dcf09fbd9..65029819d 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,5 +1,6 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSourceOptions } from "typeorm"; import { join, dirname } from "path"; import { DefaultPosition, @@ -95,7 +96,7 @@ const iamMigrationsGlob = join( ); const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); -export default registerAs("database", (): TypeOrmModuleOptions => { +export function buildDataSourceOptions(): DataSourceOptions { return { type: "postgres", host: process.env.DB_HOST ?? "localhost", @@ -111,17 +112,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => { // The search_path is instead applied per-connection via a pool `connect` // handler in app.module.ts (see setPoolSearchPath). entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], - autoLoadEntities: true, migrations: [ - // IAM schema + tables must be created before freight migrations iamMigrationsGlob, freightMigrationsGlob, ], - migrationsRun: true, migrationsTransactionMode: "each", - // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, logging: process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; -}); +} + +export default registerAs("database", (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, +})); diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index 2ae202ebd..03baa31e2 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -1,21 +1,11 @@ // apps/edr-freight-api/src/data-source.ts -import 'dotenv/config'; -import { DataSource } from 'typeorm'; -//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed +import "dotenv/config"; +import { DataSource, DataSourceOptions } from "typeorm"; +import { buildDataSourceOptions } from "./config/database.config"; export const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5433), - username: process.env.DB_USER ?? 'postgres', - password: process.env.DB_PASSWORD ?? '', - database: process.env.DB_NAME ?? 'edr_freight', - schema: 'freight', // default schema for entities without an explicit schema - entities: [__dirname + '/**/*.entity{.ts,.js}'], - migrations: [__dirname + '/migrations/*{.ts,.js}'], - synchronize: false, - logging: process.env.TYPEORM_LOGGING === 'true', -}); + ...buildDataSourceOptions(), + schema: "freight", +} as DataSourceOptions); -// Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. +export default AppDataSource; diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts new file mode 100644 index 000000000..7f331e1d3 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -0,0 +1,24 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; +import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas"; +import { buildDataSourceOptions } from "../config/database.config"; + +async function main(): Promise { + await ensurePostgresSchemas(buildDataSourceOptions()); + + await AppDataSource.initialize(); + try { + const applied = await AppDataSource.runMigrations(); + for (const migration of applied) { + console.log(`applied: ${migration.name}`); + } + if (applied.length === 0) console.log("no pending migrations"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From af1d64d0bd5fe7a708d44e224d2d256389451ef6 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 14:27:23 +0000 Subject: [PATCH 2/6] fix --- apps/edr-freight-api/package.json | 1 + .../backfill-missing-unload-inventory.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 20bfa1bad..14c28f469 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -27,6 +27,7 @@ "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", + "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts new file mode 100644 index 000000000..9d51107fe --- /dev/null +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -0,0 +1,63 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +/** + * One-off backfill for bookings caught by the autoArriveAtFinalYard bug + * (fixed in booking-journey.service.ts): the bulk final-yard arrival used to + * flip booking status to ARRIVED/COMPLETED without ever emitting + * booking.unloadedAtYard, so WarehouseInventoryService never created their + * warehouse_inventory row. Reuses the same idempotent listener the live + * event now calls, so it's safe to re-run. + */ +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const bookings: { id: string; tradeDirection: string }[] = await dataSource.query( + `SELECT b.id, b.trade_direction AS "tradeDirection" + FROM freight.bookings b + WHERE b.deleted_at IS NULL + AND b.trade_direction IN ('IMPORT', 'DOMESTIC') + AND b.status IN ('ARRIVED', 'COMPLETED') + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi + WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL + )`, + ); + + if (bookings.length === 0) { + console.log('No bookings missing their unload inventory row.'); + return; + } + + console.log(`Backfilling ${bookings.length} booking(s)...`); + for (const booking of bookings) { + await inventory.handleBookingUnloadedAtYard({ + bookingId: booking.id, + tradeDirection: booking.tradeDirection, + }); + console.log(` - ${booking.id} (${booking.tradeDirection})`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 1998c74b9e5b6d0783abd887e2bf7ccccc2584a3 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 1 Aug 2026 08:42:18 +0000 Subject: [PATCH 3/6] fix(freight-portal): add missing /api prefix to bulk truck upload endpoint BulkTruckUploadModal hand-built its POST URL and omitted the global /api prefix every other booking call uses, causing a 404. Added CUSTOMER_TRUCKS_BULK to URL_CONSTANTS.BOOKINGS and switched the modal to use it. --- apps/edr-freight-web/portal/src/constants/URLS.ts | 2 ++ .../BookingDetailPage/components/BulkTruckUploadModal.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 928281042..22421400f 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -130,6 +130,8 @@ export const URL_CONSTANTS = { CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`, + CUSTOMER_TRUCKS_BULK: (id: string) => + `/api/bookings/${id}/customer-trucks/bulk`, CUSTOMER_TRUCK: (id: string, assignmentId: string) => `/api/bookings/${id}/customer-trucks/${assignmentId}`, }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx index 5f1f8a51e..eec8183af 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid import { useMutation } from "@tanstack/react-query"; import { client } from "@/utils/api"; +import { URL_CONSTANTS } from "@/constants/URLS"; import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; interface BulkTruckUploadModalProps { @@ -32,7 +33,7 @@ export function BulkTruckUploadModal({ const uploadMutation = useMutation({ mutationFn: async () => { - const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, { + const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), { trucks: parsed, }); return data; From 0d8c63328a5ccf81eab45dd764585a5167e5b79d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 1 Aug 2026 09:55:21 +0000 Subject: [PATCH 4/6] add items per wagon map to cargo types and sync bulk rate units - Implemented in the to manage the physical item capacity for each wagon type. - Added a new migration to create the column in the table. - Introduced method in to update rate units when cargo type unit of measure changes. - Updated booking calculations to consider items per wagon for break-bulk cargo. - Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application. - Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings. --- ...0000000000-AddCargoTypeItemsPerWagonMap.ts | 24 ++++++ ...40000000000-SyncBulkRateUnitsToCargoUom.ts | 36 +++++++++ .../bookings/booking-pricing.service.ts | 7 +- .../rule-engine/dto/create-cargo-type.dto.ts | 14 +++- .../rule-engine/entities/cargo-type.entity.ts | 10 +++ .../interfaces/rates.repository.interface.ts | 7 ++ .../repositories/rates.repository.ts | 12 +++ .../services/cargo-types.service.ts | 77 ++++++++++++++++++- .../train-scheduling/booking-batch.service.ts | 9 ++- .../train-scheduling/fleet-plan.util.ts | 8 +- .../train-capacity.util.spec.ts | 57 ++++++++++++++ .../train-scheduling/train-capacity.util.ts | 55 ++++++++++++- .../train-scheduling/wagon-plan.util.ts | 13 +++- .../components/contracts/GlExchangePanel.tsx | 10 ++- .../src/lib/display-timezone.test.ts | 41 ++++++++++ apps/edr-freight-web/backoffice/src/main.tsx | 3 + .../backoffice/src/pages/AuditLog.tsx | 3 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 54 ++++++++++--- .../components/ReminderList.tsx | 9 ++- .../components/activity-log/activity-card.tsx | 12 ++- .../shared/pages/audit-log/audit-log-page.tsx | 21 +++-- apps/edr-freight-web/portal/src/main.tsx | 3 + .../MyPortalPage/components/ActivityRow.tsx | 6 +- .../components/BookingPaymentPanel.tsx | 3 +- .../src/pages/bookings/NewBookingPage.tsx | 1 + .../new-booking-form/step8-review.tsx | 8 +- packages/ui-common/package.json | 1 + .../ui-common/src/lib/display-timezone.ts | 48 ++++++++++++ 28 files changed, 506 insertions(+), 46 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts create mode 100644 apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts create mode 100644 packages/ui-common/src/lib/display-timezone.ts diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts new file mode 100644 index 000000000..153928b04 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon + * type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it + * runs out of rated tonnage, so allocation must respect BOTH limits. Stored as + * a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the + * cargo_type_wagon_types join rows, kept in sync by the cargo-types service. + */ +export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface { + name = 'AddCargoTypeItemsPerWagonMap3120000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts new file mode 100644 index 000000000..b2a931542 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rates created before their commodity's unit_of_measure was flipped kept the + * old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery) + * quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only + * the name differs — so renaming is safe. Going forward the cargo-types + * service syncs rates on every uom change; this backfills the drift. + */ +export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface { + name = 'SyncBulkRateUnitsToCargoUom3140000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_ITEM' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_ITEM' + AND r."rate_unit" = 'PER_TON'`, + ); + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_TON' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_TON' + AND r."rate_unit" = 'PER_ITEM'`, + ); + } + + public async down(): Promise { + // Irreversible rename-by-join: the pre-sync unit is not recorded. Both + // units bill identically, so rolling back the code needs no data change. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0eb65bf5d..b44c1c5e0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,7 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; -import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util'; +import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1188,8 +1188,9 @@ export class BookingPricingService { ); if (!(capacity > 0)) return null; // Break-bulk (PER_ITEM): `tons` above is the item count; size by - // indivisible items instead of pretending the count is tonnage. - const byItems = bulkItemWagonsRequired(booking, capacity); + // indivisible items instead of pretending the count is tonnage. Best + // count across allowed wagon types, each capped by its items-fit. + const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); if (byItems > 0) return byItems; return Math.max(1, Math.ceil(tons / capacity)); } catch { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6f7882c76..6d9cb82a2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -32,6 +32,18 @@ export class CreateCargoTypeDto { @IsUUID('4', { each: true }) wagonTypeIds?: string[]; + @ApiPropertyOptional({ + description: + 'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' + + '(e.g. { "": 4, "": 6 }). Required for every wagonTypeId when ' + + 'unitOfMeasure is PER_ITEM.', + type: 'object', + additionalProperties: { type: 'integer', minimum: 1 }, + }) + @IsOptional() + @IsObject() + itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 037bf08be..b096613bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -50,6 +50,16 @@ export class CargoType extends BaseEntity { }) wagonTypes?: WagonType[]; + /** + * PER_ITEM (break-bulk) only: how many whole items physically fit each + * allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }). + * Allocation loads min(this fit, floor(capacityTons / perItemTons)) per + * wagon — floor space and rated tonnage bind independently. Keys are kept a + * subset of the wagonTypes join rows by the cargo-types service. + */ + @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) + itemsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index b3ee54d20..1570e0d23 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -28,6 +28,13 @@ export interface IRatesRepository { create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; + /** + * Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure. + * Both units bill the same stored quantity — only the name differs — so a + * uom change must rename the units or bookings keep quoting "per ton" for + * counted cargo. Returns the number of rates flipped. + */ + syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise; } export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index abd10cd98..43c716a3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository { async softDelete(id: string): Promise { await this.repo.softDelete(id); } + + async syncBulkQuantityUnit( + cargoTypeId: string, + unitOfMeasure: 'PER_TON' | 'PER_ITEM', + ): Promise { + const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM'; + const result = await this.repo.update( + { cargoTypeId, rateUnit: from }, + { rateUnit: unitOfMeasure }, + ); + return result.affected ?? 0; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 8a72cf1f8..34287a023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -1,5 +1,11 @@ -import { PaginatedResponse } from '@edr/types'; -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -11,6 +17,7 @@ import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { DisplayOrderService } from './display-order.service'; @Injectable() @@ -18,6 +25,8 @@ export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepository: IRatesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -38,6 +47,34 @@ export class CargoTypesService { return this.repository.findByCode(code); } + /** + * PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed + * wagon type — allocation caps each wagon at min(fit, tonnage) and a missing + * fit would silently fall back to tonnage-only loading. Returns the map + * trimmed to the allowed ids (stale keys from a removed wagon type drop out); + * null when the cargo is not PER_ITEM or has no wagon types. + */ + private resolveItemsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + itemsPerWagonMap?: Record | null; + }): Record | null { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) { + return null; + } + const map: Record = {}; + for (const wagonTypeId of input.wagonTypeIds) { + const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]); + if (!Number.isInteger(fit) || fit < 1) { + throw new BadRequestException( + `itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`, + ); + } + map[wagonTypeId] = fit; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -62,26 +99,58 @@ export class CargoTypesService { unitOfMeasure: dto.unitOfMeasure ?? null, // Join rows are written by the save (RESTRICT FK rejects unknown ids). wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + itemsPerWagonMap: dto.itemsPerWagonMap, + }), displayOrder, }); } /** Update an existing cargo type. */ async update(id: string, dto: UpdateCargoTypeDto): Promise { - await this.findById(id); + const existing = await this.findById(id); if (dto.parentGroupId) { if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + // Re-validate the fit map whenever anything it depends on moves — a partial + // update merges with the stored values so e.g. adding a wagon type without + // its fit still 400s. Untouched fields leave the stored map alone. + const touchesItemsFit = + wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } : {}), + ...(touchesItemsFit + ? { + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + itemsPerWagonMap: + itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap, + }), + } + : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the + // same stored quantity) — sync them or bookings keep quoting "per ton" for + // counted cargo. + if ( + dto.unitOfMeasure !== undefined && + dto.unitOfMeasure !== existing.unitOfMeasure && + (dto.unitOfMeasure === CargoUnitOfMeasure.PerTon || + dto.unitOfMeasure === CargoUnitOfMeasure.PerItem) + ) { + await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure); + } return updated; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 5ce75abd2..5203c3a14 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -69,6 +69,7 @@ import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemsFitFor, bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, @@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit { // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). - const byItems = bulkItemWagonsRequired(booking, capacityTons); + // `dimsFor` resolved dims from the first allowed wagon type, so charge that + // same type's configured items-fit alongside its capacity. + const byItems = bulkItemWagonsRequired( + booking, + capacityTons, + bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id), + ); return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 631078ef6..bea01af0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n if (booking.freightType === 'BULK') { const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` - // holds the item count there, not tons. - const byItems = bulkItemWagonsRequired(booking, capacity); + // holds the item count there, not tons. No wagon type is fixed yet, so use + // the best count across the cargo's allowed types (per-type items-fit + // respected); falls back to `capacity` when the relation isn't loaded. + const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); if (byItems > 0) return byItems; const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index fd5fe05ed..ead3a4e8b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -2,6 +2,7 @@ import { bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, consistUsage, consistViolations, @@ -76,6 +77,62 @@ describe('train-capacity.util', () => { expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); }); + + describe('configured items-fit (floor space vs tonnage)', () => { + it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => { + // floor(70/20) = 3 by tonnage < 4 by floor space. + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17); + }); + + it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => { + // floor(70/10) = 7 by tonnage, but only 4 fit physically. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13); + }); + + it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => { + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17); + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17); + // floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8); + }); + }); + }); + + describe('bulkItemWagonsForAllowedTypes', () => { + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('picks the fewest-wagon allowed type, each capped by its own fit', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + itemsPerWagonMap: { nw5: 4, nw7: 6 }, + }; + // 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons; + // NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13); + }); + + it('equals the old max-capacity estimate when no fits are configured', () => { + const cargoType = { + wagonTypes: [ + { id: 'a', capacityTons: 50 }, + { id: 'b', capacityTons: 70 }, + ], + }; + // Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17); + }); + + it('falls back to the given capacity when the relation is missing', () => { + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17); + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17); + }); }); describe('bookingCargoTons (break-bulk weight preference)', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 061dc0dbd..867da564b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -120,6 +120,12 @@ export function bookingCargoTons(booking: { * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — * callers then fall back to the pooled-tonnage math. + * + * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from + * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon + * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T + * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls + * back to tonnage-only (legacy cargo types without a configured fit). */ export function bulkItemWagonsRequired( booking: { @@ -128,6 +134,7 @@ export function bulkItemWagonsRequired( bulkTotalWeightTons?: number | string | null; }, capacityTons: number, + itemsFit?: number | null, ): number { if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; const quantity = num(booking.cargoTotalWeightVgm); @@ -136,10 +143,56 @@ export function bulkItemWagonsRequired( const perItemTons = totalWeightTons / quantity; // ponytail: an item heavier than a whole wagon still charges 1 wagon per // item; reject such bookings at creation time if the case turns real. - const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity; + const itemsPerWagon = Math.min(byTonnage, byFloor); return Math.max(1, Math.ceil(quantity / itemsPerWagon)); } +type ItemFitCargoType = { + wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; + itemsPerWagonMap?: Record | null; +} | null; + +/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ +export function bulkItemsFitFor( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, +): number | null { + const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN; + return Number.isFinite(fit) && fit >= 1 ? fit : null; +} + +/** + * Break-bulk wagon count when no single wagon type is fixed yet: the best + * (fewest-wagon) count across the cargo type's allowed wagon types, each + * respecting its own items-fit. With no fits configured this equals the old + * max-capacity estimate; with no allowed types it degrades to + * `fallbackCapacityTons` tonnage-only. + */ +export function bulkItemWagonsForAllowedTypes( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons); + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkItemWagonsRequired( + booking, + num(wagonType.capacityTons), + bulkItemsFitFor(cargoType, wagonType.id), + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 619ebbde6..ef3089925 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, + consistViolations, +} from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -175,7 +180,11 @@ export function buildBulkWagonPlan( // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — // their tonnage must NOT pool with PER_TON cargo (an item can't split // across wagons the way loose tonnage can). - const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity)); + const itemSlotsByBooking = bookings.map((b) => + // The plan fixed THIS wagon type, so its configured items-fit binds — not + // the best fit across the cargo's allowed types. + bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), + ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); const totalWeight = roundTons( bookings.reduce( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx index 8f213e4ec..852a89cc1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx @@ -29,7 +29,6 @@ import { Upload, UserCheck, } from "lucide-react"; -import dayjs from "dayjs"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; import { isViewable } from "@edr/ui-common"; @@ -299,7 +298,14 @@ function DocumentRow({ {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} {doc.uploadedByName ?? "Global Logistics"} ·{" "} - {dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")} + {new Date(doc.uploadedAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} diff --git a/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts new file mode 100644 index 000000000..33ac36437 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import "@edr/ui-common/display-timezone"; + +// The pin must hold on ANY machine timezone — these assertions are the bug: +// before the patch they only passed on a PC already set to UTC+3. +describe("display-timezone pin (EAT, UTC+3)", () => { + const utcMidnight = new Date("2026-01-01T00:00:00Z"); + + it("formats Date.toLocale* in EAT regardless of machine timezone", () => { + expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe( + "03:00:00", + ); + // 22:00 UTC is already the NEXT day in EAT. + expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe( + "2026-01-02", + ); + }); + + it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => { + const fmt = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + expect(fmt.format(utcMidnight)).toBe("03:00"); + expect(fmt).toBeInstanceOf(Intl.DateTimeFormat); + expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain( + "en-GB", + ); + }); + + it("respects an explicit timeZone option", () => { + expect( + utcMidnight.toLocaleTimeString("en-GB", { + hour12: false, + timeZone: "UTC", + }), + ).toBe("00:00:00"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index aad3bbb89..3de483c5b 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -1,3 +1,6 @@ +// Must stay the first import: pins all date/time display to EAT before any +// module can create a formatter in the PC's local timezone. +import "@edr/ui-common/display-timezone"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx index 2fe61e56a..5374bcee7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import { format } from "date-fns"; import { Input } from "@/shared/common/ui/input"; import { Select, @@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => { {log.message} {log.timestamp && !isNaN(new Date(log.timestamp).getTime()) - ? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss") + ? new Date(log.timestamp).toLocaleString("sv-SE") : "N/A"} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index a515e5614..e37db97cc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord { unitOfMeasure?: string | null; /** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */ wagonTypes?: { id: string; code?: string; name?: string }[]; + /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */ + itemsPerWagonMap?: Record | null; isActive?: boolean; displayOrder?: number; } +/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ +const ITEMS_FIT_PREFIX = "itemsFit__"; + const str = (v: unknown): string => (v == null ? "" : String(v)); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); @@ -131,15 +136,33 @@ const CargoTypesPage = () => { // Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list). const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate); - const formFields = useMemo( - () => - FORM_FIELDS.map((field) => - field.name === "wagonTypeIds" - ? { ...field, options: wagonTypeOptions ?? [] } - : field, - ), - [wagonTypeOptions], - ); + const formFields = useMemo(() => { + const base = FORM_FIELDS.map((field) => + field.name === "wagonTypeIds" + ? { ...field, options: wagonTypeOptions ?? [] } + : field, + ); + // PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how + // many whole items physically fit that wagon (floor space binds before + // tonnage). Shown only while the wagon type is picked; the API requires a + // fit for every selected type on PER_ITEM cargo. + const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({ + name: `${ITEMS_FIT_PREFIX}${opt.value}`, + label: `Items per ${opt.label} wagon`, + type: "number", + required: true, + placeholder: "e.g. 4", + showIf: (values) => + values.unitOfMeasure === "PER_ITEM" && + Array.isArray(values.wagonTypeIds) && + (values.wagonTypeIds as string[]).includes(opt.value), + getInitialValue: (record) => + (record as CargoNode).itemsPerWagonMap?.[opt.value], + })); + const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds"); + base.splice(wagonTypesAt + 1, 0, ...fitFields); + return base; + }, [wagonTypeOptions]); const [search, setSearch] = useState(""); const [formMode, setFormMode] = useState(null); @@ -203,7 +226,18 @@ const CargoTypesPage = () => { const countAtRoot = (childrenOf.get("__root__") ?? []).length; const handleSubmit = (values: Record) => { - const payload: Record = { ...values }; + // Fold the per-wagon-type fit inputs into the API's map shape. Null when + // none are visible (not PER_ITEM) so an update clears stale fits. + const payload: Record = {}; + const itemsPerWagonMap: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (key.startsWith(ITEMS_FIT_PREFIX)) { + itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value); + } else { + payload[key] = value; + } + } + payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null; // Add always attaches to the page we're on; edit keeps the node's parent. if (formMode?.kind === "create" && current) { payload.parentGroupId = current.id; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx index 20ed30fad..4bba1d713 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx @@ -107,7 +107,14 @@ const ReminderList = () => { Remind {dayjs(reminder.remindAt).fromNow()}

- ({dayjs(reminder.remindAt).format("MMM D, h:mm A")}) + ( + {new Date(reminder.remindAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} + ) diff --git a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx index 92d4b624a..78ca90a56 100644 --- a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx +++ b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx @@ -1,9 +1,7 @@ -import * as React from "react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/shared/common/ui/card"; import { Badge } from "@/shared/common/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar"; -import { format } from "date-fns"; import { Clock, User, @@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) { const iconBg = "bg-gray-100 dark:bg-gray-800"; const whiteBg = "bg-white dark:bg-gray-900"; - const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy"); - const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss"); + const formattedDate = new Date(activity.timestamp).toLocaleDateString( + "en-US", + { month: "short", day: "numeric", year: "numeric" }, + ); + const formattedTime = new Date(activity.timestamp).toLocaleTimeString( + "en-GB", + { hour12: false }, + ); return ( { switch (severity) { @@ -472,10 +471,14 @@ export default function AuditLogPageShared({ textSubtle, )}> - {format(new Date(log.timestamp), "MMM d, yyyy")} + {new Date(log.timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} - {format(new Date(log.timestamp), "HH:mm:ss")} + {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })} @@ -817,13 +820,17 @@ export default function AuditLogPageShared({
- {format( - new Date(log.timestamp), - "MMM d, yyyy", + {new Date(log.timestamp).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + year: "numeric", + }, )} - {format(new Date(log.timestamp), "HH:mm:ss")} + {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 983aa5b7a..49fec0edf 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -1,3 +1,6 @@ +// Must stay the first import: pins all date/time display to EAT before any +// module can create a formatter in the PC's local timezone. +import "@edr/ui-common/display-timezone"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx index ea06ae3e5..bba2b5696 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -1,5 +1,4 @@ import { Box, Group, Text } from "@mantine/core"; -import { format } from "date-fns"; import { memo } from "react"; import { STATUS_CONFIG, cv } from "../constants"; @@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({ - {format(new Date(booking.createdAt), "MMM d")} + {new Date(booking.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 1cb46d156..d921308d0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -106,7 +106,8 @@ function Countdown({ day: "numeric", hour: "2-digit", minute: "2-digit", - })} + })}{" "} + EAT {onPay && (