The PNR is the CBE_BILL reference the customer actually pays against, but it
is stamped onto the booking at payment-initiation time — it is a column on
neither the invoice nor the payment. Both surfaces read it back by source id,
the same lookup the sealed invoice PDF already did, so screen, export and
document now agree.
The export's join casts bk.id::text rather than i.source_id::uuid: source_id
is a bare varchar pointer that is not always a UUID (EIMS self-test rows
carry a slug), and casting that direction throws on those rows.
The settled method is split across two stores: a gateway settlement records
the real provider on the linked freight.payments row (cbe-bill, telebirr)
while the invoice's own payments ledger only writes a flat "GATEWAY"; a
manual settlement has no payments row at all and the ledger is the only
source (BANK_TRANSFER, OFFLINE, or whatever PayInvoiceDto.method carried).
invoicePaymentMethodExpr folds both into one UPPER_SNAKE vocabulary —
provider first, newest ledger entry as the fallback — and the list filter,
the export field and the export filter all use that same expression, so the
screen and the file can never disagree.
The paymentMethods param is deliberately not validated against a fixed list:
the manual pay endpoint takes a free-form method, so an IsIn would silently
drop real values.
Guards the two facts the report exists to get right: the cancellation
fee is never a payable, and exactly one wagon-cancellation status
(CREDIT_AVAILABLE) is a live liability. Adding a status to
WAGON_CANCELLATION_STATUSES now fails here until someone decides which
side of the ledger it lands on.
Also pins the sort expressions to the union wrapper alias — a branch
alias would resolve at build time and 42P01 at runtime, since the runner
appends ORDER BY outside the subquery.
The receivable/payable split contradicted how money actually moves, in
three ways that each changed a headline number:
- The wagon-cancellation FEE was booked as a payable. It is money the
customer owes EDR (raised ISSUED and unpaid at request time), so it
belongs on the receivable side while open. The sign was inverted.
- A whole-booking wagon cancellation was booked at the source invoice's
full paid_amount, and never cleared: the booking stays CANCELLED and
the invoice stays PAID even after the credit is rebooked. The real
liability is the ledger row's credit_amount, and only while it sits in
CREDIT_AVAILABLE — cancellation refunds no cash, it hands back
bookable credit redeemed by creating another booking.
- Shipping-line debt in UNBILLED has no invoice row at all, so an
invoice-only fact table could not see it. That is the un-batched half
of the debt, in the report whose stated purpose is shipping-line
credit.
The report is now a UNION of the three tables that hold the answer:
invoices with a balance (plus prepayments against dead bookings),
UNBILLED shipping_line_credits, and CREDIT_AVAILABLE
booking_wagon_cancellations. A booking already carried by the
cancellation ledger is excluded from the invoice branch so its money is
counted once. Fully settled invoices are dropped — zero exposure is
neither a receivable nor a payable.
Branches are re-projected through an explicit column list before being
unioned: UNION matches by position and TypeORM does not preserve
addSelect order, which silently reordered one branch into
"gross, exposure, side_key, ..." and failed with "UNION types text and
numeric cannot be matched".
Verified against Postgres with a rollback-only fixture covering every
side, plus EXPLAIN over each filter combination and every sortable
column.
Targets could only be committed weekly, monthly, quarterly or yearly, so a
figure the business quotes per half-year or per 90 days had to be split by
hand into buckets it was never expressed in. The reports already re-gather
a target into whatever grain the viewer asks for; this just lets the plan
be entered at the grain it was agreed in.
Adds day, half-year, nine-month and 90-day, matching the report units added
alongside. normalisePeriodStart snaps each to its block start with the same
calendar-year anchoring the SQL uses — Jan/Jul for half-years, Jan/Oct for
nine-months, days 1/91/181/271 for 90-day blocks, including the same cap on
the fourth block so late December does not snap into a stub of its own.
That agreement is the load-bearing part. The unique index is keyed on
period_start, and a target snapped to a boundary the report does not bucket
on is a plan measured against a period that does not exist. The two halves
live in different files and different languages, so the spec pins the
boundaries rather than trusting them to stay in step.
Adds operations-targets.service.spec.ts, which the module had none of:
every period type, idempotency, the leap year, and the block-four cap.
A target is a quota, not a flat allowance. The Plan column spread it evenly
and kept asking for the same twelfth of a yearly figure no matter how far
behind the year had fallen, so the one number operations actually needs —
what must move per month for the rest of the year — was nowhere on the
page.
Plan now keeps its meaning and a Required column sits beside it. Plan is
the committed spread and never moves, which is the whole reason it stays:
Implement Rate is measured against it, so a month that missed still reads
as a month that missed. Required is the same target read as a quota — at
each bucket, whatever is still outstanding spread across the time still
left. A 1,200 t year 20% met by June asks 140 t of June and 960 t of
December, which is 1,200 less the 240 delivered. Over-delivery clamps to
zero rather than going negative.
Attainment is deliberately measured with the user's date bounds stripped
(attainmentCtx) and every other filter left in place. Reusing the report's
own filtered aggregate would make a July-only view read year-to-date as
nothing delivered and demand the entire year's tonnage from one month —
the failure would look like a plausible number, not an error.
Granularity gains half-year, nine-month and 90-day. Postgres has no
date_trunc for any of them, so PERIOD_UNITS entries became builders rather
than fragments to interpolate, and all eight blocks anchor to the calendar
year. Nine does not divide twelve and 90 does not divide 365: a nine-month
year is Jan-Sep plus a short Oct-Dec, and the fourth 90-day block absorbs
the remainder at 95 days. That last one is a choice — uncapped floor
division opens a five-day stub bucket every December, which is noise
rather than a period.
Two consequences of the shared unit table, both handled here:
- plannedRowsSql now generates a day at a time and groups, instead of
stepping by the bucket width. The ragged blocks restart each January, so
stepping 90 days from January 1st walks off the anchor in the second
year. Day grain also gets partial-bucket overlap for free, at the same
sub-day precision the old clipping had.
- nextPeriodOrdinalExpr asks the unit for its next block start rather than
adding its own step. revenue-by-period evaluates a regression there, and
a ragged unit's final block is shorter than its nominal width, so + step
would land past the next block and forecast at the wrong x.
Verified against Postgres 16 with the entities synchronised into it: all
272 report/granularity combinations in the registry EXPLAIN clean, and the
1,200 t drill-down sums back to 1,200 at every one of the eight grains.
A station target's plan is per station AND per cargo type; the other two
dimensions already carry the category inside dimension_key. update() kept
whatever was stored whenever the payload omitted the key, and the admin
form builds its payload from visible fields only — so switching "Plan by"
away from Station left the old category behind on a row that no longer has
any use for one.
That row is not merely untidy. It survives the COALESCE(cargo_category,'')
unique index alongside the legitimate null-category row for the same key,
plannedRowsSql groups by the column, and the two plan rows then join the
same operated row through the FULL OUTER JOIN: the category lists twice,
each line carrying the full operated tonnage, while the summary tiles are
computed separately and stay correct — so the table disagrees with its own
totals and nothing says why.
create() and update() now resolve the slot through one place, which is the
point: the two paths cannot drift again. cargoCategory is derived from the
effective dimension rather than carried over, and a station target without
one is rejected instead of stored as a plan the report can never match.
dimensionKey is checked against the vocabulary the reports actually emit —
CARGO_CATEGORIES / CONTAINER_CLASSES, or live yards.code for stations. An
unknown key used to store fine, list fine and fall back to showing the raw
key, while being a plan no report would ever find.
Also drops @Global from the module. Nothing outside it injects either
service; the reports read both tables in raw SQL, so the docstring's stated
reason for being global was not true.
Adds maintenanceFrom/maintenanceTo to WagonListFilters and wires a
wagons-only "Last maintenance" date-range filter into FleetResourcePage,
alongside the existing Registered filter. Server-paged, so the range is
resolved by the API (see wagons.service.ts).
Adds maintenanceFrom/maintenanceTo to ListWagonsQueryDto and applies them
in WagonsService.buildListQuery as a correlated subquery against
wagon_status_logs (last flip to MAINTENANCE), mirroring the existing
createdFrom/createdTo range filter. Both ends inclusive, whole days.
A category with no invoice lines in a period simply had no row, so a
category going quiet was indistinguishable from one that never existed,
and filtering to a category that was never billed returned an empty table.
The query is now three levels. The aggregate groups as before. A grid
crosses every period that saw revenue with every category the filter
allows, and LEFT JOINs the aggregate onto it so a missing combination
lands at zero. The wrapper does the display rounding and the labelling.
Two things had to move for that to be correct:
- The lag() window is now in the wrapper. A window function only sees the
rows its own query level produces, so left on the aggregate it would
skip a category's silent periods — billed in January and March, it
would read March's prior as January and report flat growth.
- The category filter is off the aggregate and enforced by the grid's
category list. Filtering the aggregate too would make the period axis
depend on the selection, which is what left the table empty when the
selected category had never been billed.
Periods come from the data, not generate_series over the date filter: a
twelve-month range over one billed month would otherwise publish eleven
months of pure zeros, and daily granularity would multiply that by thirty.
The Categories KPI is now "Categories with revenue" — a bare count of live
categories reads as a contradiction next to a table listing all fourteen.
EXPLAIN-validated against the dev database across seven filter shapes,
including the empty-array case (hence unnest(ARRAY[...]) over VALUES,
which is a syntax error when empty).
Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr
CATEGORY_LABEL_EXPR wraps the classifying CASE, so it only works where the
classification happens in the same SELECT. A report that classifies in a
subquery and labels in the wrapper has a plain key column to label instead.
CATEGORY_LABEL_OF takes that key expression; CATEGORY_LABEL_EXPR is now
defined through it, so its three existing callers are unchanged. Mirrors
CATEGORY_LABEL_OF in operations-classification.ts.
Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr
role-dashboards.config.ts's ROLE_LAYOUTS was a hand-typed table of ~40
raw role/position keys (including edr_freight_app/xxx ad-hoc
sub-positions), matched against the user's held keys client-side.
Replaced with resolveOverviewLayout(), which just picks the
highest-priority key out of whatever GET /overview/layouts returns —
the same 'server filters by permission, frontend renders what comes
back' shape Reports already uses, so the frontend no longer needs to
know any individual permission key.
Hard cutover: no fallback to the old key table. Positions not covered
by the previous commit's grants (the ad-hoc department sub-positions)
will render the executive layout until granted a layout permission.
New catalog endpoint, same shape as GET /reports: returns the overview
layouts (key + label) the caller holds the matching
edr_freight_app:overview:<layout>:view permission for, in priority
order. Backend enforcement to go with the permission-based frontend
resolver (next commit) — a caller can no longer land on a layout their
JWT doesn't actually carry the permission for.
Adds edr_freight_app:overview:<layout>:view for each of the 6 overview
dashboard layouts (clearance, occ, operation, marketer, finance,
executive), seeded via OVERVIEW_LAYOUT_PERMISSIONS alongside the
existing report permissions.
Granted 1:1 to match today's role-dashboards.config.ts ROLE_LAYOUTS
key table, appended only at the terminal EDR_FREIGHT_ROLES /
EDR_FREIGHT_POSITIONS assembly points (never inside the reusable
ROLE_PERMISSION_PRESETS/POSITION_PERMISSION_PRESETS builders) so
composite positions like chief don't leak multiple layout grants from
the role arrays they spread.
The ~20 ad-hoc edr_freight_app/xxx department sub-positions are not
backfilled here and will fall back to the executive layout until
granted manually via the IAM positions admin screen.
Part of switching overview layout resolution from role/position-key
matching to permission checks, mirroring how reports already work.
seed-occ-july-2026 loads the OCC plan and operated figures the operations
reports were built against, so the plan-versus-actual tables have real
numbers to check.
tmp-ops-plan, tmp-ops-verify, tmp-mkdb and tmp-ops-reconcile were
scratch: written to reconcile those figures while the reports were being
built, and superseded by the seed above.
The list was the last major board still on ad-hoc filtering: usePagination
plus FleetToolbar's hand-rolled Selects, a debounced search and a manual
sort Select, assembled into a filters object by hand. It now uses
useFilters/FilterBar like every other list, so pagination, search, sort
and filters all travel as URL params and a link reproduces the view.
Origin and destination ride the shared Route filter, which no longer
requires both ends — filtering by origin alone stays possible, and each
end now takes several stations.
The card/table view toggle moves into FilterBar's children slot, and the
row tinting for shipping-line and direction is untouched.
Date fields rendered as a native <input type="date">, which ignores the
form's styling and varies by browser. They now use Mantine's DateInput,
kept on the `YYYY-MM-DD` string the API's date columns take — no Date
round-trip, so none of the UTC-parsing shift that new Date("2026-01-01")
introduces east of Greenwich.
Labels were passed as an element, which put a flex box inside the
<label>: a line of dead space above every input, and Mantine's own
required asterisk pushed onto its own line. Passing the plain string and
withAsterisk lets Mantine render both.
`source` named the subsystem and `sourceId` was a raw UUID, so the list
could not say which record an invoice belonged to, and search matched
only the invoice number and that UUID — nobody types a UUID.
Every source except a shipping-line credit hangs off a booking, directly
or through the warehouse/first-mile/last-mile record, so the list read
now resolves each row to a booking reference, GRN or shipping line and
sends it as `sourceRef`. Search spans the same ground plus the customer
name, with the raw sourceId still matchable so a pasted UUID keeps
working.
plannedValueExpr matched a target only when its period_type and
period_start equalled the report's bucket exactly, so a monthly plan
vanished the moment you viewed by quarter, by year, or by day. The plan
column simply went empty and the implement rate read 0%.
plannedRowsSql replaces it with a derived table: each target is spread
evenly over the days it covers, then re-gathered into whichever bucket
the report shows. Three monthly targets add up to a quarter exactly, a
daily view gets a thirty-first of the month, and a week straddling a
month boundary draws proportionally on both. The even spread is an
assumption and the only one available — a monthly figure says nothing
about which days inside it were busier — so PLAN_GRANULARITY_NOTE says so
in each report's description.
The share is clipped to the user's date filter as well as to the bucket,
or filtering to July and viewing by year would sit a whole year's plan
next to one month's work. Reports FULL OUTER JOIN it so a category that
was planned but never ran still publishes, at 0% — dropping the row would
hide a total miss, which is the one thing a plan-versus-actual table is
for.
The OCC report plans a station lane per cargo type — Nagad–Mojo container
and Nagad–Mojo fertilizer are separate numbers — but a target's identity
was period + metric + dimension + dimensionKey, so the two collided on
one slot. `cargo_category` is now part of the row and of the uniqueness
check; it stays null for cargo_category and container_class targets,
whose dimensionKey already carries the category.
The config grid showed raw codes (VOLUME_TONS, cargo_category, a yard
code). The list read now sends readable twins alongside the stored codes,
which stay exactly as they are because the reports join on them — the
same shape YardDistancesService uses. Labels resolve per dimension rather
than from one merged map: CONTAINER_EXPORT exists in both vocabularies
and reads differently in each, and merging them gave every cargo-category
row the container-class wording.
Bookings, contracts and train schedules all validated originYardId /
destinationYardId (originStationId / destinationStationId) as a single
@IsUUID and matched with `=`, so a list could be narrowed to exactly one
lane. The filter bar can now ask for several stations per end, and each
end independently, which needs the same on the server.
@IdListParam() is the shared transform: one id, a comma-separated list,
or a repeated query param, always landing as a string[]. It yields
undefined rather than [] when nothing usable is left — a repository that
branches on `?.length` can then never hand TypeORM an empty array, which
compiles to the syntax error IN (). It stays backwards compatible with
the single-value form, so existing deep links and saved views are
unaffected.
Matching moves to IN (:...ids) — for contracts inside the two existing
EXISTS subqueries, which keeps meaning "has a route from one of these
origins" AND "has a route to one of these destinations", not necessarily
the same route. All three statements were EXPLAIN-validated against
edr_dev.
The route body paired two single Selects behind an Apply gated on
`origin && destination`, so the only question it could ask was "A to B".
"Everything leaving Nagad" and "everything arriving at Gelan" are both
real operator questions, and neither was expressible.
Origin and destination are now independent multi-selects, either of which
may be left empty: OR inside a side, AND across the two. A hint line says
so in words, because two stacked pickers do not communicate that on their
own, and a swap button flips the ends for a return leg.
The value is a tagged flat list — ["o:<id>", "d:<id>", ...] — because
url.ts knows exactly one encoding, comma-split inside one query param;
the tags are what buy back the two sides. decodeRouteValue still reads
the old untagged pair, so existing deep links and saved views keep
working. routeParams(originKey, destinationKey) maps each side onto its
own API param and omits an empty side entirely rather than sending a
blank one.
Each side's dropdown stays shut until something is typed. That needs two
levers, not one: openOnFocus={false} covers the focus, but MultiSelect's
PillsInput root also calls openDropdown() on every click with no prop to
gate it, so dropdownOpened is driven off the search text instead.
A date filter's `v` holds UTC instants — startOfDayIso/endOfDayIso end in
toISOString(). Both readers took `iso.slice(0, 10)`, which is the UTC
calendar day. East of UTC those differ: at UTC+3 a local start-of-day is
21:00Z on the PREVIOUS day, so the pill printed the "from" date one day
early and reopening the picker preselected that wrong day. The "to" side
happened to look right, which is what made it read as cosmetic.
isoToLocalDateStr reads the instant back in local time for the picker;
the pill goes through the shared formatDate, so it now reads
"Aug 20, 2026 → Aug 25, 2026" instead of a raw ISO fragment. Single-sided
operators carry their operator word — "Created | Aug 20" alone doesn't
say whether that is a floor or a ceiling.
Radio.Card's only checked cue is a border tint, which is invisible at the
p="xs" the three format cards use — so the dialog gave no sign of which
format was picked. ReportExportButton's cards already carry a
Radio.Indicator; this adds the same one, dropped when the card was
compressed to an icon-over-label stack.
Default is now xlsx rather than csv: typed number and date columns mean a
spreadsheet opens it without the "is this text?" pass. It falls back to
dataset.formats[0] so the dialog can never preset a format the dataset
does not offer.
That default also made the over-cap alert misleading. It suggested
switching to CSV whenever the format was not CSV, but CSV_ROW_CAP and
XLSX_ROW_CAP are both 50_000 — from the new default that buys nothing.
It is now gated on dataset.caps.csv > cap, so it appears only from PDF,
where the cap really is lower.
Extends the warehouse dashboard with 3 metrics the screenshot target
needed but the backend didn't expose: emptyContainers (AVAILABLE
containers — closest proxy, no literal EMPTY status exists),
importTrains (reuses the import arrival queue definition), and
exportTrains (reuses the Djibouti export arrival queue definition) via
SchedulingReadFacade. Reorders the frontend metric grid to match and
fixes the Empty Containers card linking to a route that doesn't exist.
Extends the stat-tile/filter/pagination pattern from the import arrival
queue to every remaining import and export tab (unloaded, eligible,
received, ready-to-load, loaded/dispatch, locate-booking), reusing
useListControls/ListControls/RuleEngineListFooter throughout. Adds a
5-tile stat row to the export tab bar and renames ImportStatCard to
WarehouseStatCard now that it's shared by both directions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Arrived/Unloaded/Dispatch Ready/Total Bookings stat tiles above the
import queue tabs, wires search+date filters and pagination into the
arrival queue table (reusing useListControls/ListControls/
RuleEngineListFooter already used by Inventory Inquiry), and adds a
copy-to-clipboard action on the truncated schedule ID.
Rows-per-page was capped at 100 in three independent places: @Max on
PaginationQueryDto, the same @Max repeated on ListWagonsQueryDto (which does
not extend the base), and MAX_PAGE_SIZE in pagination.util. The first two
reject with a 400, the third silently truncates, so a larger page size had to
be lifted in all three or the endpoints that opted in would refuse it --
train schedules, routes, locomotives, wagons, audit, rule engine, built
trains, batch board and the rest.
No service overrides maxPageSize, so the util constant is the effective cap
everywhere it is reached.
Adds a spec pinning the three together: 500 validates, 501 rejects, and the
util returns take: 500 rather than truncating. A fourth copy of the number
lives in the backoffice data-table footer and is noted there.
The padding around an enum filter's checkbox/radio carried the hover cue but
swallowed the click: the only element that toggles a Mantine Checkbox is its
native <label>, which wraps its own text and nothing else. The row's padding
and the gutter beside the input square lie outside it, so styling those on
`root` produced an area that looked interactive and was not.
Stretch a `::before` over the relatively-positioned root. The pseudo-element
belongs to the label's own box, so a click anywhere in the row lands on the
label and toggles the input. `cursor: pointer` moves to the root for the same
reason -- the affordance should cover exactly what is clickable.
The accordion's open state was derived from the selection on every render,
which made it fully controlled with nothing driving it. Clicking a group that
had no fields selected opened it for one render and the recomputed value
immediately shut it again, so such a group could only be opened by selecting
something inside it — and conversely a group with a selection could not be
collapsed at all.
Open state is now real state with an onChange, seeded from the fields marked
default rather than the live selection, so clearing every field doesn't close
the groups underneath the user. Search still force-opens every group holding a
match, but only as a display override — the manual state survives and returns
when the search clears.
The overview layout table matched invented keys (`edr_operations_officer`,
`edr_marketing`, …) that only ever existed as IAM roles. The positions
actually configured under the unit use their own keys — `edr_freight_app/opn`,
`ethiopian_gl`, `edr_freight_app/finance` — so most staff fell through to the
executive fallback regardless of desk.
Map every position key in the current org tree, roots and sub-positions
alike, and keep the legacy role-form keys so accounts that model the desks
as roles still resolve. Finance was previously unmapped entirely.
Also drop a stray console.log from resolveOverviewLayout.
Safety (`edr_freight_app/sf_146`) stays unmapped — no such layout exists yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
customers, contracts, invoices, payments, train schedules, and locomotives /
trains / wagons via the fleet page's config.
FilterBar pages pass controls.params into the children slot. The four pages
still on ad-hoc filtering pass their own hand-built filter object instead,
which is why ExportButton takes plain params rather than a UseFilters — it
would otherwise have been blocked behind migrating those pages. FleetResource
serves seven slugs from config, so it gets an optional exportKey there and
renders nothing for the four slugs with no dataset yet.
Auditing each page's real filter keys against the dataset declarations turned
up three gaps where an on-screen filter would have silently not applied to the
export: invoices sends a singular "status" (the dataset only had the
multiselect "statuses"), contracts sends paymentCurrency, serviceTypeId and
route origin/destination, and train schedules sends freightType. Added all of
them — contract routes filter through EXISTS on contract_routes since they are
one-to-many, and train-schedule freightType through EXISTS on the bookings
aboard, matching the list service.
Verified in the browser: the button renders on each page, and the invoices
dialog follows that page's own filter object — selecting Paid moves the count
from 126 to 100, which matches the database. Filter pass-through checked
against the database for invoices, payments, wagons, contracts and train
schedules.
customers, contracts, invoices, payments, train-schedules, locomotives,
trains and wagons. 319 fields across the nine datasets, all reusing the
existing engine — no change to export.types.ts was needed, which is the
result the bookings-first phase was meant to test.
Per-dataset notes worth keeping:
- trains resolves route, stations and current yard, which the list endpoint
never loads — the UI shows raw FK uuids there today.
- wagons reads tare/payload/length off wagon_types (they are not on the
wagon), and reproduces the service's attachStatusDates() as correlated
subqueries. wagon_status_logs stores from_status/to_status, not status.
- payments applies no soft-delete guard: freight.payments has neither
deleted_at nor updated_at, so the usual predicate is a 42703. Failure
columns are failer_code/failer_message. payment_refunds stores MINOR
units, so refundedTotal divides by 100.
- train-schedules derives freightType from the bookings aboard rather than
a column, matching the list service.
- customers stays one row per company; profiles, bookings and invoice
totals aggregate in subqueries. Verified no row multiplication: trains,
customers and contracts each return exactly their counted row count while
selecting one-to-many aggregate fields.
EXPLAIN-validated against the database: every dataset's widest query, its
count query, and all 319 fields individually. That run caught five columns
typed varchar rather than timestamp (companies.date_registered,
renewal_date, renewed_from, renewed_to and invoices.eims_ack_date), which
were being pushed through to_char and would have 500'd the moment anyone
ticked them; they now export verbatim.
All nine count endpoints verified equal to SELECT count(*) on their table.
A Stripe-style export dialog over the /exports catalog: searchable field
picker grouped by related entity, format choice, row scope, saved presets,
and a live row count.
The picker is what makes 77 fields usable. Groups auto-expand only when they
already hold a selection, so the dialog opens showing the default columns and
their groups rather than a wall of checkboxes; searching force-expands so a
match can't hide inside a collapsed group. Group headers carry a tri-state
checkbox and an n/total badge.
The row count comes from /exports/:key/count with the page's own filters, so
the button reads 'Export 223 rows' before anything is downloaded, and turns
into a cap warning with a one-click 'export the first N' escape when the
result is too large for the chosen format.
ExportButton takes plain params rather than a UseFilters instance —
four of the pages that need this haven't migrated to FilterBar yet, and
coupling to the hook would have blocked them. Pagination keys are stripped in
one place instead of at every call site. It renders nothing when the catalog
omits the dataset, so the catalog's permission filtering IS the UI gate.
Presets reuse useSavedViews unchanged by encoding the preset as a query
string; a preset naming a field the catalog no longer offers is dropped on
load rather than 400ing the download. Download errors go through
extractDownloadErrorMessage, without which the server's row-cap message
degrades to 'Request failed with status code 400'.
Adds a parallel export system the reports module can also draw on. A dataset
describes a table's exportable fields — including related-entity detail the
list page never shows — and the engine assembles a query from whichever fields
the caller picked.
GET /exports catalog (metadata only; select/requires never ship)
GET /exports/:key/count exact row count + per-format caps
GET /exports/:key/download csv | xlsx | pdf
Two invariants carry the design:
- Every lazy join is a LEFT join, and ExportJoin has no 'kind' field to make
anything else expressible. An inner join added because a checkbox was ticked
would change the rowset, so two exports of the same filters would disagree on
their row count.
- Because of that, the count cannot depend on field selection, so /count runs
base + alwaysJoin only and is exact rather than an estimate. Verified: count
and the delivered file both report 223 rows.
One-to-many relations (a booking's containers) aggregate in a correlated
subquery rather than joining, so a row can never multiply.
Export rides each dataset's existing view permission — no new permission keys
and no seeder change. Sensitive columns are simply never declared as fields:
raw gateway payloads, signature blobs, error dumps, raw jsonb snapshots,
internal user UUIDs and review notes are all absent by construction.
bookings ships 77 fields across 10 groups. scripts/validate-export-datasets.ts
EXPLAINs every dataset's widest query, its count query, and each field on its
own against the real database — the per-field pass is what catches a field
referencing a join it forgot to declare, which otherwise only fails when that
one field is picked alone.