Merge branch 'freight/nati-2' into freight/feat/element-chat

This commit is contained in:
Nathnael
2026-08-17 12:53:39 +00:00
63 changed files with 2408 additions and 1126 deletions

388
CLAUDE.md
View File

@@ -1,102 +1,362 @@
# EDR Platform — Developer Guide
> This file is the contract. If something here contradicts the code, the code is the
> truth and this file is a bug — fix it in the same PR.
**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
you to the right module or page without a repo-wide grep.
## Overview
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
Management and Passenger Management applications, a payment microservice, plus shared
types, NestJS utilities, and React component libraries.
The freight domain is the largest and most active area. Its core flow is:
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
Fees (storage, demurrage, double handling, truck detention) and allocation rules
(warehouse/yard/zone) hang off the warehouse stage.
## Apps
| App | Package name | Purpose | Port |
| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
The two domains are **not built the same way**. Check which stack you are in before
copying a pattern across:
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
| App | Package name | Stack | Default port |
| ------------------------------ | --------------------------- | ---------------------- | ------------ |
| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
Those are the **fallbacks compiled into the code**, not what you will be running. Every
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
`./wt ports` for who currently holds what.
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
## Packages
| Package | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
| `@edr/prettier-config` | Shared Prettier configuration |
| Package | Location | Purpose |
| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
The three `config/*` packages are nested one level deeper than the rest — `packages/config`
itself is not a package.
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
type in `packages/types/src` changes nothing for consumers until you rebuild:
```bash
pnpm turbo build --filter=@edr/types
```
If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
| Command | Description |
| -------------------- | ---------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Run only freight API + web |
| `pnpm dev:passenger` | Run only passenger API + web |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
| Command | Description |
| ----------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:freight:api` | Freight API only |
| `pnpm dev:freight:portal` | Freight portal only |
| `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm dev:passenger` | Passenger API + web |
| `pnpm dev:payment` | Payment API |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests (turbo) |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
| `pnpm lint` | **Does not work** — see below |
## Standards
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
`turbo run lint` dies with `eslint: not found` even though every package declares a
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
"lint clean".
- **TypeScript strict mode** is enabled in every package and app.
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
- **Conventional commits** are enforced via commitlint on every commit.
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
`--config` explicitly there.
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
```
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
gate-pass scenarios). Read the script before running one; several write real rows.
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
freight tables in `freight`.
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
skill (below) or write a short Node script using `pg` and run it from
`apps/edr-freight-api`, where `pg` resolves.
## Hard rules
These are non-negotiable. Everything else is a strong default.
- **pnpm only.** Never run `npm install` or `yarn`.
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
reach for `any` to make an error go away.
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
in every config and it has already corrupted this database twice (see *Migrations*).
All schema changes go through migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
- **Controllers** never contain business logic.
- **All entities** extend `BaseEntity` from `@edr/api-common` `createdAt`, `updatedAt`,
`deletedAt` (soft delete).
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
TypeScript properties are `camelCase`.
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
offer the safe version.
## Auth
## Architecture
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
### NestJS module shape
When auth integration is needed, use placeholder TODO comments:
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
### Data access — the real model
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
There are two sanctioned ways to read and write, and you must pick the right one:
## Port Assignments
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
- `edr-freight-api`: 3001
- `edr-freight-web/portal`: 5173
- `edr-freight-web/backoffice`: 5183
- `edr-passenger-api`: 3002
- `edr-payment-api`: 3003
- `edr-passenger-web/portal`: 5174
- `edr-passenger-web/backoffice`: 5184
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
It carries one obligation:
## Database Layout
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
so they join the caller's transaction.
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
no timeout and will wait forever.
### Migrations
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
- `migrationsRun: false`**migrations do NOT run on API boot.** They run as a separate
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
migration`), with `migrationsTransactionMode: 'each'`.
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
migrations yourself before `docker compose up freight-api`, e.g.
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
It silently applies zero freight migrations while exiting 0.
- Consequences you must design for:
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
`forFeature()` registrations), but the standalone migration `DataSource`
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
for this to break again** — diff the package's entity classes against `iamEntities`
when bumping it.
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
and breaks ties non-deterministically. Check before adding one:
```bash
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
```
The prefix must be unused *and* higher than the newest recorded row. Note the
`freight.migrations` table has far more rows (~309) than this folder has files — most
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
backfills guarded by `WHERE col IS NULL`.
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
was recorded in `migrations` while its column was absent — it had been dropped out of band.
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
re-introduce the outage it fixed.
### Auth
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
Add a permission there before referencing it.
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
`backoffice` for employees, `portal` for customers. Without it the API 403s with
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
Ownership checks are separate from permission checks. A staff user passes
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
## Frontend conventions
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
check the installed version before copying a snippet.
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
freight web apps. Prefer it over re-implementing a component.
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
`.message` and degrades to `"Request failed with status code 400"`. Use
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
keep the synchronous version — their bodies are already parsed JSON.
- Server-side guards must be reflected in the UI. If the API will reject the action, the
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
- Prefer disabling a control with a visible reason over silently hiding it.
## Notifications
In-app notifications resolve recipients from the company's **linked portal users**. If a
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
SMS and email still send, because they address the company's phone and email directly. Check
this before debugging a "missing notification".
## PDF generation
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
assume a headless browser exists.
## Adding a new module to a NestJS app
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` directly).
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common`
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
2. Export from `src/index.ts`.
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
3. Component is a functional component with a `ComponentNameProps` interface
(named-exported alongside the default).
## Definition of done
A change is done when **all** of these hold. State explicitly which you ran.
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
dev database without error.
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
something the new code reads — applied to the dev database, since watch mode will not run it.
4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
then run the specs covering what you touched and confirm you introduced no new failure.
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
type-check is your only unused-code signal.
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
endpoint, or ran the query. If you could not, say so plainly.
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
the summary. Never describe unverified work as done.
### Hooks do not run
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
hand; do not assume the hook caught it.
## Known traps
| Trap | What happens | What to do |
| --- | --- | --- |
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
## Project skills
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
| Skill | Use for |
| --- | --- |
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
## Working style
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
from a filename.
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
trade-off before changing files.
- **Small, reviewable commits**, one logical change each, conventional message.
- **Branch from `dev`; PRs target `dev`.**
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.

View File

@@ -1,313 +0,0 @@
# EDR Platform — Developer Guide
> This file is the contract. If something here contradicts the code, the code is the
> truth and this file is a bug — fix it in the same PR.
## Overview
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
Management and Passenger Management applications, a payment microservice, plus shared
types, NestJS utilities, and React component libraries.
The freight domain is the largest and most active area. Its core flow is:
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
Fees (storage, demurrage, double handling, truck detention) and allocation rules
(warehouse/yard/zone) hang off the warehouse stage.
## Apps
| App | Package name | Purpose | Default port |
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
## Packages
| Package | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
| `@edr/prettier-config` | Shared Prettier configuration |
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
type in `packages/types/src` changes nothing for consumers until you rebuild:
```bash
pnpm turbo build --filter=@edr/types
```
If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
| Command | Description |
| --------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:freight:api` | Freight API only |
| `pnpm dev:freight:portal` | Freight portal only |
| `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm dev:passenger` | Passenger API + web |
| `pnpm dev:payment` | Payment API |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests (turbo) |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
```
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
gate-pass scenarios). Read the script before running one; several write real rows.
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- `psql` is not installed on the dev machine. To query the database, write a short Node
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
## Hard rules
These are non-negotiable. Everything else is a strong default.
- **pnpm only.** Never run `npm install` or `yarn`.
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
reach for `any` to make an error go away.
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
in every config and it has already corrupted this database twice (see *Migrations*).
All schema changes go through TypeORM migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** extend `BaseEntity` from `@edr/api-common``createdAt`, `updatedAt`,
`deletedAt` (soft delete).
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
TypeScript properties are `camelCase`.
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
offer the safe version.
## Architecture
### NestJS module shape
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
### Data access — the real model
There are two sanctioned ways to read and write, and you must pick the right one:
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
It carries one obligation:
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
so they join the caller's transaction.
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
no timeout and will wait forever.
### Migrations
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
- `migrationsRun: false`**migrations do NOT run on API boot.** They run as a separate
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
migration`), with `migrationsTransactionMode: 'each'`.
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
migrations yourself before `docker compose up freight-api`, e.g.
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
It silently applies zero freight migrations while exiting 0.
- Consequences you must design for:
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
`forFeature()` registrations), but the standalone migration `DataSource`
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
for this to break again** — diff the package's entity classes against `iamEntities`
when bumping it.
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
backfills guarded by `WHERE col IS NULL`.
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
was recorded in `migrations` while its column was absent — it had been dropped out of band.
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
re-introduce the outage it fixed.
### Auth
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
Add a permission there before referencing it.
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
Ownership checks are separate from permission checks. A staff user passes
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
## Frontend conventions
- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version
before copying a snippet.
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
freight web apps. Prefer it over re-implementing a component.
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
`.message` and degrades to `"Request failed with status code 400"`. Use
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
keep the synchronous version — their bodies are already parsed JSON.
- Server-side guards must be reflected in the UI. If the API will reject the action, the
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
- Prefer disabling a control with a visible reason over silently hiding it.
## Notifications
In-app notifications resolve recipients from the company's **linked portal users**. If a
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
SMS and email still send, because they address the company's phone and email directly. Check
this before debugging a "missing notification".
## PDF generation
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
assume a headless browser exists.
## Adding a new module to a NestJS app
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` directly).
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common`
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
2. Export from `src/index.ts`.
3. Component is a functional component with a `ComponentNameProps` interface
(named-exported alongside the default).
## Definition of done
A change is done when **all** of these hold. State explicitly which you ran.
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
dev database without error.
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
something the new code reads — applied to the dev database, since watch mode will not run it.
4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**,
so a fully green suite is not the bar. Run the specs covering what you touched and confirm
you introduced no new failure.
5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these
automatically (see below), so run them yourself.
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
endpoint, or ran the query. If you could not, say so plainly.
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
the summary. Never describe unverified work as done.
### Hooks do not run
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
hand; do not assume the hook caught it.
## Known traps
| Trap | What happens | What to do |
| --- | --- | --- |
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
## Project skills
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
| Skill | Use for |
| --- | --- |
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
## Working style
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
from a filename.
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
trade-off before changing files.
- **Small, reviewable commits**, one logical change each, conventional message.
- **Branch from `dev`; PRs target `dev`.**
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.

View File

@@ -0,0 +1,49 @@
import { DataSource } from "typeorm";
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in an audit trail.
*/
export interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
export function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === "string" && name.trim()) return name.trim();
if (name && typeof name === "object") {
const localized =
name.en ??
Object.values(name).find((v) => typeof v === "string" && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/**
* Display names for a set of IAM user ids — one query for the whole set.
* `iam.users` is owned by the auth system and has no entity here, so it is read
* directly. A miss is not an error: the caller still holds the id and can fall
* back to it.
*/
export async function resolveIamUserNames(
dataSource: DataSource,
userIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
if (ids.length === 0) return resolved;
const rows = (await dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return resolved;
}

View File

@@ -0,0 +1,72 @@
import eimsConfigFactory from "./eims.config";
const REQUIRED = {
EIMS_ENABLED: "true",
EIMS_CLIENT_ID: "cid",
EIMS_CLIENT_SECRET: "secret",
EIMS_API_KEY: "apikey",
EIMS_TIN: "0000000000",
};
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
const prior: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(vars)) {
prior[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
fn();
} finally {
for (const [key, value] of Object.entries(prior)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
};
describe("eims.config — private key / certificate resolution", () => {
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("leaves a PEM with real newlines untouched", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("throws naming all three key/cert options when none are set", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY_PATH: undefined,
EIMS_PRIVATE_KEY_BASE64: undefined,
EIMS_PRIVATE_KEY: undefined,
EIMS_CERTIFICATE_PATH: "/dev/null",
},
() => {
expect(() => eimsConfigFactory()).toThrow(
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
);
},
);
});
it("is satisfied by any single one of the three key options", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(() => eimsConfigFactory()).not.toThrow();
},
);
});
});

View File

@@ -30,6 +30,23 @@ export interface EimsConfig {
privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string;
/**
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
* container that can't be given a host bind mount can still receive it as a plain env var.
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
* > `privateKeyPath`.
*/
privateKeyBase64: string;
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
certificateBase64: string;
/**
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
* three inline forms, and the hardest for a broken transport step to mangle since there's no
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
*/
privateKeyPem: string;
/** Inline alternative to `certificateBase64`, same precedence rule. */
certificatePem: string;
httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number;
@@ -80,7 +97,19 @@ export interface EimsInvoiceConfig {
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
@@ -89,6 +118,14 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -115,14 +152,14 @@ export interface EimsInvoiceConfig {
buyerIdNumber: string | null;
}
const REQUIRED_VARS = [
"EIMS_CLIENT_ID",
"EIMS_CLIENT_SECRET",
"EIMS_API_KEY",
"EIMS_TIN",
"EIMS_PRIVATE_KEY_PATH",
"EIMS_CERTIFICATE_PATH",
] as const;
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
const REQUIRED_ANY_OF: string[][] = [
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
];
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback;
@@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
return map;
};
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
const normalizePem = (raw: string | undefined): string => {
if (!raw) return "";
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
@@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => {
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
httpTimeoutMs,
tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
@@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
@@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => {
if (!enabled) return base;
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
for (const vars of REQUIRED_ANY_OF) {
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
}
if (missing.length > 0) {
throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,

View File

@@ -92,7 +92,7 @@ export class BillingController {
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
@@ -104,7 +104,7 @@ export class BillingController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,

View File

@@ -16,6 +16,7 @@ import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -48,10 +49,18 @@ export interface PayInvoiceOptions {
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** Row shape of the manual-payments worklist. */
export type OfflineUsdInvoiceRow = Invoice & {
booking: OfflineUsdBookingInfo | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
};
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
@@ -340,22 +349,23 @@ export class BillingService {
}
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway) and ETB invoices Finance settles by hand (bank
* transfer / counter) instead of the customer paying online. Open ones by
* default or a single status when filtered; both currencies unless
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
* trade direction and pay-window deadline so the UI can show the countdown
* and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
currency?: "USD" | "ETB";
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -364,11 +374,16 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
@@ -381,7 +396,8 @@ export class BillingService {
);
}
const [items, total] = await qb.getManyAndCount();
const [rawItems, total] = await qb.getManyAndCount();
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items
.filter((i) => i.source === "booking")
@@ -389,11 +405,43 @@ export class BillingService {
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
select: [
"id",
"reference",
"tradeDirection",
"paymentDeadline",
"paymentStatus",
],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
// Shipping-line credit invoices bill many bookings at once; each credit
// keeps its own booking link, so collect them per invoice.
const creditInvoiceIds = items
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
.map((i) => i.id);
const credits = creditInvoiceIds.length
? await this.dataSource.getRepository(ShippingLineCredit).find({
where: { invoiceId: In(creditInvoiceIds) },
relations: { booking: true },
})
: [];
const bookingsByInvoice = new Map<
string,
OfflineUsdInvoiceRow["bookings"]
>();
for (const c of credits) {
if (!c.invoiceId || !c.booking) continue;
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
list.push({
id: c.booking.id,
reference: c.booking.reference,
tradeDirection: c.booking.tradeDirection ?? null,
});
bookingsByInvoice.set(c.invoiceId, list);
}
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
@@ -403,19 +451,22 @@ export class BillingService {
? {
id: b.id,
reference: b.reference,
tradeDirection: b.tradeDirection ?? null,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
bookings: bookingsByInvoice.get(inv.id) ?? [],
} as OfflineUsdInvoiceRow;
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* or counter payment: stores the slip against the invoice and settles the
* FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
@@ -434,11 +485,6 @@ export class BillingService {
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}

View File

@@ -84,7 +84,13 @@ export class PdfRenderService {
const page = await browser.newPage();
const thermal = opts.thermal ?? false;
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
// is defined as the LARGER of the content's height and the viewport's own height, so a
// receipt shorter than the viewport would otherwise report the viewport height back, not
// its true content height — a real page-length trailing blank space bug, not theoretical
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
// so scrollHeight always reflects the content, never the viewport.
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
@@ -154,7 +160,7 @@ export class PdfRenderService {
// browser context regardless, same as the closure form would be.
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
}
private injectPdfPrintStyles(html: string): string {

View File

@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
@IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"])
currency?: "USD" | "ETB";
}

View File

@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
buyerCityCodes: {},
...over,
});
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
expect(doc.BuyerDetails).toEqual({
City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Country: null,
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
@@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");

View File

@@ -234,8 +234,13 @@ export interface EimsMapperContext {
* from a registered invoice").
*/
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
/**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
@@ -252,9 +257,15 @@ export interface EimsMapperContext {
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
invoiceDiscount?: number | null;
@@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
* exchange rate.
*/
/**
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
* a guessed code onto a tax document is worse than refusing to file.
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* numeric, otherwise looked up by name (case- and space-insensitive).
*
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/
function resolveLocationCode(
field: "Region" | "Wereda",
field: "Region" | "Wereda" | "City",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
@@ -319,12 +335,61 @@ function resolveLocationCode(
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -440,7 +505,16 @@ export function toEimsInvoice(
return {
BuyerDetails: {
City: context.buyerCity ?? null,
// No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
@@ -455,7 +529,12 @@ export function toEimsInvoice(
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,

View File

@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
// Consumed by NotificationInboxModule for portal recipient targeting.
ExternalProfileRepository,
CompanyProfileRepository,
// Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup
// already used for every customer company at onboarding, reused for EDR's own TIN.
ETradeService,
],
})
export class CompaniesModule { }

View File

@@ -7,6 +7,7 @@ import {
ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -1141,16 +1142,55 @@ export class CompaniesService {
return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
/**
* List a company's change requests, newest first (backoffice review). Actor
* ids are resolved to display names here — the history screen has to say who
* asked for a change and who sent it back, not print two uuids.
*/
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
const requests = await this.changeRequestRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(
requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
);
for (const request of requests) {
request.submittedByName = request.submittedBy
? (names.get(request.submittedBy) ?? null)
: null;
request.reviewedByName = request.reviewedBy
? (names.get(request.reviewedBy) ?? null)
: null;
}
return requests;
}
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
await this.findCompanyById(companyId);
return this.revisionRepo.findByCompanyId(companyId);
const revisions = await this.revisionRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
for (const revision of revisions) {
revision.actorName = revision.actorId
? (names.get(revision.actorId) ?? null)
: null;
}
return revisions;
}
/**
* Display names for actor ids, one query for the whole list. A lookup failure
* degrades the history to ids rather than failing the request — the entry is
* still worth showing without the name.
*/
private async resolveActorNames(
actorIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
try {
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
}
/**
@@ -1519,6 +1559,7 @@ export class CompaniesService {
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
@@ -1556,6 +1597,12 @@ export class CompaniesService {
`Change request ${id} is already ${request.status}`,
);
}
await this.notifyChangeRequestReturned(
request,
"changes_requested",
note,
reviewerId,
);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
@@ -1566,6 +1613,35 @@ export class CompaniesService {
);
}
/**
* Tell the customer desk a change request came back unapproved. Best-effort:
* a missing company or an unresolvable reviewer name must not fail the
* reviewer's decision, which is already the point of the try/catch.
*/
private async notifyChangeRequestReturned(
request: CompanyChangeRequest,
outcome: "rejected" | "changes_requested",
note: string,
reviewerId?: string,
): Promise<void> {
try {
const company = await this.companiesRepo.findById(request.companyId);
if (!company) return;
const names = await this.resolveActorNames([reviewerId]);
this.companyNotifier.changeRequestReturned(
company,
request.id,
outcome,
note,
reviewerId ? (names.get(reviewerId) ?? null) : null,
);
} catch (err) {
this.logger.warn(
`Could not notify the customer desk about ${request.id}: ${String(err)}`,
);
}
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);

View File

@@ -244,6 +244,35 @@ export class CompanyNotifierService {
);
}
/**
* A reviewer did NOT approve a customer's profile changes — they rejected it
* or sent it back for correction. The customer desk (Marketing included, via
* the `customers:get_notification` key) owns the follow-up with the customer,
* so the decision has to reach their inbox; without this it was silent, and
* only visible to whoever happened to reopen the customer's History tab.
*/
changeRequestReturned(
company: Company,
changeRequestId: string,
outcome: "rejected" | "changes_requested",
note: string,
reviewerName?: string | null,
): void {
const rejected = outcome === "rejected";
const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : "";
this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()}${company.id}`);
this.notifyStaff(
company,
rejected
? "Customer profile changes rejected"
: "Customer profile changes sent back for correction",
`${company.name}'s profile changes were ` +
`${rejected ? "rejected" : "sent back for correction"}${by}. ` +
`Reason: ${note}`,
{ changeRequestId, outcome, note, reviewerName: reviewerName ?? null },
);
}
// ── Customer-facing: a specific document needs correcting ──────────────────
/**

View File

@@ -23,8 +23,12 @@ export class ChangeRequestResponseDto {
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
/** Who filed the request, for the history screen (null when unresolvable). */
submittedByName: string | null;
submittedAt: Date | null;
reviewedBy: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
@@ -39,8 +43,10 @@ export class ChangeRequestResponseDto {
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedByName = req.submittedByName ?? null;
this.submittedAt = req.submittedAt ?? null;
this.reviewedBy = req.reviewedBy ?? null;
this.reviewedByName = req.reviewedByName ?? null;
this.reviewedAt = req.reviewedAt ?? null;
this.createdAt = req.createdAt;
this.updatedAt = req.updatedAt;

View File

@@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit, for the history screen (null when unresolvable). */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: Date;
@@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto {
this.id = revision.id;
this.companyId = revision.companyId;
this.actorId = revision.actorId ?? null;
this.actorName = revision.actorName ?? null;
this.summary = revision.summary;
this.changes = revision.changes ?? [];
this.createdAt = revision.createdAt;

View File

@@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity {
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt?: Date | null;
/**
* Display names for {@link submittedBy} / {@link reviewedBy}, resolved from
* `iam.users` on read. Not columns — the history screen has to name the
* person who asked for the change, and an opaque uuid does not.
*/
submittedByName?: string | null;
reviewedByName?: string | null;
}

View File

@@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity {
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
changes!: CompanyRevisionChange[];
/**
* Display name for {@link actorId}, resolved from `iam.users` on read. Not a
* column — history has to name who made the edit, and a uuid does not.
*/
actorName?: string | null;
}

View File

@@ -110,6 +110,7 @@ function makeService(overrides?: {
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
);
return {

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import {
ContractDocPhase,
isDeliveryOrderFileCode,
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -155,6 +157,7 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -988,7 +991,39 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return filtered;
return this.attachContractSummary(filtered);
}
/**
* Queue rows show the parent contract's reference and lane. Booking has no
* contract relation, and a bare initiated instance may not carry yards yet —
* so batch-load the contracts (with routes) and fill in what's missing:
* `contractReference` always, origin/destination yards only when the booking
* lacks them (its own route wins).
*/
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
if (!ids.length) return bookings;
const contracts = await this.contractsRepository.findAll({
where: { id: In(ids) },
relations: { routes: { originYard: true, destinationYard: true } },
});
const byId = new Map(contracts.map((c) => [c.id, c]));
for (const b of bookings) {
const contract = b.contractId ? byId.get(b.contractId) : undefined;
if (!contract) continue;
const row = b as Booking & { contractReference?: string | null };
row.contractReference = contract.reference ?? null;
if (b.originYard && b.destinationYard) continue;
const routes = contract.routes ?? [];
const route =
routes.find((r) => r.id === b.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) continue;
b.originYard = b.originYard ?? route.originYard;
b.destinationYard = b.destinationYard ?? route.destinationYard;
}
return bookings;
}
async djQueue(): Promise<Booking[]> {
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
filtered.push(b);
}
}
return filtered;
return this.attachContractSummary(filtered);
}
}

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
ContractDocumentChange,
diffSnapshots,
@@ -20,28 +21,6 @@ export interface RecordRevisionInput {
stepId?: string | null;
}
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in the audit trail.
*/
interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === 'string' && name.trim()) return name.trim();
if (name && typeof name === 'object') {
const localized =
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/** Pre-computed changes (contract fields), rather than a document diff. */
export interface RecordChangesInput {
contractId: string;
@@ -120,23 +99,12 @@ export class ContractDocumentHistoryService {
private async resolveActorNames(
actorIds: string[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(actorIds.filter(Boolean))];
if (ids.length === 0) return resolved;
try {
const rows = (await this.dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
return resolved;
}
/** Revision history for a contract, newest first. */

View File

@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors";
const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
/**
* A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
* first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
*/
const describeBytes = (bytes: Buffer): string => {
const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
return `${bytes.length} bytes, starts with "${preview}"`;
};
/**
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
*
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
return this.config.get<EimsConfig>("eims")!;
}
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
/**
* RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
* text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
* literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
* none is usable.
*/
getPrivateKey(): KeyObject {
if (this.privateKey) return this.privateKey;
const path = this.cfg.privateKeyPath;
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
const source = privateKeyPem
? "EIMS_PRIVATE_KEY"
: privateKeyBase64
? "EIMS_PRIVATE_KEY_BASE64"
: `EIMS_PRIVATE_KEY_PATH (${path})`;
if (!privateKeyPem && !privateKeyBase64 && !path) {
throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
}
let bytes: Buffer;
try {
bytes = privateKeyPem
? Buffer.from(privateKeyPem, "utf8")
: privateKeyBase64
? Buffer.from(privateKeyBase64, "base64")
: readFileSync(path);
} catch (err) {
throw new EimsConfigException(
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
// Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
// error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
// genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
throw new EimsConfigException(
`EIMS private key from ${source} does not look like a PEM key after decoding ` +
`(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
`or truncation, and not base64 applied twice.`,
);
}
let key: KeyObject;
try {
key = createPrivateKey(readFileSync(path));
key = createPrivateKey(bytes);
} catch (err) {
// The path is operational information, not a secret; the key material never appears.
// The source is operational information, not a secret; the key material never appears.
throw new EimsConfigException(
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
if (key.asymmetricKeyType !== "rsa") {
throw new EimsConfigException(
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
`EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
);
}
@@ -51,11 +98,26 @@ export class EimsCredentialsProvider {
return key;
}
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
/**
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
* `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
* pasted text respectively); otherwise read from `certificatePath`.
*/
getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64;
const path = this.cfg.certificatePath;
const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
if (pem) {
this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
return this.certificateBase64;
}
if (inline) {
this.certificateBase64 = inline;
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
return this.certificateBase64;
}
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
let bytes: Buffer;

View File

@@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -172,6 +174,9 @@ const build = (
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
// Same static-config seller the real EimsSellerCacheService falls back to when it has never
// successfully fetched e-Trade — matches prior behavior for every test in this file.
{ getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService,
);
/**
@@ -474,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest
.fn()
.mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed"));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsConfigException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsIrn: null,
eimsLastError: expect.objectContaining({ kind: "CONFIG" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -26,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import {
assertEimsInvoiceConfig,
buildEimsContext,
buildEimsSeller,
} from "./eims-invoice-context";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
EimsInvoiceStatus,
@@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService {
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
private readonly sellerCache: EimsSellerCacheService,
) {}
private get cfg(): EimsConfig {
@@ -125,27 +123,29 @@ export class EimsInvoiceRegistrationService {
const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId);
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
const request = toEimsInvoice(
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
let irn: string;
let ackDate: string | undefined;
let signedQR: string | undefined;
try {
// The request can only be built now: InvoiceCounter and PreviousIrn come from the
// reservation. Building it — and everything after — stays inside this try: a reservation is
// held from here on, and *any* failure past this point, mapper or wire, must release it
// through settleFailure rather than leave it orphaned as a permanent system-wide block.
const request = toEimsInvoice(
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
@@ -471,6 +471,15 @@ export class EimsInvoiceRegistrationService {
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*
* Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
* every error that actually touches the wire is normalized to `EimsApiException` before it gets
* here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
* threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
* pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
* touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
* country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
* any HTTP call went out, and releasing the reservation is always safe, never a guess.
*/
private async settleFailure(
invoiceId: string,
@@ -478,10 +487,12 @@ export class EimsInvoiceRegistrationService {
err: unknown,
): Promise<void> {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
// Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN",
kind: api?.kind ?? localKind,
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
@@ -588,10 +599,14 @@ export class EimsInvoiceRegistrationService {
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "EIMS rejected an invoice"
? error.kind === "CONFIG" || error.kind === "LOCAL"
? "EIMS filing failed before reaching MoR"
: "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
? error.kind === "CONFIG" || error.kind === "LOCAL"
? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.`
: `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },

View File

@@ -0,0 +1,155 @@
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
mobilePhone: "0911000000",
regularPhone: "",
...over,
});
const build = (cfg: EimsConfig = eimsConfig()) => {
const resolveCompanyData = jest.fn();
const extractRegistrationData = jest.fn().mockReturnValue(registrationData());
const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService;
const config = { get: () => cfg } as unknown as ConfigService;
const service = new EimsSellerCacheService(etrade, config);
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
companyInfo: {},
businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked
});
await service.refresh();
const seller = service.getSellerDetails(cfg);
// The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's
// differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)".
expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C.");
});
it("e-Trade fills a field only when the static value is blank", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({
sellerLegalName: "",
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.VatNumber).toBe("0000000000");
expect(seller.Email).toBe("finance@example.et");
});
it("falls back to the static config entirely when e-Trade has never been reachable", () => {
const cfg = eimsConfig();
const { service } = build(cfg);
// No refresh() ever called/succeeded — cached stays null.
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName);
expect(seller.Region).toBe(cfg.invoice.sellerRegion);
});
it("does no I/O at all — filing never triggers an e-Trade request", () => {
const { service, resolveCompanyData, cfg } = build();
service.getSellerDetails(cfg);
service.getSellerDetails(cfg);
expect(resolveCompanyData).not.toHaveBeenCalled();
});
});
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down"));
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
});
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves
const refreshing = service.refresh();
await jest.advanceTimersByTimeAsync(10_000);
await refreshing;
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
} finally {
jest.useRealTimers();
}
});
it("does not start a second e-Trade request while one is already in flight", async () => {
const { service, resolveCompanyData } = build();
let resolveCall: (value: unknown) => void = () => {};
resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve)));
const first = service.refresh();
const second = service.refresh();
resolveCall({ companyInfo: {}, businessInfo: {} });
await Promise.all([first, second]);
expect(resolveCompanyData).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
/**
* EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same
* e-Trade business-registry lookup already used for every customer company at onboarding — instead
* of the whole thing being hand-maintained `EIMS_SELLER_*` config.
*
* **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates
* `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested
* with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a
* value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the
* e-Trade-derived value when the static one is blank; a static value, once set, is never replaced.
* This also means the durable fallback is the static config, not this cache — the in-memory
* snapshot disappearing on a process restart is harmless, not a reliability gap: every field it
* could supply already has a working static value today, so filing is unaffected either way.
*
* `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response
* shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field
* exists anywhere in what e-Trade returns. They stay on static config permanently, same as
* `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch.
*
* Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/
* position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain
* field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a
* refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate
* deviations from that precedent, both because `ETradeService` has no request timeout configured
* at all (confirmed by reading it) and is a third-party dependency, unlike the DB:
* - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot;
* - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight
* returns the same in-flight promise instead of starting a duplicate request.
*
* `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration
* never depends on e-Trade being reachable at that moment, whether or not it ever has been.
*/
@Injectable()
export class EimsSellerCacheService implements OnModuleInit {
private readonly logger = new Logger(EimsSellerCacheService.name);
/** Only the e-Trade-derived fields, used solely to fill a blank static value. */
private cached: Partial<EimsSellerDetails> | null = null;
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
private refreshing: Promise<void> | null = null;
// ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a
// filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever
// matters; EDR's own business registration changes rarely enough that this is a generous
// ceiling, not a real one.
private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Bounded locally since `ETradeService` itself sets none — see the class comment. */
private static readonly REFRESH_TIMEOUT_MS = 10_000;
constructor(
private readonly etrade: ETradeService,
private readonly config: ConfigService,
) {}
onModuleInit(): void {
void this.refresh();
const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS);
timer.unref?.();
}
/**
* Static config wins whenever it's non-blank — that's the value already confirmed against MoR.
* e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on
* every registration.
*/
getSellerDetails(cfg: EimsConfig): EimsSellerDetails {
const fallback = buildEimsSeller(cfg);
const e = this.cached;
return {
...fallback,
LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName),
Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone),
Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region),
Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda),
City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City),
};
}
/** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */
async refresh(): Promise<void> {
if (this.refreshing) return this.refreshing;
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<void> {
try {
const cfg = this.config.get<EimsConfig>("eims")!;
const { companyInfo, businessInfo } = await this.withTimeout(
this.etrade.resolveCompanyData(cfg.tin),
EimsSellerCacheService.REFRESH_TIMEOUT_MS,
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
};
} catch (err) {
this.logger.warn(
`EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`,
);
}
}
/**
* `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only
* stops *waiting* on the request — nothing cancels the underlying HTTP call (no
* `AbortController` wired into `ETradeService`), so a timed-out request may still complete in
* the background; its result is simply never read.
*/
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
}

View File

@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
});
describe("EimsCredentialsProvider", () => {
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
const providerFor = (cfg: {
privateKeyPath?: string;
certificatePath?: string;
privateKeyBase64?: string;
certificateBase64?: string;
privateKeyPem?: string;
certificatePem?: string;
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
});
it("loads the key from inline base64, no file involved", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers inline base64 over the path when both are set", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
// A path that would fail if it were ever actually read.
const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from inline base64 as-is, no re-encoding", () => {
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
});
it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
// Simulates the real failure this guards against: a truncated/mangled env var still decodes
// as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
/does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
);
});
it("loads the key from the raw PEM env var directly, no encoding step", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers the raw PEM var over base64 and path when all three are set", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({
privateKeyPem: pem,
privateKeyBase64: Buffer.from("garbage").toString("base64"),
privateKeyPath: join(dir, "nope.key"),
}).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
});
});

View File

@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},
@@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
privateKeyBase64: "",
certificateBase64: "",
privateKeyPem: "",
certificatePem: "",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
autoSubmit: false,

View File

@@ -10,7 +10,9 @@ export type EimsFailureKind =
| "FORBIDDEN"
| "RULE_VALIDATION"
| "SERVER"
| "UNKNOWN";
| "UNKNOWN"
| "CONFIG"
| "LOCAL";
/** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException {

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { DocumentsModule } from "../billing/documents/documents.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
@@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity";
@@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
DocumentsModule,
// For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with
// ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule,
// so this stays a plain one-directional import, not a new cycle.
CompaniesModule,
],
controllers: [EimsInvoiceController],
providers: [
@@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
EimsSellerCacheService,
],
exports: [
EimsAuthService,

View File

@@ -2425,6 +2425,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
FREIGHT_PERMS.invoices.confirmOffline,
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
@@ -2506,6 +2508,12 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.suspend,
FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
// Marketing follows up with the customer when a reviewer sends profile
// changes back, so they sit on the customer desk: read-only on the customer
// record (no verify/deactivate — the decision stays with the chief) plus the
// desk key the change-request pings are addressed to.
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.customers.getNotification,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;

View File

@@ -324,7 +324,7 @@ const App = () => {
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
?tab=invoices|payments|manual-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
@@ -361,7 +361,7 @@ const App = () => {
/>
<Route
path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/>
<Route
path="invoices/:id"

View File

@@ -20,11 +20,10 @@ import {
FileX2,
} from "lucide-react";
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
@@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
@@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? humanize(c.code),
).then(view)
)
}
style={{
textDecoration:
@@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
component="button"
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
fileId,
`Document ${i + 1}`,
).then(view)
}
onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)}
>
Document {i + 1}
</Anchor>
@@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? "License document",
).then(view)
)
}
style={{
textDecoration:
@@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Group>
</Stack>
</Modal>
{viewer}
</>
);
}

View File

@@ -1,9 +1,8 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
@@ -36,6 +35,12 @@ interface TimelineEntry {
at: string;
note?: string | null;
summary?: string;
/** Who filed the change (the customer, or staff editing during onboarding). */
requestedBy?: string | null;
/** When they filed it — the "asked" half of the ask/decide pair below. */
requestedAt?: string | null;
/** Who decided (approved / rejected / sent it back to marketing). */
decidedBy?: string | null;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
@@ -43,10 +48,18 @@ interface TimelineEntry {
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
// Sending a request back is what "reverted to marketing" means here: the
// request stays open and marketing owns the follow-up with the customer.
changes_requested: { label: "Sent back to marketing", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */
function actorLine(verb: string, who?: string | null, when?: string | null) {
if (!who && !when) return null;
return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`;
}
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
@@ -132,6 +145,9 @@ function fromChangeRequest(
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
requestedBy: r.submittedByName,
requestedAt: r.submittedAt ?? r.createdAt,
decidedBy: r.reviewedByName,
fieldDiffs,
docDiffs,
};
@@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
requestedBy: rev.actorName,
fieldDiffs,
docDiffs,
};
@@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
@@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) {
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
openFileInNewTab(file.id, file.name);
if (entries.length === 0) {
return (
@@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) {
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
const requestedLine = actorLine(
entry.kind === "revision" ? "Edited" : "Requested",
entry.requestedBy,
entry.requestedAt,
);
const decidedLine = actorLine(
entry.kind === "changes_requested"
? "Sent back to marketing"
: entry.kind === "rejected"
? "Rejected"
: "Approved",
entry.decidedBy,
entry.kind === "revision" ? null : entry.at,
);
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Text>
</Group>
{/* Who asked, and who decided. Without this the feed said what
changed and when, but never named a person — the first thing
anyone auditing a returned request needs. */}
{(requestedLine || decidedLine) && (
<Stack gap={2}>
{requestedLine && (
<Text size="xs" c="dimmed">
{requestedLine}
</Text>
)}
{decidedLine && (
<Text size="xs" c="dimmed">
{decidedLine}
</Text>
)}
</Stack>
)}
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
<strong>
{entry.kind === "changes_requested"
? "What was asked for:"
: "Note:"}
</strong>{" "}
{entry.note}
</Text>
</Alert>
)}
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -3,34 +3,28 @@ import {
ActionIcon,
Box,
Card,
Group,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service";
import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* Operations "Clearance Documents" hub — the worklist for self-clearance
@@ -43,16 +37,14 @@ import {
const PAGE_SIZE = 10;
/**
* Status filter options (values = `statuses` param). FULLY_EXECUTED is the
* post-approval status of intercity (domestic) bookings kept in the list as
* history, otherwise an approved intercity row vanishes from the hub.
* The hub's baseline scope — FULLY_EXECUTED is the post-approval status of
* intercity (domestic) bookings, kept in as history so an approved intercity
* row doesn't just vanish. Sent whenever the Status pill has no narrower pick.
*/
const BOOKING_STATUS_OPTIONS = [
{
value:
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED",
label: "All statuses",
},
const DEFAULT_STATUSES =
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED";
const STATUS_OPTIONS = [
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
@@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [
{ value: "CUSTOMER", label: "Customer" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
const filterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS,
// No pick ⇒ no `statuses` param at all; the query fills in
// DEFAULT_STATUSES itself, same as the old Select's "All statuses" row.
toParams: ({ v }) => ({ statuses: v[0] }),
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{
key: "created",
label: "Created",
type: "date",
toParams: dateRangeParams("createdFrom", "createdTo"),
},
],
[filterOptions],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE });
const page = pagination.pageIndex + 1;
const filter: BookingListFilter = useMemo(
() => ({
...(controls.params as unknown as BookingListFilter),
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the status scope
// above are what isolate exactly this worklist.
customsClearingEnabled: "false",
statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES,
}),
[controls.params],
);
const bookingsQuery = useQuery({
queryKey: [
"clearance-documents",
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
customerKindFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
// clearance statuses are what isolate exactly this worklist.
bookingsService.list({
statuses: bookingStatuses,
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(customerKindFilter
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
queryKey: ["clearance-documents", "bookings", filter],
queryFn: () => bookingsService.list(filter),
placeholderData: keepPreviousData,
});
@@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() {
);
const total = bookingsQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
@@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract, customer or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Booked by"
data={CUSTOMER_KIND_OPTIONS}
value={customerKindFilter}
onChange={(v) => {
setCustomerKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by booked by"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
</Group>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents"
/>
</Box>
{showEmpty ? (
@@ -420,18 +289,7 @@ export default function ClearanceDocumentsPage() {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>

View File

@@ -52,6 +52,47 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
): string {
if (!yard) return "—";
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
);
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—",
destinationLabel: b.destinationYard?.name ?? "—",
originLabel: yardLabel(b.originYard),
destinationLabel: yardLabel(b.destinationYard),
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.original.originLabel}</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{row.original.destinationLabel}</Text>
</Group>
<RouteLabel
origin={row.original.originLabel}
destination={row.original.destinationLabel}
/>
),
},
{
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
}
return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
/>
</Box>
);

View File

@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import "./contract-clearance-table.css";
const prettyStatus = (s?: string | null) =>
(s ?? "")
@@ -214,15 +215,24 @@ function RouteCell({
}) {
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500}>
{destination}
</Text>
</Group>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
normally (cells are otherwise nowrap) so it never spills over. */}
<Text
size="sm"
fw={500}
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center">
<DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm">
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null}
</Stack>
) : (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter}
/>
</Box>

View File

@@ -0,0 +1,94 @@
/*
* Scoped to .edr-clearance-table — the DataTable container div on the
* Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-clearance-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-clearance-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-clearance-table th,
.edr-clearance-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-clearance-table .mantine-Badge-root {
max-width: none;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-clearance-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-clearance-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-clearance-table th:last-child,
.edr-clearance-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -23,6 +23,7 @@ import {
Banknote,
Contact,
Download,
ExternalLink,
Eye,
FileSignature,
FileText,
@@ -69,7 +70,7 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
downloadBookingFile,
fetchViewableFile,
openFileInNewTab,
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
@@ -81,12 +82,7 @@ import type {
} from "@/types/customer";
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
useFileViewer,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
@@ -146,7 +142,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: company, isLoading } = useQuery(
@@ -271,9 +266,7 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
>
<Eye size={14} />
</ActionIcon>
@@ -282,9 +275,7 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
style={{
maxWidth: 170,
textAlign: "left",
@@ -339,7 +330,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canReview],
[canReview],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -522,9 +513,7 @@ export default function CustomerDetailPage() {
aria-label="View"
data-stop-row-click
onClick={() =>
void fetchViewableFile(row.original.id, row.original.name).then(
view,
)
openFileInNewTab(row.original.id, row.original.name)
}
>
<Eye size={16} />
@@ -564,7 +553,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canRequestDocChange],
[canRequestDocChange],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -1163,9 +1152,7 @@ export default function CustomerDetailPage() {
lineClamp={1}
style={{ flex: 1, textAlign: "left" }}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
openFileInNewTab(doc.id, doc.name)
}
>
{doc.name}
@@ -1176,9 +1163,7 @@ export default function CustomerDetailPage() {
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
openFileInNewTab(doc.id, doc.name)
}
>
<Eye size={15} />
@@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">
{/* Reviewing a customer means reading every document, so offer the
whole set at once — each opens in its own tab. The loop is
synchronous inside the click handler on purpose: that is what
keeps the browser treating all of them as user-initiated. */}
<Group justify="flex-end">
<Button
variant="light"
leftSection={<ExternalLink size={16} />}
disabled={documents.length === 0}
onClick={() =>
documents.forEach((d) => openFileInNewTab(d.id, d.name))
}
>
Open all {documents.length > 0 && `(${documents.length})`}
</Button>
</Group>
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
@@ -1316,9 +1318,7 @@ export default function CustomerDetailPage() {
<Anchor
component="button"
type="button"
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
size="xs"
style={{
textDecoration:
@@ -1424,7 +1424,6 @@ export default function CustomerDetailPage() {
onClose={() => setChangeRequestDoc(null)}
/>
{viewer}
</PageContainer>
);
}

View File

@@ -1,7 +1,5 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -13,7 +11,7 @@ import {
FREIGHT_PERMS,
} from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
@@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonStatusActions from "@/components/wagons/WagonStatusActions";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
import {
@@ -43,11 +40,46 @@ import {
type FleetListFilters,
type FleetRecord,
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { useDebouncedValue } from "@mantine/hooks";
import { DataTable, DataTableFooter } from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
// trains/containers/cargoes have no server-side `listFilters` config (see
// resources.ts) — they get a plain client-only Status filter instead, off a
// fixed enum rather than "whatever status happens to exist in the currently
// loaded rows" (which would create a circular dependency: filterDefs feeds
// useFilters, which feeds the query that produces those rows).
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
{ value: "AVAILABLE", label: "Available" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
{ value: "DEACTIVATED", label: "Deactivated" },
];
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
{ value: "AVAILABLE", label: "Available" },
{ value: "LOADED", label: "Loaded" },
{ value: "IN_TRANSIT", label: "In transit" },
{ value: "MAINTENANCE", label: "Maintenance" },
{ value: "DAMAGED", label: "Damaged" },
];
const CARGO_STATUS_OPTIONS: FilterOption[] = [
{ value: "PENDING", label: "Pending" },
{ value: "LOADED", label: "Loaded" },
{ value: "IN_TRANSIT", label: "In transit" },
{ value: "DELIVERED", label: "Delivered" },
{ value: "UNLOADED", label: "Unloaded" },
];
const FALLBACK_STATUS_OPTIONS: Partial<Record<FleetResourceSlug, FilterOption[]>> = {
trains: TRAIN_STATUS_OPTIONS,
containers: CONTAINER_STATUS_OPTIONS,
cargoes: CARGO_STATUS_OPTIONS,
};
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -70,18 +102,9 @@ const FleetResourcePage = () => {
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
// Wagons and locomotives page in the database; the rest still list in full
// and page in the browser (see `pagedHandlers` in fleet.service).
const serverPaged = isFleetServerPaginated(slug);
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
@@ -95,77 +118,6 @@ const FleetResourcePage = () => {
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
if (!serverFilteredSlugs.includes(slug)) return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const currentYardId = listFilterValues.currentYardId;
const availability = listFilterValues.availability;
const trainNumber = listFilterValues.trainNumber;
const trainId = listFilterValues.trainId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (availability && availability !== "ALL") {
(filters as { availability?: string }).availability = availability;
}
if (trainNumber && trainNumber !== "ALL") {
(filters as { trainNumber?: string }).trainNumber = trainNumber;
}
if (trainId && trainId !== "ALL") {
filters.trainId = trainId;
}
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
const wagonTypeId = listFilterValues.wagonTypeId;
if (wagonTypeId && wagonTypeId !== "ALL") {
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
}
// The plain locomotives list has no server-side search — its page window
// does, so the term is only sent on the paginated path.
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
filters.search = debouncedSearch.trim();
}
return filters;
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
const pagedFilters = useMemo(
(): FleetListFilters => ({
...serverListFilters,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(dateFrom ? { createdFrom: dateFrom } : {}),
...(dateTo ? { createdTo: dateTo } : {}),
}),
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const purge = useMutation(api.fleet.purge.mutationOptions());
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
@@ -202,40 +154,8 @@ const FleetResourcePage = () => {
enabled: slug === "wagons",
});
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
setListFilterValues({});
}, [slug, setPagination]);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn || usesServerListFilters) return [];
if (slug === "vehicles" || slug === "drivers") {
return [
{ value: "ALL", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
];
}
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn, usesServerListFilters, slug]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -291,25 +211,78 @@ const FleetResourcePage = () => {
};
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
// One pill per configured server list filter (status/yard/wagon type/train…),
// built off `config.listFilters` — same source the old plain `<Select>` row
// read, just reshaped into FilterDefs. Falls back to a plain client-only
// Status filter for the 3 slugs with no server-side list filters at all.
const filterDefs: FilterDef[] = useMemo(() => {
const dateDef: FilterDef = {
key: "created",
label: "Registered",
type: "date",
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
key: filter.key,
label: filter.label,
type: "enum",
multiple: false,
options: filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
// `controls.params` already carries every filter's mapped param name (status/
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
// object; the unpaged one is the same minus pagination and the date range
// (which stays client-only for the non-server-paged slugs, see below).
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
return rest as FleetListFilters;
}, [slug, controls.params]);
const pagedFilters = useMemo(
(): FleetListFilters => controls.params as unknown as FleetListFilters,
[controls.params],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const purge = useMutation(api.fleet.purge.mutationOptions());
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
const created = controls.values.created;
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
return false;
}
// Every other filter (status/yard/wagon type/…) was already applied
// server-side for these slugs — re-checking here against a plain field
// equality would be wrong for one (a wagon's "trainNumber" filter
// matches either of two DIFFERENT columns server-side, not one).
if (usesServerListFilters) return true;
const status = controls.values.status;
if (status && String(record.status ?? "") !== status.v[0]) return false;
const term = controls.searchText.trim().toLowerCase();
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
: Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const start = (controls.page - 1) * controls.pageSize;
return filteredRows.slice(start, start + controls.pageSize);
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
// (not a DataTable) just needs the raw pieces out of it below.
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: value ?? "ALL",
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
size="sm"
radius="lg"
w={200}
searchable={filter.data.length > 8}
comboboxProps={{ withinPortal: true }}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Group gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">Status:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setStatusFilter(option.value)}
>
{option.label}
</Button>
))}
</Group>
</Group>
) : null}
</Group>
}
/>
viewId={`fleet-${slug}`}
>
<SegmentedControl
value={viewMode}
onChange={(value) => setViewMode(value as FleetViewMode)}
size="sm"
radius="lg"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={14} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={14} />
<span>Cards</span>
</Group>
),
},
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
</FilterBar>
</Box>
{viewMode === "table" ? (
@@ -696,18 +643,8 @@ const FleetResourcePage = () => {
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
pagination={dtPagination}
tableOptions={dtTableOptions}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
pageCount={pageCount}
totalCount={totalCount}
onPaginationChange={setPagination}
onPaginationChange={dtTableOptions!.onPaginationChange!}
onEdit={
canUpdate
? (record) => {

View File

@@ -29,13 +29,13 @@ const TABS = [
Panel: InvoicesPanel,
},
{
key: "usd-payments",
label: "USD Payments",
key: "manual-payments",
label: "Manual Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;

View File

@@ -5,14 +5,20 @@ import {
Card,
Group,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react";
import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -22,6 +28,7 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
@@ -83,7 +90,7 @@ export default function InvoicesPanel() {
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
const { data: summary } = useQuery(
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
@@ -190,39 +197,30 @@ export default function InvoicesPanel() {
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Total collected
</Text>
<Text size="xl" fw={700} c="edr-text">
{etbFromUsd !== null
? formatMoney(etbCollected + etbFromUsd, "ETB")
: formatMoney(etbCollected, "ETB")}
</Text>
<Text size="xs" c="dimmed">
{etbFromUsd !== null
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD`
: "USD rate unavailable — ETB collected only"}
</Text>
</Card>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Collected ETB only
</Text>
<Text size="xl" fw={700} c="edr-text">
{formatMoney(etbCollected, "ETB")}
</Text>
</Card>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Collected USD only
</Text>
<Text size="xl" fw={700} c="edr-text">
{formatMoney(usdCollected, "USD")}
</Text>
</Card>
</SimpleGrid>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Collected in ETB",
value: formatMoney(etbCollected, "ETB"),
icon: Banknote,
color: "blue",
},
{
label: "Collected in USD",
value: formatMoney(usdCollected, "USD"),
icon: Landmark,
color: "violet",
},
]}
/>
<Card p={0}>
<Stack gap={0}>

View File

@@ -11,6 +11,7 @@ import {
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
/** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) {
return (
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
*/
function ConfirmCell({
row,
onConfirm,
}: {
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const now = useNow(deadline);
if (row.booking && !deadline) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
}
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
/**
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
* parent. Lists open USD and ETB invoices (import and export alike) that
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency: currency || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"}
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text>
),
},
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) {
return (
<Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
<Group gap={6} wrap="nowrap">
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
);
},
},
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{
id: "status",
header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
if (row.original.status === "PAID" || !canConfirm) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
},
},
],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl
size="sm"
radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<Box miw={1160}>
<DataTable
columns={columns}
data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
? "No invoices match your search."
: "No invoices awaiting manual payment confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
<Text fw={700}>Confirm manual payment</Text>
}
radius="md"
size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
marks the booking as paid exactly as if the customer had paid
online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
label="Payment slip / receipt"
description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
label="Payment reference"
description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}

View File

@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
containers: [
{
containerNumber,
containerSize: containerSize ?? undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setContainerSize(null);
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Select
label="Container Type"
placeholder="Select container size"
value={containerSize}
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
data={[
{ value: "20", label: "20 ft" },
{ value: "40", label: "40 ft" },
]}
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"

View File

@@ -25,6 +25,53 @@ export async function downloadBookingFile(
URL.revokeObjectURL(url);
}
/**
* Open a stored file in its own browser tab.
*
* Two things make this less trivial than an `<a target="_blank">`:
* - `GET /files/:id` is authenticated, so the bytes have to come through the
* axios client and be handed over as a blob URL (same reason as
* {@link fetchViewableFile}).
* - The tab therefore has to be opened *synchronously*, inside the click
* gesture, and filled once the download resolves — a `window.open()` after an
* `await` is blocked as a popup. That also means a loop over several
* documents opens one tab each, all within the same gesture.
*
* `noopener` is deliberately not passed: it makes `window.open` return null, and
* the handle is what lets us navigate the tab. `opener` is nulled instead.
*/
export function openFileInNewTab(id: string, filename: string): void {
const tab = window.open("", "_blank");
if (tab) {
tab.opener = null;
tab.document.title = filename;
if (tab.document.body) {
tab.document.body.textContent = `Opening ${filename}`;
}
}
void filesService.download(id).then(
(blob) => {
const url = URL.createObjectURL(blob);
if (tab) tab.location.replace(url);
// Popup blocked — fall back to a save, so the click still does something.
else {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
}
// Revoking immediately would cancel the tab's own load of the URL.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
(error: unknown) => {
if (tab?.document.body) {
tab.document.body.textContent = `Could not open ${filename}.`;
}
console.error(`Failed to open file ${id}`, error);
},
);
}
/**
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes

View File

@@ -59,7 +59,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd(
filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> {
@@ -70,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
/** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData();
body.append("file", file);

View File

@@ -111,7 +111,11 @@ export interface CompanyChangeRequest {
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
/** Who filed the request — resolved from `iam.users`, null when unknown. */
submittedByName: string | null;
submittedAt: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -139,6 +143,8 @@ export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit — resolved from `iam.users`, null when unknown. */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */
@@ -22,17 +24,21 @@ export interface PaginatedInvoices {
}
/**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
* carry the shipment's pay-window deadline so the list can show the same
* countdown the customer sees — Finance must confirm before it closes.
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* rows carry the shipment's trade direction and pay-window deadline so the list
* can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/
export interface OfflineUsdInvoice extends Invoice {
booking: {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: string | null;
paymentStatus: string;
} | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
}
export interface PaginatedOfflineUsdInvoices {

View File

@@ -11,7 +11,7 @@ import {
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { CheckCircle2, Clock, CreditCard } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
@@ -235,7 +235,7 @@ export function WagonCancellationCard({
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{canRequest && !openRow && !creditRow && (
{/* {canRequest && !openRow && !creditRow && (
<Button
variant="default"
radius="md"
@@ -244,7 +244,7 @@ export function WagonCancellationCard({
>
Cancel wagons
</Button>
)}
)} */}
</Group>
{openRow ? (

View File

@@ -26,7 +26,7 @@ import {
LayoutList,
MoreVertical,
Package,
// Plus,
Search,
Train,
Wallet,

View File

@@ -218,17 +218,14 @@ export class PaymentsController {
}
@Post(":bookingId/force-confirm")
@PassengerStaff([
PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@PassengerStaff([PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Force-confirm payment & generate ticket (back-office only)",
summary: "Force-confirm payment & generate ticket (ticket-generate permission)",
description:
"Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " +
"Use when a vendor payment completed but the webhook was never delivered. Idempotent.",
"Use when a vendor payment completed but the webhook was never delivered. Idempotent. " +
"Requires `edr_passenger_app:tickets:generate` (admins bypass).",
})
forceConfirm(
@Param("bookingId") bookingId: string,

View File

@@ -31,6 +31,9 @@ const SectionHeader = ({ title }: { title: string }) => (
function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage);
// Mirrors the API guard on POST /payments/:bookingId/force-confirm —
// tickets:generate, with the usual super-admin / org-admin bypass.
const canGenerateTicket = usePermission(PERMS.tickets.generate);
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
@@ -298,7 +301,7 @@ function BookingsPageContent() {
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];

View File

@@ -10,8 +10,10 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function ClassesPage() {
function ClassesPageContent() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
@@ -352,3 +354,11 @@ export default function ClassesPage() {
</div>
);
}
export default function ClassesPage() {
return (
<PermissionGuard permission={PERMS.classes.view}>
<ClassesPageContent />
</PermissionGuard>
);
}

View File

@@ -10,6 +10,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { fleetApi, apiClient } from '@/lib/api';
import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
type Tab = 'types' | 'coaches' | 'utilization';
@@ -142,7 +144,7 @@ const renderBedVisualization = (coach: any) => {
);
};
export default function CoachesPage() {
function CoachesPageContent() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
@@ -929,3 +931,11 @@ export default function CoachesPage() {
</div>
);
}
export default function CoachesPage() {
return (
<PermissionGuard permission={PERMS.coaches.view}>
<CoachesPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes';
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface RouteStop {
stationId: string;
@@ -166,7 +168,7 @@ function RouteCoachesTab({ routes }: { routes: any[] }) {
);
}
export default function RoutesPage() {
function RoutesPageContent() {
const [activeTab, setActiveTab] = useState<Tab>('routes');
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
@@ -922,3 +924,11 @@ export default function RoutesPage() {
</div>
);
}
export default function RoutesPage() {
return (
<PermissionGuard permission={PERMS.routes.view}>
<RoutesPageContent />
</PermissionGuard>
);
}

View File

@@ -14,6 +14,8 @@ import { usePagination } from '@/lib/use-pagination';
import { formatDateTime } from '@/lib/utils';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import DateTimePicker from '@/components/ui/DateTimePicker';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface Schedule {
id: string;
@@ -52,7 +54,7 @@ interface Coach {
coachType?: { name: string };
}
export default function SchedulesPage() {
function SchedulesPageContent() {
const [showModal, setShowModal] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@@ -1248,3 +1250,11 @@ export default function SchedulesPage() {
</div>
);
}
export default function SchedulesPage() {
return (
<PermissionGuard permission={PERMS.schedules.view}>
<SchedulesPageContent />
</PermissionGuard>
);
}

View File

@@ -6,6 +6,7 @@ import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi }
import { routesApi } from '@/lib/api/routes';
import { usePermissionStrict } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react';
@@ -15,7 +16,7 @@ import {
SeatBlockReasonCategory,
} from '@edr/types';
export default function SeatsPage() {
function SeatsPageContent() {
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [selectedRoute, setSelectedRoute] = useState('');
@@ -1491,3 +1492,11 @@ function SeatIcon({
</div>
);
}
export default function SeatsPage() {
return (
<PermissionGuard permission={PERMS.seats.view}>
<SeatsPageContent />
</PermissionGuard>
);
}

View File

@@ -11,8 +11,10 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { stationsApi } from '@/lib/api';
import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function StationsPage() {
function StationsPageContent() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
@@ -379,3 +381,11 @@ export default function StationsPage() {
</div>
);
}
export default function StationsPage() {
return (
<PermissionGuard permission={PERMS.stations.view}>
<StationsPageContent />
</PermissionGuard>
);
}

View File

@@ -13,8 +13,10 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { Train as TrainType } from '@/types';
import { formatDate } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function TrainsPage() {
function TrainsPageContent() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
@@ -366,3 +368,11 @@ export default function TrainsPage() {
</div>
);
}
export default function TrainsPage() {
return (
<PermissionGuard permission={PERMS.trains.view}>
<TrainsPageContent />
</PermissionGuard>
);
}

View File

@@ -81,13 +81,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Master Data',
items: [
{ name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Classes', href: '/classes', icon: Settings },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view },
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view },
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view },
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
]
},
{

96
docs/MAP.md Normal file
View File

@@ -0,0 +1,96 @@
# Repo map — start here
Routing table for "where does X live". Read this before a repo-wide grep. Paths are from
the repo root. The rules and traps are in [`../CLAUDE.md`](../CLAUDE.md); this file only
answers *where*.
## Pick your stack first
| If you are working on… | Code lives in | Stack |
| --------------------------------- | --------------------------------- | -------------------- |
| Freight API / business logic | `apps/edr-freight-api/src` | NestJS + TypeORM |
| Freight customer UI | `apps/edr-freight-web/portal` | React + Vite + Mantine v9 |
| Freight staff UI | `apps/edr-freight-web/backoffice` | React + Vite + Mantine v9 |
| Passenger API | `apps/edr-passenger-api` | NestJS + **Prisma** |
| Passenger UI | `apps/edr-passenger-web/*` | **Next.js** |
| Payments (intents, webhooks) | `apps/edr-payment-api` | NestJS + TypeORM |
| Gateway integrations | `packages/payment-providers` | — |
| A shared type or enum | `packages/types/src` | rebuild after editing |
| A shared React component | `packages/ui-common/src` | — |
| A Nest decorator/filter/base class| `packages/api-common/src` | — |
## Freight API entry points
| File | What it is |
| --- | --- |
| `src/main.ts` | Boot, port (`PORT`, falls back to 3001), global pipes |
| `src/app.module.ts` | Every module is registered here — the index of the API |
| `src/config/database.config.ts` | Connection, pooler `search_path` handling, `iamEntities` list |
| `src/data-source.ts` | Standalone DataSource used by migrations only (no `autoLoadEntities`) |
| `src/migrations/` | TypeORM migrations (39 files; check for timestamp clashes) |
| `src/seed/freight-permissions.registry.ts` | Every freight permission; declare before use |
| `src/scripts/` | One-off and `seed:*` scripts — several write real rows |
## Freight modules by domain
All under `apps/edr-freight-api/src/modules/`.
| Domain | Modules |
| --- | --- |
| **Booking & commercial** | `bookings` `contracts` `contract-templates` `consignments` `cargoes` `companies` `user-trade-access` `transit-agents` `shipping-lines` |
| **Warehouse & yard** | `warehouses` `facilities` `container-management` |
| **Rail operations** | `trains` `train-schedules` `train-scheduling` `train-sets` `wagons` `wagon-types` `locomotives` `routes` `scheduling` `scheduling-reschedule` `interchange-documents` |
| **Road / first & last mile** | `first-mile` `last-mile` `last-mile-requests` `drivers` `vehicles` `truck-types` `fleet-history` `fuel` `maintenance` `gps-tracking` `tracking` |
| **Money** | `billing` `payment` `exchange-settings` |
| **Identity & access** | `auth` `otp` `verifayda` `audit` |
| **Documents & files** | `files` `file-upload-settings` `signatures` `stamp-settings` `logo-settings` `minio` |
| **Comms** | `notifications` `notification-inbox` `support-chat` `support-content` |
| **Ops & admin** | `backoffice` `overview` `reports` `dropdown-settings` `rule-engine` `compliance` `procurement` `incidents` `import-operations` `eims` `ai` `health` |
Each module follows `module → controller → service → repository`, with `entities/` and
`dto/` alongside.
## Freight web
Pages live in `src/pages/`, roughly mirroring the API domains.
- **portal** (customer): `bookings` `contracts` `consignments` `billing` `payments`
`tracking` `accounts` `customers` `shipping-line` `support` `settings`, plus
`MyPortalPage/`, `MySignaturePage.tsx`, `EDRFreightLandingPage.tsx`.
- **backoffice** (staff): `bookings` `contracts` `contract_templates` `consignments`
`customers` `billing` `invoices` `documents` `fleet` `dashboard` `admin` `configuration`
`auth` `ai`, plus many single-file pages (`AuditLogsPage.tsx`, `ActivityLogPage.tsx`,
`BulkUploadPage.tsx`, `ContentManagementPage.tsx`, …).
Shared components and theme come from `@edr/ui-common` — check there before writing one.
## Tests
| Suite | Location | Run with |
| --- | --- | --- |
| Unit / spec | beside the code, `*.spec.ts` | `pnpm --filter @edr/freight-api test` |
| Freight e2e (Cypress, containerized) | `e2e/freight` | `pnpm e2e:freight:ci` |
| Passenger e2e | `e2e/` + `e2e/run.sh` | `pnpm test:e2e:passenger` |
| UI e2e (Playwright) | `e2e-ui/` | `pnpm test:e2e:ui` |
| Integration | `integration/` | `pnpm it:up`, `pnpm it:test` |
The freight e2e stack has no host Xvfb — use `ci` (containerized), not `run`/`open`.
Its compose project name is fixed (`edr-freight-e2e`), so only one can run on this
machine at a time.
## Documents
| Doc | Covers | Trust |
| --- | --- | --- |
| `../CLAUDE.md` | The contract: rules, traps, definition of done | Current — fix in the same PR if wrong |
| `docs/TESTING.md` | Test strategy | — |
| `docs/e2e-test-matrix.md`, `docs/ui-e2e-test-matrix.md` | Coverage matrices | — |
| `docs/ISSUES.md`, `docs/SOLUTIONS.md` | Running log of problems and fixes | Historical |
| `docs/uploads.md` | File upload handling | — |
| `docs/qa/edr-freight-qa-test-plan.md` | QA test plan | — |
| `../DEPLOYMENT.md` | Deploy process | — |
| `../ITMLS_DB_Design.md`, `../orgstructure.md` | Design notes | Historical |
| `../E2E_TEST_REPORT.md`, `../checkpoint.md` | Point-in-time snapshots | **Stale by design — dated artifacts, not references** |
Root-level `*.sql` and `*.dump` files are ad-hoc data snapshots, not part of the schema.
Migrations are the only source of truth for schema.