diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts index 39e6da2f2..8580c7774 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts index 0896e6be0..813e833fc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -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)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index f0ca9cfa5..a2d2fb53f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 8d54e5e86..bbadfda55 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index bfa80b453..3eb579b86 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -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]) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index ab3ba6118..1ed7883c9 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -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 { `) as Promise; } +/** Container types are 2 rows that change about never. */ +async function containerTypeOptions(ds: DataSource): Promise { + 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; +} + +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 { + 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) { diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts index c302d9d3b..40c8e6980 100644 --- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -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(); +/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */ +const fieldsCache = new Map(); + +/** + * 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 }, + ds: DataSource, +): Promise { + 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, diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts index db12f0211..71abdad88 100644 --- a/apps/edr-freight-api/src/modules/exports/export.types.ts +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -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; filters: ExportFilterDef[]; /** Must name a field whose `sortExpr` references only the base alias. */ defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index aea35a9bc..46c6ebad2 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 9a7010b05..28edee8b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -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, { diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index c97d14b68..3acd3bd05 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -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)