Commit Graph

4841 Commits

Author SHA1 Message Date
Marshal
a809215e74 feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices 2026-08-20 10:29:58 +00:00
Marshal
87ae075031 fix issue 2026-08-20 10:29:58 +00:00
Stephanos A
6adfd10bc9 feat: add seat conflict assertion for route bookings 2026-08-20 10:29:58 +00:00
Marshal
1ba54829df feat(bookings): show per-document upload/review audit and download icon on clearance detail 2026-08-20 10:29:58 +00:00
Marshal
8eb3099694 feat(bookings): show per-document upload/review audit and download icon on clearance detail 2026-08-20 10:29:58 +00:00
Marshal
e97766c135 Implement new feature for user authentication and improve error handling 2026-08-20 10:29:58 +00:00
Abubeker Yasin
369f37e46b Merge pull request #1360 from Tria-plc/alpha
Alpha
2026-08-20 12:34:35 +03:00
Abubeker Yasin
e174efb8e0 fix: ( passenger ) read permissions from position types and all positions 2026-08-20 12:33:39 +03:00
Nathnael
ac585fbbd2 feat(data-table): typeable rows-per-page control
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.
2026-08-20 09:06:06 +00:00
Nathnael
ac8f03f69a feat(pagination): raise the page-size ceiling to 500
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.
2026-08-20 09:05:54 +00:00
Nathnael
54cf0c2944 fix(filters): make the whole filter option row clickable
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.
2026-08-20 09:05:31 +00:00
Marshal
8d7551bb8e fix(portal): show declaration, T1 and Djibouti clearance documents to the customer 2026-08-20 08:43:08 +00:00
Marshal
d7752f4386 fix(portal): show declaration, T1 and Djibouti clearance documents to the customer 2026-08-20 08:36:02 +00:00
Nathnael
87ec7cec07 fix(export-ui): let field groups be expanded and collapsed by hand
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.
2026-08-20 08:25:47 +00:00
Nathnael
6e95c5b8b8 fix(backoffice): map overview layouts to the org's real position keys
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>
2026-08-20 08:20:00 +00:00
marshal
f1b6444489 Merge pull request #1359 from Tria-plc/freight_feature/usermanagement
fix(clearance): flag bookings with documents still awaiting GL approval
2026-08-20 10:39:05 +03:00
Marshal
ca774c536f fix(clearance): flag bookings with documents still awaiting GL approval 2026-08-20 07:37:35 +00:00
Nathnael
1f11c3a4ca feat(export-ui): mount the export button on the eight remaining list pages
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.
2026-08-20 07:17:25 +00:00
marshal
bd94dd7306 Merge pull request #1358 from Tria-plc/freight_feature/usermanagement
fix(portal): surface document upload on cleared bookings until payment
2026-08-20 10:17:23 +03:00
Marshal
e41ce2ed6c fix(portal): surface document upload on cleared bookings until payment 2026-08-20 07:16:18 +00:00
Nathnael
8c941fdf4b feat(exports): add the remaining eight datasets
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.
2026-08-20 07:10:36 +00:00
marshal
ead4f0a293 Merge pull request #1357 from Tria-plc/freight_feature/usermanagement
View manual (offline) payment channel settings
2026-08-20 09:59:00 +03:00
Marshal
4b07ff328d View manual (offline) payment channel settings
Confirm offline (bank transfer) invoice payment
2026-08-20 06:57:57 +00:00
Nathnael
42b9f30057 feat(export-ui): field-picker export dialog, mounted on bookings
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'.
2026-08-20 06:44:29 +00:00
marshal
d4bc419d43 Merge pull request #1356 from Tria-plc/freight_feature/usermanagement
feat(bookings): two-level clearance charges (port + misc) billed to c…
2026-08-20 08:49:26 +03:00
Marshal
c38fcff00d feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices 2026-08-20 05:48:16 +00:00
Abubeker Yasin
a00606823e Merge pull request #1355 from Tria-plc/dev
Merge dev to alpha
2026-08-20 08:45:14 +03:00
Nathnael
62f7b91315 feat(exports): dataset-driven table export, starting with bookings
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.
2026-08-20 05:29:04 +00:00
Nathnael
ce90be5c88 fix(reports): stop the 'first N rows' option failing on large exports
The export path used one number for two different things: the format's hard
row cap, and the caller's explicit 'give me the first N rows'. Because
resolveExportCap() returned min(requested, formatCap) and runAll() then threw
when the result reached it, picking 'Records: First 100' in the export dialog
400'd on any report with more than 100 rows — the user asked to be truncated
and got an error instead.

Splits them: formatRowCap() is the hard, non-caller-controllable ceiling that
still throws when exceeded (a silently short file hides missing rows), while
resolveRowLimit() is the deliberate truncation and is honoured by slicing.
Verified against a 223-row dataset: limit=5 now returns 5 rows, and no limit
returns all 223.
2026-08-20 05:28:51 +00:00
marshal
d04f35539a Merge pull request #1354 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-20 08:28:12 +03:00
Marshal
c138da6137 feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices 2026-08-20 05:24:57 +00:00
Nathnael
fc2b5ee0e3 refactor(reports): export through the shared tabular writer
Completes the writer extraction whose other half landed in fb21ad154.
reports.controller now builds a TabularDoc and calls TabularExportService,
so report-export.service.ts and report-export-request.util.ts are dead and
removed — HEAD was carrying both copies with the controller still on the old
one.

Reports gain CSV for free, and the PDF path now passes buildTabularFallbackPdf
as its fallback: previously it passed none, so a box without Chromium silently
returned PdfRenderService's ~900-character generic text dump instead of a
table. Adds a spec covering the CSV writer's quoting of embedded commas and
double quotes — the reason this uses ExcelJS's csv writer rather than a
hand-rolled join.
2026-08-20 05:16:15 +00:00
Stephanos A.
c52b9229c4 Merge pull request #1353 from Tria-plc/alpha
Alpha
2026-08-20 07:57:24 +03:00
Stephanos A
5d6a21b6be Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha 2026-08-20 07:46:33 +03:00
Stephanos A
f93b3cd993 feat: add seat conflict assertion for route bookings 2026-08-20 07:46:15 +03:00
Marshal
4adb53b486 fix issue 2026-08-20 04:27:16 +00:00
marshal
e2d32defc0 Merge pull request #1352 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-20 07:04:21 +03:00
Marshal
a58157a7ee feat(bookings): show per-document upload/review audit and download icon on clearance detail 2026-08-20 04:03:27 +00:00
Marshal
8a2b9306dc feat(bookings): show per-document upload/review audit and download icon on clearance detail 2026-08-20 03:59:14 +00:00
Hagernesh
fc55f48371 feat(eims): implement POST /v1/bulkRegister
New endpoints:
  POST invoices/eims/bulk-register  { invoiceIds: [...] }  — trigger
  POST eims/webhook/bulk-register                          — MoR's callback

Fundamentally different shape from single register: bulkRegister
answers only {conversationId, status:202} immediately: MoR processes
the array asynchronously and pushes the real per-invoice results
(a mix of accepted/rejected in one array, per the collection's own
examples) to a webhook configured out of band. So this ships as two
halves that don't share a call stack — EimsBulkRegistrationService.
registerBulk() reserves a contiguous block of counters (durable
reservation, same doctrine as single register, extended to N items)
and submits; handleBulkCallback(), invoked by the new
EimsWebhookController whenever MoR gets around to it, settles.

New EimsSystemState.inFlightConversationId is the bulk equivalent of
inFlightInvoiceId — a whole batch outstanding, not one invoice — and
the two markers block each other since they share the same counter
sequence. The conversation id isn't known until MoR's 202 arrives, so
reservation stamps a locally-generated placeholder first (same
commit-before-the-network-call reasoning as single register), then
swaps it for MoR's real id right after — the only value the callback
can actually use to find the batch again.

Only the first invoice in a bulk batch chains via PreviousIrn — every
other item gets an empty string, matching the collection's own
two-invoice example exactly (MoR doesn't expect a batch to chain to
IRNs that don't exist yet at submission time).

Webhook has no auth (MoR has no JWT to send) — the conversation id
embedded in the payload is what stands between this and a forged
callback: an item only ever touches an invoice actually holding that
exact id, and an unknown id is logged and ignored, never applied.

Migration 3580000000000: eims_system_state.in_flight_conversation_id,
invoices.eims_bulk_conversation_id (tags which batch an invoice was
submitted in, so a stuck batch — webhook never arrived — can be found
and reconciled by conversation id). Applied to dev DB and recorded in
freight.migrations directly (idempotent IF NOT EXISTS DDL).

Not live-testable from this sandbox (no route to MoR's real gateway).
Signing the whole array as one envelope, the way single /v1/register
was confirmed live to need despite the collection's raw example
showing no envelope, is the reasonable extension of that confirmed
behavior, not a blind guess — but it has not itself been exercised
against the real gateway. Left for the first live bulk attempt to
confirm, same as every other MoR-facing assumption this integration
has made.
2026-08-19 18:35:09 +00:00
Marshal
174bc1a7bd Implement new feature for user authentication and improve error handling 2026-08-19 17:07:48 +00:00
Nathnael
fb21ad1541 feat(WIP): filtering, exporting and more reports 2026-08-19 13:51:18 +00:00
Abubeker Yasin
4fcb8d02f4 Merge pull request #1350 from Tria-plc/alpha
fix: ( fayda ) reject a second booking for the same identity on one d…
2026-08-19 15:33:42 +03:00
Abubeker Yasin
014b1a6d47 fix: ( fayda ) reject a second booking for the same identity on one departure 2026-08-19 15:28:10 +03:00
Nathnael Wondisha
8e11b3a168 Merge pull request #1349 from Tria-plc/freight/nati-2
Freight/nati 2
2026-08-19 13:17:07 +03:00
Nathnael
e964a9b8f4 chore: more reporting 2026-08-19 10:14:46 +00:00
Nathnael
54f52f077c fix(page-header): cap title/subtitle width
Title and subtitle grew unbounded, relying only on flex-squeeze from action
buttons to ever truncate. Caps the container at 640px and gives the
subtitle the same truncate treatment the title already had, so a long
page title or description (report descriptions in particular) always
ellipses instead of stretching the header.
2026-08-19 10:12:16 +00:00
Nathnael
6687def4af feat(reports): use the shared FilterBar instead of ReportFilters
Swaps ReportView's bespoke ReportFilters for the same FilterBar/useFilters
combo BookingRequestsPage uses — pills, saved-view-ready state, URL sync.
toFilterDefs() maps the report catalog's own filter vocabulary
(daterange/date/select/multiselect/text) onto FilterDef, matched against
every report definition's backend param handling. The search box only
shows for reports that actually implement `search` server-side, so it's
not a dead control on the rest. ReportFilters.tsx is now dead, removed.
2026-08-19 10:12:03 +00:00
Nathnael
7c56607582 fix(filters): honor a date filter's own operator, not always "between"
DateBody always initialized its op state to DEFAULT_OP.date ("between"),
ignoring a def's `operators` restriction. A single-operator exact-date
filter (e.g. `operators: ["before"]`) opened on the range UI with no way
off it, since OperatorSelect hides itself when there's only one choice.
Default to the def's first allowed operator instead.
2026-08-19 10:11:48 +00:00
marshal
62ff18a301 Merge pull request #1346 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-19 13:11:09 +03:00