Replace the icon-and-cards landing with a photographic hero that
alternates
between the Furi station and freight train photos, a single headline,
and two
compact glass cards that pin the background on hover. Below it: the
corridor
rail, one showcase mockup per portal (passenger trip search, freight
live
tracking), the existing stats and footer.
- Add the official EDR logo as transparent green/white PNGs (lockup and
mark),
used in the header, footer and favicon
- Switch to Plus Jakarta Sans and a palette keyed to the logo green
- Split the page into components under src/components
- Header links: Passenger, Freight, Publications
edrsc.com had no entry point — a visitor could not tell which of the two
apps they wanted. This adds @edr/landing: one static page whose job is to
make that choice obvious, with the two doors above the fold and the rest
kept to supporting context.
Fills in apps/edr-landing/, which until now held an orphaned next-env.d.ts
from an abandoned Next 15 start and was built by nothing.
- Next.js 14 static export. Versions mirror the passenger portal exactly,
so they resolve to what the lockfile already had and add 6 packages.
Depends on no workspace package — @edr/ui-common's Tailwind 4 tokens do
not fit this Tailwind 3 setup.
- Destinations come from NEXT_PUBLIC_PASSENGER_URL / NEXT_PUBLIC_FREIGHT_URL
as origins, with the entry path appended in src/lib/apps.ts. Freight links
to /portal rather than /, which is that app's own marketing landing.
- Design lifted from EDRFreightLandingPage so the two public surfaces read
as one railway. Figures (752 km, ~16 h) are the ones freight already
asserts publicly.
- Fonts load as a stylesheet, not next/font: next/font fetches from
fonts.googleapis.com during `next build` and failed the build outright on
a machine without network. Verified deterministic over repeated builds.
- CountUp renders its final value server-side and the reveal animation's
hidden state is scoped behind html.js, so the page is complete with
JavaScript off instead of blank.
turbo.json: a static export's artifact is out/, which the build task did not
list — a cache hit would have restored a build with the artifact missing.
Also gitignored.
Dockerfile.web: passes the two NEXT_PUBLIC_* build args through (a static
export inlines them at build time, so runtime env does nothing), and exempts
this app from the VITE_* required-check that guards the freight Vite apps and
would otherwise fail its build.
Claude-Session: https://claude.ai/code/session_011ZMMHwK4Ham1Dt19FVZQj3
dev independently added 3850000000000-Publications.ts. Renumbered the
DJF branch's own 3850000000000-ExchangeSettingsPerCurrency.ts to the
next free slot (3880000000000, above this branch's other new
migrations) per the project's unique-timestamp rule. Unpushed and
un-shared, so a rename is safe here — updated the class name/name
property to match, and the already-applied local dev DB's recorded
migration row.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
Currency dropdowns/pickers (AdditionalPaymentsTab, ClearanceChargesTab,
PhasedClearanceActionPanel, AdviseDutyCard, ContractRequestsPage,
ruleEngine/resources, WarehouseRulesPage, VehicleDetailPage,
FeePreviewModal) offer DJF alongside ETB/USD; GlCreateBookingForm's
currency selector gets allowDjf next to allowUsd. Narrow 'ETB'|'USD'
type unions widened to include 'DJF' across the warehouse
billingCurrency plumbing (useWarehouses, warehouse.service, api.ts)
and the customer/invoice types.
Ad-hoc money() formatters (BookingTrucksPanel, AccrualDashboard,
ImportTrucksPage, EmptyReturnRequestsPage) and formatMoney call sites
that hardcoded 2 decimals (wagon-cancellation cards, BookingRequestDetailPage,
WagonCancellationsPage, PaymentsPage, WarehouseInvoicesPage) now use
currencyDecimals() from @edr/ui-common so DJF renders with 0 decimals
instead of forced cents. The 3 duplicate overview formatCurrency/
formatAmount helpers (typed 'ETB'|'USD') widen to accept any currency.
Two correctness fixes: OverviewRecentBookingsTable's currency==='USD'
? 'USD' : 'ETB' was mislabeling every non-USD currency as ETB; and
WarehouseInvoicesPage's gateway-method default now routes any
non-ETB currency (not just USD) to WAAFI, so DJF invoices get a
working default instead of TELEBIRR (ETB-only).
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
exchangeSettings.service/hook/card follow the backend's new shape: a
single ExchangeSettings object becomes a list, get() becomes list(),
and setFallbackRate(rate) becomes setFallbackRate(currency, rate).
ExchangeRateSettingsCard now renders one row per currency instead of
one hardcoded USD->ETB form.
manualPaymentSettings.service/hook/card add djfEnabled alongside
etb/usdEnabled, matching the backend's new column.
InvoicesPage's collected-summary KPI strip adds a DJF tile and folds
its ETB-equivalent into the existing 'Total collected' conversion,
reading each currency's rate from the now-list-shaped settings query.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
lib/currency.ts re-exports the shared @edr/ui-common formatter (was a
local implementation always forcing 2 decimals, wrong for DJF).
Currency pickers (new-booking-form, new-contract-form, new-shipment
Currency selector and schemas) offer DJF wherever USD is offered.
The bigger piece: offline-payment.ts's isUsdCurrency/isUsdOfflineBooking
assumed exactly two payment rails (ETB online, USD offline) and picked
one. DJF supports BOTH, so it's replaced with independent
canPayOnline/canPayOffline predicates, updated across the 5 call sites
that gated the Pay button vs. the bank-transfer badge. PaymentMethodModal's
WAAFI/CAC Bank entries (Djibouti gateways mislabeled USD-only) now list
DJF, and a currency that matches no provider returns no providers
instead of silently offering all of them (was returning every provider,
including the ETB-only one, on any unmatched currency).
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
New lib/currency.ts is the single source of truth for currency
display across both freight web apps: symbol, name and decimals per
code (DJF is zero-decimal by convention, unlike ETB/USD). Portal's own
currency lib and backoffice's ad-hoc formatters are rewired onto this
in the next two commits instead of each guessing decimals.
CurrencySelector gets an allowDjf prop mirroring the existing allowUsd
gate, so import-shipment currency pickers can offer DJF the same way
they offer USD.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
The billing KPI queries fan out per currency with a FILTER (WHERE
payment.currency = '...') per column rather than a generic GROUP BY,
so DJF needs an explicit third variant at each of the 7 query sites
(revenueMtd*, amount*, revenue* pairs) plus the matching DTO fields.
The already-generic queries (getBookingsByCurrency,
getRevenueByCurrency, company-dashboard's sumPaidSpendByCurrency)
needed no change — they GROUP BY currency and pick up DJF on their own.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
Adds DJF to freight.payments_currency_enum (migration 3860000000000,
alone in its own migration per Postgres's ADD VALUE-in-a-transaction
restriction) and to manual_payment_settings (djf_enabled column,
migration 3870000000000, default true).
Replaces the binary ETB/USD assumptions that would have silently
mispriced or discarded a DJF booking:
- booking-pricing / contract-pricing: usdToEtb scalar -> a rate table
keyed by source currency (ExchangeService.getRateTable), so a
contract-frozen rate converts into whatever currency the booking is
paid in instead of being dropped when neither leg is ETB or USD.
- warehouse-fee / booking-wagon-cancellation: normalizeCurrency no
longer coerces anything non-ETB to USD.
- additional-charge: convertAmount no longer bails out for a currency
that isn't literally ETB or USD.
- manual-payment-settings: isEnabled/enabledCurrencies cover DJF.
Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to
the export/report currency filter option lists.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
exchange_settings was a single row holding the USD->ETB fallback only.
Restructured to one row per foreign currency (adds a currency column,
migration 3850000000000) so DJF gets its own fallback rate, source and
sync timestamp instead of a parallel column. Service/controller/DTO
follow: get/loadFallbackRate/saveFallbackRate/setManualRate all take a
currency now, GET /exchange-settings returns the list, and
PATCH /exchange-settings/:currency sets one. Per-currency manual-rate
ceiling (USD ~10,000, DJF ~100) replaces the old fixed bound.
Adds a spec exercising the multi-currency CBE parse and the USD<->DJF
pivot against a fixture payload.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
CbeExchangeProvider now parses every currency CBE quotes (USD, DJF, ...)
from the single existing daily-rates fetch instead of hardcoding USD
only, and ExchangeService.getRate gains a pivot step so a pair neither
quoted directly nor as its inverse (e.g. USD->DJF) is derived by
triangulating through the provider's base currency (ETB). Fallback
rates and the load/save callbacks become per-currency instead of a
single USD->ETB scalar. ExchangeService.getRateTable resolves a whole
currency->target rate table in one call for pricing loops.
No behavior change for existing USD/ETB callers.
Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
- Implemented for Excel download of wagon performance report sections, including column width adjustment and timestamped filenames.
- Created to compute derived wagon performance figures based on movements and status logs, ensuring consistency with API data.
- Enhanced with new fields for tracking last movement and statistics window days for improved reporting capabilities.
- Implemented BookingWagonsPanel to show allocated wagons, their containers, and export functionality.
- Integrated the new panel into BookingRequestDetailPage and BookingRequestsPage.
- Enhanced wagon cancellation modal to support rebooking of wagon cancellations with partner units.
- Updated API service to include a method for downloading wagons workbook.
- Modified types and constants to accommodate new features related to wagons.
- Adjusted various components and pages to ensure compatibility with the new wagon-related functionality.
Ready to Load listed inventory and offered an auto-load picker, but the
warehouse floor works train by train: a train stands at the yard, its
bookings board wagon by wagon, it rolls. That view existed only on the
train schedule workspace, which the warehouse staff do not run.
Ready to Load now has an Items / By train switch. By train is a mirror
of the schedule's own column, placed where the loading actually happens.
Nothing in it has its own rules. Train position, the per-yard loading and
unloading windows, and the per-booking Load / Wagons / Unload actions all
come from the train-scheduling endpoints the schedule workspace already
calls, so a Load that would be refused there is disabled here with the
same reason and the two surfaces cannot disagree. No train-scheduling
code is touched.
What the warehouse adds is what the schedule cannot see: which of the
train's bookings are physically in the shed, with their GRN and
inspection state, laid out along the flow the staff follow -- receive and
GRN, inspect, ready, open the loading window, train at yard, load per
wagon, dispatch, unload at port.
Wagons
- The wagon modal calls the same per-wagon journey endpoints, so every
server gate (train at the yard, window started, PAID, GRN) is the
schedule's own.
- Wagons go one at a time in order: the server flips the booking to
IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so
sequential is required, not merely tidy. A failure stops the run, the
wagons already sent stay done, and the toast says how many, so a retry
only resends the rest.
- Deliberately not mirrored: cancelling wagons that will not ride, and
the direct truck-to-train handover. Both are commercial decisions
(fees, credits, GRN waiver) that belong to the schedule workspace.
loadable-trains takes includeDispatched. Loading follows the train after
it rolls, since a mid-corridor warehouse boards its cargo when the train
stands at its yard, and the train-centric view needs the same set the
schedule offers Load on. The default stays pre-dispatch only, so the
existing auto-load picker is unchanged.
Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect
without importing it, left behind by the self-haul assignment work.
The customer truck card on the booking's Logistics tab is now usable for
every cargo type, with an Excel template and bulk upload that match what
the API enforces.
Excel template and bulk upload
- The template is built per booking. Container bookings get the booking's
own containers with their sizes on a reference sheet and sample rows
paired 20ft+20ft; bulk (PER_TON) bookings get a Planned Tons column;
counted cargo (PER_ITEM: machinery, RoRo vehicles) gets Planned Quantity.
- Parsing validates the whole file before anything is posted: plate,
driver, truck type, ISO container numbers, containers on the booking,
duplicates across rows, containers already on a truck, and the capacity
rule (one 40ft alone, or two 20ft). Errors quote the Excel row number.
- The bulk DTO reuses AddCustomerTruckDto instead of a drifted copy that
lacked plannedTons/plannedQuantity, so bulk cargo can be uploaded at all.
- A partial failure is reported per row in the modal instead of closing it
as if every truck had been created.
Capacity rule in the form
- The container picker shows sizes and stops offering a second container
once a 40ft is picked, or a 40ft once a 20ft is picked.
- PER_ITEM cargo commits by item count; tonnage becomes optional.
Shared vocabulary
- CUSTOMER_TRUCK_TYPES and ISO_CONTAINER_NUMBER move to @edr/types so the
API validators, the dropdown and the template read one list.
When assignment is open
- The card always renders and states why assignment is closed (EDR
haulage, unpaid, train not arrived, cargo already loaded) rather than
vanishing.
- Self-haul is blocked only once EDR has committed to the road leg: an
approved last-mile request or an existing last-mile leg. A delivery
address whose request is still awaiting confirmation, submitted or
rejected no longer blocks the customer from bringing their own truck.
Collection on exports has no approval step and still blocks as before.
Both the multi-truck service and the legacy single-truck path read the
same SQL fragment (edrHaulsThisBooking), and the portal applies the same
rule with a notice that assigning a truck makes the pending request
unapprovable. The last-mile side already refuses to approve a booking
carrying a customer truck, so the two paths stay mutually exclusive.
Verified: EXPLAIN on the new SQL against edr_dev, type-check clean for
freight-api and portal, 12 util specs pass (5 new). Backoffice type-check
fails only in pre-existing user-management files.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The menu item was gated on the brand-new empty_return_requests:view key,
which on the dev DB is held by one position (Operations Chief) — so the
Director and Dispatcher who already work Container Returns two rows above
it never saw the entry at all.
Viewing the queue now accepts that key OR warehouse_inventory:view, the
key the rest of the Imports menu runs on, in the sidebar, the route guard
and the API's read routes alike. Approving and rejecting stay on the
review key on their own, and the buttons come up disabled (with the
reason) for a viewer who lacks it rather than firing into a 403.
The dedicated keys are still there to hand out per position whenever
that call gets made.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Typing container numbers let a customer request a return for a box that
never came in on that booking — and gave them a blank field to guess at
when they did not have the numbers to hand.
The portal now lists the booking's own containers, minus any already
spoken for by a live request, as a tick list; the count follows the
ticks. The API backs that up: every submitted number must be one of the
booking's containers, and a booking with no container numbers recorded
says so instead of offering an empty form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>