Commit Graph

3705 Commits

Author SHA1 Message Date
Marshal
24f5d94709 feat(clearance): preview charge documents before and after upload 2026-08-21 07:10:50 +00:00
Marshal
4c549029fe feat(clearance): preview charge documents before and after upload 2026-08-21 07:04:22 +00:00
Marshal
ce3fde676e fix issue 2026-08-20 18:30:28 +00:00
Marshal
0e8e6acb04 fix issue 2026-08-20 18:28:40 +00:00
Marshal
a5e6cab248 fix issue 2026-08-20 18:16:03 +00:00
Marshal
d99b294c21 fix issue 2026-08-20 18:10:29 +00:00
Marshal
b72cbab535 changes 2026-08-20 18:06:13 +00:00
Marshal
934ed43ccb feat: enhance GlDjiboutiClearanceListPage with tab navigation and improved filtering
- 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.
2026-08-20 17:44:52 +00:00
Marshal
0d34b9f5e3 feat: enhance ClearanceChargesSection with file viewing and downloading capabilities 2026-08-20 16:07:16 +00:00
Marshal
9e1d5ee9f2 [200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries 2026-08-20 15:45:42 +00:00
Hagernesh
7f772efcb3 feat(warehouses): make dashboard date range + warehouse filter real
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.
2026-08-20 13:30:28 +00:00
Hagernesh
d4c19d7e40 finance only permission for Additional charges 2026-08-20 12:59:33 +00:00
Hagernesh
6e6d6329b6 feat(bookings): add Additional Payments tab, add-charge modal, portal pay
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.
2026-08-20 12:59:33 +00:00
Hagernesh
0ceb595714 feat(bookings): add ad-hoc additional-charge module
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.
2026-08-20 12:59:33 +00:00
marshal
3604e78fbe Merge pull request #1364 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
2026-08-20 14:49:27 +03:00
Nathnael
f55645fea9 chore(scripts): add the OCC July 2026 seed, drop the throwaway ops scripts
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.
2026-08-20 11:37:12 +00:00
Nathnael
1b08666527 refactor(train-scheduling): move the schedule list onto the shared FilterBar
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.
2026-08-20 11:37:02 +00:00
Nathnael
d6f1c5fb43 fix(rule-engine): use a real date picker, and stop the label wrapping
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.
2026-08-20 11:37:02 +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
Nathnael
684c3173f4 feat(filters): filter by either end of a route, and by several stations per end
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.
2026-08-20 11:24:33 +00:00
Nathnael
d7894d28f1 fix(filters): read date filter values back in local time, not UTC
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.
2026-08-20 11:24:16 +00:00
Nathnael
8949325a9c fix(export-ui): indicate the selected format and default to xlsx
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.
2026-08-20 11:23:55 +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
Hagernesh
f9987e515c feat(warehouses): filters/pagination on all import+export queue tabs
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>
2026-08-20 10:29:59 +00:00
Hagernesh
d4c51ec63a feat(warehouses): add import-ops stat tiles and arrival-queue filters
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.
2026-08-20 10:29:59 +00:00
Abubeker Yasin
2f35b0a717 fix: ( passenger ) read permissions from position types and all positions 2026-08-20 10:29:59 +00:00
Marshal
bcf684814d fix(clearance): flag bookings with documents still awaiting GL approval 2026-08-20 10:29:59 +00:00
Marshal
21a611f1b7 fix(portal): surface document upload on cleared bookings until payment 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
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
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
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
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