Commit Graph

1354 Commits

Author SHA1 Message Date
ghost2023
260d5a1590 feat(reports): cascade an unmet plan onto the periods that remain
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.
2026-08-21 17:40:35 +03:00
ghost2023
c8b44d4d04 fix(operations-targets): derive cargo category from its dimension
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.
2026-08-21 17:37:21 +03:00
ghost2023
ad5caddf60 feat(wagons): filter list by last-maintenance date range
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.
2026-08-21 15:54:53 +03:00
ghost2023
91c9c3e513 feat(reports): list unbilled categories at zero in revenue-by-category
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
2026-08-21 15:51:18 +03:00
ghost2023
3ce58d4c57 refactor(reports): label revenue categories from a key column
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
2026-08-21 15:51:05 +03:00
ghost2023
17e41aa767 feat(overview): serve GET /overview/layouts filtered by 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.
2026-08-21 15:43:26 +03:00
Nathnael
7a2383f02c fix: revenue by customer 2026-08-21 10:57:42 +00:00
Nathnael
25d13baa6f chore: reporting and filtering 2026-08-21 09:25:21 +00:00
Nathnael
f0d66ce8f9 feat(invoices): show and search what an invoice was raised against
`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.
2026-08-20 11:36:50 +00:00
Nathnael
05efdd5d54 feat(reports): spread a plan across its own period, then re-bucket it
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.
2026-08-20 11:36:42 +00:00
Nathnael
f28b6faf29 feat(operations-reporting): plan station targets per cargo category
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.
2026-08-20 11:36:32 +00:00
Nathnael
2ec818e590 Merge branch 'dev' into freight/nati-2
# Conflicts:
#	apps/edr-freight-api/src/app.module.ts
#	apps/edr-freight-api/src/seed/freight-permissions.registry.ts
#	apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
#	apps/edr-freight-web/backoffice/src/constants/URLS.ts
#	apps/edr-freight-web/backoffice/src/lib/permissions.ts
2026-08-20 11:33:19 +00:00
Nathnael
7446adaa88 feat(api): accept several yards on each end of a route filter
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.
2026-08-20 11:24:46 +00:00
Hagernesh
0066a87972 feat(warehouses): add empty-containers/import-trains/export-trains to dashboard
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.
2026-08-20 10:46:42 +00:00
Marshal
bcf684814d fix(clearance): flag bookings with documents still awaiting GL approval 2026-08-20 10:29:59 +00:00
Marshal
26d864a55e View manual (offline) payment channel settings
Confirm offline (bank transfer) invoice payment
2026-08-20 10:29:59 +00:00
Marshal
c889797870 feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices 2026-08-20 10:29:59 +00:00
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
Marshal
1ba54829df feat(bookings): show per-document upload/review audit and download icon on clearance detail 2026-08-20 10:29:58 +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
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
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
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
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
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
Nathnael
fb21ad1541 feat(WIP): filtering, exporting and more reports 2026-08-19 13:51:18 +00: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
Marshal
ad479bdcf2 issue fix 2026-08-19 10:10:04 +00:00
Marshal
13f66dfb66 changes 2026-08-19 08:41:51 +00:00
Marshal
f4a654f273 Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement 2026-08-19 06:42:36 +00:00
Marshal
cd1e9c38a7 feat: add new audit endpoints for consolidation approvals and invoice management 2026-08-19 06:42:19 +00:00
marshal
2942ad8d75 Merge pull request #1342 from Tria-plc/freight_feature/usermanagement
feat: implement parity checks for 20ft container bookings to ensure e…
2026-08-19 09:21:43 +03:00
Marshal
e6e4c07077 feat: implement parity checks for 20ft container bookings to ensure even numbers 2026-08-19 06:20:47 +00:00
marshal
c80d9c5abc Merge pull request #1339 from Tria-plc/freight_feature/usermanagement
feat: implement consolidation approval process for shared-wagon bookings
2026-08-18 17:12:28 +03:00
Marshal
db64a1878b feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
2026-08-18 13:59:15 +00:00
marshal
c1150f8af2 Merge pull request #1337 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-18 16:19:34 +03:00
Marshal
40904049cf feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
2026-08-18 13:17:55 +00:00
Nathnael Wondisha
5e96394450 Merge pull request #1336 from Tria-plc/freight/nati-2
lifecycle
2026-08-18 16:14:38 +03:00
Nathnael
dd6df306f2 lifecycle 2026-08-18 13:11:48 +00:00
Nathnael Wondisha
065f34aa67 Merge pull request #1335 from Tria-plc/freight/feat/yard-loc-ac
feat: yard scoping to position
2026-08-18 15:57:03 +03:00
Nathnael
6823a32fee feat: yard scoping to position 2026-08-18 12:55:11 +00:00
Nathnael Wondisha
bd5d6d72b5 Merge pull request #1334 from Tria-plc/freight/feat/foreign-investors
fix: approval window
2026-08-18 15:51:45 +03:00
Marshal
22a3fb98ee feat: Implement consolidated booking functionality
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage.
- Enhanced BookingRequestsPage to display paired bookings in a single row.
- Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair.
- Updated contracts service to include methods for manual consolidation of odd-20ft bookings.
- Created new components for selecting and editing consolidation partners.
- Added tests for paired decision logic and manual consolidation scenarios.
- Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
2026-08-18 12:50:35 +00:00
Nathnael
cbcc9a02e6 fix: approval window 2026-08-18 12:44:02 +00:00
Nathnael Wondisha
b95952d67a Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors
Freight/feat/foreign investors
2026-08-18 14:49:28 +03:00
Nathnael
431bd3c98b feat(companies): let a co-operative switch back to eTrade as well
Both manual-registration routes exist for one reason — eTrade holds no record
to fetch — so leaving one is the same act whichever it is. A co-operative that
has since taken out a trade licence had no way out at all: the flag is chosen
in the wizard, and an onboarded company can no longer reach it.

revert-to-etrade now accepts either flag and strips both, and a co-operative's
ACTIVE roles go back to Pending with it: those approvals were granted without a
per-role business licence, because a co-op owes none, and the moment it stops
being one that licence is due. Rejected, suspended and blacklisted roles are
left alone — promoting a blocked role to "awaiting approval" would launder a
staff decision away. An investor's roles are untouched; their licences were
always due and already reviewed.

Switching INTO a route stays with the wizard's nationality/role step, which
owns the mutually-exclusive rules; a second entry point would restate all three.
2026-08-18 11:37:43 +00:00
Nathnael
0b8b9c39ab fix(companies): clear the typed registration when the manual-entry box is un-ticked
Going back in the wizard and un-ticking co-operative or investment licence used
to write the flag and nothing else. The registration the customer had typed
stayed on the company row, so `hasRegistrationDetails` still read as a passed
eTrade lookup, resume dropped them at their furthest step rather than the
company one, and the application could be finished on unverified data with no
flag left on it for the backoffice to show.

That transition now costs what the settings switch costs: the eTrade-sourced
columns and the manager captured beside them are cleared, and onboarding drops
back to the company step so the TIN actually goes through eTrade. Both the
reset payload and the attribute strip are now shared with
`revertToRegularCompany`, which did this correctly already.
2026-08-18 09:00:21 +00:00