feat(bookings): filter by container count, export per-type quantities

Container filters on the booking-requests list:
- "Container type" — bookings carrying that type.
- "Containers" — a count of BOXES (booking_container is one row per line with
  a quantity, so this sums quantity rather than counting rows), as an exact
  value or a range. It reads the container-type filter when one is set, so the
  one control answers both "10 containers in total" and "10 forty-footers".

Export gains a column per container type ("20FT containers", "40FT
containers"), plus the total "Containers" column and the two filters. Container
types are reference rows, not a constant, so `ExportDataset` gains an optional
`dynamicFields` resolver — DB-driven columns appended to the static list and
cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`.
Adding a 45ft container type adds its column with no code change. The type id
is interpolated into raw SQL (ExportField.select has no parameter bag), so the
resolver drops any id that is not a uuid.

Also repoints the export's "Container VGM" column at the per-line sum. It was
projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0
for container freight — the same trap the tonnage fix addressed — so the column
read 0 for every portal-created container booking. Non-zero on dev data goes
from 54 to 170 of 208 container bookings.
This commit is contained in:
Nathnael
2026-08-28 10:02:01 +00:00
parent 339b8a8682
commit 74fb05207c
11 changed files with 310 additions and 13 deletions

View File

@@ -1,7 +1,10 @@
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContainerVgmSql,
bookingContentMatchSql,
bookingContentSql,
bookingHasContainerTypeSql,
} from './booking-content.sql';
describe('bookingContentSql', () => {
@@ -62,3 +65,44 @@ describe('bookingContentMatchSql', () => {
expect(sql.trimEnd().endsWith(')')).toBe(true);
});
});
describe('bookingContainerCountSql', () => {
// booking_container is one row per LINE carrying a quantity, so counting rows
// would report a 54-container booking as 1.
it('sums the line quantities rather than counting lines', () => {
expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
});
it('counts every type by default and one type when scoped', () => {
expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
expect(bookingContainerCountSql('b', true)).toContain(
'bc.container_type_id = :containerTypeId',
);
});
it('is 0, never NULL, so a bound comparison still decides', () => {
expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
});
it('ignores soft-deleted lines', () => {
expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
});
it('rewrites the booking reference under another alias', () => {
expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
});
});
describe('bookingContainerVgmSql', () => {
// The whole point: b.cargo_total_weight_vgm is 0 for portal container
// bookings, so the weight has to come off the lines.
it('reads the lines, never the booking-level column', () => {
const sql = bookingContainerVgmSql('b');
expect(sql).toContain('SUM(bc.total_vgm_tons)');
expect(sql).not.toContain('cargo_total_weight_vgm');
expect(sql).toContain('bc.deleted_at IS NULL');
});
});

View File

@@ -56,3 +56,45 @@ export function bookingContentMatchSql(alias = 'b'): string {
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
}
/**
* Containers on a booking, as a count of physical boxes — `booking_container`
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
* quantity rather than counting rows.
*
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
* number filter answer both "10 containers in total" and "10 forty-footers":
* the count filter reads the container-type filter when one is set, and counts
* every type when it is not.
*/
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
return `(SELECT COALESCE(SUM(bc.quantity), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL${
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
})`;
}
/** Bookings carrying at least one line of `:containerTypeId`. */
export function bookingHasContainerTypeSql(alias = 'b'): string {
return `EXISTS (SELECT 1 FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL
AND bc.container_type_id = :containerTypeId)`;
}
/**
* Container VGM on a booking, in tons — the sum of the per-line totals.
*
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
* container freight (VGM is captured per container, later, in operations), so
* reading the booking-level column showed every portal container booking as
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
*/
export function bookingContainerVgmSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL)`;
}

View File

@@ -23,7 +23,9 @@ import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContentMatchSql,
bookingHasContainerTypeSql,
} from './booking-content.sql';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
@@ -73,6 +75,10 @@ export interface BookingListFilterOptions {
cargoTypeId?: string;
/** Contains-search over content: description, commodity name, container types. */
cargoText?: string;
/** Bookings carrying this container type; also scopes the container count. */
containerTypeId?: string;
containersMin?: number;
containersMax?: number;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
@@ -1196,6 +1202,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
cargoText: `%${options.cargoText}%`,
});
}
if (options.containerTypeId) {
qb.andWhere(bookingHasContainerTypeSql('booking'), {
containerTypeId: options.containerTypeId,
});
}
// One count filter, two questions: with a container type picked it counts
// that type, without one it counts every box on the booking.
if (options.containersMin != null || options.containersMax != null) {
const count = bookingContainerCountSql(
'booking',
Boolean(options.containerTypeId),
);
if (options.containersMin != null) {
qb.andWhere(`${count} >= :containersMin`, {
containersMin: options.containersMin,
});
}
if (options.containersMax != null) {
qb.andWhere(`${count} <= :containersMax`, {
containersMax: options.containersMax,
});
}
}
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,

View File

@@ -1846,6 +1846,9 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2074,6 +2077,9 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,

View File

@@ -81,6 +81,28 @@ export class FilterBookingDto {
)
cargoText?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Bookings carrying this container type. Also scopes containersMin/Max to it.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Minimum container count — of containerTypeId when set, else of all types',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMin?: number;
@ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMax?: number;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])

View File

@@ -3,8 +3,11 @@ import { DataSource } from 'typeorm';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContentMatchSql,
bookingContainerVgmSql,
bookingContentSql,
bookingHasContainerTypeSql,
} from '../../bookings/booking-content.sql';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
@@ -19,7 +22,7 @@ import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line
import { Train } from '../../trains/entities/train.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportFilterOption } from '../export-filter.util';
import { ExportDataset } from '../export.types';
import { ExportDataset, ExportField } from '../export.types';
/**
* Domain semantics that the retired `bookings-list` report used to share.
@@ -32,6 +35,7 @@ const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
/** What the customer described as the booking's contents — see the helper. */
const CONTENT = bookingContentSql('b');
const CONTAINER_COUNT = bookingContainerCountSql('b');
const STATUS_OPTIONS = [
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
@@ -67,6 +71,53 @@ async function cargoTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
`) as Promise<ExportFilterOption[]>;
}
/** Container types are 2 rows that change about never. */
async function containerTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
return ds.query(`
SELECT id AS value, COALESCE(label, code) AS label
FROM freight.container_types
WHERE deleted_at IS NULL AND is_active
ORDER BY display_order, code
`) as Promise<ExportFilterOption[]>;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* One column per container type ("20FT", "40FT", …), each the box count of
* that type on the booking. Resolved from `container_types` rather than
* hardcoded, so adding a 45ft adds its column without a deploy of this file.
*
* The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
* string with no parameter bag — so ids that are not uuids are dropped rather
* than spliced. They come from our own table; the guard is for the day someone
* changes that column's type.
*/
async function containerTypeFields(ds: DataSource): Promise<ExportField[]> {
const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
SELECT id, code, label
FROM freight.container_types
WHERE deleted_at IS NULL AND is_active
ORDER BY display_order, code
`);
return rows
.filter((r) => UUID_RE.test(r.id))
.map((r) => {
const name = r.label || r.code;
return {
key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
label: `${name} containers`,
type: 'number' as const,
group: 'cargo',
select: `(SELECT COALESCE(SUM(bc.quantity), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
AND bc.container_type_id = '${r.id}')::int`,
};
});
}
export const bookingsDataset: ExportDataset = {
key: 'bookings',
title: 'Bookings',
@@ -157,9 +208,13 @@ export const bookingsDataset: ExportDataset = {
{ key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
{ key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
// Boxes, not lines: booking_container is one row per LINE with a quantity.
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
// column at 0 for container freight, so it read 0 for every such booking.
{ key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
@@ -216,6 +271,8 @@ export const bookingsDataset: ExportDataset = {
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
],
dynamicFields: containerTypeFields,
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
@@ -236,6 +293,9 @@ export const bookingsDataset: ExportDataset = {
] },
{ key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
{ key: 'cargoText', label: 'Content contains', type: 'text' },
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
@@ -259,6 +319,16 @@ export const bookingsDataset: ExportDataset = {
// Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
// With a container type picked the count is of THAT type, else of every box.
const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
// coerceFilterParams yields null (not undefined) for an unset filter, and
// Number(null) is 0 — which would silently apply ">= 0" to every export.
const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
const min = num(params.containersMin);
const max = num(params.containersMax);
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
if (params.search) {

View File

@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm';
import type { ExportField } from './export.types';
const DAY_MS = 24 * 60 * 60 * 1000;
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
@@ -64,6 +66,26 @@ export function coerceFilterParams(
*/
const optionsCache = new Map<string, ExportFilterOption[]>();
/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
const fieldsCache = new Map<string, ExportField[]>();
/**
* A dataset's full field list: its static fields plus whatever `dynamicFields`
* resolves from the DB. Every read of `dataset.fields` goes through this, so
* the catalog and the download agree on which keys exist.
*/
export async function resolveDatasetFields(
dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise<ExportField[]> },
ds: DataSource,
): Promise<ExportField[]> {
if (!dataset.dynamicFields) return dataset.fields;
const cached = fieldsCache.get(dataset.key);
if (cached) return cached;
const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
fieldsCache.set(dataset.key, resolved);
return resolved;
}
export async function resolveFilterOptions(
filters: ExportFilterDef[],
ds: DataSource,

View File

@@ -103,6 +103,15 @@ export interface ExportDataset {
alwaysJoin?: string[];
groups: ExportGroup[];
fields: ExportField[];
/**
* Extra fields resolved from reference data and appended to `fields` — one
* column per row of some small, rarely-changing table (a column per container
* type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
*
* The SQL these build is interpolated, not bound, so a resolver MUST validate
* anything it splices in; see `bookingsDataset` for the uuid guard.
*/
dynamicFields?: (ds: DataSource) => Promise<ExportField[]>;
filters: ExportFilterDef[];
/** Must name a field whose `sortExpr` references only the base alias. */
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };

View File

@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { resolveFilterOptions } from './export-filter.util';
import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
import {
EXPORT_MIME,
formatRowCap,
@@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
* the schema — they never leave the server.
*/
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
const toCatalogEntry = (
dataset: ExportDataset,
fields: ExportField[],
): ExportCatalogEntry => ({
key: dataset.key,
title: dataset.title,
description: dataset.description,
group: dataset.group,
groups: dataset.groups,
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
key,
label,
type,
@@ -72,7 +75,7 @@ export class ExportsController {
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
return Promise.all(
allowed.map(async (d) => ({
...toCatalogEntry(d),
...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
filters: await resolveFilterOptions(d.filters, this.dataSource),
})),
);
@@ -102,7 +105,10 @@ export class ExportsController {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const fields = this.resolveFields(dataset, query.fields);
const fields = ExportsController.pickFields(
await resolveDatasetFields(dataset, this.dataSource),
query.fields,
);
const rows = await this.runner.run(dataset, fields, query, directions, {
cap: formatRowCap(format),
@@ -134,15 +140,15 @@ export class ExportsController {
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
* all of them on an unparameterised call is nobody's intent.
*/
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] {
if (raw?.trim()) {
const picked = pickByKey(dataset.fields, raw);
const picked = pickByKey(all, raw);
// pickByKey falls back to everything when nothing matched; for a dataset
// the safer read of "all keys unknown" is still the default set.
if (picked.length !== dataset.fields.length) return picked;
if (picked.length !== all.length) return picked;
}
const defaults = dataset.fields.filter((f) => f.default);
return defaults.length ? defaults : dataset.fields;
const defaults = all.filter((f) => f.default);
return defaults.length ? defaults : all;
}
private resolve(key: string, user: TCurrentUser): ExportDataset {

View File

@@ -166,6 +166,15 @@ export default function BookingRequestsPage() {
// already mounted just works, and every filter — direction included —
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
// split), so a deep link can never land behind "More filters" unseen.
// Container types, flattened out of the reference data's size groups.
const containerTypeOptions = useMemo(
() =>
(refData?.containers ?? []).flatMap((group) =>
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
),
[refData],
);
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{
@@ -221,6 +230,27 @@ export default function BookingRequestsPage() {
secondary: true,
placeholder: "Commodity, description or container type",
},
{
key: "containerTypeId",
label: "Container type",
type: "enum",
multiple: false,
options: containerTypeOptions,
secondary: true,
},
{
// Counts boxes. Scoped to the container-type filter when one is set, so
// this one control answers "10 containers" and "10 forty-footers" both.
key: "containers",
label: "Containers",
type: "number",
secondary: true,
operators: ["is", "between"],
toParams: (v) =>
v.op === "between"
? { containersMin: v.v[0], containersMax: v.v[1] }
: { containersMin: v.v[0], containersMax: v.v[0] },
},
{
key: "serviceTypeId",
label: "Service",
@@ -282,7 +312,13 @@ export default function BookingRequestsPage() {
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
[filterOptions, yardOptions, serviceTypeOptions, cargoTypeOptions],
[
filterOptions,
yardOptions,
serviceTypeOptions,
cargoTypeOptions,
containerTypeOptions,
],
);
const controls = useFilters(bookingFilterDefs, {

View File

@@ -77,6 +77,11 @@ export interface BookingListFilter {
cargoTypeId?: string;
/** Contains-search over content: cargo description, commodity, container types. */
cargoText?: string;
/** Bookings carrying this container type; also scopes containersMin/Max to it. */
containerTypeId?: string;
/** Container count bounds — of containerTypeId when set, else of all types. */
containersMin?: string;
containersMax?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
@@ -205,6 +210,9 @@ export const bookingsService = {
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
if (filter.cargoText) params.cargoText = filter.cargoText;
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
if (filter.containersMin) params.containersMin = filter.containersMin;
if (filter.containersMax) params.containersMax = filter.containersMax;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency)
@@ -246,6 +254,9 @@ export const bookingsService = {
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
if (filter.cargoText) params.cargoText = filter.cargoText;
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
if (filter.containersMin) params.containersMin = filter.containersMin;
if (filter.containersMax) params.containersMax = filter.containersMax;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency)