Report descriptions run to a paragraph. ReportDescription wraps Mantine's
Spoiler to clamp them to two lines, and the toggle only renders when the
text actually overflows.
PageHeader kept its subtitle on a hard truncate class, which would have
pinned the spoiler to one line, so a ReactNode subtitle now renders as-is
and owns its own layout. A string subtitle still truncates as before.
Eleven report columns bucketed their timestamp to a bare day with to_char,
which is wrong for anything a user reads as an event rather than a period:
two departures on the same date, or a wagon request fulfilled hours after it
was raised, were indistinguishable in the output.
The renderer only shows the time when the value actually has one, keyed off
the string rather than a per-column flag — a genuine day bucket would
otherwise render as 12:00 AM, which reads as data rather than as absence.
- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
- planned couples: loose wagons join the train at a route stop, added
from the schedule yards tab; capacity credits them per corridor edge
and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
endpoints; builder detaches now always write adjustment-log rows
Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Empty container returns now generate a downloadable Equipment Interchange Receipt: container number & size, exact return timestamp, depot/location, condition, and the carrier/booking reference. Customers can download it from their booking's Documents tab (own-booking returns only); staff can pull any via the same endpoint.
- Added tab navigation for All, Import, Export, and On hold shipments.
- Implemented a new LivePill component to show real-time updates.
- Refactored shipment filtering logic to accommodate new tab functionality.
- Improved UI elements including buttons and text inputs for better user experience.
- Updated styles for table cells to allow text wrapping for long company names.
- Adjusted theme to use Space Grotesk font for headings.
- Added tab navigation for All, Import, Export, and On hold shipments.
- Implemented a new LivePill component to show real-time updates.
- Refactored shipment filtering logic to accommodate new tab functionality.
- Improved UI elements including buttons and text inputs for better user experience.
- Updated styles for table cells to allow text wrapping for long company names.
- Adjusted theme to use Space Grotesk font for headings.
Backoffice: new tab on the booking detail page (?tab=additional-charges,
already linked from the row menu) with an Add Charge modal — Save draft or
Send to customer.
Portal: charges panel on the booking detail page, Pay now per charge via
the existing OTP/CBE-bill payment flow.
Also fixes: GET /bookings/:id/additional-charges was returning DRAFT
charges to the customer (hidden client-side only) — now filtered
server-side per caller.
New freight.additional_charge table, independent of BookingClearanceCharge
(unbounded per booking, free-text reason). Draft -> send issues an invoice
and notifies the customer in-app/SMS/email; settles via the standard
.invoice.paid event. Adds the Additional charges row-menu entry
next to Cancel booking, permission-gated.
Not included: the Additional Payments tab UI, add-charge modal, portal pay
flow.
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.
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 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.
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.