mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
388
CLAUDE.md
388
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 build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||
| `pnpm dev:freight:api` | Freight API only |
|
||||
| `pnpm dev:freight:portal` | Freight portal only |
|
||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||
| `pnpm dev:passenger` | Passenger API + web |
|
||||
| `pnpm dev:payment` | Payment API |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests (turbo) |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| `pnpm lint` | **Does not work** — see below |
|
||||
|
||||
## Standards
|
||||
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
|
||||
`turbo run lint` dies with `eslint: not found` even though every package declares a
|
||||
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
|
||||
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
|
||||
"lint clean".
|
||||
|
||||
- **TypeScript strict mode** is enabled in every package and app.
|
||||
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
|
||||
- **Conventional commits** are enforced via commitlint on every commit.
|
||||
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
|
||||
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
|
||||
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
|
||||
`--config` explicitly there.
|
||||
|
||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||
```
|
||||
|
||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||
|
||||
## Environment & database
|
||||
|
||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||
no port `5433`/`5434` is published anywhere in the repo.
|
||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||
remote database.
|
||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||
per-connection in a pool `connect` handler instead. See
|
||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||
through API calls or message queues.
|
||||
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
|
||||
freight tables in `freight`.
|
||||
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
|
||||
skill (below) or write a short Node script using `pg` and run it from
|
||||
`apps/edr-freight-api`, where `pg` resolves.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are non-negotiable. Everything else is a strong default.
|
||||
|
||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||
reach for `any` to make an error go away.
|
||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||
All schema changes go through migrations.
|
||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
|
||||
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
|
||||
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
|
||||
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
|
||||
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
|
||||
- **Controllers** never contain business logic.
|
||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||
`deletedAt` (soft delete).
|
||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||
TypeScript properties are `camelCase`.
|
||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||
offer the safe version.
|
||||
|
||||
## Auth
|
||||
## Architecture
|
||||
|
||||
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
|
||||
### NestJS module shape
|
||||
|
||||
When auth integration is needed, use placeholder TODO comments:
|
||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
|
||||
|
||||
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
|
||||
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
|
||||
### Data access — the real model
|
||||
|
||||
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
|
||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||
|
||||
## Port Assignments
|
||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
|
||||
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
|
||||
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
|
||||
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
|
||||
|
||||
- `edr-freight-api`: 3001
|
||||
- `edr-freight-web/portal`: 5173
|
||||
- `edr-freight-web/backoffice`: 5183
|
||||
- `edr-passenger-api`: 3002
|
||||
- `edr-payment-api`: 3003
|
||||
- `edr-passenger-web/portal`: 5174
|
||||
- `edr-passenger-web/backoffice`: 5184
|
||||
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||
It carries one obligation:
|
||||
|
||||
## Database Layout
|
||||
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||
|
||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||
so they join the caller's transaction.
|
||||
|
||||
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||
no timeout and will wait forever.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
|
||||
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
|
||||
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
|
||||
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
|
||||
and breaks ties non-deterministically. Check before adding one:
|
||||
|
||||
```bash
|
||||
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
|
||||
```
|
||||
|
||||
The prefix must be unused *and* higher than the newest recorded row. Note the
|
||||
`freight.migrations` table has far more rows (~309) than this folder has files — most
|
||||
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
|
||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||
backfills guarded by `WHERE col IS NULL`.
|
||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||
re-introduce the outage it fixed.
|
||||
|
||||
### Auth
|
||||
|
||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||
|
||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||
Add a permission there before referencing it.
|
||||
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
|
||||
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
|
||||
`backoffice` for employees, `portal` for customers. Without it the API 403s with
|
||||
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
|
||||
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||
|
||||
Ownership checks are separate from permission checks. A staff user passes
|
||||
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
|
||||
check the installed version before copying a snippet.
|
||||
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||
freight web apps. Prefer it over re-implementing a component.
|
||||
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||
keep the synchronous version — their bodies are already parsed JSON.
|
||||
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||
|
||||
## Notifications
|
||||
|
||||
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||
SMS and email still send, because they address the company's phone and email directly. Check
|
||||
this before debugging a "missing notification".
|
||||
|
||||
## PDF generation
|
||||
|
||||
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||
assume a headless browser exists.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
|
||||
`<feature>.{module,controller,service,repository}.ts` files.
|
||||
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||
4. The service injects the repository class (not `Repository<T>` directly).
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||
6. Register the module in the app's `app.module.ts`.
|
||||
|
||||
## Adding a new shared component to `@edr/ui-common`
|
||||
|
||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||
2. Export from `src/index.ts`.
|
||||
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
|
||||
3. Component is a functional component with a `ComponentNameProps` interface
|
||||
(named-exported alongside the default).
|
||||
|
||||
## Definition of done
|
||||
|
||||
A change is done when **all** of these hold. State explicitly which you ran.
|
||||
|
||||
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
|
||||
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
|
||||
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
|
||||
dev database without error.
|
||||
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
|
||||
something the new code reads — applied to the dev database, since watch mode will not run it.
|
||||
4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
|
||||
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
|
||||
then run the specs covering what you touched and confirm you introduced no new failure.
|
||||
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
|
||||
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
|
||||
type-check is your only unused-code signal.
|
||||
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||
endpoint, or ran the query. If you could not, say so plainly.
|
||||
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||
the summary. Never describe unverified work as done.
|
||||
|
||||
### Hooks do not run
|
||||
|
||||
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||
hand; do not assume the hook caught it.
|
||||
|
||||
## Known traps
|
||||
|
||||
| Trap | What happens | What to do |
|
||||
| --- | --- | --- |
|
||||
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
|
||||
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
|
||||
|
||||
## Project skills
|
||||
|
||||
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||
|
||||
| Skill | Use for |
|
||||
| --- | --- |
|
||||
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||
|
||||
## Working style
|
||||
|
||||
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||
from a filename.
|
||||
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||
trade-off before changing files.
|
||||
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||
- **Branch from `dev`; PRs target `dev`.**
|
||||
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -2497,6 +2497,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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Select
|
||||
data={BOOKING_STATUS_OPTIONS}
|
||||
value={bookingStatuses}
|
||||
onChange={(v) => {
|
||||
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
radius="lg"
|
||||
w={220}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 130 }}
|
||||
aria-label="Filter by direction"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight type"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Booked by"
|
||||
data={CUSTOMER_KIND_OPTIONS}
|
||||
value={customerKindFilter}
|
||||
onChange={(v) => {
|
||||
setCustomerKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by booked by"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by ownership"
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Created date range"
|
||||
value={[createdFrom, createdTo]}
|
||||
onChange={([from, to]) => {
|
||||
setCreatedFrom(from ? new Date(from) : null);
|
||||
setCreatedTo(to ? new Date(to) : null);
|
||||
resetPage();
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
aria-label="Created date range"
|
||||
/>
|
||||
</Group>
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="clearance-documents"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
@@ -420,18 +289,7 @@ export default function ClearanceDocumentsPage() {
|
||||
state: { from: "/dashboard/contracts/clearance-documents" },
|
||||
})
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
});
|
||||
}, [config?.listFilters, listFilterValues, dynamicOptions]);
|
||||
// One pill per configured server list filter (status/yard/wagon type/train…),
|
||||
// built off `config.listFilters` — same source the old plain `<Select>` row
|
||||
// read, just reshaped into FilterDefs. Falls back to a plain client-only
|
||||
// Status filter for the 3 slugs with no server-side list filters at all.
|
||||
const filterDefs: FilterDef[] = useMemo(() => {
|
||||
const dateDef: FilterDef = {
|
||||
key: "created",
|
||||
label: "Registered",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
};
|
||||
if (config?.listFilters?.length) {
|
||||
return [
|
||||
...config.listFilters.map((filter): FilterDef => ({
|
||||
key: filter.key,
|
||||
label: filter.label,
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: filter.dynamicOptions
|
||||
? (dynamicOptions[filter.dynamicOptions] ?? [])
|
||||
: (filter.options ?? []),
|
||||
})),
|
||||
dateDef,
|
||||
];
|
||||
}
|
||||
const fallback = FALLBACK_STATUS_OPTIONS[slug];
|
||||
return fallback
|
||||
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
|
||||
: [dateDef];
|
||||
}, [config, dynamicOptions, slug]);
|
||||
|
||||
const controls = useFilters(filterDefs, { pageSize: 10 });
|
||||
|
||||
// On the server-paged path the page window, the search and the registration
|
||||
// date range are all resolved by the API — nothing is filtered client-side.
|
||||
// `controls.params` already carries every filter's mapped param name (status/
|
||||
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
|
||||
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
|
||||
// object; the unpaged one is the same minus pagination and the date range
|
||||
// (which stays client-only for the non-server-paged slugs, see below).
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
|
||||
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
|
||||
return rest as FleetListFilters;
|
||||
}, [slug, controls.params]);
|
||||
|
||||
const pagedFilters = useMemo(
|
||||
(): FleetListFilters => controls.params as unknown as FleetListFilters,
|
||||
[controls.params],
|
||||
);
|
||||
|
||||
const listQuery = useQuery({
|
||||
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
|
||||
enabled: !serverPaged,
|
||||
});
|
||||
const pagedQuery = useQuery({
|
||||
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
|
||||
enabled: serverPaged,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const activeQuery = serverPaged ? pagedQuery : listQuery;
|
||||
const { isLoading, isError, error } = activeQuery;
|
||||
const allRows = useMemo(
|
||||
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
|
||||
[serverPaged, pagedQuery.data, listQuery.data],
|
||||
);
|
||||
const create = useMutation(api.fleet.create.mutationOptions());
|
||||
const update = useMutation(api.fleet.update.mutationOptions());
|
||||
const remove = useMutation(api.fleet.remove.mutationOptions());
|
||||
const purge = useMutation(api.fleet.purge.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
|
||||
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
|
||||
// The API already applied every filter and cut the page — re-filtering here
|
||||
// would drop rows the server deliberately returned.
|
||||
if (serverPaged) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
// The date range applies even when the API already filtered the list —
|
||||
// it is not one of the server-side filters.
|
||||
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
|
||||
if (usesServerListFilters) return true;
|
||||
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
|
||||
const created = controls.values.created;
|
||||
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
|
||||
return false;
|
||||
}
|
||||
// Every other filter (status/yard/wagon type/…) was already applied
|
||||
// server-side for these slugs — re-checking here against a plain field
|
||||
// equality would be wrong for one (a wagon's "trainNumber" filter
|
||||
// matches either of two DIFFERENT columns server-side, not one).
|
||||
if (usesServerListFilters) return true;
|
||||
const status = controls.values.status;
|
||||
if (status && String(record.status ?? "") !== status.v[0]) return false;
|
||||
const term = controls.searchText.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return config.searchKeys.some((key) =>
|
||||
String(record[key] ?? "")
|
||||
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
|
||||
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
|
||||
|
||||
const totalCount = serverPaged
|
||||
? (pagedQuery.data?.meta.total ?? 0)
|
||||
: filteredRows.length;
|
||||
const pageCount = serverPaged
|
||||
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
|
||||
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
: Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
if (serverPaged) return filteredRows;
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRows.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
|
||||
const start = (controls.page - 1) * controls.pageSize;
|
||||
return filteredRows.slice(start, start + controls.pageSize);
|
||||
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
|
||||
|
||||
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
|
||||
// (not a DataTable) just needs the raw pieces out of it below.
|
||||
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
|
||||
|
||||
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
|
||||
if (!config) return [];
|
||||
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
showSearch={config.supportsSearch}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
aria-label="Created date range"
|
||||
placeholder="Created date range"
|
||||
value={[dateFrom, dateTo]}
|
||||
onChange={([from, to]) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={240}
|
||||
/>
|
||||
{listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
key={filter.key}
|
||||
aria-label={filter.label}
|
||||
placeholder={filter.data[0]?.label ?? filter.label}
|
||||
data={filter.data}
|
||||
value={filter.value}
|
||||
onChange={(value) => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
[filter.key]: value ?? "ALL",
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={200}
|
||||
searchable={filter.data.length > 8}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
<Group gap={4} wrap="wrap">
|
||||
<Text size="xs" fw={500} c="dimmed">Status:</Text>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={statusFilter === option.value ? "filled" : "outline"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
viewId={`fleet-${slug}`}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={(value) => setViewMode(value as FleetViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Table2 size={14} />
|
||||
<span>Table</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<LayoutGrid size={14} />
|
||||
<span>Cards</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
|
||||
/>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
@@ -696,18 +643,8 @@ const FleetResourcePage = () => {
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found`}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
pagination={dtPagination}
|
||||
tableOptions={dtTableOptions}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
|
||||
rows={pagedRows}
|
||||
status={tableStatus}
|
||||
emptyMessage={`No ${itemLabel} found`}
|
||||
pagination={pagination}
|
||||
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={dtTableOptions!.onPaginationChange!}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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