feat(bookings): filter and export booking content

The booking-requests list could filter by freight type but not by what is
actually in the booking, and the export's only cargo column showed the
commodity name — blank for every container booking, which stores no
commodity at all.

Adds one resolver, `bookingContentSql`, that answers "what did the customer
say is in this booking" per freight type: the container lines they entered
("2 × 40FT, 1 × 20FT") for container freight, since the wizard asks them for
no description; the commodity they picked from the cargo tree for bulk,
falling back to their free-text description.

List filters:
- "Content" — a single select flattening the cargo tree the same way the
  booking wizard presents it (group, then each commodity as "Bulk → Wheat").
  Picking a GROUP matches its whole subtree via a recursive walk, so "Bulk"
  returns all 44 bulk bookings rather than the 0 that carry the group id
  itself. This makes the existing, previously unexposed `cargoTypeId` param
  group-aware.
- "Content contains" — a contains-search over the description, the commodity
  name and the container types, so container bookings are reachable by "40FT"
  even though they carry no words of the customer's own.

Both apply through `applyListFilters`, so the list, its summary tiles and its
facets agree, and both are declared on the bookings export dataset — the
export button already forwards the page's filters verbatim.

Export fields: "Content" (default), plus "Cargo description" as its own
column. The old `cargo` column is unchanged and still selectable, relabelled
"Cargo (commodity)"; it loses only its default tick, so saved presets that
name it keep working.
This commit is contained in:
Nathnael
2026-08-28 09:42:56 +00:00
parent 0e9cfbce66
commit 339b8a8682
8 changed files with 251 additions and 4 deletions

View File

@@ -0,0 +1,58 @@
/**
* What the customer said is IN the booking, per freight type — the list
* filter, the summary and the export all read this one expression so the
* column, the pill and the sheet can never disagree.
*
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
* back to the free-text description for a bare group or a legacy row
* that has no commodity.
* CONTAINER the wizard asks for no description at all — VGM and contents are
* captured later in operations — so the closest thing to the
* customer's own words is the container lines they entered:
* "2 × 40FT, 1 × 20FT".
*
* Containers are checked FIRST: a container booking has no `cargo_type_id`
* (the API rejects one), so the order only matters for a mixed legacy row,
* where the physical lines are the better answer.
*/
export function bookingContentSql(alias = 'b'): string {
return `COALESCE(
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id), ''),
NULLIF(${alias}.cargo_free_text, ''))`;
}
/**
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
* matches every commodity beneath it — the same group→commodity drill-down the
* booking wizard offers, read back. Recursive because `cargo_types` is an
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
*/
export const CARGO_TYPE_SUBTREE_SQL = `(
WITH RECURSIVE sub AS (
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
UNION ALL
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
)
SELECT id FROM sub)`;
/**
* Contains-match over every part of the content a customer can type or pick:
* their own description, the commodity's name, and the container types on the
* booking. Bind `:cargoText` already wrapped in `%`.
*/
export function bookingContentMatchSql(alias = 'b'): string {
return `(${alias}.cargo_free_text ILIKE :cargoText
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id
AND cgt.cargo_type_name ILIKE :cargoText)
OR EXISTS (SELECT 1 FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
}