- ruleEngineFooterProps.ts: toRuleEngineFooterProps() — the
useFilters-to-RuleEngineListFooter pagination adapter had already been
hand-written twice (TrucksOnSitePage, CompliancePage) with the same
pageSize-drop risk useFilters itself just had fixed; extracted before a
third copy could drift.
- CompliancePage, FuelPurchasePage, IncidentsPage: ListControls ->
FilterBar + applyClientFilters, same mechanical pattern as the
warehouse pages (search + one date range, endpoints take no params at
all per the inventory sweep — confirmed correct bucket, not assumed).
13 useState fields -> 11 FilterDef entries + useFilters. Notable pieces:
- the deep-link-follow useEffect is gone entirely — controls.values reads
live off the URL every render, so a link opened while the page is
already mounted (the header document-review alarm) just works, and
every filter auto-pins its own pill once it has a value (FilterBar's
secondary/pinned split), so a deep link can never land behind "More
filters" hidden. Confirmed the encoding stays back-compatible with the
existing ?statuses=A,B&tradeDirection=IMPORT links via url.test.ts.
- "Paid, not allocated" (a checkbox that overrides the payment-status
filter) became a boolean FilterDef with a custom toParams — ordered
after paymentStatus in the defs array so its toParams wins on merge,
matching the original override semantics exactly.
- booking kind and status stay pinned pills (always visible, matching the
old primary row); direction/freight/payment/ownership/yards/dates are
secondary (behind "More filters", matching the old advanced collapse).
fix(useFilters): pageSize was a hardcoded constant, never read from the
URL, with no setter — the DataTable/RuleEngineListFooter page-size
dropdown silently did nothing on every page using the filter bar,
including the already-shipped ContractRequestsPage pilot. Added a `size`
URL param (mirrors `page`), `setPageSize`, and wired
`tableProps().onPaginationChange` to route page-size vs page-index
changes to the right setter instead of only ever calling setPage().
feat(clientFilter): applyClientFilters — the Family-B bridge the plan
called for. Generalizes ListControls'/useListControls' one hardcoded
search box + one date range to every FilterDef, matched against
`row[def.key]`. Reuses matchesDayRange/toDayString from
hooks/useListControls.ts (imported, not duplicated) so the inclusive-
range/timezone-safe semantics stay defined in exactly one place. Lets a
page ship the full pill-bar UI immediately and flip to server-side
filtering later by deleting one function call — no endpoint changes
required up front.
Migrated to the filter bar (mechanical, pattern established by
ContractRequestsPage):
- WarehouseInvoicesPage, LoadedInventoryPage, TrucksOnSitePage — Family B
(ListControls/useListControls → FilterBar + applyClientFilters)
- CustomersPage — Family A (useState bag → useFilters), no filter pills
needed here (search + sort only), the SegmentedControl "view" stays
page-level tab state (like ContractStatusTabs), not a filter pill —
it now resets the page via controls.setPage(1) on change, the same
hazard useFilters already guards its own filters against
The two-zone layout (left: filters, right: sort+save) hardcoded
flexWrap: "nowrap" on the outer container so the right zone would never
get pushed below the left on desktop. On a phone that's the same
two-column squeeze with no room for either side: the search box and
right-hand controls got clipped/overlapped instead of stacking.
- outer container: flex-col below the `sm` breakpoint (stacks to two full-
width rows), flex-row + nowrap at `sm` and up (the original two-zone,
right-pinned behavior)
- right zone swapped from <Group wrap="nowrap"> to a plain div with
Tailwind classes — Group's `wrap` prop sets an inline flex-wrap style,
which always wins over a `sm:flex-nowrap` class regardless of
breakpoint, so the old responsive attempt could never have worked
- search input's hardcoded minWidth: 220 (wider than a lot of phones)
dropped to a shrinkable 160
- saved-view card grid: base 2 columns forced text-clipping card overflow
on a phone; base is now 1, growing to 2+ only once there's room
Replace the modal "type a name to save" flow with a one-click Save that
auto-generates the label from the active filters (describeQuery, reusing
the same formatter FilterPill uses for pill text — one source of truth,
nothing to type, nothing to fall out of sync). Saved views render as a
grid of cards at the top of the filter bar instead of being buried in a
dropdown.
- useSavedViews: localStorage state, extracted so FilterBar owns one
instance instead of two components each subscribing separately
- SaveViewButton: filled (not outline) since it's the one action-y
control among a row of filter pills; flashes to a disabled "Saved" +
checkmark state for 1.5s on click, in addition to the corner toast —
the toast alone wasn't registering as feedback
- SavedViewCards: click to apply, per-card delete, active one highlighted
- moved from the wrapping filter-pill row into the pinned right-hand zone
(with sort) behind its own divider, so it can't shove pills around
- Save icon: BookmarkPlus -> Save (floppy disk reads as "save" faster)
- More filters button: outline/gray -> default variant — same low-
contrast-outline problem the inactive filter pill had
- Clear button font bumped xs -> sm to match the rest of the bar
Replace the ad-hoc filter controls with a URL-linkable pill filter bar:
each filter is a pill that opens a type-aware popover (text/enum/date/
number/boolean, each with the right operator set), overflow filters live
behind a searchable "More filters" menu, sorting is a separate control,
and filter state round-trips through the URL query string (shareable,
back/forward-safe, backward compatible with existing ?statuses=A,B links).
Frontend (apps/edr-freight-web/backoffice/src/components/filters/):
- FilterDef schema + a pure url.ts codec (parse/serialize/toApiParams),
with a 24-case round-trip + malformed-input test suite
- useFilters hook driving react-query params straight from useSearchParams,
debounced search, saved views in localStorage (@mantine/hooks
useLocalStorage), page-reset-on-filter-change baked into one
setSearchParams call instead of a separate effect
- FilterBar/FilterPill/OperatorSelect/MoreFiltersMenu/SortControl +
per-type popover bodies (Mantine)
- ContractRequestsPage migrated end to end as the pilot
Backend (apps/edr-freight-api):
- pagination.util: applySort() — whitelisted sortBy resolved against a
per-module column map (never interpolated), with a mandatory `id ASC`
tiebreaker so paginating a non-unique sort can't drop/duplicate rows
- facets.util: computeFacets() — one GROUP BY per enum column, each
omitting its own predicate, so picking a value doesn't hide its siblings
- contracts/bookings: list-summary now returns real filter-scoped facet
counts (contracts' getStatusCounts was unfiltered/global; superseded)
- deleted drivers/vehicles findAllWithFilters — dead code that
interpolated an unwhitelisted sortBy straight into orderBy()
- migration: missing bookings(status)/wagons(status) indexes +
(created_at DESC, id ASC) partials on the hot list tables
UI polish pass: inactive pill uses the opaque "default" variant instead
of a faint tinted outline, active pill uses "light" not "filled", larger
X hit target, applied filters sort first, sort control separated behind
a divider on the right and wraps independently from the filter row,
popover option rows are fully clickable (count moved inside the native
label) with bigger hit area and font, fixed a real date-filter bug where
the calendar's own portal falsely registered as an "outside click" and
closed the popover, and fixed a timezone bug where bare YYYY-MM-DD
strings were parsed as UTC instead of local time (shifts a day for EAT).
Not in this commit: rollout to the other ~59 list pages, the Ethiopian-
calendar DateBody branch, and the Family-B (client-side) bridge mode —
tracked in the filter-bar plan.
Empty containers loaded onto an export departure carry no booking and no
wagon allocation, so their wagons printed as EMPTY — no cargo allocated —
staff checking the paper against the consist found boxes the list denied.
Those wagons now print the container numbers with cargo type EMPTY
CONTAINER, count toward the 40ft/20ft tallies, and get their own summary
tile; only genuinely bare wagons keep the empty wording.
Empties had no way onto a departure: the return record could name a train
but nothing seated it on a wagon. Export schedules now expose a loading
action that packs selected returns onto free wagons at one 40ft or two 20ft
each, enforced both in the picker and in the API (existing empties on the
schedule count against their wagon).
Adds container_size, train_schedule_id and wagon_sequence_no to
freight.empty_container_returns.
Advancing an empty container return to WAGON_ALLOCATED opened no dialog,
so the wagon_allocation_reference column stayed null and nobody knew which
departure carried the empty. Now the action opens a picker of scheduled
EXPORT trains (DRAFT/SCHEDULED) and records the selection.
PNR isn't a column on Invoice/Payment — it's written onto Booking.pnrCode
when a CBE_BILL payment initiates. toDocumentModel() now looks it up by
invoice.sourceId and adds it to the existing summary grid, shown only
when present.
- Added ShippingLineBookingCompletionController and associated service to handle the completion of shipping line bookings.
- Introduced a new module for booking completion to maintain module separation and avoid cyclic dependencies.
- Updated the train scheduling global rules to set default desk hours to 24 hours.
- Modified existing services and entities to accommodate the new booking completion logic.
- Enhanced the front-end components to support the new booking completion flow, including updates to the booking detail and bookings pages.
- Implemented validation and error handling for booking completion, ensuring that only approved bookings can be completed.
- Added migration to set default desk hours in the database.
- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
dispatch() (LOADED -> DISPATCHED) routed through the shared transition()
helper, which only flipped status/timestamp and never called
applyCapacityDelta. deliver() (import pickup) already decrements
correctly, so export cargo leaving by train inflated currentWeight/
currentVolume/currentContainers forever instead of freeing capacity.
Add optional freeCapacity hook to transition(), wire dispatch() to it,
mirroring the negative-delta pattern already used in deliver().
New logo-settings module (mirrors stamp-settings): single uploaded logo,
stored via FilesService/MinIO, injected as a data URL into invoice/receipt,
contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a
matching backoffice settings page and settings:logo:view/manage permissions.
>
Direct truck-to-train export cargo skips the warehouse, so the existing
carriage acceptance sheet ready notice (fired on warehouse receive)
never reached these bookings. Their handover moment is the load itself.
Extract notifyCarriageAcceptanceReady into a shared notifications util
(was private to WarehouseInventoryService) and call it from
BookingJourneyService.loadBooking for EXPORT + DIRECT_TO_TRAIN bookings,
right after the GRN gate, before the load transaction proceeds.
Extracted the export route's format/cap/column-whitelist branching out
of the controller into pure functions (resolveExportFormat,
resolveExportCap, resolveExportColumns) and added a spec: unknown
format falls back to xlsx, limit clamps to the format cap and ignores
non-positive/NaN input, unknown field keys are dropped and an
all-unknown fields list falls back to every column instead of
shipping a blank sheet. Was untested branching logic before this.
Export bookings whose cargo goes straight from customer truck to wagon
never get a warehouse GRN, so the load gate (assertExportReceivedWithGrn)
rejects them forever unless staff first flip exportHandoverMode on the
booking detail page, then come back and retry Load.
Add a Truck to Train button next to Load in the schedule workspace's
on-train list, shown for EXPORT bookings at the boarding yard. One click
sets exportHandoverMode=DIRECT_TO_TRAIN then loads the booking.
- ReportPage drops its own PageHeader (and the back arrow); ReportView
now optionally renders the header itself (pageHeader prop) with
export/refresh as its actions. Embedded ReportSection usage is
unaffected (keeps the inline toolbar next to filters).
- Replace the two xlsx/pdf icon buttons with one Export button opening
a dialog: format as large icon radio cards, fields as checkboxes
(select-all toggle), record count (default all, capped per format).
Export applies the report's current filters and sort.
- Backend: export route accepts fields (whitelisted against the
report's own columns) and limit; ReportExportService takes an
optional column subset instead of always dumping every column.
- Fixed a real bug found while wiring this up: runAll() ignored the
caller's sortBy/sortOrder and always used the report's default sort,
so exports silently didn't match whatever order was on screen.
- Report daterange filters now use DatePickerInput + the shared
getDateRangePresets() (Today/Last 7 days/This month/...) instead of
two bare DateInputs, matching every other date-range filter in the
app.
- Removed the reports hub grid page. /dashboard/reports now redirects
to the first report the caller has access to, or /dashboard if they
have none.
Replaces every remaining split from/to Mantine DateInput/DatePickerInput pair
with a single DatePickerInput type="range", sharing one preset list (Today,
Last 7/30 days, this/last month, YTD) via getDateRangePresets(). Covers
ListControls (18 consumers), FleetResourcePage, ContractRequestsPage,
BookingRequestsPage (created + scheduled ranges), WagonCancellationsPage,
BatchBoardPage, ClearanceDocumentsPage, ShipmentRequestsPage.
Native Mantine range picker, not the shadcn DateRangePicker, to match each
page's existing design system instead of clashing with it.
Reports date-range filter (ReportFilters.tsx) intentionally left untouched.
ReportDefinition gets an optional chart {type: line|bar, x, y[]} field —
plots the same rows the table gets, no separate query. Frontend adds a
table/chart toggle (defaults to table) using the existing recharts
dependency, no new package.
Chart view fetches up to 100 rows (the API's page-size ceiling) instead
of the table's current page, so it doesn't silently plot a fraction of
the filtered set; shows a truncation note past that cap.
Wired onto 5 reports as proof: wagon-fleet-status, locomotive-fleet-
status, booking-status-breakdown, revenue-summary (bar), and
global-logistics-wagons (line). Everything else stays table-only —
charting is opt-in per report, not a default.