mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight/nati-2' into freight/feat/element-chat
This commit is contained in:
376
CLAUDE.md
376
CLAUDE.md
@@ -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 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 |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `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.
|
||||
|
||||
313
CLAUDE_NEW.md
313
CLAUDE_NEW.md
@@ -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.
|
||||
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal 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;
|
||||
}
|
||||
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(", ")}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ──────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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,10 +123,17 @@ 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.
|
||||
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,
|
||||
buildEimsSeller(cfg),
|
||||
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.
|
||||
@@ -141,11 +146,6 @@ export class EimsInvoiceRegistrationService {
|
||||
relatedDocument,
|
||||
}),
|
||||
);
|
||||
|
||||
let irn: string;
|
||||
let ackDate: string | undefined;
|
||||
let signedQR: string | undefined;
|
||||
try {
|
||||
// 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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="clearance-documents"
|
||||
/>
|
||||
<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>
|
||||
</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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
// 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,
|
||||
});
|
||||
}, [config?.listFilters, listFilterValues, dynamicOptions]);
|
||||
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)}
|
||||
viewId={`fleet-${slug}`}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<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>
|
||||
) : null}
|
||||
</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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,6 +268,7 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
@@ -198,9 +280,29 @@ export default function UsdPaymentsPanel() {
|
||||
>
|
||||
{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's bank slip
|
||||
first — this cannot be undone.
|
||||
marks the booking as paid — exactly as if the customer had paid
|
||||
online. Upload the customer'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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
// Plus,
|
||||
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
96
docs/MAP.md
Normal 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.
|
||||
Reference in New Issue
Block a user