Merge pull request #1063 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-01 13:04:33 +03:00
committed by GitHub
30 changed files with 602 additions and 211 deletions

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`,
);
}
}

View File

@@ -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<void> {
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<void> {
// Irreversible rename-by-join: the pre-sync unit is not recorded. Both
// units bill identically, so rolling back the code needs no data change.
}
}

View File

@@ -16,7 +16,7 @@ import {
containersPerWagonForSize, containersPerWagonForSize,
wagonsPerUnitForSize, wagonsPerUnitForSize,
} from '../rule-engine/container-type.util'; } 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 { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service'; import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -1188,8 +1188,9 @@ export class BookingPricingService {
); );
if (!(capacity > 0)) return null; if (!(capacity > 0)) return null;
// Break-bulk (PER_ITEM): `tons` above is the item count; size by // Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage. // indivisible items instead of pretending the count is tonnage. Best
const byItems = bulkItemWagonsRequired(booking, capacity); // count across allowed wagon types, each capped by its items-fit.
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
if (byItems > 0) return byItems; if (byItems > 0) return byItems;
return Math.max(1, Math.ceil(tons / capacity)); return Math.max(1, Math.ceil(tons / capacity));
} catch { } catch {

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types'; 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 { export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -32,6 +32,18 @@ export class CreateCargoTypeDto {
@IsUUID('4', { each: true }) @IsUUID('4', { each: true })
wagonTypeIds?: string[]; wagonTypeIds?: string[];
@ApiPropertyOptional({
description:
'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' +
'(e.g. { "<nw5-id>": 4, "<nw7-id>": 6 }). Required for every wagonTypeId when ' +
'unitOfMeasure is PER_ITEM.',
type: 'object',
additionalProperties: { type: 'integer', minimum: 1 },
})
@IsOptional()
@IsObject()
itemsPerWagonMap?: Record<string, number> | null;
@ApiPropertyOptional({ default: false }) @ApiPropertyOptional({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -50,6 +50,16 @@ export class CargoType extends BaseEntity {
}) })
wagonTypes?: WagonType[]; 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<string, number> | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false }) @Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean; requiresDirectorApproval!: boolean;

View File

@@ -28,6 +28,13 @@ export interface IRatesRepository {
create(data: Partial<Rate>): Promise<Rate>; create(data: Partial<Rate>): Promise<Rate>;
update(id: string, data: Partial<Rate>): Promise<Rate | null>; update(id: string, data: Partial<Rate>): Promise<Rate | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;
/**
* 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<number>;
} }
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');

View File

@@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository {
async softDelete(id: string): Promise<void> { async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id); await this.repo.softDelete(id);
} }
async syncBulkQuantityUnit(
cargoTypeId: string,
unitOfMeasure: 'PER_TON' | 'PER_ITEM',
): Promise<number> {
const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM';
const result = await this.repo.update(
{ cargoTypeId, rateUnit: from },
{ rateUnit: unitOfMeasure },
);
return result.affected ?? 0;
}
} }

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types'; import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util'; import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -11,6 +17,7 @@ import {
CARGO_TYPES_REPOSITORY, CARGO_TYPES_REPOSITORY,
ICargoTypesRepository, ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface'; } from '../interfaces/cargo-types.repository.interface';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
import { DisplayOrderService } from './display-order.service'; import { DisplayOrderService } from './display-order.service';
@Injectable() @Injectable()
@@ -18,6 +25,8 @@ export class CargoTypesService {
constructor( constructor(
@Inject(CARGO_TYPES_REPOSITORY) @Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository, private readonly repository: ICargoTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepository: IRatesRepository,
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
@@ -38,6 +47,34 @@ export class CargoTypesService {
return this.repository.findByCode(code); 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<string, number> | null;
}): Record<string, number> | null {
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) {
return null;
}
const map: Record<string, number> = {};
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. */ /** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> { async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName); const code = generateCode(dto.cargoTypeName);
@@ -62,26 +99,58 @@ export class CargoTypesService {
unitOfMeasure: dto.unitOfMeasure ?? null, unitOfMeasure: dto.unitOfMeasure ?? null,
// Join rows are written by the save (RESTRICT FK rejects unknown ids). // Join rows are written by the save (RESTRICT FK rejects unknown ids).
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
itemsPerWagonMap: this.resolveItemsPerWagonMap({
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeIds: dto.wagonTypeIds ?? [],
itemsPerWagonMap: dto.itemsPerWagonMap,
}),
displayOrder, displayOrder,
}); });
} }
/** Update an existing cargo type. */ /** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> { async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id); const existing = await this.findById(id);
if (dto.parentGroupId) { if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId); const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); 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, { const updated = await this.repository.update(id, {
...columns, ...columns,
...(wagonTypeIds ...(wagonTypeIds
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } ? { 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`); 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; return updated;
} }

View File

@@ -69,6 +69,7 @@ import {
LocomotiveLimits, LocomotiveLimits,
WagonTypeDimensions, WagonTypeDimensions,
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired, bulkItemWagonsRequired,
bookingGrossWeightTons, bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive, deriveTrainCapacityFromLocomotive,
@@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit {
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity). // 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); return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
} }

View File

@@ -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 { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { import {
@@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
if (booking.freightType === 'BULK') { if (booking.freightType === 'BULK') {
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
// holds the item count there, not tons. // holds the item count there, not tons. No wagon type is fixed yet, so use
const byItems = bulkItemWagonsRequired(booking, capacity); // 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; if (byItems > 0) return byItems;
const weight = Number(booking.cargoTotalWeightVgm ?? 0); const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity)); return Math.max(1, Math.ceil(weight / capacity));

View File

@@ -2,6 +2,7 @@ import {
bookingCargoTons, bookingCargoTons,
bookingGrossWeightTons, bookingGrossWeightTons,
bookingTrainLengthMeters, bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired, bulkItemWagonsRequired,
consistUsage, consistUsage,
consistViolations, consistViolations,
@@ -76,6 +77,62 @@ describe('train-capacity.util', () => {
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(400, 0), 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)', () => { describe('bookingCargoTons (break-bulk weight preference)', () => {

View File

@@ -120,6 +120,12 @@ export function bookingCargoTons(booking: {
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. * 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) — * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
* callers then fall back to the pooled-tonnage math. * 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( export function bulkItemWagonsRequired(
booking: { booking: {
@@ -128,6 +134,7 @@ export function bulkItemWagonsRequired(
bulkTotalWeightTons?: number | string | null; bulkTotalWeightTons?: number | string | null;
}, },
capacityTons: number, capacityTons: number,
itemsFit?: number | null,
): number { ): number {
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
const quantity = num(booking.cargoTotalWeightVgm); const quantity = num(booking.cargoTotalWeightVgm);
@@ -136,10 +143,56 @@ export function bulkItemWagonsRequired(
const perItemTons = totalWeightTons / quantity; const perItemTons = totalWeightTons / quantity;
// ponytail: an item heavier than a whole wagon still charges 1 wagon per // 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. // 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)); return Math.max(1, Math.ceil(quantity / itemsPerWagon));
} }
type ItemFitCargoType = {
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
itemsPerWagonMap?: Record<string, number> | 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. */ /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number { export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons); return num(slot.tareWeightTons) + num(slot.cargoTons);

View File

@@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; 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_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760; 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 — // 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 // their tonnage must NOT pool with PER_TON cargo (an item can't split
// across wagons the way loose tonnage can). // 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 itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons( const totalWeight = roundTons(
bookings.reduce( bookings.reduce(

View File

@@ -29,7 +29,6 @@ import {
Upload, Upload,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common"; import { isViewable } from "@edr/ui-common";
@@ -299,7 +298,14 @@ function DocumentRow({
<Text size="xs" c="dimmed" mt={4} truncate> <Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "} {doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "} {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,
})}
</Text> </Text>
</Box> </Box>
</Group> </Group>

View File

@@ -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");
});
});

View File

@@ -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 { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { format } from "date-fns";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { import {
Select, Select,
@@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => {
<TableCell>{log.message}</TableCell> <TableCell>{log.message}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{log.timestamp && !isNaN(new Date(log.timestamp).getTime()) {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"} : "N/A"}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord {
unitOfMeasure?: string | null; unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */ /** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
wagonTypes?: { id: string; code?: string; name?: string }[]; wagonTypes?: { id: string; code?: string; name?: string }[];
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
itemsPerWagonMap?: Record<string, number> | null;
isActive?: boolean; isActive?: boolean;
displayOrder?: number; 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 str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); 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). // Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate); const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
const formFields = useMemo<FormFieldDef[]>( const formFields = useMemo<FormFieldDef[]>(() => {
() => const base = FORM_FIELDS.map((field) =>
FORM_FIELDS.map((field) => field.name === "wagonTypeIds"
field.name === "wagonTypeIds" ? { ...field, options: wagonTypeOptions ?? [] }
? { ...field, options: wagonTypeOptions ?? [] } : field,
: field, );
), // PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how
[wagonTypeOptions], // 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 [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null); const [formMode, setFormMode] = useState<FormMode | null>(null);
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
const countAtRoot = (childrenOf.get("__root__") ?? []).length; const countAtRoot = (childrenOf.get("__root__") ?? []).length;
const handleSubmit = (values: Record<string, unknown>) => { const handleSubmit = (values: Record<string, unknown>) => {
const payload: Record<string, unknown> = { ...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<string, unknown> = {};
const itemsPerWagonMap: Record<string, number> = {};
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. // Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) { if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id; payload.parentGroupId = current.id;

View File

@@ -107,7 +107,14 @@ const ReminderList = () => {
Remind {dayjs(reminder.remindAt).fromNow()} Remind {dayjs(reminder.remindAt).fromNow()}
</p> </p>
<span className="text-[11px] text-gray-400"> <span className="text-[11px] text-gray-400">
({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",
})}
)
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/shared/common/ui/card"; import { Card, CardContent } from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge"; import { Badge } from "@/shared/common/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
import { format } from "date-fns";
import { import {
Clock, Clock,
User, User,
@@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
const iconBg = "bg-gray-100 dark:bg-gray-800"; const iconBg = "bg-gray-100 dark:bg-gray-800";
const whiteBg = "bg-white dark:bg-gray-900"; const whiteBg = "bg-white dark:bg-gray-900";
const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy"); const formattedDate = new Date(activity.timestamp).toLocaleDateString(
const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss"); "en-US",
{ month: "short", day: "numeric", year: "numeric" },
);
const formattedTime = new Date(activity.timestamp).toLocaleTimeString(
"en-GB",
{ hour12: false },
);
return ( return (
<Card <Card

View File

@@ -213,7 +213,6 @@ export default function AuditLogPageShared({
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800"; const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
const primaryBtn = const primaryBtn =
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900"; "bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
const primaryIcon = "text-gray-900 dark:text-gray-100";
const getSeverityColor = (severity: string) => { const getSeverityColor = (severity: string) => {
switch (severity) { switch (severity) {
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
textSubtle, textSubtle,
)}> )}>
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{format(new Date(log.timestamp), "MMM d, yyyy")} {new Date(log.timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span> </span>
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{format(new Date(log.timestamp), "HH:mm:ss")} {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
</span> </span>
<span className="inline-flex items-center gap-2 min-w-0"> <span className="inline-flex items-center gap-2 min-w-0">
<span className={cn(textSubtle)}></span> <span className={cn(textSubtle)}></span>
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex flex-col"> <div className="flex flex-col">
<span className={cn("text-sm", textStrong)}> <span className={cn("text-sm", textStrong)}>
{format( {new Date(log.timestamp).toLocaleDateString(
new Date(log.timestamp), "en-US",
"MMM d, yyyy", {
month: "short",
day: "numeric",
year: "numeric",
},
)} )}
</span> </span>
<span className={cn("text-xs", textSubtle)}> <span className={cn("text-xs", textSubtle)}>
{format(new Date(log.timestamp), "HH:mm:ss")} {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
</span> </span>
</div> </div>
</TableCell> </TableCell>

View File

@@ -14,7 +14,7 @@
"dependencies": { "dependencies": {
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*", "@edr/ui-common": "workspace:*",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.6.0",
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0", "@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",

View File

@@ -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 { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";

View File

@@ -1,5 +1,4 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { format } from "date-fns";
import { memo } from "react"; import { memo } from "react";
import { STATUS_CONFIG, cv } from "../constants"; import { STATUS_CONFIG, cv } from "../constants";
@@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({
</Text> </Text>
</Box> </Box>
<Text fz={11} c="edr-muted" className="shrink-0"> <Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")} {new Date(booking.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</Text> </Text>
</Group> </Group>
); );

View File

@@ -106,7 +106,8 @@ function Countdown({
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}{" "}
EAT
</Text> </Text>
{onPay && ( {onPay && (
<Button <Button

View File

@@ -68,6 +68,7 @@ function formatPriceUnit(unit: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
PER_CONTAINER: "per container", PER_CONTAINER: "per container",
PER_TON: "per ton", PER_TON: "per ton",
PER_ITEM: "per item",
PER_WAGON: "per wagon", PER_WAGON: "per wagon",
PER_KM: "per km", PER_KM: "per km",
FLAT: "flat", FLAT: "flat",

View File

@@ -10,7 +10,6 @@ import {
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { format } from "date-fns";
import { import {
Calendar, Calendar,
CheckCircle2, CheckCircle2,
@@ -239,7 +238,12 @@ export function Step8Review({
values.destinationYard; values.destinationYard;
const scheduleLabel = values.scheduledDate const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy") ? new Date(values.scheduledDate).toLocaleDateString("en-US", {
weekday: "long",
month: "short",
day: "numeric",
year: "numeric",
})
: "—"; : "—";
const directionLabel = direction const directionLabel = direction

View File

@@ -7,6 +7,7 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./display-timezone": "./src/lib/display-timezone.ts",
"./styles.css": "./dist/index.css", "./styles.css": "./dist/index.css",
"./theme.css": "./src/styles/theme.css" "./theme.css": "./src/styles/theme.css"
}, },

View File

@@ -0,0 +1,48 @@
/**
* Side-effect module: pins every Intl-based date/time display to East Africa
* Time (UTC+3) so all users see the same wall-clock times no matter what
* timezone their PC is set to. Import it FIRST in the app entry, before any
* other app module, so no module-scope formatter is created unpatched:
*
* import "@edr/ui-common/display-timezone";
*
* Call sites that pass an explicit `timeZone` option keep it. date-fns and
* dayjs `format()` do NOT go through Intl and stay PC-local — don't use them
* to display API timestamps.
*/
export const DISPLAY_TIME_ZONE = "Africa/Addis_Ababa";
type Locales = string | string[] | undefined;
type LocaleMethod = (
this: Date,
locales?: Locales,
options?: Intl.DateTimeFormatOptions,
) => string;
function pinned(original: LocaleMethod): LocaleMethod {
return function (locales, options) {
return original.call(this, locales, {
...options,
timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
});
};
}
Date.prototype.toLocaleString = pinned(Date.prototype.toLocaleString);
Date.prototype.toLocaleDateString = pinned(Date.prototype.toLocaleDateString);
Date.prototype.toLocaleTimeString = pinned(Date.prototype.toLocaleTimeString);
const OriginalDateTimeFormat = Intl.DateTimeFormat;
function PinnedDateTimeFormat(
locales?: Locales,
options?: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
return new OriginalDateTimeFormat(locales, {
...options,
timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
});
}
// Keep `instanceof Intl.DateTimeFormat` and the static method working.
PinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
PinnedDateTimeFormat.supportedLocalesOf = OriginalDateTimeFormat.supportedLocalesOf;
Intl.DateTimeFormat = PinnedDateTimeFormat as unknown as typeof Intl.DateTimeFormat;

259
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/ui-common version: link:../../../packages/ui-common
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.6.0
version: 5.4.0(react-hook-form@7.77.0(react@19.2.6)) version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)
'@mantine/core': '@mantine/core':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -589,7 +589,7 @@ importers:
version: 5.101.0(react@19.2.6) version: 5.101.0(react@19.2.6)
'@tria-plc/iamui': '@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps': '@vis.gl/react-google-maps':
specifier: ^1.8.3 specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2143,6 +2143,84 @@ packages:
peerDependencies: peerDependencies:
react-hook-form: ^7.55.0 react-hook-form: ^7.55.0
'@hookform/resolvers@5.6.0':
resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
'@typeschema/main': '>=0.13.7'
'@vinejs/vine': ^2.0.0 || ^3.0.0
ajv: ^8.12.0
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^0.7.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
effect: ^3.10.3
fluentvalidation-ts: ^3.0.0
fp-ts: ^2.7.0
io-ts: ^2.0.0
joi: ^17.0.0
nope-validator: '>=0.12.0'
react-hook-form: ^7.55.0
superstruct: '>=0.12.0'
typanion: ^3.3.2
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
vest: '>=3.0.0'
yup: ^1.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@sinclair/typebox':
optional: true
'@standard-schema/spec':
optional: true
'@typeschema/main':
optional: true
'@vinejs/vine':
optional: true
ajv:
optional: true
ajv-errors:
optional: true
ajv-formats:
optional: true
arktype:
optional: true
ata-validator:
optional: true
class-transformer:
optional: true
class-validator:
optional: true
computed-types:
optional: true
effect:
optional: true
fluentvalidation-ts:
optional: true
fp-ts:
optional: true
io-ts:
optional: true
joi:
optional: true
nope-validator:
optional: true
superstruct:
optional: true
typanion:
optional: true
valibot:
optional: true
vest:
optional: true
yup:
optional: true
zod:
optional: true
'@humanwhocodes/config-array@0.13.0': '@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -13040,6 +13118,20 @@ snapshots:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
react-hook-form: 7.77.0(react@19.2.6) react-hook-form: 7.77.0(react@19.2.6)
'@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.77.0(react@19.2.6)
optionalDependencies:
'@sinclair/typebox': 0.27.10
'@standard-schema/spec': 1.1.0
ajv: 8.20.0
ajv-formats: 2.1.1(ajv@8.20.0)
class-transformer: 0.5.1
class-validator: 0.14.4
effect: 3.21.0
zod: 4.4.3
'@humanwhocodes/config-array@0.13.0': '@humanwhocodes/config-array@0.13.0':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@@ -16573,130 +16665,6 @@ snapshots:
- utf-8-validate - utf-8-validate
- vite - vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
@@ -17905,16 +17873,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0: babel-polyfill@6.26.0:
dependencies: dependencies:
babel-runtime: 6.26.0 babel-runtime: 6.26.0
@@ -22927,15 +22885,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- react-is - react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies: dependencies:
date-fns: 3.6.0 date-fns: 3.6.0
@@ -24167,24 +24116,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1