enhance contract and booking services with server-side search and validation improvements

- Added  parameter to  and  for server-side free-text search on contract reference, company name, and booking details.
- Introduced new validation errors in  for container clashes and space issues when creating bookings.
- Implemented paginated dropdown settings retrieval in .
- Updated  to fetch active yards using a new method that handles pagination.
- Enhanced  with a  method to fetch all records by walking through pages.
- Refactored  to support filtering and pagination in schedule listings.
- Improved  to return a paginated list of facilities.
- Updated UI components in  and  to utilize debounced search inputs for better performance.
- Added alerts in  to inform users about booking constraints related to splits and capacity.
- Enhanced  to display notifications for split bookings and capacity usage.
This commit is contained in:
Marshal
2026-07-12 10:51:31 +00:00
parent 6695c5448e
commit 4b7f6d2548
108 changed files with 3187 additions and 1368 deletions

View File

@@ -27,6 +27,7 @@
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.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", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.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", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",

View File

@@ -0,0 +1,44 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
/**
* Base query DTO for every paginated list endpoint. Extend it and add the
* module's own filter fields; sort-field whitelists stay in the subclass
* because the allowed columns differ per resource.
*
* All list endpoints built on this return the shared `PaginatedResponse<T>`
* envelope from `@edr/types` (`items` + `meta`), produced by
* `common/utils/pagination.util.ts`.
*/
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 1)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 20)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({
description: 'Free-text search, applied server-side (resource-specific columns).',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@Transform(({ value }) => String(value).toUpperCase())
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,85 @@
import { PaginatedResponse, PaginationMeta } from '@edr/types';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
/** Raw page/pageSize as they arrive from a query DTO (both optional). */
export interface PageRequest {
page?: number;
pageSize?: number;
}
export interface PaginationOptions {
defaultPageSize?: number;
maxPageSize?: number;
}
export interface NormalizedPage {
page: number;
pageSize: number;
skip: number;
take: number;
}
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
export function normalizePagination(
request: PageRequest,
options: PaginationOptions = {},
): NormalizedPage {
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
const maxPageSize = options.maxPageSize ?? MAX_PAGE_SIZE;
const page = Math.max(1, Math.floor(request.page ?? 1) || 1);
const requested = Math.floor(request.pageSize ?? defaultPageSize) || defaultPageSize;
const pageSize = Math.min(Math.max(1, requested), maxPageSize);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
export function buildPaginationMeta(
total: number,
page: number,
pageSize: number,
): PaginationMeta {
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
}
/**
* Apply skip/take to a query builder, run it, and wrap the result in the
* shared `PaginatedResponse` envelope. Ordering and filtering must already be
* applied by the caller.
*/
export async function paginateQuery<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
request: PageRequest,
options?: PaginationOptions,
): Promise<PaginatedResponse<T>> {
const { page, pageSize, skip, take } = normalizePagination(request, options);
const [items, total] = await qb.skip(skip).take(take).getManyAndCount();
return { items, meta: buildPaginationMeta(total, page, pageSize) };
}
/**
* Paginate an already-materialized array. Prefer `paginateQuery` (DB-level
* LIMIT/OFFSET); use this only for lists that are inherently in-memory.
*/
export function paginateArray<T>(
rows: readonly T[],
request: PageRequest,
options?: PaginationOptions,
): PaginatedResponse<T> {
const { page, pageSize, skip } = normalizePagination(request, options);
return {
items: rows.slice(skip, skip + pageSize),
meta: buildPaginationMeta(rows.length, page, pageSize),
};
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial-batch splits no longer promote a ONE_TIME contract to GENERAL.
* Instead the reduced booking is flagged is_split, and the booking gate lets
* the customer book exactly the remainder under the still-ONE_TIME contract.
*/
export class AddBookingIsSplit2110000000000 implements MigrationInterface {
name = 'AddBookingIsSplit2110000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE
`);
// Quantities the booking carried before the split — the remainder ledger
// for ONE_TIME contracts, which have no quantity cap to derive it from.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split
`);
}
}

View File

@@ -605,6 +605,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
async findAllPaginated(options: BookingListFilterOptions & { async findAllPaginated(options: BookingListFilterOptions & {
page: number; page: number;
pageSize: number; pageSize: number;
search?: string;
sortBy?: string; sortBy?: string;
sortOrder?: 'ASC' | 'DESC'; sortOrder?: 'ASC' | 'DESC';
}): Promise<{ }): Promise<{
@@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
this.applyListFilters(qb, options); this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this
// list query joins — so it lives here, not in applyListFilters (shared
// with getListSummaryMetrics, whose query builder has no joins).
if (options.search) {
qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` },
);
}
if (options.sortBy === 'isGovernment') { if (options.sortBy === 'isGovernment') {
qb.orderBy('booking.isGovernment', 'DESC') qb.orderBy('booking.isGovernment', 'DESC')
.addOrderBy('booking.priorityScore', 'DESC') .addOrderBy('booking.priorityScore', 'DESC')

View File

@@ -1211,6 +1211,7 @@ export class BookingsService {
destinationYardId: filter.destinationYardId, destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment, isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
search: filter.search,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });
@@ -1262,6 +1263,7 @@ export class BookingsService {
// Global Logistics only clears customs bookings; non-customs clearance is // Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue. // reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true, customsClearingEnabled: true,
search: filter.search,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });
@@ -1286,6 +1288,7 @@ export class BookingsService {
// Company-wide: payables span all of the customer's services. // Company-wide: payables span all of the customer's services.
companyId: company.id, companyId: company.id,
companyProfileId: filter.companyProfileId, companyProfileId: filter.companyProfileId,
search: filter.search,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });

View File

@@ -125,6 +125,16 @@ export class FilterBookingDto {
@IsOptional() @IsOptional()
consolidationPaired?: string; consolidationPaired?: string;
@ApiPropertyOptional({
description:
'Free-text search across booking reference, company name, and contract reference.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1)) @Transform(({ value }) => (value ? parseInt(value, 10) : 1))

View File

@@ -166,6 +166,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
contractKind?: string | null; contractKind?: string | null;
/**
* The customer paid a partial batch offer and this booking was reduced to the
* offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a
* split booking releases the single-active-booking slot for the remainder —
* the contract kind itself is never changed.
*/
@Column({ name: 'is_split', type: 'boolean', default: false })
isSplit!: boolean;
/**
* Quantities this booking carried BEFORE it was reduced by a split — the
* split chain's source of truth for the outstanding remainder (ONE_TIME
* contracts have no quantity cap to derive it from). Bulk: total tons;
* container: units per size. Null until the booking is split.
*/
@Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true })
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */ /** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true }) @Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
createdByRole?: string | null; createdByRole?: string | null;

View File

@@ -199,6 +199,8 @@ export class BookingRequestService {
reviewedByStaffId: staffId ?? null, reviewedByStaffId: staffId ?? null,
reviewedAt: new Date(), reviewedAt: new Date(),
} as never); } as never);
const contract = await this.contractsService.findById(request.contractId);
this.notifier.shipmentRequestRejected(contract, request.reference, note);
return (await this.repo.findById(requestId)) ?? request; return (await this.repo.findById(requestId)) ?? request;
} }

View File

@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // invoiceService {} as never, // invoiceService
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
); );
return { service, contractsRepository }; return { service, contractsRepository };

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never, invoiceService as never,
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
); );
return { return {

View File

@@ -24,9 +24,11 @@ import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util'; import { hasFreightPermission } from '../../common/freight-permission.util';
@@ -40,6 +42,11 @@ import { CreateBookingUnderContractDto } from './dto/create-booking-under-contra
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
export interface CreateBookingUnderContractResult { export interface CreateBookingUnderContractResult {
booking: Booking; booking: Booking;
warnings: string[]; warnings: string[];
@@ -74,6 +81,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService)) @Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService)) @Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService, private readonly bookingTransitionService: BookingTransitionService,
) {} ) {}
@@ -90,10 +99,22 @@ export class ContractBookingService {
// A contract whose quantity cap was fully booked is completed — no further // A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still // bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking) // open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt. // reopens the contract on the next booking attempt. A ONE_TIME contract
// only closes via a finished split chain, so its room is the outstanding
// split remainder rather than a cap line.
if (contract.status === 'CONTRACT_CLOSED') { if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract); let hasRoom: boolean;
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
hasRoom = outstanding
? contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
: (outstanding.bulk?.outstanding ?? 0) > 0.001
: false;
} else {
const capacity = await this.computeCapacity(contract);
hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
}
if (!hasRoom) { if (!hasRoom) {
throw new BadRequestException( throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.', 'This contract is completed — the full contracted quantity has been booked.',
@@ -121,12 +142,21 @@ export class ContractBookingService {
// ONE_TIME: a single shipment at a time. The slot frees only if the prior // ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping), // booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4). // letting the customer re-book within contract validity (doc §10.4).
// EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
// the slot for the leftover, but the next booking must take the WHOLE
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
if (contract.contractKind === 'ONE_TIME') { if (contract.contractKind === 'ONE_TIME') {
const active = await this.countActiveBookings(contractId); if (await this.hasSplitBooking(contractId)) {
if (active > 0) { await this.assertExactRemainder(contract, dto);
throw new BadRequestException( } else {
'This one-time contract already has an active booking.', const active = await this.countActiveBookings(contractId);
); if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
} }
} else { } else {
// GENERAL: draw down against the cargo quantity cap until it is full. // GENERAL: draw down against the cargo quantity cap until it is full.
@@ -181,6 +211,10 @@ export class ContractBookingService {
scheduledDate: dto.scheduledDate ?? null, scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null, direction: contract.tradeDirection ?? null,
}); });
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
} }
// Hard capacity gate: a container line whose total weight exceeds the // Hard capacity gate: a container line whose total weight exceeds the
@@ -573,6 +607,31 @@ export class ContractBookingService {
Number(booking.cargoTotalWeightVgm) > 0; Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = []; const warnings: string[] = [];
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
// persisted, only the day re-picked) sizes from the booking itself.
if (contract.tradeDirection === 'EXPORT') {
if (hasCargo) {
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{ scheduledDate: new Date(dto.scheduledDate) },
) as Booking;
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
} else {
await this.assertExportTrainSpace(contract, null, dto, {
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
});
}
}
// First completion persists cargo and draws contract capacity; a resubmit // First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day. // the shipment day.
@@ -869,6 +928,196 @@ export class ContractBookingService {
.getCount(); .getCount();
} }
/**
* Whether the contract is in split-remainder mode: some booking on it was
* reduced by a paid partial batch offer and still holds capacity. A split
* booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
* hold and the contract falls back to the plain single-slot rule — the
* customer can rebook the whole quantity again.
*/
private async hasSplitBooking(contractId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.getCount();
return count > 0;
}
/**
* Outstanding split remainder of a ONE_TIME contract: what the FIRST split
* booking carried before its reduction (its pre_split_quantities snapshot —
* one-time contracts have no quantity cap to derive this from) minus
* everything currently booked on the contract. Bookings that never shipped
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
private async splitOutstanding(
contract: Contract,
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.orderBy('b.created_at', 'ASC')
.getOne();
if (!first?.preSplitQuantities) return null;
const booked = await this.bookedQuantities(contract);
if (contract.freightType === 'CONTAINER') {
const bySize = new Map<string, { total: number; outstanding: number }>();
for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
bySize.set(size, {
total: Number(total),
outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
});
}
return { bySize, bulk: null };
}
const total = Number(first.preSplitQuantities.bulkTons ?? 0);
return {
bySize: new Map(),
bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
};
}
/**
* EXPORT whole-booking single-train gate. Export bookings never split — the
* entire booking must ride ONE open train on the chosen day. When no train
* fits it whole (trying every fillable train on the corridor, earliest
* first), reject BEFORE anything is written, with the largest still-bookable
* space (tons for bulk via the cargo type's wagon type; wagons/containers
* for container freight) so the customer knows what he CAN book.
*/
private async assertExportTrainSpace(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<void> {
if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
/**
* Unsaved booking twin carrying exactly what the batch engine's capacity
* math reads: yards + day for the leg, container lines WITH their container
* type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
* (wagon-type FK) for tons→wagons conversion.
*/
private async buildExportProbe(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<Booking> {
const probe = new Booking();
probe.freightType = contract.freightType;
probe.tradeDirection = contract.tradeDirection;
probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
// Entity types are non-nullable; a missing yard just makes legOf() match no
// train, which surfaces as "no export train for this day" — the right failure.
probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
probe.destinationYardId = (yards?.destinationYardId ??
route?.destinationYardId) as string;
if (contract.freightType === 'CONTAINER') {
const lines = await Promise.all(
(dto.containers ?? []).map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const bc = new BookingContainer();
bc.containerSize = line.containerSize;
bc.quantity = line.quantity;
bc.containerTypeId = ct.id;
bc.containerType = ct;
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0),
0,
);
return bc;
}),
);
probe.bookingContainers = lines;
probe.cargoTotalWeightVgm = lines.reduce(
(sum, l) => sum + Number(l.totalVgmTons ?? 0),
0,
);
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
probe.cargoType =
(await this.dataSource
.getRepository(CargoType)
.findOne({ where: { id: cargoTypeId } })) ?? undefined;
}
return probe;
}
/**
* ONE_TIME split chain: the next booking must take the WHOLE outstanding
* remainder — a one-time contract is a single shipment, so the only way it
* fragments is the system splitting it on train capacity, never the customer
* choosing a partial amount.
*/
private async assertExactRemainder(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return; // no live split booking — nothing to pin the remainder to
if (contract.freightType === 'CONTAINER') {
const sizes = new Set<string>([
...outstanding.bySize.keys(),
...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
]);
for (const size of sizes) {
const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
const requested = (dto.containers ?? [])
.filter((l) => (l.containerSize ?? '') === size)
.reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
if (requested !== remaining) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole remainder: ` +
`${remaining} × ${size || 'container'} container(s), got ${requested}.`,
);
}
}
return;
}
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole ` +
`remaining ${remaining} tons, got ${requested}.`,
);
}
}
// ── GENERAL contract quantity cap (draw-down) ────────────────────────────── // ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/** /**
@@ -980,34 +1229,96 @@ export class ContractBookingService {
}); });
} }
/**
* Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
* lines as-is, or — for a ONE_TIME contract in split-remainder mode —
* synthesized lines whose cap is the first split booking's pre-split
* snapshot and whose remaining is the outstanding remainder, i.e. the exact
* quantity the next booking must take.
*/
async capacityView(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const capacity = await this.computeCapacity(contract);
if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
return capacity;
}
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return capacity;
if (contract.freightType === 'CONTAINER') {
return [...outstanding.bySize.entries()].map(([size, s]) => ({
containerSize: size,
cargoTypeId: null,
cap: s.total,
booked: s.total - s.outstanding,
remaining: s.outstanding,
}));
}
const bulk = outstanding.bulk;
if (!bulk) return [];
return [
{
containerSize: null,
cargoTypeId: null,
cap: bulk.total,
booked: round3(bulk.total - bulk.outstanding),
remaining: bulk.outstanding,
},
];
}
/** /**
* Complete the contract once its quantity cap is fully consumed. Runs after * Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including a split remainder * every booking created under a GENERAL contract, and under a ONE_TIME
* being rebooked): when no capped scope line has capacity left, the contract * contract in split-remainder mode (a split remainder being rebooked): when
* moves to CONTRACT_CLOSED even though its validity window is still open — * no capped scope line has capacity left, the contract moves to
* blocking further bookings and shipment requests, including inside an open * CONTRACT_CLOSED even though its validity window is still open — blocking
* booking window. Never throws: a status hiccup must not undo the booking * further bookings and shipment requests, including inside an open booking
* that was just created. * window. Never throws: a status hiccup must not undo the booking that was
* just created.
*/ */
private async maybeCompleteContract(contract: Contract): Promise<void> { private async maybeCompleteContract(contract: Contract): Promise<void> {
try { try {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry // ONE_TIME contracts are governed by the single-active-booking slot, so
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to // they normally complete by expiry — EXCEPT once a booking was split: the
// 3 decimals); container caps are integers and unaffected. // remainder chain draws down the split booking's pre-split snapshot, and
const exhausted = capacity.every( // the contract completes when the outstanding remainder hits zero.
(c) => c.remaining != null && c.remaining <= 0.001, // (An unsplit ONE_TIME never completes here, so re-booking after an
); // expired unpaid booking keeps working.)
if (!exhausted) return; if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return;
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round
// to 3 decimals); container quantities are integers and unaffected.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
} else {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, { await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED', status: 'CONTRACT_CLOSED',
} as never); } as never);
this.logger.log( this.logger.log(
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`, `Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
); );
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
@@ -1025,7 +1336,7 @@ export class ContractBookingService {
private async bookedQuantities( private async bookedQuantities(
contract: Contract, contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> { ): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED']; const releasing = RELEASING_BOOKING_STATUSES;
if (contract.freightType === 'CONTAINER') { if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource const rows = await this.dataSource
.getRepository(BookingContainer) .getRepository(BookingContainer)
@@ -1213,6 +1524,8 @@ export class ContractBookingService {
currency: string | null; currency: string | null;
pairingErrors: string[]; pairingErrors: string[];
capacityErrors: string[]; capacityErrors: string[];
containerClashErrors: string[];
spaceErrors: string[];
lineItems: PriceLineItemDto[]; lineItems: PriceLineItemDto[];
totalAmount: number; totalAmount: number;
}> { }> {
@@ -1227,6 +1540,8 @@ export class ContractBookingService {
currency: null, currency: null,
pairingErrors: [], pairingErrors: [],
capacityErrors: [], capacityErrors: [],
containerClashErrors: [],
spaceErrors: [],
lineItems: [], lineItems: [],
totalAmount: 0, totalAmount: 0,
}; };
@@ -1312,12 +1627,50 @@ export class ContractBookingService {
contract.tradeDirection, contract.tradeDirection,
); );
// A physical container rides one train only — surface a clash with another
// active booking on the same day + route in the preview, so the form can
// hard-block before the create call rejects with the same rule.
let containerClashErrors: string[] = [];
if (dto.scheduledDate) {
const numbers = lines.flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
const clashes = await this.findContainerClashesOnTrain(
[...new Set(numbers)],
dto.scheduledDate,
{
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
);
containerClashErrors = clashes.map(
(c) =>
`${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
);
}
// EXPORT rides whole or not at all — surface the single-train space check
// in the preview so the form hard-blocks BEFORE the create call rejects
// with the same message (including how much space is still bookable).
let spaceErrors: string[] = [];
if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
const probe = await this.buildExportProbe(contract, route, dto);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
}
}
return { return {
overweightLines: computed.overweightLines, overweightLines: computed.overweightLines,
overweightSurchargeAmount, overweightSurchargeAmount,
currency: computed.currency, currency: computed.currency,
pairingErrors, pairingErrors,
capacityErrors, capacityErrors,
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems, lineItems: computed.lineItems,
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
}; };
@@ -1445,6 +1798,37 @@ export class ContractBookingService {
route: { originYardId?: string | null; destinationYardId?: string | null }, route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string, excludeBookingId?: string,
): Promise<void> { ): Promise<void> {
const clashes = await this.findContainerClashesOnTrain(
numbers,
scheduledDate,
route,
excludeBookingId,
);
if (clashes.length) {
const detail = clashes
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
/**
* Container numbers among `numbers` that already sit on another active
* booking of the same train — same day and same route. One row per clashing
* number. Bookings without route yards (legacy rows) match on the day alone
* rather than let through.
*/
private async findContainerClashesOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<Array<{ containerNumber: string; reference: string }>> {
if (!numbers.length) return [];
const qb = this.dataSource const qb = this.dataSource
.getRepository(BookingContainerUnit) .getRepository(BookingContainerUnit)
.createQueryBuilder('unit') .createQueryBuilder('unit')
@@ -1474,18 +1858,7 @@ export class ContractBookingService {
} }
const clashes: Array<{ containerNumber: string; reference: string }> = const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany(); await qb.getRawMany();
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
} }
private async assert20ftPairableAtCreate( private async assert20ftPairableAtCreate(
@@ -1528,8 +1901,8 @@ export class ContractBookingService {
preferReefer: boolean, preferReefer: boolean,
): Promise<ContainerType> { ): Promise<ContainerType> {
const sizeFt = parseInt(size, 10); const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 }); const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false); const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) { if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`); throw new BadRequestException(`No container type configured for size ${size}.`);
} }

View File

@@ -145,6 +145,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract changes requested', msg); this.inApp(c, 'Contract changes requested', msg);
} }
/** GL rejected a shipment request filed under the contract. */
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
const msg =
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
(note ? ` Reason: ${note}.` : '') +
` Please contact us for details.`;
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
this.inApp(c, 'Shipment request rejected', msg, {
type: NotificationType.BOOKING_STATUS,
data: { contractId: c.id, reference: requestRef },
});
}
// ── Clearance milestones needing customer action ────────────────────────── // ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -80,9 +80,9 @@ export class ContractPricingService {
const sizes = (contract.cargoScope ?? []) const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize) .map((c) => c.containerSize)
.filter((s): s is string => !!s); .filter((s): s is string => !!s);
const { data: containerTypes } = await this.containerTypesService.findAll({ const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true, isActive: true,
pageSize: 500, pageSize: 100,
}); });
for (const size of sizes) { for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20; const sizeFt = size === '40ft' ? 40 : 20;

View File

@@ -852,11 +852,12 @@ export class ContractsController {
@Get(':id/capacity') @Get(':id/capacity')
@ApiOperation({ @ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
}) })
async capacity(@Param('id', ParseUUIDPipe) id: string) { async capacity(@Param('id', ParseUUIDPipe) id: string) {
const contract = await this.contractsService.findById(id); const contract = await this.contractsService.findById(id);
return this.contractBookingService.computeCapacity(contract); return this.contractBookingService.capacityView(contract);
} }
// ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── // ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────

View File

@@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
options: ContractListFilterOptions & { options: ContractListFilterOptions & {
page: number; page: number;
pageSize: number; pageSize: number;
search?: string;
sortBy?: string; sortBy?: string;
sortOrder?: 'ASC' | 'DESC'; sortOrder?: 'ASC' | 'DESC';
}, },
@@ -128,6 +129,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
this.applyListFilters(qb, options); this.applyListFilters(qb, options);
// Free-text search across contract reference and customer (company) name.
// Applied here (not in applyListFilters) because only this query joins the
// `company` alias — the summary-metrics query builder does not.
if (options.search) {
qb.andWhere(
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
{ search: `%${options.search}%` },
);
}
const sortField = const sortField =
options.sortBy === 'contractValidUntil' options.sortBy === 'contractValidUntil'
? 'contract.contractValidUntil' ? 'contract.contractValidUntil'

View File

@@ -579,6 +579,7 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency, paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom, createdFrom: filter.createdFrom,
createdTo: filter.createdTo, createdTo: filter.createdTo,
search: filter.search,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });

View File

@@ -71,6 +71,15 @@ export class FilterContractDto {
@IsDateString() @IsDateString()
createdTo?: string; createdTo?: string;
@ApiPropertyOptional({
description: 'Free-text search across contract reference and company name.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1)) @Transform(({ value }) => (value ? parseInt(value, 10) : 1))

View File

@@ -10,12 +10,14 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards"; import { FreightAdmin } from "../../common/booking-guards";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownSettingsService } from "./dropdown-settings.service"; import { DropdownSettingsService } from "./dropdown-settings.service";
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
return this.service.list(); return this.service.list();
} }
// Must be declared before @Get(":id") so "paged" isn't captured as an id.
@Get("paged")
@ApiOperation({
summary: "Paged admin listing of dropdown settings (server-side search)",
})
listPaged(@Query() query: ListDropdownSettingsQueryDto) {
return this.service.listPaged(query);
}
@Get(":id") @Get(":id")
@ApiOperation({ summary: "Get a dropdown setting by ID" }) @ApiOperation({ summary: "Get a dropdown setting by ID" })
getById(@Param("id", ParseUUIDPipe) id: string) { getById(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1,8 +1,11 @@
import { BaseRepository } from "@edr/api-common"; import { BaseRepository } from "@edr/api-common";
import { PaginatedResponse } from "@edr/types";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { paginateQuery } from "../../common/utils/pagination.util";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { DropdownOption } from "./entities/dropdown-option.entity"; import { DropdownOption } from "./entities/dropdown-option.entity";
import { DropdownSetting } from "./entities/dropdown-setting.entity"; import { DropdownSetting } from "./entities/dropdown-setting.entity";
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface"; import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
@@ -44,6 +47,27 @@ export class DropdownSettingsRepository
}); });
} }
findPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>> {
// Soft-deleted rows are excluded automatically by the query builder
// (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC).
const qb = this.repository
.createQueryBuilder("setting")
.leftJoinAndSelect("setting.children", "option")
.orderBy("setting.label", query.sortOrder ?? "ASC")
.addOrderBy("option.order", "ASC");
if (query.search) {
qb.andWhere(
"(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)",
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async replaceOptions( async replaceOptions(
settingId: string, settingId: string,
options: Array<Partial<DropdownOption>>, options: Array<Partial<DropdownOption>>,

View File

@@ -5,8 +5,11 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { PaginatedResponse } from "@edr/types";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownOption } from "./entities/dropdown-option.entity"; import { DropdownOption } from "./entities/dropdown-option.entity";
@@ -16,71 +19,6 @@ import {
IDropdownSettingsRepository, IDropdownSettingsRepository,
} from "./interfaces/dropdown-settings.repository.interface"; } from "./interfaces/dropdown-settings.repository.interface";
const STATIONS_TER_CODE = "stations_ter";
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
{
value: "inside_addis_ababa",
label: "Addis Ababa",
note: "Inside country",
order: 1,
},
{
value: "inside_adama",
label: "Adama",
note: "Inside country",
order: 2,
},
{
value: "inside_mojo",
label: "Mojo",
note: "Inside country",
order: 3,
},
{
value: "inside_awash",
label: "Awash",
note: "Inside country",
order: 4,
},
{
value: "inside_mieso",
label: "Mieso",
note: "Inside country",
order: 5,
},
{
value: "inside_dire_dawa",
label: "Dire Dawa",
note: "Inside country",
order: 6,
},
{
value: "outside_ali_sabieh",
label: "Ali Sabieh",
note: "Outside country",
order: 7,
},
{
value: "outside_holhol",
label: "Holhol",
note: "Outside country",
order: 8,
},
{
value: "outside_djibouti_city",
label: "Djibouti City",
note: "Outside country",
order: 9,
},
{
value: "outside_doraleh_terminal",
label: "Doraleh Terminal",
note: "Outside country",
order: 10,
},
];
@Injectable() @Injectable()
export class DropdownSettingsService { export class DropdownSettingsService {
constructor( constructor(
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
return this.repository.findAll(); return this.repository.findAll();
} }
listPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>> {
return this.repository.findPaged(query);
}
async getById(id: string): Promise<DropdownSetting> { async getById(id: string): Promise<DropdownSetting> {
const setting = await this.repository.findById(id); const setting = await this.repository.findById(id);
if (!setting) throw new NotFoundException(`Setting ${id} not found`); if (!setting) throw new NotFoundException(`Setting ${id} not found`);
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
return this.getById(setting.id); return this.getById(setting.id);
} }
async seedDefaultStations(): Promise<void> {
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
if (!existing) {
await this.create({
code: STATIONS_TER_CODE,
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: {
searchable: true,
clearable: true,
version: "temporary",
},
children: DEFAULT_STATION_OPTIONS,
});
return;
}
if ((existing.children?.length ?? 0) === 0) {
await this.repository.replaceOptions(
existing.id,
DEFAULT_STATION_OPTIONS,
);
}
}
async update( async update(
id: string, id: string,
dto: UpdateDropdownSettingDto, dto: UpdateDropdownSettingDto,

View File

@@ -0,0 +1,8 @@
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
/**
* Query params for the paged admin listing (`GET /dropdown-settings/paged`).
* `search` matches code, label and description server-side. The entity has no
* status/isActive flag, so the base pagination fields are all that's needed.
*/
export class ListDropdownSettingsQueryDto extends PaginationQueryDto {}

View File

@@ -1,3 +1,6 @@
import { PaginatedResponse } from "@edr/types";
import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto";
import { DropdownOption } from "../entities/dropdown-option.entity"; import { DropdownOption } from "../entities/dropdown-option.entity";
import { DropdownSetting } from "../entities/dropdown-setting.entity"; import { DropdownSetting } from "../entities/dropdown-setting.entity";
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
export interface IDropdownSettingsRepository { export interface IDropdownSettingsRepository {
findAll(): Promise<DropdownSetting[]>; findAll(): Promise<DropdownSetting[]>;
findPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>>;
findById(id: string): Promise<DropdownSetting | null>; findById(id: string): Promise<DropdownSetting | null>;
findByCode(code: string): Promise<DropdownSetting | null>; findByCode(code: string): Promise<DropdownSetting | null>;

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
@@ -19,15 +20,8 @@ export class ApprovalRulesController {
@Get() @Get()
@RuleEngineView('approval-rules') @RuleEngineView('approval-rules')
@ApiOperation({ summary: 'List approval rules' }) @ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListApprovalRulesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
requiresDirectorApproval:
query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Get('chain') @Get('chain')

View File

@@ -5,6 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
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 { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
@@ -19,19 +20,8 @@ export class CargoTypesController {
@Get() @Get()
@RuleEngineView('cargo-types') @RuleEngineView('cargo-types')
@ApiOperation({ summary: 'List cargo types' }) @ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListCargoTypesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
parentGroupId: query['parentGroupId'],
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
} }
@Post('reorder') @Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
@@ -19,12 +20,8 @@ export class ContainerTypesController {
@Get() @Get()
@RuleEngineView('container-types') @RuleEngineView('container-types')
@ApiOperation({ summary: 'List container types' }) @ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListContainerTypesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Post('reorder') @Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
@@ -19,13 +20,8 @@ export class PriorityConfigsController {
@Get() @Get()
@RuleEngineView('priority-configs') @RuleEngineView('priority-configs')
@ApiOperation({ summary: 'List priority configs' }) @ApiOperation({ summary: 'List priority configs' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListPriorityConfigsQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Get(':id') @Get(':id')

View File

@@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateRateDto } from '../dto/create-rate.dto'; import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
@@ -22,13 +23,8 @@ export class RatesController {
@Get() @Get()
@RuleEngineView('rates') @RuleEngineView('rates')
@ApiOperation({ summary: 'List rates' }) @ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListRatesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
status: query['status'],
rateType: query['rateType'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Get('live') @Get('live')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
@@ -19,16 +20,8 @@ export class ServiceTypesController {
@Get() @Get()
@RuleEngineView('service-types') @RuleEngineView('service-types')
@ApiOperation({ summary: 'List service types' }) @ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListServiceTypesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
} }
@Post('reorder') @Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLinesService } from '../services/shipping-lines.service'; import { ShippingLinesService } from '../services/shipping-lines.service';
@@ -17,12 +18,8 @@ export class ShippingLinesController {
@Get() @Get()
@RuleEngineView('shipping-lines') @RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'List shipping lines' }) @ApiOperation({ summary: 'List shipping lines' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListRuleEngineQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Get(':id') @Get(':id')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRulesService } from '../services/weight-limit-rules.service'; import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
@@ -17,13 +18,8 @@ export class WeightLimitRulesController {
@Get() @Get()
@RuleEngineView('weight-limit-rules') @RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'List weight limit rules' }) @ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListWeightLimitRulesQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
tradeDirection: query['tradeDirection'],
containerTypeId: query['containerTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Get(':id') @Get(':id')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto'; import { CreateYardDto } from '../dto/create-yard.dto';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto'; import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto'; import { UpdateYardDto } from '../dto/update-yard.dto';
@@ -19,13 +20,8 @@ export class YardsController {
@Get() @Get()
@RuleEngineView('yards') @RuleEngineView('yards')
@ApiOperation({ summary: 'List yards' }) @ApiOperation({ summary: 'List yards' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: ListYardsQueryDto) {
return this.service.findAll({ return this.service.findAll(query);
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
country: query['country'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
} }
@Post('reorder') @Post('reorder')

View File

@@ -0,0 +1,129 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
/**
* Query-string booleans arrive as strings; implicit conversion is disabled
* app-wide, so coerce explicitly. Mirrors the previous controller behaviour
* (`query['flag'] === 'true'`): only the literal "true" is truthy.
*/
const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined =>
value === undefined || value === null || value === '' ? undefined : value === true || value === 'true';
/**
* Shared list query for rule-engine resources. Every rule-engine list endpoint
* returns the standard `PaginatedResponse` envelope (`items` + `meta`) built by
* `common/utils/pagination.util.ts`; `search` is applied server-side against
* each resource's human-readable columns (see the repository `findPaged`).
*/
export class ListRuleEngineQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by active flag.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isActive?: boolean;
}
export class ListCargoTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by director-approval requirement.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ description: 'Filter by parent cargo-type group.' })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ enum: ['displayOrder', 'cargoTypeName', 'code', 'createdAt'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder', 'cargoTypeName', 'code', 'createdAt'])
sortBy?: string;
}
export class ListContainerTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListPriorityConfigsQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ enum: ['WAGON', 'CURRENCY', 'CUSTOMS'] })
@IsOptional()
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListServiceTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by standalone-bookable flag.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ enum: ['displayOrder', 'serviceName', 'code', 'createdAt'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder', 'serviceName', 'code', 'createdAt'])
sortBy?: string;
}
export class ListYardsQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by yard country.' })
@IsOptional()
@IsString()
@MaxLength(50)
country?: string;
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ enum: ['stepOrder'], default: 'stepOrder' })
@IsOptional()
@IsIn(['stepOrder'])
sortBy?: string;
}
export class ListRatesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by rate status (DRAFT, PENDING_APPROVAL, LIVE...).' })
@IsOptional()
@IsString()
@MaxLength(20)
status?: string;
@ApiPropertyOptional({ description: 'Filter by derived rate type.' })
@IsOptional()
@IsString()
@MaxLength(50)
rateType?: string;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by container type.' })
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({ description: 'Filter by trade direction (IMPORT/EXPORT/BOTH).' })
@IsOptional()
@IsString()
@MaxLength(10)
tradeDirection?: string;
}

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ApprovalRule } from '../entities/approval-rule.entity'; import { ApprovalRule } from '../entities/approval-rule.entity';
export interface IApprovalRulesRepository { export interface IApprovalRulesRepository {
@@ -6,6 +8,7 @@ export interface IApprovalRulesRepository {
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>; findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>; findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>; findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>>;
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>; create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>; update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { CargoType } from '../entities/cargo-type.entity'; import { CargoType } from '../entities/cargo-type.entity';
export interface ICargoTypesRepository { export interface ICargoTypesRepository {
@@ -6,6 +8,7 @@ export interface ICargoTypesRepository {
findByCode(code: string): Promise<CargoType | null>; findByCode(code: string): Promise<CargoType | null>;
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>; findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>; findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>>;
create(data: Partial<CargoType>): Promise<CargoType>; create(data: Partial<CargoType>): Promise<CargoType>;
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>; update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ContainerType } from '../entities/container-type.entity'; import { ContainerType } from '../entities/container-type.entity';
export interface IContainerTypesRepository { export interface IContainerTypesRepository {
@@ -6,6 +8,7 @@ export interface IContainerTypesRepository {
findByCode(code: string): Promise<ContainerType | null>; findByCode(code: string): Promise<ContainerType | null>;
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>; findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>; findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>>;
create(data: Partial<ContainerType>): Promise<ContainerType>; create(data: Partial<ContainerType>): Promise<ContainerType>;
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>; update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { PriorityConfig } from '../entities/priority-config.entity'; import { PriorityConfig } from '../entities/priority-config.entity';
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY'); export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
@@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository {
findById(id: string): Promise<PriorityConfig | null>; findById(id: string): Promise<PriorityConfig | null>;
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>; findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>; findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>>;
findAllActive(): Promise<PriorityConfig[]>; findAllActive(): Promise<PriorityConfig[]>;
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>; create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>; update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { Rate } from '../entities/rate.entity'; import { Rate } from '../entities/rate.entity';
export interface IRatesRepository { export interface IRatesRepository {
@@ -13,6 +15,7 @@ export interface IRatesRepository {
}): Promise<Rate | null>; }): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>; findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>; findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>>;
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>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ServiceType } from '../entities/service-type.entity'; import { ServiceType } from '../entities/service-type.entity';
export interface IServiceTypesRepository { export interface IServiceTypesRepository {
@@ -6,6 +8,7 @@ export interface IServiceTypesRepository {
findByCode(code: string): Promise<ServiceType | null>; findByCode(code: string): Promise<ServiceType | null>;
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>; findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>; findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>>;
create(data: Partial<ServiceType>): Promise<ServiceType>; create(data: Partial<ServiceType>): Promise<ServiceType>;
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>; update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { ShippingLine } from '../entities/shipping-line.entity'; import { ShippingLine } from '../entities/shipping-line.entity';
export interface IShippingLinesRepository { export interface IShippingLinesRepository {
@@ -6,6 +8,7 @@ export interface IShippingLinesRepository {
findByCode(code: string): Promise<ShippingLine | null>; findByCode(code: string): Promise<ShippingLine | null>;
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>; findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>; findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>>;
create(data: Partial<ShippingLine>): Promise<ShippingLine>; create(data: Partial<ShippingLine>): Promise<ShippingLine>;
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>; update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
export interface IWeightLimitRulesRepository { export interface IWeightLimitRulesRepository {
@@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository {
): Promise<WeightLimitRule | null>; ): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>; findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>; findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>; create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>; update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from 'typeorm';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { Yard } from '../entities/yard.entity'; import { Yard } from '../entities/yard.entity';
export interface IYardsRepository { export interface IYardsRepository {
@@ -6,6 +8,7 @@ export interface IYardsRepository {
findByCode(code: string): Promise<Yard | null>; findByCode(code: string): Promise<Yard | null>;
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>; findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>; findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
create(data: Partial<Yard>): Promise<Yard>; create(data: Partial<Yard>): Promise<Yard>;
update(id: string, data: Partial<Yard>): Promise<Yard | null>; update(id: string, data: Partial<Yard>): Promise<Yard | null>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ApprovalRule } from '../entities/approval-rule.entity'; import { ApprovalRule } from '../entities/approval-rule.entity';
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface'; import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
@@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/**
* Paged list in the standard envelope. Chain grouping is preserved: rows are
* grouped by chain (requiresDirectorApproval) first, then step order.
*/
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
const qb = this.repo
.createQueryBuilder('rule')
.orderBy('rule.requiresDirectorApproval', 'ASC')
.addOrderBy('rule.stepOrder', query.sortOrder ?? 'ASC');
if (query.requiresDirectorApproval !== undefined) {
qb.andWhere('rule.requiresDirectorApproval = :requiresDirectorApproval', {
requiresDirectorApproval: query.requiresDirectorApproval,
});
}
if (query.search) {
qb.andWhere(
'(rule.actionLabel ILIKE :search OR rule.requiredRole ILIKE :search OR rule.blocksRole ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> { async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { CargoType } from '../entities/cargo-type.entity'; import { CargoType } from '../entities/cargo-type.entity';
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface'; import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
@@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (name/code) in the standard envelope. */
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
const qb = this.repo
.createQueryBuilder('cargoType')
.leftJoinAndSelect('cargoType.parent', 'parent')
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('cargoType.isActive = :isActive', { isActive: query.isActive });
}
if (query.requiresDirectorApproval !== undefined) {
qb.andWhere('cargoType.requiresDirectorApproval = :requiresDirectorApproval', {
requiresDirectorApproval: query.requiresDirectorApproval,
});
}
if (query.parentGroupId !== undefined) {
qb.andWhere('cargoType.parentGroupId = :parentGroupId', { parentGroupId: query.parentGroupId });
}
if (query.search) {
qb.andWhere('(cargoType.cargoTypeName ILIKE :search OR cargoType.code ILIKE :search)', {
search: `%${query.search}%`,
});
}
return paginateQuery(qb, query);
}
async create(data: Partial<CargoType>): Promise<CargoType> { async create(data: Partial<CargoType>): Promise<CargoType> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ContainerType } from '../entities/container-type.entity'; import { ContainerType } from '../entities/container-type.entity';
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface'; import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
@@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (label/code) in the standard envelope. */
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
const qb = this.repo
.createQueryBuilder('containerType')
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('containerType.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere('(containerType.label ILIKE :search OR containerType.code ILIKE :search)', {
search: `%${query.search}%`,
});
}
return paginateQuery(qb, query);
}
async create(data: Partial<ContainerType>): Promise<ContainerType> { async create(data: Partial<ContainerType>): Promise<ContainerType> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { PriorityConfig } from '../entities/priority-config.entity'; import { PriorityConfig } from '../entities/priority-config.entity';
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface'; import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
@@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (label/type/currency) in the standard envelope. */
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
const qb = this.repo
.createQueryBuilder('config')
.orderBy(`config.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.type !== undefined) {
qb.andWhere('config.type = :type', { type: query.type });
}
if (query.isActive !== undefined) {
qb.andWhere('config.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere(
'(config.label ILIKE :search OR config.type ILIKE :search OR config.currency ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async findAllActive(): Promise<PriorityConfig[]> { async findAllActive(): Promise<PriorityConfig[]> {
return this.repo.find({ return this.repo.find({
where: { isActive: true }, where: { isActive: true },

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { Rate } from '../entities/rate.entity'; import { Rate } from '../entities/rate.entity';
import { IRatesRepository } from '../interfaces/rates.repository.interface'; import { IRatesRepository } from '../interfaces/rates.repository.interface';
@@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (type/status/unit/currency), newest first. */
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
const qb = this.repo
.createQueryBuilder('rate')
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
if (query.status) {
qb.andWhere('rate.status = :status', { status: query.status });
}
if (query.rateType) {
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<Rate>): Promise<Rate> { async create(data: Partial<Rate>): Promise<Rate> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ServiceType } from '../entities/service-type.entity'; import { ServiceType } from '../entities/service-type.entity';
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface'; import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
@@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (name/code/description) in the standard envelope. */
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
const qb = this.repo
.createQueryBuilder('serviceType')
.orderBy(`serviceType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('serviceType.isActive = :isActive', { isActive: query.isActive });
}
if (query.canBeBookedAlone !== undefined) {
qb.andWhere('serviceType.canBeBookedAlone = :canBeBookedAlone', {
canBeBookedAlone: query.canBeBookedAlone,
});
}
if (query.search) {
qb.andWhere(
'(serviceType.serviceName ILIKE :search OR serviceType.code ILIKE :search OR serviceType.description ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ServiceType>): Promise<ServiceType> { async create(data: Partial<ServiceType>): Promise<ServiceType> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { ShippingLine } from '../entities/shipping-line.entity'; import { ShippingLine } from '../entities/shipping-line.entity';
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface'; import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
@@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (label/code/mappedToCode), ordered by code. */
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
const qb = this.repo
.createQueryBuilder('line')
.orderBy('line.code', query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('line.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere(
'(line.label ILIKE :search OR line.code ILIKE :search OR line.mappedToCode ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ShippingLine>): Promise<ShippingLine> { async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface'; import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
@@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/**
* Paged list with the container relation loaded, newest first. `search`
* matches the trade direction and the joined container type's label/code.
*/
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
const qb = this.repo
.createQueryBuilder('rule')
.leftJoinAndSelect('rule.containerType', 'containerType')
.orderBy('rule.createdAt', query.sortOrder ?? 'DESC');
if (query.containerTypeId) {
qb.andWhere('rule.containerTypeId = :containerTypeId', {
containerTypeId: query.containerTypeId,
});
}
if (query.tradeDirection) {
qb.andWhere('rule.tradeDirection = :tradeDirection', { tradeDirection: query.tradeDirection });
}
if (query.search) {
qb.andWhere(
'(rule.tradeDirection ILIKE :search OR containerType.label ILIKE :search OR containerType.code ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> { async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm'; import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { Yard } from '../entities/yard.entity'; import { Yard } from '../entities/yard.entity';
import { IYardsRepository } from '../interfaces/yards.repository.interface'; import { IYardsRepository } from '../interfaces/yards.repository.interface';
@@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository {
return this.repo.findAndCount(options); return this.repo.findAndCount(options);
} }
/** Paged list with server-side search (label/code/country) in the standard envelope. */
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
const qb = this.repo
.createQueryBuilder('yard')
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
.addOrderBy('yard.label', 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('yard.isActive = :isActive', { isActive: query.isActive });
}
if (query.country) {
qb.andWhere('yard.country = :country', { country: query.country });
}
if (query.search) {
qb.andWhere(
'(yard.label ILIKE :search OR yard.code ILIKE :search OR yard.country ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<Yard>): Promise<Yard> { async create(data: Partial<Yard>): Promise<Yard> {
const entity = this.repo.create(data); const entity = this.repo.create(data);
return this.repo.save(entity); return this.repo.save(entity);

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRule } from '../entities/approval-rule.entity'; import { ApprovalRule } from '../entities/approval-rule.entity';
@@ -17,26 +19,9 @@ export class ApprovalRulesService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
/** List approval rules. */ /** List approval rules — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
requiresDirectorApproval?: boolean; return this.repository.findPaged(query);
page?: number;
pageSize?: number;
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
}
const [data, total] = await this.repository.findAndCount({
where,
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get approval chain for a cargo type flag. */ /** Get approval chain for a cargo type flag. */

View File

@@ -1,7 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
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 { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity'; import { CargoType } from '../entities/cargo-type.entity';
@@ -19,33 +20,9 @@ export class CargoTypesService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
/** List cargo types with pagination and optional filtering. */ /** List cargo types — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
isActive?: boolean; return this.repository.findPaged(query);
requiresDirectorApproval?: boolean;
parentGroupId?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId;
if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
relations: { parent: true },
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a single cargo type by ID. */ /** Get a single cargo type by ID. */

View File

@@ -1,6 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util'; import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity'; import { ContainerType } from '../entities/container-type.entity';
@@ -18,24 +20,9 @@ export class ContainerTypesService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
/** List container types with pagination. */ /** List container types — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
isActive?: boolean; return this.repository.findPaged(query);
page?: number;
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a single container type by ID. */ /** Get a single container type by ID. */

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import { PriorityConfig } from '../entities/priority-config.entity'; import { PriorityConfig } from '../entities/priority-config.entity';
import { import {
@@ -16,25 +18,9 @@ export class PriorityConfigsService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
async findAll(filter: { /** List priority configs — standard paginated envelope with server-side search. */
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; async findAll(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
isActive?: boolean; return this.repository.findPaged(query);
page?: number;
pageSize?: number;
}): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.type !== undefined) where.type = filter.type;
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
async findById(id: string): Promise<PriorityConfig> { async findById(id: string): Promise<PriorityConfig> {

View File

@@ -5,7 +5,9 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { CreateRateDto } from '../dto/create-rate.dto'; import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity'; import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util'; import { deriveRateType } from '../entities/rate-type.util';
@@ -19,26 +21,9 @@ export class RatesService {
private readonly repository: IRatesRepository, private readonly repository: IRatesRepository,
) {} ) {}
/** List rates with pagination. */ /** List rates — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
status?: string; return this.repository.findPaged(query);
rateType?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.rateType) where.rateType = filter.rateType;
const [data, total] = await this.repository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } };
} }
/** Return all currently LIVE rates. */ /** Return all currently LIVE rates. */

View File

@@ -1,7 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util'; import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity'; import { ServiceType } from '../entities/service-type.entity';
@@ -19,30 +20,9 @@ export class ServiceTypesService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
/** List service types with pagination and optional filtering. */ /** List service types — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
isActive?: boolean; return this.repository.findPaged(query);
canBeBookedAlone?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
if (filter.search) where.serviceName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a single service type by ID. */ /** Get a single service type by ID. */

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLine } from '../entities/shipping-line.entity'; import { ShippingLine } from '../entities/shipping-line.entity';
import { import {
@@ -14,24 +16,9 @@ export class ShippingLinesService {
private readonly repository: IShippingLinesRepository, private readonly repository: IShippingLinesRepository,
) {} ) {}
/** List shipping lines with pagination. */ /** List shipping lines — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
isActive?: boolean; return this.repository.findPaged(query);
page?: number;
pageSize?: number;
}): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a shipping line by ID. */ /** Get a shipping line by ID. */

View File

@@ -5,7 +5,9 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import { import {
@@ -20,27 +22,9 @@ export class WeightLimitRulesService {
private readonly repository: IWeightLimitRulesRepository, private readonly repository: IWeightLimitRulesRepository,
) {} ) {}
/** List weight limit rules with pagination. */ /** List weight limit rules — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
containerTypeId?: string; return this.repository.findPaged(query);
tradeDirection?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a single weight limit rule by ID. */ /** Get a single weight limit rule by ID. */

View File

@@ -1,6 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util'; import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateYardDto } from '../dto/create-yard.dto'; import { CreateYardDto } from '../dto/create-yard.dto';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto'; import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity'; import { Yard } from '../entities/yard.entity';
@@ -15,26 +17,9 @@ export class YardsService {
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
/** List yards with pagination. */ /** List yards — standard paginated envelope with server-side search. */
async findAll(filter: { async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
isActive?: boolean; return this.repository.findPaged(query);
country?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.country) where.country = filter.country;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC', label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
} }
/** Get a yard by ID. */ /** Get a yard by ID. */

View File

@@ -0,0 +1,140 @@
import { ConflictException } from '@nestjs/common';
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Export whole-booking single-train gate: an export booking never splits — it
* rides one train whole or is rejected. The report must try every fillable
* train on the day (first full → use the second), and when none fits, say how
* much space is still bookable so the customer knows what he CAN book.
*/
describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => {
const DAY = new Date('2026-07-20T10:00:00Z');
const schedule = (id: string) => ({
id,
status: 'SCHEDULED',
direction: 'EXPORT',
scheduledDepartureDate: DAY,
bookingWindowStatus: 'OPEN',
windowPhase: null, // legacy gate: OPEN alone makes it fillable
});
const fullGraph = (id: string) => ({
id,
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null, // legacy two-stop pseudo-route — no milestone query
scheduleBookings: [],
trainSet: {
locomotive: {
maxPullWeightTons: 500,
maxTrainLengthMeters: 140,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
},
});
const exportBooking = (cargoTons: number) =>
({
id: 'bk-exp',
freightType: 'BULK',
tradeDirection: 'EXPORT',
scheduledDate: DAY,
originYardId: 'yard-a',
destinationYardId: 'yard-b',
cargoTotalWeightVgm: cargoTons,
bookingContainers: [],
}) as unknown as Booking;
// A reserved bulk booking heavy enough to exhaust the 500t pull budget.
const heavyReserved = {
id: 'bk-heavy',
freightType: 'BULK',
originYardId: 'yard-a',
destinationYardId: 'yard-b',
cargoTotalWeightVgm: 476,
bookingContainers: [],
} as unknown as Booking;
let service: BookingBatchService;
let trainSchedulesRepository: {
findAll: jest.Mock;
findByIdWithFullGraph: jest.Mock;
findById: jest.Mock;
};
let bookingsRepository: { findReservedForSchedule: jest.Mock };
beforeEach(() => {
trainSchedulesRepository = {
findAll: jest.fn().mockResolvedValue([]),
findByIdWithFullGraph: jest
.fn()
.mockImplementation(async (id: string) => fullGraph(id)),
findById: jest.fn(),
};
bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) };
// WagonType.find() → [] so representative default dims apply (bulk 60t
// payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null).
const genericRepo = { find: jest.fn().mockResolvedValue([]) };
const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) };
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
{} as never, // trainScheduleBookingsRepository
{} as never, // notifier
{} as never, // scheduler
{} as never, // trainSchedulingService
{} as never, // billing
{} as never, // bookingWindowGateway
{} as never, // pricingService
);
});
it('uses the other train when the first one is full', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
schedule('train-1'),
schedule('train-2'),
]);
bookingsRepository.findReservedForSchedule.mockImplementation(
async (id: string) => (id === 'train-1' ? [heavyReserved] : []),
);
const report = await service.exportSpaceReport(exportBooking(60));
expect(report.scheduleId).toBe('train-2');
});
it('rejects a booking no single train fits and reports the bookable space', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]);
const report = await service.exportSpaceReport(exportBooking(900));
expect(report.scheduleId).toBeNull();
expect(report.bestAvailable).not.toBeNull();
expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0);
expect(report.bestAvailable!.cargoTons).toBeLessThan(900);
expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/);
expect(report.fullMessage).toMatch(/single train whole/);
await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow(
ConflictException,
);
});
it('says no train is accepting bookings when the day has none', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([]);
const report = await service.exportSpaceReport(exportBooking(60));
expect(report.scheduleId).toBeNull();
expect(report.fullMessage).toBe(
'No export train is accepting bookings for this day',
);
});
});

View File

@@ -38,8 +38,16 @@ import {
BATCH_BOARD_STATUSES, BATCH_BOARD_STATUSES,
BatchBoardQueryDto, BatchBoardQueryDto,
} from './dto/batch-board-query.dto'; } from './dto/batch-board-query.dto';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import {
Freight,
PaginatedResponse,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from "@edr/types";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "../billing/billing.service";
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { import {
@@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util';
*/ */
type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
/**
* Result of the export whole-booking single-train space check. `scheduleId`
* is the earliest fillable train that carries the whole booking, or null when
* none can — then `bestAvailable` reports the largest single-train leftover
* in the booking's own units and `fullMessage` is the customer-facing copy.
*/
export interface ExportSpaceReport {
scheduleId: string | null;
trainsForDay: boolean;
corridorMatched: boolean;
need: Capacity;
bestAvailable: { wagons: number; cargoTons: number } | null;
fullMessage: string | null;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */ /** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup { interface RouteDayGroup {
originYardId: string; originYardId: string;
@@ -241,15 +264,10 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[]; bookings: BatchBoardBooking[];
} }
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper /** Paginated batch-board list in the shared `{items, meta}` envelope — the API
* already uses `data`, and the frontend's unwrap() strips one `data` level. */ * response wrapper already uses `data`, and the frontend's unwrap() strips one
export interface BatchBoardListResponse { * `data` level. */
items: BatchBoardSchedule[]; export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** /**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
@@ -542,12 +560,18 @@ export class BookingBatchService implements OnModuleInit {
// ---- export FCFS ----------------------------------------------------------- // ---- export FCFS -----------------------------------------------------------
/** /**
* Export is first-come-first-serve: no window cycle, no priority, no batch. * Whole-booking single-train space report for an EXPORT booking. Export
* Pick the earliest open export train on the booking's corridor/day that still * bookings never split — the entire booking must ride ONE train, so the
* fits the booking. Throws ConflictException when every train is full — the * report scans every fillable export train on the booking's corridor/day
* staff accept fails and no more export bookings are taken. * (earliest first) for one whose remaining budget fits the whole need. When
* none fits, `bestAvailable` carries the largest single-train leftover
* converted into the booking's own units (base caps, no overage tolerance)
* so the customer can be told exactly how much he COULD book on that day.
*/ */
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> { async exportSpaceReport(
booking: Booking,
need?: Capacity,
): Promise<ExportSpaceReport> {
if (!booking.scheduledDate) { if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date'); throw new BadRequestException('Booking has no scheduled date');
} }
@@ -573,15 +597,19 @@ export class BookingBatchService implements OnModuleInit {
(a, b) => (a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
); );
if (!candidates.length) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims); const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false; const dims = this.dimsFor(booking, wagonDims);
const report: ExportSpaceReport = {
scheduleId: null,
trainsForDay: candidates.length > 0,
corridorMatched: false,
need: required,
bestAvailable: null,
fullMessage: null,
};
for (const candidate of candidates) { for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id, candidate.id,
@@ -592,15 +620,101 @@ export class BookingBatchService implements OnModuleInit {
const budget = await this.remainingBudget(schedule, limits, wagonDims); const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId); const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true; report.corridorMatched = true;
if (budget.fits(required, leg)) return schedule.id; if (budget.fits(required, leg)) {
// Earliest fitting train wins — no need to keep sizing leftovers.
report.scheduleId = schedule.id;
return report;
}
const available = this.bookableWithin(budget.remainingFor(leg), dims);
if (
!report.bestAvailable ||
available.cargoTons > report.bestAvailable.cargoTons ||
(available.cargoTons === report.bestAvailable.cargoTons &&
available.wagons > report.bestAvailable.wagons)
) {
report.bestAvailable = available;
}
} }
if (!corridorMatched) {
throw new ConflictException( report.fullMessage = this.exportFullMessage(booking, report);
'No export train is accepting bookings for this day', return report;
}
/**
* Largest booking (in the requester's own wagon-type units) that a single
* train's leftover base capacity could still admit: bounded by free wagon
* slots, free train length, and the locomotive's remaining pull weight
* (gross — each wagon's tare eats into it before any cargo does).
*/
private bookableWithin(
remaining: Capacity,
dims: PerWagonDims,
): { wagons: number; cargoTons: number } {
const byLength =
dims.lengthMeters > 0
? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
: Math.floor(Math.max(0, remaining.wagons));
const maxWagons = Math.max(
0,
Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
);
let bestTons = 0;
let usableWagons = 0;
for (let w = 1; w <= maxWagons; w++) {
if (w * dims.tareWeightTons > remaining.weightTons) break;
usableWagons = w;
const tons = Math.min(
w * dims.capacityTons,
remaining.weightTons - w * dims.tareWeightTons,
);
if (tons > bestTons) bestTons = tons;
}
return {
wagons: usableWagons,
cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
};
}
/** Customer-facing "train is full" copy carrying the bookable leftover. */
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
if (!report.trainsForDay || !report.corridorMatched) {
return 'No export train is accepting bookings for this day';
}
const best = report.bestAvailable;
const base =
'Not enough train space — an export booking must ride a single train whole, ' +
'and no open train on this day can carry it. ';
if (!best || best.wagons <= 0) {
return base + 'No capacity is left on this day — pick another shipment day.';
}
if (booking.freightType === 'BULK') {
return (
base +
`The largest remaining space is about ${best.cargoTons} tons ` +
`(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
); );
} }
throw new ConflictException('Train is full — no export capacity left for this day'); return (
base +
`The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
`(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
'reduce the booking or pick another day.'
);
}
/**
* Export is first-come-first-serve: no window cycle, no priority, no batch.
* Pick the earliest open export train on the booking's corridor/day that still
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
const report = await this.exportSpaceReport(booking, need);
if (report.scheduleId) return report.scheduleId;
throw new ConflictException(
report.fullMessage ?? 'Train is full — no export capacity left for this day',
);
} }
/** /**
@@ -700,8 +814,11 @@ export class BookingBatchService implements OnModuleInit {
async getBatchBoard( async getBatchBoard(
query: BatchBoardQueryDto = {}, query: BatchBoardQueryDto = {},
): Promise<BatchBoardListResponse> { ): Promise<BatchBoardListResponse> {
const page = query.page ?? 1; // Board cards are heavy (per-schedule booking summaries), so the default
const pageSize = query.pageSize ?? 12; // page is smaller than the toolkit-wide 20.
const { page, pageSize, skip, take } = normalizePagination(query, {
defaultPageSize: 12,
});
// Status filter: any subset of the lifecycle. Omitted = all statuses, so // Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history. // arrived / cancelled / dispatched schedules stay visible as history.
@@ -763,8 +880,8 @@ export class BookingBatchService implements OnModuleInit {
route: { originYard: true, destinationYard: true, milestones: { yard: true } }, route: { originYard: true, destinationYard: true, milestones: { yard: true } },
}, },
order: { [sortBy]: sortOrder } as never, order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize, skip,
take: pageSize, take,
}); });
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
@@ -800,13 +917,7 @@ export class BookingBatchService implements OnModuleInit {
board.push(this.buildScheduleSummary(s, items)); board.push(this.buildScheduleSummary(s, items));
} }
return { return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
items: board,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
} }
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */

View File

@@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
/** /**
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL * applySplit split-marking behaviour: the reduced booking is flagged is_split
* (both the parent contract row and the booking's denormalized copy) so the split * and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME
* remainder can be rebooked. A GENERAL booking is left untouched. * contracts). The contract kind is NEVER changed — a ONE_TIME contract stays
* ONE_TIME through the split chain.
*/ */
describe('BookingSplitService — applySplit ONE_TIME promotion', () => { describe('BookingSplitService — applySplit split marking', () => {
const bookingId = 'bk-1'; const bookingId = 'bk-1';
const contractId = 'ct-1'; const contractId = 'ct-1';
const offerId = 'of-1'; const offerId = 'of-1';
@@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
id: bookingId, id: bookingId,
contractId, contractId,
contractKind: bookingContractKind, contractKind: bookingContractKind,
cargoTotalWeightVgm: 50,
}), }),
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
softDelete: jest.fn().mockResolvedValue(undefined), softDelete: jest.fn().mockResolvedValue(undefined),
@@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
return { service, bookingRepo, contractRepo }; return { service, bookingRepo, contractRepo };
}; };
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => { it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => {
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); const { service, bookingRepo } = buildService('ONE_TIME');
await service.applySplit(bookingId); await service.applySplit(bookingId);
expect(bookingRepo.update).toHaveBeenCalledWith( expect(bookingRepo.update).toHaveBeenCalledWith(
bookingId, bookingId,
expect.objectContaining({ contractKind: 'GENERAL' }), expect.objectContaining({
); isSplit: true,
expect(contractRepo.update).toHaveBeenCalledWith( preSplitQuantities: { bulkTons: 50 },
contractId, cargoTotalWeightVgm: 30,
expect.objectContaining({ contractKind: 'GENERAL' }), wagonsRequired: 3,
}),
); );
}); });
it('leaves a GENERAL booking untouched (no contract promotion)', async () => { it('never changes the contract kind — ONE_TIME stays ONE_TIME', async () => {
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
await service.applySplit(bookingId);
expect(contractRepo.update).not.toHaveBeenCalled();
expect(bookingRepo.update).not.toHaveBeenCalledWith(
bookingId,
expect.objectContaining({ contractKind: expect.anything() }),
);
});
it('leaves a GENERAL contract untouched too', async () => {
const { service, contractRepo } = buildService('GENERAL'); const { service, contractRepo } = buildService('GENERAL');
await service.applySplit(bookingId); await service.applySplit(bookingId);

View File

@@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { import {
BookingBatchOffer, BookingBatchOffer,
OfferedLine, OfferedLine,
@@ -34,11 +33,14 @@ export interface SizedOffer {
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
* returns to the contract's quantity cap (derived live from booking_container * returns to the contract's quantity cap (derived live from booking_container
* rows, so reducing the lines releases it automatically) and can be rebooked in * rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity. A ONE_TIME contract is promoted to * any later window within contract validity. The reduced booking is flagged
* GENERAL on split (see applySplit) so its remainder is actually rebookable. * is_split (see applySplit); the contract kind never changes. On a ONE_TIME
* Once the remainder is rebooked and the cap hits zero, ContractBookingService * contract a split booking releases the single-active-booking slot, but the
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment * next booking must take the WHOLE remainder — the split chain is the only way
* requests, even while validity and a booking window are still open. * a ONE_TIME contract produces multiple bookings. Once the remainder is
* rebooked and the cap hits zero, ContractBookingService completes the
* contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
* while validity and a booking window are still open.
*/ */
@Injectable() @Injectable()
export class BookingSplitService { export class BookingSplitService {
@@ -215,11 +217,26 @@ export class BookingSplitService {
if (!offer) return; if (!offer) return;
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
// Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
// contract this is the ledger the outstanding remainder is derived from
// (there is no contract quantity cap to fall back on).
const preSplit = await manager.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, cargoTotalWeightVgm: true },
});
const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};
if (offer.offeredLines?.length) { if (offer.offeredLines?.length) {
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
const lines = await manager.getRepository(BookingContainer).find({ const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId }, where: { bookingId },
}); });
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
}
preSplitQuantities.bySize = bySize;
for (const line of lines) { for (const line of lines) {
const kept = keptByLine.get(line.id); const kept = keptByLine.get(line.id);
if (!kept) { if (!kept) {
@@ -251,34 +268,24 @@ export class BookingSplitService {
} }
} }
} }
} else {
preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
} }
// is_split releases the ONE_TIME single-active-booking slot for the
// remainder (whole-remainder-only, enforced at booking creation) and
// switches the contract into remainder-based completion. The contract
// kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
// split chain.
await manager.getRepository(Booking).update(bookingId, { await manager.getRepository(Booking).update(bookingId, {
wagonsRequired: offer.offeredWagons, wagonsRequired: offer.offeredWagons,
cargoTotalWeightVgm: offer.offeredWeightTons, cargoTotalWeightVgm: offer.offeredWeightTons,
totalAmount: offer.offeredAmount, totalAmount: offer.offeredAmount,
pricingBreakdown: offer.offeredPricingBreakdown, pricingBreakdown: offer.offeredPricingBreakdown,
isSplit: true,
preSplitQuantities,
} as never); } as never);
// A ONE_TIME contract permits a single active booking, which would block the
// split remainder from ever being rebooked. Promote the parent contract (and
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
// down against the cap like any general contract, within the same validity.
const booking = await manager.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, contractId: true, contractKind: true },
});
if (booking?.contractKind === 'ONE_TIME') {
await manager
.getRepository(Booking)
.update(bookingId, { contractKind: 'GENERAL' } as never);
if (booking.contractId) {
await manager
.getRepository(Contract)
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
}
}
await manager await manager
.getRepository(BookingBatchOffer) .getRepository(BookingBatchOffer)
.update(offer.id, { status: 'APPLIED' }); .update(offer.id, { status: 'APPLIED' });

View File

@@ -1,15 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator';
import {
IsIn, import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export const BATCH_BOARD_STATUSES = [ export const BATCH_BOARD_STATUSES = [
'DRAFT', 'DRAFT',
@@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [
] as const; ] as const;
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number]; export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
/** Filters for the batch monitoring board list (import schedules, all statuses). */ /**
export class BatchBoardQueryDto { * Filters for the batch monitoring board list (import schedules, all statuses).
@ApiPropertyOptional({ default: 1, minimum: 1 }) * `page`/`pageSize`/`search`/`sortOrder` come from the shared
@IsOptional() * {@link PaginationQueryDto}; search matches train number, route yards,
@Type(() => Number) * stations, or locomotive code (case-insensitive).
@IsInt() */
@Min(1) export class BatchBoardQueryDto extends PaginationQueryDto {
page?: number;
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.', 'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
@@ -58,15 +40,6 @@ export class BatchBoardQueryDto {
@IsIn(['OPEN', 'FULL', 'CLOSED']) @IsIn(['OPEN', 'FULL', 'CLOSED'])
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED'; bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
@ApiPropertyOptional({
description:
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
})
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' }) @ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
@IsOptional() @IsOptional()
@IsISO8601() @IsISO8601()
@@ -91,9 +64,4 @@ export class BatchBoardQueryDto {
@IsOptional() @IsOptional()
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[]) @IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
sortBy?: BatchBoardSortField; sortBy?: BatchBoardSortField;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
} }

View File

@@ -0,0 +1,64 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
TRAIN_SCHEDULE_STATUSES,
TrainScheduleStatus,
} from '../../train-schedules/entities/train-schedule.entity';
export const TRAIN_SCHEDULE_SORT_FIELDS = [
'createdAt',
'scheduledDepartureDate',
'reference',
'trainNumber',
'status',
] as const;
export type TrainScheduleSortField = (typeof TRAIN_SCHEDULE_SORT_FIELDS)[number];
/**
* Schedules have no freight-type column — the type is DERIVED from the
* bookings aboard (see `resolveScheduleFreightType`): a single kind yields
* CONTAINER or BULK, both kinds yield MIXED, no bookings yield null (never
* matched by this filter).
*/
export const TRAIN_SCHEDULE_FREIGHT_TYPES = ['CONTAINER', 'BULK', 'MIXED'] as const;
export type TrainScheduleFreightType = (typeof TRAIN_SCHEDULE_FREIGHT_TYPES)[number];
/**
* Query for the train-schedule list (container/bulk boards). Pagination and
* free-text `search` come from the shared {@link PaginationQueryDto}; search
* matches schedule reference, train number, route yards, stations, or
* locomotive code (case-insensitive). The remaining fields are exact-match
* filters that the search never widens.
*/
export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_SORT_FIELDS as unknown as string[])
sortBy?: TrainScheduleSortField;
/** Lifecycle status (exact match). */
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_STATUSES })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_STATUSES as unknown as string[])
status?: TrainScheduleStatus;
/** Derived freight type of the bookings aboard (exact match). */
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_FREIGHT_TYPES })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
freightType?: TrainScheduleFreightType;
/** Origin station/yard id (exact match). */
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
/** Destination station/yard id (exact match). */
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
}

View File

@@ -41,6 +41,7 @@ import {
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
@@ -743,16 +744,16 @@ export class TrainSchedulingController {
@Get("container/schedules") @Get("container/schedules")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ summary: "List container train schedules" }) @ApiOperation({ summary: "List container train schedules (paginated)" })
getContainerTrainSchedules() { getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(); return this.trainSchedulingService.getContainerTrainSchedules(query);
} }
@Get("bulk/schedules") @Get("bulk/schedules")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ summary: "List bulk train schedules" }) @ApiOperation({ summary: "List bulk train schedules (paginated)" })
getBulkTrainSchedules() { getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(); return this.trainSchedulingService.getContainerTrainSchedules(query);
} }
@Get("container/schedules/:id") @Get("container/schedules/:id")

View File

@@ -17,8 +17,21 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import {
DataSource,
EntityManager,
FindOptionsWhere,
ILike,
In,
Not,
QueryFailedError,
Raw,
} from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -50,6 +63,10 @@ import { CreateContainerTrainScheduleDto } from './dto/create-container-train-sc
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
@@ -2641,8 +2658,42 @@ export class TrainSchedulingService {
return Object.assign(detail, { warehouseAutomation }); return Object.assign(detail, { warehouseAutomation });
} }
async getContainerTrainSchedules() { async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
const schedules = await this.trainSchedulesRepository.findAll({ const { page, pageSize, skip, take } = normalizePagination(query);
// Exact-match filters (enum/id semantics). Freight type is derived from
// the bookings aboard — no column to match — so it rides on `id` as an
// EXISTS fragment instead.
const base: FindOptionsWhere<TrainSchedule> = {};
if (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId;
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
// Search fans out across every human-recognizable label; each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, reference: like as never },
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
// Newest-created first (the client can re-sort; this is the default order).
const sortBy = query.sortBy ?? 'createdAt';
const sortOrder = query.sortOrder ?? 'DESC';
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: { relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true } }, trainSet: { locomotive: true, locomotives: { locomotive: true } },
// Yards carry the route's display name used by mapScheduleListItem; // Yards carry the route's display name used by mapScheduleListItem;
@@ -2652,10 +2703,14 @@ export class TrainSchedulingService {
destinationStation: true, destinationStation: true,
scheduleBookings: { booking: true }, scheduleBookings: { booking: true },
}, },
// Newest-created first (the client can re-sort; this is the default order). order: { [sortBy]: sortOrder } as never,
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' }, skip,
take,
}); });
return schedules.map((s) => this.mapScheduleListItem(s)); return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
meta: buildPaginationMeta(total, page, pageSize),
};
} }
async getContainerTrainScheduleById(id: string) { async getContainerTrainScheduleById(id: string) {
@@ -3869,6 +3924,33 @@ export class TrainSchedulingService {
}; };
} }
/**
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
* Schedules with no bookings (type null) match nothing. Applied to `id` so
* the list query stays on findAndCount instead of a query-builder rewrite.
*/
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
`AND b.freight_type ${cmp} :${param})`;
if (freightType === 'MIXED') {
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
);
}
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
{ ftIs: freightType, ftNot: freightType },
);
}
private resolveScheduleFreightType( private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null { ): 'CONTAINER' | 'BULK' | 'MIXED' | null {

View File

@@ -0,0 +1,23 @@
import { AppDataSource } from "../data-source";
import { DropdownSettingsSeeder } from "../seed/dropdown-settings.seeder";
/**
* Seed the dropdown settings catalog (codes/labels only, no options) into an
* EMPTY dropdown_settings table; skips entirely if any rows exist. Run on
* demand:
* pnpm --filter @edr/freight-api seed:dropdown-settings
*/
async function run() {
await AppDataSource.initialize();
try {
const seeder = new DropdownSettingsSeeder(AppDataSource);
await seeder.run();
} finally {
await AppDataSource.destroy();
}
}
run().catch((error) => {
console.error("Failed to seed dropdown settings:", error);
process.exit(1);
});

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import {
DropdownSetting,
DropdownSettingMeta,
} from "../modules/dropdown-settings/entities/dropdown-setting.entity";
interface DefaultDropdownSetting {
code: string;
label: string;
description: string;
multiple: boolean;
meta?: DropdownSettingMeta | null;
}
/**
* Known dropdown settings, seeded as empty catalogs (no options). The options
* are managed by the admin from the backoffice Dropdown Settings editor.
*/
const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
{
code: "stations_ter",
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: { searchable: true, clearable: true, version: "temporary" },
},
{
code: "general_contract_period",
label: "General Contract Period (months)",
description:
"How many months a general contract stays open for ordering after activation.",
multiple: false,
},
{
code: "contract_validity_periods",
label: "Contract Validity Periods (days)",
description:
"Validity durations (in days) a staff can choose when accepting a submitted contract.",
multiple: false,
},
{
code: "ro_vessel_min_days",
label: "RO vessel minimum lead time (days)",
description:
"Minimum days between today and the vessel departure date on an export Release Order.",
multiple: false,
},
];
@Injectable()
export class DropdownSettingsSeeder {
private readonly logger = new Logger(DropdownSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const settingRepository = this.dataSource.getRepository(DropdownSetting);
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`dropdown_settings already has ${existing} rows — skipping seed`,
);
return;
}
// Insert setting rows only — no DropdownOption rows. Options start empty
// and are configured by the admin from the backoffice editor.
await settingRepository.insert(
DEFAULT_DROPDOWN_SETTINGS.map((setting) => ({
code: setting.code,
label: setting.label,
description: setting.description,
multiple: setting.multiple,
meta: setting.meta ?? null,
})),
);
this.logger.log(
`Seeded ${DEFAULT_DROPDOWN_SETTINGS.length} dropdown settings with empty options`,
);
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
interface OnboardingField { interface OnboardingField {
@@ -576,88 +575,66 @@ export class FileUploadSettingsSeeder {
constructor(private readonly dataSource: DataSource) { } constructor(private readonly dataSource: DataSource) { }
async run() { async run() {
await this.dataSource.transaction(async (manager) => { const settingRepository = this.dataSource.getRepository(FileUploadSetting);
const settingRepository = manager.getRepository(FileUploadSetting);
const fieldRepository = manager.getRepository(FileUploadField);
const allSettings: Array< // Seed only into an empty table: any existing rows (including
OnboardingDocumentSetting & { description: string } // soft-deleted ones, which would still conflict on the unique `code`)
> = [ // mean the data is admin-managed, so leave it untouched.
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ const existing = await settingRepository.count({ withDeleted: true });
...s, if (existing > 0) {
description: COMPANY_ONBOARDING_DESCRIPTION, this.logger.log(
})), `file_upload_settings already has ${existing} rows — skipping seed`,
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ );
...s, return;
description: CLEARANCE_DESCRIPTION, }
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
for (const documentSetting of allSettings) { const allSettings: Array<
await settingRepository.upsert( OnboardingDocumentSetting & { description: string }
{ > = [
code: documentSetting.code, ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
label: documentSetting.label, ...s,
description: documentSetting.description, description: COMPANY_ONBOARDING_DESCRIPTION,
entity: documentSetting.entity, })),
}, ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
{ ...s,
conflictPaths: { code: true }, description: CLEARANCE_DESCRIPTION,
}, })),
); ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
const setting = await settingRepository.findOne({ // Insert setting rows only — no FileUploadField rows. Fields start empty
where: { code: documentSetting.code }, // and are configured from the backoffice file-settings editor; the field
select: { id: true, code: true }, // definitions above are kept as reference defaults.
}); await settingRepository.insert(
allSettings.map((documentSetting) => ({
if (!setting) { code: documentSetting.code,
throw new Error( label: documentSetting.label,
`file_upload_setting_seed_failed:${documentSetting.code}`, description: documentSetting.description,
); entity: documentSetting.entity,
} })),
);
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
documentSetting.fields.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,
helpText: field.helpText,
isRequired: field.isRequired,
isMultiple: field.isMultiple,
maxFiles: field.maxFiles,
allowedExtensions: [...field.allowedExtensions],
maxSizeMb: field.maxSizeMb,
displayOrder: field.displayOrder ?? index + 1,
})),
);
}
});
this.logger.log( this.logger.log(
"Ensured company onboarding + booking clearance file upload settings", `Seeded ${allSettings.length} file upload settings with empty fields`,
); );
} }
} }

View File

@@ -24,9 +24,10 @@ export interface GlShipmentQuantities {
hazardousQuantity: number; hazardousQuantity: number;
reeferQuantity: number; reeferQuantity: number;
}>; }>;
/** Bulk: tons (or item count) + hazardous qty. */ /** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number; bulkQuantity: number;
bulkHazardousQuantity: number; bulkHazardousQuantity: number;
bulkReeferQuantity: number;
} }
/** /**
@@ -113,6 +114,30 @@ export function computeGlShipmentTotal(
amount: rate.unitPrice * qty, amount: rate.unitPrice * qty,
}); });
} }
if (contract.isHazardous && q.bulkHazardousQuantity > 0) {
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
if (hz) {
lines.push({
label: hz.label,
unitPrice: hz.unitPrice,
unit: hz.unit,
quantity: q.bulkHazardousQuantity,
amount: hz.unitPrice * q.bulkHazardousQuantity,
});
}
}
if (contract.isReefer && q.bulkReeferQuantity > 0) {
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
if (rf) {
lines.push({
label: rf.label,
unitPrice: rf.unitPrice,
unit: rf.unit,
quantity: q.bulkReeferQuantity,
amount: rf.unitPrice * q.bulkReeferQuantity,
});
}
}
} }
const total = lines.reduce((s, l) => s + l.amount, 0); const total = lines.reduce((s, l) => s + l.amount, 0);

View File

@@ -137,8 +137,12 @@ export function AllocateBookingWizard({
enabled: opened, enabled: opened,
}), }),
); );
// Paginated {items, meta} list; the newest 100 schedules comfortably cover
// every DRAFT schedule the wizard can attach to.
const schedulesQuery = useQuery( const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }), api.trainScheduling.scheduleList.queryOptions({
input: { filters: { pageSize: 100 } },
}),
); );
const routesQuery = useQuery( const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }), api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
@@ -163,7 +167,7 @@ export function AllocateBookingWizard({
const matchingSchedules = useMemo( const matchingSchedules = useMemo(
() => () =>
(schedulesQuery.data ?? []).filter( (schedulesQuery.data?.items ?? []).filter(
(s: TrainScheduleListItem) => (s: TrainScheduleListItem) =>
s.status === "DRAFT" && s.status === "DRAFT" &&
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType), (!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),

View File

@@ -100,7 +100,8 @@ export const QUERY_KEYS = {
locomotives: (routeId?: string) => locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const, ["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const, stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const, schedules: (filters?: unknown) =>
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const, track: (id: string) => ["train-scheduling", "track", id] as const,
batchBoard: (filters?: unknown) => batchBoard: (filters?: unknown) =>

View File

@@ -211,7 +211,9 @@ export function useContractMutations(contractId: string) {
toast.success("Booking created under contract"); toast.success("Booking created under contract");
void invalidateContractDetail(qc, contractId); void invalidateContractDetail(qc, contractId);
}, },
onError: () => toast.error("Failed to create booking"), // Surface the server's reason (e.g. a container already booked on the same
// train) instead of a generic failure.
onError: (e: Error) => toast.error(e.message || "Failed to create booking"),
}); });
const completeBooking = useMutation({ const completeBooking = useMutation({

View File

@@ -14,9 +14,6 @@ import {
patchRuleEngineListRecord, patchRuleEngineListRecord,
} from "@/utils/queryInvalidation"; } from "@/utils/queryInvalidation";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
export const useRuleEngineList = ( export const useRuleEngineList = (
resource: RuleEngineResourceSlug, resource: RuleEngineResourceSlug,
params: RuleEngineListParams, params: RuleEngineListParams,
@@ -26,8 +23,7 @@ export const useRuleEngineList = (
queryFn: () => ruleEngineService.list(resource, params), queryFn: () => ruleEngineService.list(resource, params),
}); });
const ORDER_LIST_PAGE_SIZE = 500; /** Full (page-walked) list used by the reorder dialog and create-position picker. */
export const useRuleEngineOrderList = ( export const useRuleEngineOrderList = (
resource: RuleEngineResourceSlug, resource: RuleEngineResourceSlug,
enabled: boolean, enabled: boolean,
@@ -36,9 +32,7 @@ export const useRuleEngineOrderList = (
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource), queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource),
queryFn: () => queryFn: () =>
ruleEngineService.list(resource, { ruleEngineService.listAll(resource, {
page: 1,
pageSize: ORDER_LIST_PAGE_SIZE,
sortBy, sortBy,
sortOrder: "ASC", sortOrder: "ASC",
}), }),
@@ -75,15 +69,11 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"), queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
queryFn: () => queryFn: () => ruleEngineService.listAll<RuleEngineRecord>("cargo-types"),
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled, enabled,
select: (result) => { select: (rows) => {
const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE }; const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
const parents = (result.data ?? []) const parents = rows
.filter((row) => row.id && String(row.id) !== excludeId) .filter((row) => row.id && String(row.id) !== excludeId)
.map((row) => { .map((row) => {
const name = String(row.cargoTypeName ?? "").trim(); const name = String(row.cargoTypeName ?? "").trim();
@@ -104,14 +94,9 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
export const useCargoLeafOptions = (enabled = true) => export const useCargoLeafOptions = (enabled = true) =>
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }), queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }),
queryFn: () => queryFn: () => ruleEngineService.listAll<RuleEngineRecord>("cargo-types"),
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled, enabled,
select: (result) => { select: (rows) => {
const rows = result.data ?? [];
const parentIds = new Set( const parentIds = new Set(
rows rows
.map((row) => row.parentGroupId) .map((row) => row.parentGroupId)
@@ -157,21 +142,12 @@ export const useContainerTypeOptions = (
) => ) =>
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', { queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
includeNone, includeNone,
}), }),
queryFn: () => queryFn: () =>
api.ruleEngine.list.call({ ruleEngineService.listAll<RuleEngineRecord>("container-types"),
resource: "container-types",
params: {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
},
}),
enabled, enabled,
select: (result) => select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
}); });
/** /**
@@ -191,20 +167,14 @@ export const useWagonTypeOptions = (enabled = true) =>
})), })),
}); });
const LIVE_RATE_PAGE_SIZE = 500;
export const useLiveRateOptions = (enabled = true) => export const useLiveRateOptions = (enabled = true) =>
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }), queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }),
queryFn: () => queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("rates", { ruleEngineService.listAll<RuleEngineRecord>("rates", { status: "LIVE" }),
page: 1,
pageSize: LIVE_RATE_PAGE_SIZE,
status: "LIVE",
}),
enabled, enabled,
select: (result) => select: (rows) =>
(result.data ?? []) rows
.filter((row) => row.id) .filter((row) => row.id)
.map((row) => { .map((row) => {
const rateType = String(row.rateType ?? "").replace(/_/g, " "); const rateType = String(row.rateType ?? "").replace(/_/g, " ");

View File

@@ -57,7 +57,7 @@ export function useWarehouse(id?: string) {
export function useWarehouseFacilities() { export function useWarehouseFacilities() {
return useQuery({ return useQuery({
queryKey: warehouseKeys.facilities(), queryKey: warehouseKeys.facilities(),
queryFn: () => warehouseService.listFacilities().then((r) => r.data), queryFn: () => warehouseService.listFacilities().then((r) => r.data.items),
}); });
} }

View File

@@ -12,6 +12,7 @@ import {
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
@@ -125,6 +126,7 @@ export default function BookingRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking-kind tabs (one-time vs general contract) replace the old status tabs. // Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME"); const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter controls (empty/null = "all"). // Per-tab filter controls (empty/null = "all").
@@ -158,6 +160,8 @@ export default function BookingRequestsPage() {
// React Query cache key per kind tab. // React Query cache key per kind tab.
tab: kindTab, tab: kindTab,
bookingType: kindTab, bookingType: kindTab,
// Server-side free-text search (booking ref, customer, contract ref).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}), ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
@@ -178,6 +182,7 @@ export default function BookingRequestsPage() {
pagination.pageIndex, pagination.pageIndex,
pagination.pageSize, pagination.pageSize,
kindTab, kindTab,
debouncedQuery,
statusFilter, statusFilter,
directionFilter, directionFilter,
freightTypeFilter, freightTypeFilter,
@@ -245,17 +250,12 @@ export default function BookingRequestsPage() {
resetPage(); resetPage();
}, [resetPage]); }, [resetPage]);
const rows = useMemo(() => { // Search is applied server-side (via the `search` filter param) — no
const items = (data?.items ?? []).map(toBookingListRow); // client-side filtering here.
const q = query.trim().toLowerCase(); const rows = useMemo(
if (!q) return items; () => (data?.items ?? []).map(toBookingListRow),
return items.filter( [data?.items],
(b) => );
b.reference.toLowerCase().includes(q) ||
b.customerLabel.toLowerCase().includes(q) ||
(b.contractReference?.toLowerCase().includes(q) ?? false),
);
}, [data?.items, query]);
const total = data?.total ?? 0; const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -496,7 +496,10 @@ export default function BookingRequestsPage() {
placeholder="Search booking, contract or customer…" placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />} leftSection={<Search size={18} />}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={ rightSection={
query && ( query && (
<ActionIcon <ActionIcon
@@ -504,7 +507,10 @@ export default function BookingRequestsPage() {
color="gray" color="gray"
radius="md" radius="md"
variant="transparent" variant="transparent"
onClick={() => setQuery("")} onClick={() => {
setQuery("");
resetPage();
}}
> >
<X size={16} /> <X size={16} />
</ActionIcon> </ActionIcon>

View File

@@ -9,6 +9,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
@@ -76,10 +77,15 @@ export default function ContractRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all"); const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
const tabStatuses = getStatusesForTab(activeTab); const tabStatuses = getStatusesForTab(activeTab);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const filter: ContractListFilter = useMemo( const filter: ContractListFilter = useMemo(
() => ({ () => ({
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
@@ -87,9 +93,17 @@ export default function ContractRequestsPage() {
sortBy: "createdAt", sortBy: "createdAt",
sortOrder: "DESC", sortOrder: "DESC",
tab: activeTab, tab: activeTab,
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}), ...(tabStatuses ? { statuses: tabStatuses } : {}),
}), }),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses], [
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
],
); );
const { data, isLoading, isError, refetch, isFetching } = const { data, isLoading, isError, refetch, isFetching } =
@@ -100,16 +114,10 @@ export default function ContractRequestsPage() {
refetch: refetchSummary, refetch: refetchSummary,
} = useContractListSummary(filter); } = useContractListSummary(filter);
const rows = useMemo(() => { const rows = useMemo(
const items = (data?.items ?? []).map(toContractListRow); () => (data?.items ?? []).map(toContractListRow),
const q = query.trim().toLowerCase(); [data?.items],
if (!q) return items; );
return items.filter(
(c) =>
c.reference.toLowerCase().includes(q) ||
c.customerLabel.toLowerCase().includes(q),
);
}, [data?.items, query]);
const total = data?.total ?? 0; const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -346,7 +354,10 @@ export default function ContractRequestsPage() {
placeholder="Search reference or customer…" placeholder="Search reference or customer…"
leftSection={<Search size={18} />} leftSection={<Search size={18} />}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={ rightSection={
query && ( query && (
<ActionIcon <ActionIcon
@@ -354,7 +365,10 @@ export default function ContractRequestsPage() {
color="gray" color="gray"
radius="md" radius="md"
variant="transparent" variant="transparent"
onClick={() => setQuery("")} onClick={() => {
setQuery("");
resetPage();
}}
> >
<X size={16} /> <X size={16} />
</ActionIcon> </ActionIcon>

View File

@@ -26,6 +26,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
@@ -46,6 +47,7 @@ type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() { export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null); const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
@@ -59,36 +61,35 @@ export default function DropdownSettingsPage() {
}; };
const closeDialog = () => setActiveDialog(null); const closeDialog = () => setActiveDialog(null);
// Table data: server-side pagination + search via GET /dropdown-settings/paged.
const listQuery = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery.trim() || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
);
const { data, isLoading, isError, error } = useQuery( const { data, isLoading, isError, error } = useQuery(
api.dropdownSettings.listPaged.queryOptions({ input: { query: listQuery } }),
);
// Full (unpaged) list feeds the KPI strip only — its aggregates span every
// setting, not just the current page.
const { data: allSettings, isLoading: kpiLoading } = useQuery(
api.dropdownSettings.list.queryOptions(), api.dropdownSettings.list.queryOptions(),
); );
const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions()); const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions());
const dropdownSettings = useMemo<DropdownSetting[]>( const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []), () => (Array.isArray(allSettings) ? allSettings : []),
[data], [allSettings],
); );
const filtered = useMemo(() => { const rows = data?.items ?? [];
const q = query.trim().toLowerCase(); const total = data?.meta.total ?? 0;
if (!q) return dropdownSettings; const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce( const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0), (sum, s) => sum + (s.children?.length ?? 0),
@@ -269,7 +270,7 @@ export default function DropdownSettingsPage() {
/> />
<KpiStrip <KpiStrip
loading={isLoading} loading={kpiLoading}
items={[ items={[
{ label: "Settings", value: dropdownSettings.length, icon: Settings }, { label: "Settings", value: dropdownSettings.length, icon: Settings },
{ label: "Total Options", value: totalOptions, icon: Boxes }, { label: "Total Options", value: totalOptions, icon: Boxes },
@@ -299,7 +300,7 @@ export default function DropdownSettingsPage() {
<DataTable <DataTable
columns={columns} columns={columns}
data={paginatedData} data={rows}
status={status} status={status}
error={ error={
isError isError

View File

@@ -38,8 +38,8 @@ import {
type FormFieldDef, type FormFieldDef,
} from "@/pages/ruleEngine/config/resources"; } from "@/pages/ruleEngine/config/resources";
import { import {
useRuleEngineList,
useRuleEngineMutations, useRuleEngineMutations,
useRuleEngineOrderList,
useWagonTypeOptions, useWagonTypeOptions,
} from "@/hooks/rule-engine/useRuleEngine"; } from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -108,14 +108,14 @@ const CargoTypesPage = () => {
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view"); const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage"); const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
// One fetch of the whole (small) set; the tree, ancestry and each level are // One fetch of the whole (small) set — page-walked because the API caps
// derived client-side so drilling between levels is instant. // pageSize at 100; the tree, ancestry and each level are derived client-side
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, { // so drilling between levels is instant.
page: 1, const { data, isLoading, isError } = useRuleEngineOrderList(
pageSize: 500, CARGO_SLUG,
sortBy: "displayOrder", true,
sortOrder: "ASC", "displayOrder",
}); );
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG); const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
@@ -141,7 +141,7 @@ const CargoTypesPage = () => {
const [formMode, setFormMode] = useState<FormMode | null>(null); const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null); const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
const all = (data?.data ?? []) as CargoNode[]; const all = (data ?? []) as CargoNode[];
const { byId, childrenOf } = useMemo(() => { const { byId, childrenOf } = useMemo(() => {
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n])); const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));

View File

@@ -210,7 +210,7 @@ const RuleEngineResourcePage = () => {
}); });
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
const rows = data?.data ?? []; const rows = data?.items ?? [];
const meta = data?.meta; const meta = data?.meta;
const pageCount = meta?.totalPages ?? 1; const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length; const totalCount = meta?.total ?? rows.length;
@@ -223,15 +223,15 @@ const RuleEngineResourcePage = () => {
); );
const createPositionOptions = useMemo(() => { const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) if (!config?.orderConfig || !createPositionList?.length)
return undefined; return undefined;
return createPositionList.data return createPositionList
.filter((row) => row.id) .filter((row) => row.id)
.map((row) => ({ .map((row) => ({
label: getOrderItemLabel(row, config.slug), label: getOrderItemLabel(row, config.slug),
value: String(row.id), value: String(row.id),
})); }));
}, [config?.orderConfig, config?.slug, createPositionList?.data]); }, [config?.orderConfig, config?.slug, createPositionList]);
const handleApproveRate = useCallback( const handleApproveRate = useCallback(
(record: RuleEngineRecord) => { (record: RuleEngineRecord) => {
@@ -528,7 +528,7 @@ const RuleEngineResourcePage = () => {
open={orderDialogOpen} open={orderDialogOpen}
onOpenChange={setOrderDialogOpen} onOpenChange={setOrderDialogOpen}
config={config} config={config}
items={orderListData?.data ?? []} items={orderListData ?? []}
isLoading={orderListLoading} isLoading={orderListLoading}
isSaving={reorder.isPending} isSaving={reorder.isPending}
onSave={(payload) => { onSave={(payload) => {

View File

@@ -244,6 +244,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration", category: "configuration",
subtitle: "Configure container sizes", subtitle: "Configure container sizes",
searchPlaceholder: "Search container types...", searchPlaceholder: "Search container types...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" }, orderConfig: { field: "displayOrder", label: "Display order" },
columns: [ columns: [
codeColumn("code"), codeColumn("code"),
@@ -273,6 +274,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration", category: "configuration",
subtitle: "Configure wagon classes used for capacity and train planning", subtitle: "Configure wagon classes used for capacity and train planning",
searchPlaceholder: "Search wagon types by name or code...", searchPlaceholder: "Search wagon types by name or code...",
// No supportsSearch: wagon-types is served by its own module, which does
// not implement server-side search (unlike the 9 rule-engine resources).
cardTitleKey: "name", cardTitleKey: "name",
columns: [ columns: [
codeColumn("code"), codeColumn("code"),
@@ -316,6 +319,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "rules", category: "rules",
subtitle: "Wagon-count, payment-currency, and customs scoring rules", subtitle: "Wagon-count, payment-currency, and customs scoring rules",
searchPlaceholder: "Search priority rules...", searchPlaceholder: "Search priority rules...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" }, orderConfig: { field: "displayOrder", label: "Display order" },
columns: [ columns: [
{ id: "type", header: "Type", accessorKey: "type" }, { id: "type", header: "Type", accessorKey: "type" },
@@ -379,6 +383,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "rules", category: "rules",
subtitle: "VGM limits by container and trade direction", subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...", searchPlaceholder: "Search weight limit rules...",
supportsSearch: true,
cardTitleKey: "containerType", cardTitleKey: "containerType",
cardSubtitleKey: "tradeDirection", cardSubtitleKey: "tradeDirection",
columns: [ columns: [
@@ -429,6 +434,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration", category: "configuration",
subtitle: "Terminal and yard locations", subtitle: "Terminal and yard locations",
searchPlaceholder: "Search yards...", searchPlaceholder: "Search yards...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" }, orderConfig: { field: "displayOrder", label: "Display order" },
columns: [ columns: [
codeColumn("code"), codeColumn("code"),
@@ -455,6 +461,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration", category: "configuration",
subtitle: "Shipping line codes and pricing mappings", subtitle: "Shipping line codes and pricing mappings",
searchPlaceholder: "Search shipping lines...", searchPlaceholder: "Search shipping lines...",
supportsSearch: true,
columns: [ columns: [
codeColumn("code"), codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" }, { id: "label", header: "Label", accessorKey: "label" },
@@ -483,6 +490,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "currency", cardSubtitleKey: "currency",
subtitle: "Freight rates and approval workflow", subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...", searchPlaceholder: "Search rates by type or status...",
supportsSearch: true,
columns: [ columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -560,6 +568,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "requiredRole", cardSubtitleKey: "requiredRole",
subtitle: "Multi-step booking approval chain", subtitle: "Multi-step booking approval chain",
searchPlaceholder: "Search approval rules...", searchPlaceholder: "Search approval rules...",
supportsSearch: true,
orderConfig: { orderConfig: {
field: "stepOrder", field: "stepOrder",
scopeField: "requiresDirectorApproval", scopeField: "requiresDirectorApproval",

View File

@@ -489,8 +489,9 @@ export default function BatchBoardPage() {
}); });
const schedules = data?.items ?? []; const schedules = data?.items ?? [];
const total = data?.total ?? 0; const total = data?.meta.total ?? 0;
const pageCount = data?.totalPages ?? 1; // The table footer expects at least one page even when the board is empty.
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
const summary = useMemo(() => { const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length; const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;

View File

@@ -15,6 +15,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import {
ArrowRight, ArrowRight,
@@ -29,7 +30,7 @@ import {
Train, Train,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
@@ -52,7 +53,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service"; import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling"; import type {
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
TrainScheduleStatus,
} from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
/** `min` for a `datetime-local` input: now, in the browser's local zone. */ /** `min` for a `datetime-local` input: now, in the browser's local zone. */
@@ -94,14 +100,18 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2"); const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL"); const [debouncedSearch] = useDebouncedValue(search, 300);
const [freightFilter, setFreightFilter] = useState("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
// the schedule's origin_station_id / destination_station_id exactly.
const [originFilter, setOriginFilter] = useState("ALL"); const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL"); const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order. // Default: newest-created first, matching the API's default order. Values
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">( // are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
"createdAt", const [sortBy, setSortBy] = useState<
); "createdAt" | "scheduledDepartureDate" | "reference"
>("createdAt");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null); const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
@@ -117,8 +127,54 @@ export default function TrainScheduleV2ListPage() {
[createOpen], [createOpen],
); );
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
// Search resets the page only once the debounced value settles — resetting
// per keystroke would refetch page 1 mid-typing.
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
// Fully server-driven list: pagination, search, filters, and sort all travel
// as query params; the response envelope carries the page + totals.
const filters = useMemo<TrainScheduleListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
...(destinationFilter !== "ALL"
? { destinationStationId: destinationFilter }
: {}),
sortBy,
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
],
);
const schedulesQuery = useQuery( const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }), api.trainScheduling.scheduleList.queryOptions({ input: { filters } }),
);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
); );
const routesQuery = useQuery( const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }), api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
@@ -153,96 +209,40 @@ export default function TrainScheduleV2ListPage() {
setLocomotiveIds([]); setLocomotiveIds([]);
}, [routeId]); }, [routeId]);
const allSchedules = schedulesQuery.data ?? []; // Filtering, sorting, and paging all happen server-side — `schedules` IS the
// current page, and the meta envelope carries the totals.
const schedules = schedulesQuery.data?.items ?? [];
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
// Status/weight tiles count the visible page only — board-wide numbers would
// need a dedicated summary endpoint now that the list is server-paginated.
const stats = useMemo(() => { const stats = useMemo(() => {
const base = { const base = {
total: allSchedules.length,
scheduled: 0, scheduled: 0,
dispatched: 0, dispatched: 0,
draft: 0, draft: 0,
weight: 0, weight: 0,
}; };
for (const s of allSchedules) { for (const s of schedules) {
if (s.status === "SCHEDULED") base.scheduled += 1; if (s.status === "SCHEDULED") base.scheduled += 1;
if (s.status === "DISPATCHED") base.dispatched += 1; if (s.status === "DISPATCHED") base.dispatched += 1;
if (s.status === "DRAFT") base.draft += 1; if (s.status === "DRAFT") base.draft += 1;
base.weight += s.totalWeightTons ?? 0; base.weight += s.totalWeightTons ?? 0;
} }
return base; return base;
}, [allSchedules]); }, [schedules]);
// Distinct origins/destinations present in the loaded schedules, for the // Corridor filter options: every yard from the shared reference list, sent
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below. // to the server as origin/destination station IDs.
const originOptions = useMemo( const yardOptions = useMemo(
() => () =>
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[], (yardsQuery.data ?? []).map((y) => ({
[allSchedules], value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
); );
const destinationOptions = useMemo(
() =>
[
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
].sort() as string[],
[allSchedules],
);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
const matched = allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
return false;
if (!query) return true;
const haystack = [
s.reference,
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
...(s.locomotives ?? []).map((l) => l.code),
s.freightType,
s.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
const dir = sortDir === "asc" ? 1 : -1;
const sorted = [...matched].sort((a, b) => {
let cmp = 0;
if (sortBy === "reference") {
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
} else {
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
const av = new Date(a[sortBy] ?? 0).getTime();
const bv = new Date(b[sortBy] ?? 0).getTime();
cmp = av - bv;
}
return cmp * dir;
});
return sorted;
}, [
allSchedules,
search,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => { const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell; const headerClassName = ruleEngineTable.headerCell;
@@ -504,7 +504,7 @@ export default function TrainScheduleV2ListPage() {
<KpiStrip <KpiStrip
items={[ items={[
{ label: "Total trains", value: stats.total, icon: Train }, { label: "Total trains", value: totalSchedules, icon: Train },
{ label: "Scheduled", value: stats.scheduled, icon: CalendarClock }, { label: "Scheduled", value: stats.scheduled, icon: CalendarClock },
{ label: "Dispatched", value: stats.dispatched, icon: Send }, { label: "Dispatched", value: stats.dispatched, icon: Send },
{ label: "Planned load", value: `${Math.round(stats.weight)}T`, icon: Weight }, { label: "Planned load", value: `${Math.round(stats.weight)}T`, icon: Weight },
@@ -526,12 +526,17 @@ export default function TrainScheduleV2ListPage() {
size="sm" size="sm"
radius="lg" radius="lg"
value={statusFilter} value={statusFilter}
onChange={(v) => v && setStatusFilter(v)} onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | TrainScheduleStatus);
resetPage();
}}
data={[ data={[
{ value: "ALL", label: "All statuses" }, { value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" }, { value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" }, { value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" }, { value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" }, { value: "CANCELLED", label: "Cancelled" },
]} ]}
w={150} w={150}
@@ -541,7 +546,11 @@ export default function TrainScheduleV2ListPage() {
size="sm" size="sm"
radius="lg" radius="lg"
value={freightFilter} value={freightFilter}
onChange={(v) => v && setFreightFilter(v)} onChange={(v) => {
if (!v) return;
setFreightFilter(v as "ALL" | FreightType);
resetPage();
}}
data={[ data={[
{ value: "ALL", label: "All freight" }, { value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" }, { value: "CONTAINER", label: "Container" },
@@ -557,10 +566,13 @@ export default function TrainScheduleV2ListPage() {
placeholder="Origin" placeholder="Origin"
searchable searchable
value={originFilter} value={originFilter}
onChange={(v) => setOriginFilter(v ?? "ALL")} onChange={(v) => {
setOriginFilter(v ?? "ALL");
resetPage();
}}
data={[ data={[
{ value: "ALL", label: "All origins" }, { value: "ALL", label: "All origins" },
...originOptions.map((o) => ({ value: o, label: o })), ...yardOptions,
]} ]}
w={160} w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
@@ -571,10 +583,13 @@ export default function TrainScheduleV2ListPage() {
placeholder="Destination" placeholder="Destination"
searchable searchable
value={destinationFilter} value={destinationFilter}
onChange={(v) => setDestinationFilter(v ?? "ALL")} onChange={(v) => {
setDestinationFilter(v ?? "ALL");
resetPage();
}}
data={[ data={[
{ value: "ALL", label: "All destinations" }, { value: "ALL", label: "All destinations" },
...destinationOptions.map((d) => ({ value: d, label: d })), ...yardOptions,
]} ]}
w={170} w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
@@ -591,12 +606,13 @@ export default function TrainScheduleV2ListPage() {
]; ];
setSortBy(by); setSortBy(by);
setSortDir(dir); setSortDir(dir);
resetPage();
}} }}
data={[ data={[
{ value: "createdAt:desc", label: "Newest created" }, { value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" }, { value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduleDate:desc", label: "Departure ↓" }, { value: "scheduledDepartureDate:desc", label: "Departure ↓" },
{ value: "scheduleDate:asc", label: "Departure ↑" }, { value: "scheduledDepartureDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" }, { value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" }, { value: "reference:desc", label: "Reference ↓" },
]} ]}
@@ -611,7 +627,7 @@ export default function TrainScheduleV2ListPage() {
{viewMode === "table" ? ( {viewMode === "table" ? (
<DataTable <DataTable
columns={columns} columns={columns}
data={paged} data={schedules}
status={tableStatus} status={tableStatus}
onRowClick={(schedule) => onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`) navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
@@ -629,7 +645,7 @@ export default function TrainScheduleV2ListPage() {
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
pageCount, pageCount,
totalCount: filtered.length, totalCount: totalSchedules,
}} }}
tableOptions={{ tableOptions={{
manualPagination: true, manualPagination: true,
@@ -648,13 +664,13 @@ export default function TrainScheduleV2ListPage() {
/> />
) : ( ) : (
<Stack gap={0}> <Stack gap={0}>
{!paged.length ? ( {!schedules.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm"> <Text py="xl" ta="center" c="dimmed" size="sm">
No train schedules found No train schedules found
</Text> </Text>
) : ( ) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => ( {schedules.map((schedule) => (
<ScheduleCard <ScheduleCard
key={schedule.id} key={schedule.id}
schedule={schedule} schedule={schedule}
@@ -675,7 +691,7 @@ export default function TrainScheduleV2ListPage() {
<RuleEngineListFooter <RuleEngineListFooter
pagination={pagination} pagination={pagination}
pageCount={pageCount} pageCount={pageCount}
totalCount={filtered.length} totalCount={totalSchedules}
itemLabel="schedules" itemLabel="schedules"
onPaginationChange={setPagination} onPaginationChange={setPagination}
/> />

View File

@@ -20,6 +20,8 @@ import {
CreateDropdownSettingDto, CreateDropdownSettingDto,
DropdownOption, DropdownOption,
DropdownSetting, DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto, UpdateDropdownOptionDto,
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
@@ -61,7 +63,8 @@ import type {
StaffBookingWindow, StaffBookingWindow,
TrainScheduleDetail, TrainScheduleDetail,
TrainScheduleFilters, TrainScheduleFilters,
TrainScheduleListItem, TrainScheduleListFilters,
TrainScheduleListResponse,
UpdateScheduleWindowRulePayload, UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload, TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse, TrainSchedulePreviewResponse,
@@ -214,13 +217,14 @@ export const api = {
trainScheduling: { trainScheduling: {
// ── Queries ──────────────────────────────────────────────────────────── // ── Queries ────────────────────────────────────────────────────────────
scheduleList: endpoint< scheduleList: endpoint<
{ freightType?: FreightType }, { freightType?: FreightType; filters?: TrainScheduleListFilters },
TrainScheduleListItem[] TrainScheduleListResponse
>( >(
"train-scheduling", "train-scheduling",
"schedules", "schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType), ({ freightType, filters }) =>
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(), trainSchedulingService.listSchedules(freightType, filters),
({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.schedules(filters),
), ),
batchBoard: endpoint< batchBoard: endpoint<
@@ -1398,7 +1402,7 @@ export const api = {
yards: endpoint<void, YardRef[]>( yards: endpoint<void, YardRef[]>(
"routes", "routes",
"yards", "yards",
() => routesService.getYards().then((r) => r.data.data), () => routesService.getYards(),
() => ["routes", "yards"], () => ["routes", "yards"],
), ),
@@ -1970,6 +1974,15 @@ export const api = {
dropdownSettingsService.list, dropdownSettingsService.list,
), ),
listPaged: endpoint<
{ query: DropdownSettingListQuery },
PaginatedDropdownSettings
>(
"dropdown-settings",
"listPaged",
({ query }) => dropdownSettingsService.listPaged(query),
),
getById: endpoint<{ id: string }, DropdownSetting>( getById: endpoint<{ id: string }, DropdownSetting>(
"dropdown-settings", "dropdown-settings",
"getById", "getById",

View File

@@ -34,6 +34,8 @@ export interface BookingListFilter {
destinationYardId?: string; destinationYardId?: string;
/** "true" = government bookings only, "false" = private only. */ /** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false"; isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@@ -183,6 +185,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId; if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment; if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.search) params.search = filter.search;
} }
const response = await client.get<PaginatedBookings>(B.BASE, { const response = await client.get<PaginatedBookings>(B.BASE, {
params, params,

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http"; import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = { export const cargoTypesService = {
/** All active cargo types (page-walked — the API caps pageSize at 100). */
async getCargoTypes() { async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', { return ruleEngineService.listAll("cargo-types", { isActive: true });
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
}, },
}; };

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http"; import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = { export const containerTypesService = {
/** All active container types (page-walked — the API caps pageSize at 100). */
async getContainerTypes() { async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', { return ruleEngineService.listAll("container-types", { isActive: true });
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
}, },
}; };

View File

@@ -16,6 +16,8 @@ export interface ContractListFilter {
tradeDirection?: string; tradeDirection?: string;
contractKind?: string; contractKind?: string;
paymentCurrency?: string; paymentCurrency?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@@ -58,6 +60,10 @@ export interface ShipmentValidation {
pairingErrors: string[]; pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */ /** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[]; capacityErrors?: string[];
/** Containers already on another active booking for the same day + route — booking cannot be created. */
containerClashErrors?: string[];
/** EXPORT only: no single open train on the chosen day can carry the whole booking — booking cannot be created. */
spaceErrors?: string[];
lineItems?: ShipmentPriceLine[]; lineItems?: ShipmentPriceLine[];
totalAmount?: number; totalAmount?: number;
} }
@@ -123,6 +129,7 @@ function buildListParams(filter?: ContractListFilter) {
if (filter) { if (filter) {
if (filter.statuses) params.statuses = filter.statuses; if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status; else if (filter.status) params.status = filter.status;
if (filter.search) params.search = filter.search;
if (filter.page != null) params.page = filter.page; if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize; if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy; if (filter.sortBy) params.sortBy = filter.sortBy;

View File

@@ -7,6 +7,8 @@ import type {
CreateDropdownSettingDto, CreateDropdownSettingDto,
DropdownOption, DropdownOption,
DropdownSetting, DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto, UpdateDropdownOptionDto,
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
@@ -19,6 +21,16 @@ export const dropdownSettingsService = {
return unwrap(response.data); return unwrap(response.data);
}, },
listPaged: async (
query: DropdownSettingListQuery,
): Promise<PaginatedDropdownSettings> => {
const response = await client.get<ApiResponse<PaginatedDropdownSettings>>(
`${BASE}/paged`,
{ params: query },
);
return unwrap(response.data);
},
getById: async (id: string): Promise<DropdownSetting> => { getById: async (id: string): Promise<DropdownSetting> => {
const response = await client.get<ApiResponse<DropdownSetting>>( const response = await client.get<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id), URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),

View File

@@ -1,6 +1,7 @@
import { api as apiClient } from '../auth/http'; import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from '@/constants/URLS';
import { ruleEngineService } from './ruleEngine/ruleEngine.service';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
@@ -76,10 +77,6 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }>
{ value: 'STOP_WORKING', label: 'Stop working' }, { value: 'STOP_WORKING', label: 'Stop working' },
]; ];
interface YardListResponse {
data: YardRef[];
}
export const routesService = { export const routesService = {
getAll: (params?: { status?: RouteStatus; search?: string }) => getAll: (params?: { status?: RouteStatus; search?: string }) =>
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }), apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
@@ -88,8 +85,9 @@ export const routesService = {
update: (id: string, data: Partial<SaveRoutePayload>) => update: (id: string, data: Partial<SaveRoutePayload>) =>
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data), apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)), deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
getYards: () => /** All active yards (page-walked — the yards list API caps pageSize at 100). */
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, { getYards: async (): Promise<YardRef[]> => {
params: { isActive: true, pageSize: 200 }, const rows = await ruleEngineService.listAll("yards", { isActive: true });
}), return rows as unknown as YardRef[];
},
}; };

View File

@@ -68,50 +68,63 @@ const defaultMeta = (
dataLength: number, dataLength: number,
page = 1, page = 1,
pageSize = 10, pageSize = 10,
): RuleEngineListMeta => ({ ): RuleEngineListMeta => {
total: dataLength, const totalPages = Math.max(1, Math.ceil(dataLength / pageSize));
page, return {
pageSize, total: dataLength,
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)), page,
}); pageSize,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
};
const isPaginatedListResult = <T extends RuleEngineRecord>( /** Standard envelope from the shared pagination toolkit: `{ items, meta }`. */
const isItemsEnvelope = <T extends RuleEngineRecord>(
value: unknown, value: unknown,
): value is RuleEngineListResult<T> => ): value is RuleEngineListResult<T> =>
Boolean(value) && Boolean(value) &&
typeof value === "object" && typeof value === "object" &&
"data" in (value ?? {}) && Array.isArray((value as { items?: unknown }).items);
Array.isArray((value as RuleEngineListResult<T>).data);
/** Legacy envelope (`{ data, meta }`) — still returned by wagon-types. */
const isLegacyEnvelope = <T extends RuleEngineRecord>(
value: unknown,
): value is { data: T[]; meta?: RuleEngineListMeta } =>
Boolean(value) &&
typeof value === "object" &&
Array.isArray((value as { data?: unknown }).data);
const normalizeList = <T extends RuleEngineRecord>( const normalizeList = <T extends RuleEngineRecord>(
payload: unknown, payload: unknown,
page = 1, page = 1,
pageSize = 10, pageSize = 10,
): RuleEngineListResult<T> => { ): RuleEngineListResult<T> => {
if (isPaginatedListResult<T>(payload)) { const candidates: unknown[] = [payload, unwrap(payload as { data: unknown })];
return {
data: payload.data, for (const body of candidates) {
meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize), if (isItemsEnvelope<T>(body)) {
}; return {
items: body.items,
meta: body.meta ?? defaultMeta(body.items.length, page, pageSize),
};
}
if (isLegacyEnvelope<T>(body)) {
return {
items: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
items: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
} }
const body = unwrap(payload as { data: unknown }) as unknown; return { items: [], meta: defaultMeta(0, page, pageSize) };
if (isPaginatedListResult<T>(body)) {
return {
data: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
data: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
return { data: [], meta: defaultMeta(0, page, pageSize) };
}; };
const normalizeEntity = <T extends RuleEngineRecord>(payload: unknown): T => { const normalizeEntity = <T extends RuleEngineRecord>(payload: unknown): T => {
@@ -140,6 +153,34 @@ export const ruleEngineService = {
return normalizeList<T>(response.data, page, pageSize); return normalizeList<T>(response.data, page, pageSize);
}, },
/**
* Fetch every row of a resource by walking the pages. The API caps pageSize
* at 100, so option/dropdown consumers that used to ask for 200-500 rows in
* one shot go through here instead of getting silently capped (or a 400).
*/
listAll: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug,
params?: Omit<RuleEngineListParams, "page" | "pageSize">,
): Promise<T[]> => {
const pageSize = 100;
const first = await ruleEngineService.list<T>(resource, {
...params,
page: 1,
pageSize,
});
const items = [...first.items];
const totalPages = first.meta.totalPages ?? 1;
for (let page = 2; page <= totalPages; page += 1) {
const next = await ruleEngineService.list<T>(resource, {
...params,
page,
pageSize,
});
items.push(...next.items);
}
return items;
},
getById: async <T extends RuleEngineRecord>( getById: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug, resource: RuleEngineResourceSlug,
id: string, id: string,

View File

@@ -30,7 +30,8 @@ import type {
TrainScheduleDetail, TrainScheduleDetail,
UpdateScheduleWindowRulePayload, UpdateScheduleWindowRulePayload,
TrainScheduleFilters, TrainScheduleFilters,
TrainScheduleListItem, TrainScheduleListFilters,
TrainScheduleListResponse,
TrainSchedulePreviewPayload, TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse, TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules, TrainSchedulingGlobalRules,
@@ -94,9 +95,22 @@ export const trainSchedulingService = {
listSchedules: async ( listSchedules: async (
freightType: FreightType = "CONTAINER", freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleListItem[]> => { filters: TrainScheduleListFilters = {},
const response = await client.get<TrainScheduleListItem[]>( ): Promise<TrainScheduleListResponse> => {
const params: Record<string, string | number> = {};
if (filters.page) params.page = filters.page;
if (filters.pageSize) params.pageSize = filters.pageSize;
if (filters.search?.trim()) params.search = filters.search.trim();
if (filters.status) params.status = filters.status;
if (filters.freightType) params.freightType = filters.freightType;
if (filters.originStationId) params.originStationId = filters.originStationId;
if (filters.destinationStationId)
params.destinationStationId = filters.destinationStationId;
if (filters.sortBy) params.sortBy = filters.sortBy;
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
const response = await client.get<TrainScheduleListResponse>(
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULES, pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULES,
{ params },
); );
return unwrap(response.data); return unwrap(response.data);
}, },

View File

@@ -219,7 +219,11 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload), apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) => update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload), apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS), // Yards list now returns the standard paginated envelope ({ items, meta }).
listFacilities: () =>
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
params: { pageSize: 100 },
}),
// ── Yards ──────────────────────────────────────────────────────────────── // ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) => listYards: (warehouseId: string) =>

View File

@@ -1,6 +1,6 @@
// Re-export the shared types from @edr/types so existing local imports keep // Re-export the shared types from @edr/types so existing local imports keep
// working. Canonical source: packages/types/src/freight/dropdown_settings.ts // working. Canonical source: packages/types/src/freight/dropdown_settings.ts
import type { Freight } from "@edr/types"; import type { Freight, PaginatedResponse } from "@edr/types";
export type DropdownOptionMeta = Freight.IDropdownOptionMeta; export type DropdownOptionMeta = Freight.IDropdownOptionMeta;
export type DropdownOption = Freight.IDropdownOption; export type DropdownOption = Freight.IDropdownOption;
@@ -10,3 +10,13 @@ export type CreateDropdownOptionDto = Freight.CreateDropdownOptionDto;
export type CreateDropdownSettingDto = Freight.CreateDropdownSettingDto; export type CreateDropdownSettingDto = Freight.CreateDropdownSettingDto;
export type UpdateDropdownOptionDto = Freight.UpdateDropdownOptionDto; export type UpdateDropdownOptionDto = Freight.UpdateDropdownOptionDto;
export type UpdateDropdownSettingDto = Freight.UpdateDropdownSettingDto; export type UpdateDropdownSettingDto = Freight.UpdateDropdownSettingDto;
/** Query params for GET /dropdown-settings/paged (server-side search). */
export interface DropdownSettingListQuery {
page?: number;
pageSize?: number;
search?: string;
}
/** Shared paginated envelope returned by GET /dropdown-settings/paged. */
export type PaginatedDropdownSettings = PaginatedResponse<DropdownSetting>;

View File

@@ -10,15 +10,23 @@ export type RuleEngineResourceSlug =
| "rates" | "rates"
| "approval-rules"; | "approval-rules";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are
* optional because the legacy wagon-types endpoint still returns the old
* four-field meta.
*/
export interface RuleEngineListMeta { export interface RuleEngineListMeta {
total: number; total: number;
page: number; page: number;
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
} }
/** Standard paginated envelope (`items` + `meta`) shared by all rule-engine lists. */
export interface RuleEngineListResult<T> { export interface RuleEngineListResult<T> {
data: T[]; items: T[];
meta: RuleEngineListMeta; meta: RuleEngineListMeta;
} }

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
export type FreightType = "CONTAINER" | "BULK" | "MIXED"; export type FreightType = "CONTAINER" | "BULK" | "MIXED";
export type SchedulingStatus = export type SchedulingStatus =
@@ -182,6 +184,33 @@ export interface TrainScheduleListItem {
status: TrainScheduleStatus | string; status: TrainScheduleStatus | string;
} }
export type TrainScheduleSortField =
| "createdAt"
| "scheduledDepartureDate"
| "reference"
| "trainNumber"
| "status";
/** Server-side query for the paginated train-schedule list. */
export interface TrainScheduleListFilters {
page?: number;
pageSize?: number;
/** Matches schedule reference, train number, route yards, stations, locomotive code. */
search?: string;
/** Lifecycle status (exact match). */
status?: TrainScheduleStatus;
/** Derived from the bookings aboard: CONTAINER/BULK = only that kind; MIXED = both. */
freightType?: FreightType;
/** Origin station/yard id (exact match). */
originStationId?: string;
/** Destination station/yard id (exact match). */
destinationStationId?: string;
sortBy?: TrainScheduleSortField;
sortOrder?: "ASC" | "DESC";
}
export type TrainScheduleListResponse = PaginatedResponse<TrainScheduleListItem>;
export interface BookableSchedule { export interface BookableSchedule {
id: string; id: string;
scheduleDate: string; scheduleDate: string;
@@ -326,13 +355,8 @@ export interface BatchBoardFilters {
sortOrder?: "ASC" | "DESC"; sortOrder?: "ASC" | "DESC";
} }
export interface BatchBoardListResponse { /** Paginated batch-board list in the shared `{items, meta}` envelope. */
items: BatchBoardSchedule[]; export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export type BookingAllocationStatus = export type BookingAllocationStatus =
| "NOT_ATTEMPTED" | "NOT_ATTEMPTED"

View File

@@ -31,24 +31,31 @@ export function patchRuleEngineListRecord(
qc.setQueriesData<RuleEngineListResult<RuleEngineRecord>>( qc.setQueriesData<RuleEngineListResult<RuleEngineRecord>>(
{ queryKey: ["rule-engine", "list", resource] }, { queryKey: ["rule-engine", "list", resource] },
(old) => { (old) => {
if (!old?.data?.length) return old; if (!old?.items?.length) return old;
const index = old.data.findIndex((row) => String(row.id) === updatedId); const index = old.items.findIndex((row) => String(row.id) === updatedId);
if (index === -1) return old; if (index === -1) return old;
const data = old.data.slice(); const items = old.items.slice();
data[index] = { ...data[index], ...updated }; items[index] = { ...items[index], ...updated };
return { ...old, data }; return { ...old, items };
}, },
); );
} }
/** Invalidate and refetch active rule-engine list queries for a resource. */ /**
* Invalidate and refetch active rule-engine list queries for a resource.
* Also covers the page-walked order/full lists (order-list key), which show
* the same rows and must refresh after any create/update/delete.
*/
export async function invalidateRuleEngineList( export async function invalidateRuleEngineList(
qc: QueryClient, qc: QueryClient,
resource: RuleEngineResourceSlug | string, resource: RuleEngineResourceSlug | string,
): Promise<void> { ): Promise<void> {
const queryKey = ["rule-engine", "list", resource] as const; const listKey = ["rule-engine", "list", resource] as const;
await qc.invalidateQueries({ queryKey }); const orderListKey = ["rule-engine", "order-list", resource] as const;
await qc.refetchQueries({ queryKey, type: "active" }); await qc.invalidateQueries({ queryKey: listKey });
await qc.invalidateQueries({ queryKey: orderListKey });
await qc.refetchQueries({ queryKey: listKey, type: "active" });
await qc.refetchQueries({ queryKey: orderListKey, type: "active" });
} }
export function invalidateRuleEngineRoot(qc: QueryClient): Promise<void> { export function invalidateRuleEngineRoot(qc: QueryClient): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More