Backs the previously-dead header date-picker and Filters button with
an actual scoped query. getDashboard() takes optional dateFrom/dateTo/
warehouseId: received (renamed from receivedToday) is the only
activity counter and respects the date range, defaulting to today when
omitted; the 10 status-backlog counters respect warehouseId only;
totalWarehouses/emptyContainers/importTrains/exportTrains stay global
since nothing ties them to a specific warehouse in the schema. Adds a
warehouse Select + date-range picker to the dashboard header, with a
caption spelling out what is and isn't scoped.
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.
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.
The footer offered a fixed <select> of 5/10/25/50, so a table could not be
paged in anything larger without a code change. Replace it with a Mantine
Autocomplete: the presets go up to 500, and any other size can be typed.
A typed value is clamped to [1, MAX_PAGE_SIZE] on commit, with MAX_PAGE_SIZE
set to 500 to match @Max on the freight API's PaginationQueryDto and
MAX_PAGE_SIZE in its pagination.util. Raising it here alone would turn the
top preset into a 400.
Changing the size also resets to the first page -- page 12 of 50-row pages
does not exist once the page holds 500, and a manual-pagination consumer
would happily request it. Done as one setPagination call so consumers see a
single fetch rather than two.
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>