mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
42
.claude/skills/edr-db/SKILL.md
Normal file
42
.claude/skills/edr-db/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: edr-db
|
||||
description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema.
|
||||
---
|
||||
|
||||
# EDR dev-DB runner
|
||||
|
||||
One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`):
|
||||
|
||||
```bash
|
||||
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
|
||||
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
|
||||
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
|
||||
node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first)
|
||||
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
|
||||
```
|
||||
|
||||
Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`,
|
||||
defaulting to the shared dev database (`edr_dev`).
|
||||
|
||||
## Rules that go with it
|
||||
|
||||
- **HARD RULE: every raw SQL statement you write into a service must pass
|
||||
`explain` here before you ship it.** A typo'd column is a runtime 500 the
|
||||
type-checker cannot catch.
|
||||
- Never assume a recorded migration applied — check `migrations <name>` **and**
|
||||
`columns <table>` together. Recorded-but-absent = schema drift; fix with a
|
||||
NEW repair migration (idempotent DDL, no-op `down()`), never by editing the
|
||||
recorded one.
|
||||
- Writes to dev data are fine for seeding/diagnosis but keep them idempotent
|
||||
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
|
||||
and non-idempotent statements have double-run here before.
|
||||
- Timestamps for new migrations: must be unique across `src/migrations/` AND
|
||||
higher than `SELECT max(timestamp) FROM public.migrations`.
|
||||
|
||||
## Diagnosing a pasted 400/500 (the recurring loop)
|
||||
|
||||
1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`.
|
||||
2. Read the service method — list its guard `throw`s. Most "bugs" are a guard
|
||||
working as designed (handover unsigned, fee unpaid, not PAID, wrong direction).
|
||||
3. Check the actual DB state for that record with this runner.
|
||||
4. Only then decide: guard doing its job (fix the UI affordance) vs real defect.
|
||||
88
.claude/skills/edr-db/query.cjs
Normal file
88
.claude/skills/edr-db/query.cjs
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dev-DB runner for the EDR freight database. psql is NOT installed on this
|
||||
* machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect
|
||||
* the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from
|
||||
* anywhere in the repo.
|
||||
*
|
||||
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
|
||||
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
|
||||
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
|
||||
* node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows
|
||||
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
|
||||
*
|
||||
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
|
||||
* back to the shared dev database.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { createRequire } = require('module');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const apiRequire = createRequire(
|
||||
path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'),
|
||||
);
|
||||
const { Client } = apiRequire('pg');
|
||||
|
||||
const cfg = {
|
||||
host: process.env.DB_HOST ?? '10.18.7.207',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
user: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? 'dcba@1234',
|
||||
database: process.env.DB_NAME ?? 'edr_dev',
|
||||
};
|
||||
|
||||
const [, , first, ...rest] = process.argv;
|
||||
|
||||
async function main() {
|
||||
if (!first) {
|
||||
console.error('usage: query.cjs "<sql>" | explain "<sql>" | columns <table> | migrations [like] | drift <table>');
|
||||
process.exit(2);
|
||||
}
|
||||
const c = new Client(cfg);
|
||||
await c.connect();
|
||||
try {
|
||||
if (first === 'columns') {
|
||||
const r = await c.query(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='freight' AND table_name=$1
|
||||
ORDER BY ordinal_position`,
|
||||
[rest[0]],
|
||||
);
|
||||
console.table(r.rows);
|
||||
} else if (first === 'migrations') {
|
||||
const like = rest[0] ? `%${rest[0]}%` : '%';
|
||||
const r = await c.query(
|
||||
`SELECT id, timestamp, name FROM public.migrations
|
||||
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
|
||||
[like],
|
||||
);
|
||||
console.table(r.rows);
|
||||
} else if (first === 'drift') {
|
||||
// Quick drift signal: DB columns for the table. Compare by eye against
|
||||
// the entity's @Column names; a recorded-but-absent column = drift.
|
||||
const r = await c.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`,
|
||||
[rest[0]],
|
||||
);
|
||||
console.log(r.rows.map((x) => x.column_name).join('\n'));
|
||||
} else if (first === 'explain') {
|
||||
await c.query('EXPLAIN ' + rest.join(' '));
|
||||
console.log('OK — statement is valid against', cfg.database);
|
||||
} else {
|
||||
const sql = [first, ...rest].join(' ');
|
||||
const started = Date.now();
|
||||
const r = await c.query(sql);
|
||||
if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows);
|
||||
console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`);
|
||||
}
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FAIL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
40
.claude/skills/standup/SKILL.md
Normal file
40
.claude/skills/standup/SKILL.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: standup
|
||||
description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone.
|
||||
---
|
||||
|
||||
# Work report (standup / ticket summary)
|
||||
|
||||
Ground every line in git. Do not reconstruct from conversation memory — commits
|
||||
are the record.
|
||||
|
||||
## Gather
|
||||
|
||||
```bash
|
||||
# Commits in the window (adjust dates; author matches "Hagernesh")
|
||||
git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \
|
||||
--pretty=format:"%h|%ad|%s" --date=short
|
||||
|
||||
# What each commit actually contains (subjects lie sometimes)
|
||||
git show --stat --pretty=format:"%s" <hash> | head -12
|
||||
|
||||
# In-flight work = part of "today" even if uncommitted
|
||||
git status --short
|
||||
git log origin/dev..HEAD --oneline # branch commits not yet in dev
|
||||
```
|
||||
|
||||
## Known pitfalls in this repo
|
||||
|
||||
- **Check subjects against contents.** Commit titles here sometimes mismatch the
|
||||
diff (e.g. a commit titled "unload export" that actually contained ISO
|
||||
container validation). Use `git show --stat` before reporting a title as fact.
|
||||
- A day with no commits usually still has uncommitted/in-flight work — report it
|
||||
as its own section with per-item status (done / uncommitted / blocked).
|
||||
- Merge commits from other authors are noise; filter with `--author`.
|
||||
|
||||
## Output format
|
||||
|
||||
One table per day: `# | Task (plain language, not the commit subject verbatim) |
|
||||
Commit / Status`. Follow with a short "carry-over / blocked" list naming what
|
||||
blocks each item. Keep it ticket-ready: no jargon that needs the repo open to
|
||||
decode.
|
||||
59
.claude/skills/verify/SKILL.md
Normal file
59
.claude/skills/verify/SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: verify
|
||||
description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting.
|
||||
---
|
||||
|
||||
# Verify a change (EDR definition of done)
|
||||
|
||||
Run these in order. Report which you ran and what each said — never call
|
||||
unverified work done.
|
||||
|
||||
## 1. Type-check exactly what you touched
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal
|
||||
```
|
||||
|
||||
Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited
|
||||
`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`:
|
||||
|
||||
```bash
|
||||
pnpm turbo build --filter=@edr/types
|
||||
```
|
||||
|
||||
## 2. Validate every raw SQL statement
|
||||
|
||||
Each new/edited `dataSource.query` / `manager.query` string must pass:
|
||||
|
||||
```bash
|
||||
node .claude/skills/edr-db/query.cjs explain "<the statement with dummy params>"
|
||||
```
|
||||
|
||||
## 3. Migration checklist (if you added one)
|
||||
|
||||
- Timestamp unique in `src/migrations/` **and** greater than
|
||||
`node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`.
|
||||
- DDL idempotent (`IF NOT EXISTS`, guarded backfills).
|
||||
- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB
|
||||
yourself or fully restart the API, then confirm with
|
||||
`query.cjs columns <table>`.
|
||||
|
||||
## 4. Tests — honest bar
|
||||
|
||||
`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite
|
||||
is not the bar. The bar: run the specs nearest what you touched and introduce
|
||||
**no new failure**. If you touched a service constructor, update its `.spec.ts`
|
||||
mocks (constructor-arity breaks are this repo's most common test regression).
|
||||
|
||||
## 5. Observe the behaviour
|
||||
|
||||
Compiling is not working. Hit the endpoint, drive the UI flow, or query the
|
||||
resulting rows. If you genuinely could not observe it, say so explicitly in the
|
||||
summary — do not imply it was seen working.
|
||||
|
||||
## 6. Before commit
|
||||
|
||||
- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this
|
||||
repo (husky shims exist but no user hooks) — nothing will catch it for you.
|
||||
- Lint the files you touched if in doubt: `pnpm turbo lint --filter=<pkg>`.
|
||||
- Do not commit or push unless the user asked.
|
||||
294
CLAUDE_NEW.md
Normal file
294
CLAUDE_NEW.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# 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: true` — **migrations run automatically on API boot**, with
|
||||
`migrationsTransactionMode: 'each'`.
|
||||
- Consequences you must design for:
|
||||
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
|
||||
data migration can execute twice. Keep one instance.
|
||||
- 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.
|
||||
- **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.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Repairs `freight.warehouse_inventory.grn_number`.
|
||||
*
|
||||
* AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but
|
||||
* the column is absent on at least one environment - it was added, then dropped
|
||||
* out-of-band (a stray `synchronize: true`, same class of damage that
|
||||
* RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has
|
||||
* the original recorded, it will never re-run it.
|
||||
*
|
||||
* Without the column, everything that reads or writes a GRN fails with
|
||||
* `column ... grn_number does not exist`:
|
||||
* - bulkReceive() -> INSERT names grn_number (receive to warehouse)
|
||||
* - importQueueByStatuses() -> Unloaded + Dispatch queues
|
||||
* - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs
|
||||
* - grnDocument() -> GRN PDF
|
||||
*
|
||||
* Idempotent: a no-op on environments where the column survived.
|
||||
*/
|
||||
export class RepairGrnNumberColumn2090000000000 implements MigrationInterface {
|
||||
name = 'RepairGrnNumberColumn2090000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
|
||||
`);
|
||||
|
||||
// Recover the GRN for rows received before the column existed: it was also
|
||||
// written into the receive note as "GRN Number: <value>".
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory
|
||||
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
WHERE grn_number IS NULL
|
||||
AND notes IS NOT NULL
|
||||
AND notes ~ 'GRN Number: '
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
|
||||
ON freight.warehouse_inventory(grn_number)
|
||||
WHERE grn_number IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately a no-op. Dropping the column is what broke these environments
|
||||
* in the first place, and the original 1828 migration already owns its own
|
||||
* down(). Reverting this repair must not re-introduce the outage.
|
||||
*/
|
||||
public async down(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { CustomerResetService } from "./customer-reset.service";
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. The customer receives the code and sets their
|
||||
* own password — staff never see or handle a credential.
|
||||
*/
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice/customers")
|
||||
@ApiBearerAuth()
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
|
||||
@Post(":companyId/reset-password")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code to a customer's primary contact",
|
||||
})
|
||||
async resetPassword(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
||||
companyId,
|
||||
dto.channel,
|
||||
);
|
||||
|
||||
if (!maskedTarget) {
|
||||
throw new NotFoundException(
|
||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
||||
);
|
||||
}
|
||||
|
||||
return { channel: dto.channel, maskedTarget };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService } from "./forgot-password.service";
|
||||
|
||||
@Injectable()
|
||||
export class CustomerResetService {
|
||||
private readonly logger = new Logger(CustomerResetService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||
private readonly forgotPasswordService: ForgotPasswordService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a reset code to the company's primary contact. Returns the masked
|
||||
* destination, or null when there is no eligible account for that channel.
|
||||
*
|
||||
* Unlike the public flow this reports failure honestly — the caller is an
|
||||
* authenticated staff member, so there is nothing to enumerate.
|
||||
*/
|
||||
async sendResetToCustomer(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<string | null> {
|
||||
const profile = await this.externalProfileRepository.findOne({
|
||||
where: { companyId, isPrimaryContact: true },
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
this.logger.warn(`Company ${companyId} has no primary contact profile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve through the same active-account gate the public flow uses, so a
|
||||
// suspended customer cannot be reactivated by a staff-triggered reset.
|
||||
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||
profile.userId,
|
||||
);
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
||||
);
|
||||
return this.forgotPasswordService.maskTarget(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
/** The channel the reset code is delivered over. */
|
||||
export enum ResetChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
}
|
||||
|
||||
export class ForgotPasswordRequestDto {
|
||||
@ApiProperty({
|
||||
description: "Email, username, or phone number of the account to reset",
|
||||
example: "name@company.com",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier!: string;
|
||||
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
}
|
||||
|
||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
export class BackofficeResetPasswordDto {
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, Logger, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
ForgotPasswordRequestDto,
|
||||
ForgotPasswordVerifyDto,
|
||||
} from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
|
||||
|
||||
/**
|
||||
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
|
||||
* this API does not set). These routes drive freight's own email-or-phone OTP
|
||||
* service instead, then hand back a ticket for IAM's public `set-password`.
|
||||
*/
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@Public()
|
||||
export class ForgotPasswordController {
|
||||
private readonly logger = new Logger(ForgotPasswordController.name);
|
||||
|
||||
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
|
||||
|
||||
@Post("forgot-password/request")
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code over email or SMS",
|
||||
description:
|
||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
||||
})
|
||||
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
||||
} catch (error) {
|
||||
// A delivery failure must not change the response shape either — log it
|
||||
// and let the caller sit on the OTP screen.
|
||||
this.logger.error(
|
||||
`Reset code delivery failed for user ${user.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.log("Reset requested for an unknown or inactive account");
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post("forgot-password/verify")
|
||||
@ApiOperation({
|
||||
summary: "Exchange a valid reset code for a single-use set-password ticket",
|
||||
description:
|
||||
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
|
||||
"alongside the same identifier and the new password.",
|
||||
})
|
||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||
return this.forgotPasswordService.verifyAndMintTicket(
|
||||
dto.identifier,
|
||||
dto.channel,
|
||||
dto.otp,
|
||||
);
|
||||
}
|
||||
}
|
||||
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
||||
|
||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
|
||||
/**
|
||||
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
|
||||
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
|
||||
*/
|
||||
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ForgotPasswordService {
|
||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve an account that is actually eligible for a password reset.
|
||||
*
|
||||
* IAM's `set-password` handler flips `isActive: true` on the user as a side
|
||||
* effect, so a reset on a deactivated account would silently resurrect it.
|
||||
* Gating here — rather than at the set-password call — is what keeps that
|
||||
* from being reachable. Mirrors IAM's own login lookup: match on any of
|
||||
* email / username / phone, and require an active credential row.
|
||||
*/
|
||||
async resolveActiveUser(identifier: string): Promise<User | null> {
|
||||
const id = identifier.trim();
|
||||
if (!id) return null;
|
||||
|
||||
return await this.activeUserQuery()
|
||||
.andWhere(
|
||||
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
|
||||
{ id },
|
||||
)
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
|
||||
async resolveActiveUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.activeUserQuery()
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
* which would silently drop the `isActive` gate.
|
||||
*/
|
||||
private activeUserQuery() {
|
||||
return this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
|
||||
.where("u.isActive = true")
|
||||
.orderBy("u.createdAt", "DESC");
|
||||
}
|
||||
|
||||
/** The address the code goes to, taken from the account — never from input. */
|
||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||
if (channel === ResetChannel.Email) {
|
||||
return user.email ? { email: user.email } : null;
|
||||
}
|
||||
return user.phoneNumber ? { phone: user.phoneNumber } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset code to the account's own email/phone. Returns the target so
|
||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
||||
* callers must discard it.
|
||||
*
|
||||
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||
* upserts. A reset request therefore overwrites any pending signup code for
|
||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
||||
* between any two flows sharing this table.
|
||||
*/
|
||||
async requestReset(
|
||||
user: User,
|
||||
channel: ResetChannel,
|
||||
): Promise<OtpTarget | null> {
|
||||
const target = this.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
await this.otpService.sendOtp(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
|
||||
* spend on the public `PATCH /api/auth/set-password`.
|
||||
*
|
||||
* Minting a `UserVerification` row rather than writing `UserCredential`
|
||||
* ourselves keeps IAM as the single owner of the password write path (old
|
||||
* credential deactivation, argon hashing, changed-at bookkeeping).
|
||||
*/
|
||||
async verifyAndMintTicket(
|
||||
identifier: string,
|
||||
channel: ResetChannel,
|
||||
otp: string,
|
||||
): Promise<ResetTicket> {
|
||||
const user = await this.resolveActiveUser(identifier);
|
||||
const target = user && this.targetFor(user, channel);
|
||||
|
||||
if (!user?.id || !target) {
|
||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||
// beyond what the request step already (deliberately) refuses to tell them.
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
||||
|
||||
const code = randomBytes(24).toString("base64url");
|
||||
const verificationCode = await hashPassword(code);
|
||||
const userId = user.id;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(UserVerification);
|
||||
// Retire any outstanding codes so only the ticket we just minted can be
|
||||
// spent — `findVerificationForPrimaryReset` reads the newest row.
|
||||
await repo.update({ userId }, { isUsed: true });
|
||||
await repo.insert({
|
||||
userId,
|
||||
otpType: EOtpType.RESET_PASSWORD,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
||||
isUsed: false,
|
||||
attemptCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(`Reset ticket minted for user ${userId}`);
|
||||
return { userId, verificationCode: code };
|
||||
}
|
||||
|
||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||
maskTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||
|
||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
import { CustomerResetService } from './customer-reset.service';
|
||||
import { ForgotPasswordController } from './forgot-password.controller';
|
||||
import { ForgotPasswordService } from './forgot-password.service';
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [FreightMeController, CheckAvailabilityController],
|
||||
providers: [FreightMeService, CheckAvailabilityService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [
|
||||
FreightMeController,
|
||||
CheckAvailabilityController,
|
||||
ForgotPasswordController,
|
||||
CustomerResetController,
|
||||
],
|
||||
providers: [
|
||||
FreightMeService,
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -1125,6 +1125,30 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||
* window. One query for the whole page.
|
||||
*/
|
||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||
const ids = bookings.map((b) => b.id);
|
||||
if (!ids.length) return;
|
||||
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT booking_id AS "bookingId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE booking_id = ANY($1::uuid[])
|
||||
AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'`,
|
||||
[ids],
|
||||
);
|
||||
const pending = new Set(rows.map((r) => r.bookingId));
|
||||
for (const b of bookings) {
|
||||
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
pending.has(b.id);
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
@@ -1135,7 +1159,7 @@ export class BookingsService {
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
const result = await this.bookingsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
...statusFilter,
|
||||
@@ -1167,6 +1191,8 @@ export class BookingsService {
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
await this.attachHandoverFlags(result.items ?? []);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
*/
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
),
|
||||
),
|
||||
} as unknown as Repository<CompanyChangeRequest>;
|
||||
}
|
||||
|
||||
function subject(rows: Row[]) {
|
||||
return new CompanyChangeRequestRepository(mockRepositoryOver(rows));
|
||||
}
|
||||
|
||||
describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
const rejected: Row = {
|
||||
id: "rejected",
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
it("returns the pending request when one is open", async () => {
|
||||
const pending: Row = {
|
||||
id: "pending",
|
||||
status: ChangeRequestStatus.Pending,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([rejected, pending]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
);
|
||||
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
);
|
||||
|
||||
expect(result?.id).toBe("rejected");
|
||||
});
|
||||
|
||||
it("returns null once a resubmit of a rejected request is approved", async () => {
|
||||
const approved: Row = {
|
||||
id: "approved",
|
||||
status: ChangeRequestStatus.Approved,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
approved,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the company has no requests", async () => {
|
||||
const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
|
||||
/**
|
||||
* The company's latest "open" request — pending (locks the customer) or the
|
||||
* most recent rejected one (drives the reapply banner + prefill). Approved
|
||||
* requests are terminal and ignored here.
|
||||
* most recent rejected one (drives the reapply banner + prefill).
|
||||
*
|
||||
* Only the company's newest request may be open. A rejection is superseded the
|
||||
* moment the customer resubmits: that resubmit opens a *new* request, so once
|
||||
* it is approved the newest request is terminal and nothing is open — even
|
||||
* though the older rejected row still sits in the table as history.
|
||||
*/
|
||||
async findLatestOpenByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
const pending = await this.findPendingByCompanyId(companyId);
|
||||
if (pending) return pending;
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Rejected },
|
||||
const latest = await this.repository.findOne({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CompanyChangeRequest | null> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.transporter]: "TR",
|
||||
};
|
||||
|
||||
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
/** Numbers per series letter: A00001..A99999, then B00001. */
|
||||
const SERIES_SIZE = 99_999;
|
||||
|
||||
@Injectable()
|
||||
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||
constructor(
|
||||
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||
const result = await this.repository.query(
|
||||
`SELECT nextval('${seqName}') AS next_id`,
|
||||
);
|
||||
const nextId = result[0].next_id as number;
|
||||
const nextId = Number(result[0].next_id);
|
||||
const offset = nextId - 1;
|
||||
const seriesIndex = Math.floor(offset / SERIES_SIZE);
|
||||
|
||||
if (seriesIndex >= SERIES_LETTERS.length) {
|
||||
throw new InternalServerErrorException(
|
||||
`Company profile reference series exhausted for type "${type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const letter = SERIES_LETTERS[seriesIndex];
|
||||
const number = (offset % SERIES_SIZE) + 1;
|
||||
const prefix = PREFIX_MAP[type];
|
||||
return `${prefix}-${String(nextId).padStart(5, "0")}`;
|
||||
return `${prefix}-${letter}${String(number).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity {
|
||||
type!: ProfileType;
|
||||
|
||||
/**
|
||||
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
|
||||
* Official profile reference (e.g. "EX-A00001"). Minted only when the profile
|
||||
* is approved (status → Active); pending/unapproved profiles carry NULL.
|
||||
* The unique index tolerates this because Postgres treats NULLs as distinct.
|
||||
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
|
||||
|
||||
@@ -593,7 +593,7 @@ export class ContractTransitionService {
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
150
apps/edr-freight-api/src/modules/gps-tracking/README.md
Normal file
150
apps/edr-freight-api/src/modules/gps-tracking/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# GPS Tracking (GT06) — Operations & Device Configuration
|
||||
|
||||
GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes
|
||||
everything about how the service is deployed and how devices are pointed at it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why GPS needs its own dedicated TCP port
|
||||
|
||||
- **Not HTTP.** GT06 devices send binary frames
|
||||
(`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`).
|
||||
An HTTP server receiving these answers `400 Bad Request` and closes.
|
||||
- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two
|
||||
listeners on the same pair collide (`EADDRINUSE`). The REST API already owns
|
||||
its port, so GPS traffic needs a separate one.
|
||||
- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so
|
||||
L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by
|
||||
domain. Routing must happen at **Layer 4 (TCP)** by port.
|
||||
- **DNS carries no port.** An A record maps a name to an IP only. The tracker
|
||||
config must state the port explicitly (e.g. `gps.example.com:5023`).
|
||||
|
||||
### Operational requirements
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Protocol | Raw TCP (not HTTP, not TLS) |
|
||||
| Default port | `5023` (configurable via `GT06_TCP_PORT`) |
|
||||
| Listener bind | `0.0.0.0` inside the `freight-gps` container |
|
||||
| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Port configuration
|
||||
|
||||
`5023` is only this project's default — **not** a GT06 protocol requirement. The
|
||||
listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured
|
||||
with the same number.
|
||||
|
||||
Host and container ports are decoupled in `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
freight-gps:
|
||||
ports:
|
||||
- "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
|
||||
environment:
|
||||
GT06_TCP_PORT: "5023" # pinned inside the container
|
||||
```
|
||||
|
||||
- The **container** always listens on `5023`.
|
||||
- The **host/public** port is configurable (443, 5023, 9000, …) via the root
|
||||
`.env`'s `GT06_TCP_PORT`.
|
||||
- This split is required because the image runs as a **non-root** user
|
||||
(`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the
|
||||
host port and forwards to `5023` inside.
|
||||
- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the
|
||||
actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`.
|
||||
- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that
|
||||
expect a TLS handshake on 443 may drop the connection.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment topology
|
||||
|
||||
The GT06 listener runs as its own process (`dist/main.gps.js`, module
|
||||
`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight`
|
||||
DB with the API; the DB is the seam (ingester writes `gps_devices` /
|
||||
`gps_positions`, API reads them).
|
||||
|
||||
```
|
||||
freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
|
||||
freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
|
||||
```
|
||||
|
||||
`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations.
|
||||
|
||||
Horizontal scale: each tracker holds one long-lived TCP connection with
|
||||
per-socket session state, so N `freight-gps` replicas can run behind an L4 LB —
|
||||
each device sticks to one replica. `ensureDevice` is safe under concurrency
|
||||
(unique IMEI).
|
||||
|
||||
---
|
||||
|
||||
## 4. Device configuration (GT06 side)
|
||||
|
||||
Config is done by **SMS to the tracker's SIM**. Commands below are the canonical
|
||||
Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware.
|
||||
Default command password is usually `123456`.
|
||||
|
||||
Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN.
|
||||
|
||||
```
|
||||
STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
|
||||
APN,<apn># # 2. carrier data APN (add ,user,pass if needed)
|
||||
SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
|
||||
GPRSON,1# # 4. enable data
|
||||
GPSON,1# # enable GPS
|
||||
TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
|
||||
RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
|
||||
```
|
||||
|
||||
Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#`
|
||||
Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#`
|
||||
|
||||
### Verify from the server
|
||||
|
||||
```bash
|
||||
docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
|
||||
nc -vz gps.example.com 5023
|
||||
curl -H "Authorization: Bearer <token>" https://api.example.com/api/gps/positions/latest
|
||||
```
|
||||
|
||||
First login packet **auto-registers** the IMEI (no manual step). `online:true`
|
||||
only when `lastSeenAt` < 5 min (computed at read time).
|
||||
|
||||
### Link a tracker to a vehicle (optional)
|
||||
|
||||
Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`):
|
||||
|
||||
```
|
||||
PATCH /api/gps/devices/:id { "vehicleId": "<uuid>", "name": "Truck 03-ET" }
|
||||
```
|
||||
|
||||
### Failure map
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| No SMS reply | SIM PIN on / no signal / wrong number |
|
||||
| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` |
|
||||
| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
|
||||
| Registered but `online:false` | packets blocked by firewall — open inbound TCP |
|
||||
| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min |
|
||||
|
||||
---
|
||||
|
||||
## 5. Security
|
||||
|
||||
- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
|
||||
reach the port can inject fake positions.
|
||||
- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
|
||||
group to the SIM provider's **APN / IP range**.
|
||||
- Trackers must use the same host+port as the server:
|
||||
`SERVER,1,gps.example.com,<port>,0#`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge (L4) termination
|
||||
|
||||
See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf)
|
||||
for an Nginx `stream {}` example, and the AWS NLB notes in the same file.
|
||||
Reminder: **L4 only** — an HTTP proxy cannot route GT06.
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
|
||||
import { NotificationInboxService } from "./notification-inbox.service";
|
||||
|
||||
@ApiTags("notifications")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
@Controller("notifications")
|
||||
export class NotificationInboxController {
|
||||
constructor(private readonly service: NotificationInboxService) {}
|
||||
|
||||
@@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service";
|
||||
|
||||
/**
|
||||
* Server → client push for in-app notifications. Clients only *listen* (no
|
||||
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
|
||||
* the handshake is authenticated in `handleConnection` and each socket joins a
|
||||
* private `user:<id>` room the service targets.
|
||||
* `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
|
||||
* controller does not cover WebSockets; the handshake is authenticated in
|
||||
* `handleConnection` and each socket joins a private `user:<id>` room the
|
||||
* service targets.
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: NOTIFICATION_WS_NAMESPACE,
|
||||
|
||||
@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
|
||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
||||
|
||||
// axios defaults to no timeout — a hanging gateway would block the caller
|
||||
// (and any transaction it sits in) indefinitely. Always bound the wait.
|
||||
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -50,6 +50,9 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// A freshly issued code gets a fresh guess budget.
|
||||
this.actionAttempts.delete(this.targetKey(target));
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
@@ -121,24 +124,44 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
||||
// and leaves the code in place — this enforces a short TTL and consumes the
|
||||
// code on success so it can never be replayed.
|
||||
// contract signature, resetting a forgotten password). Unlike verifyOtp above
|
||||
// — which marks a target verified and leaves the code in place — this enforces
|
||||
// a TTL and consumes the code on success so it can never be replayed.
|
||||
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async verifyOtpForAction(phone: string, otp: string) {
|
||||
const otpData = await this.otpRepository.findByPhone(phone);
|
||||
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
|
||||
// within its own TTL. `otp_verifications` has no attempt column, so the
|
||||
// counter lives here and the code is burned once the budget is spent.
|
||||
// Per-process: it resets on restart and is not shared across replicas — a
|
||||
// persisted counter needs a migration on OtpVerification.
|
||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||
private readonly actionAttempts = new Map<string, number>();
|
||||
|
||||
private targetKey(target: OtpTarget): string {
|
||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
target: OtpTarget,
|
||||
otp: string,
|
||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||
) {
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"No verification code was requested for this phone",
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
: "No verification code was requested for this phone",
|
||||
);
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
if (ageMs > ttlMs) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
@@ -146,11 +169,24 @@ export class OtpService {
|
||||
}
|
||||
|
||||
if (otpData.otp !== otp) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||
export class ReleaseOrderDto {
|
||||
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
gateOutTime?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container bookings only: the operator chose not to weigh this truck. ' +
|
||||
'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
weighingSkipped?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
// Reserve is retired from the operator flow — a stored export item advances
|
||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||
// READY_FOR_PICKUP is the way back out for an IMPORT item that was parked in
|
||||
// storage from READY_FOR_PICKUP; without it, Store is a one-way door.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
@@ -150,6 +151,37 @@ export class HandoverService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder loop: until a self-haul handover is signed, re-send the sign
|
||||
* notification (in-app + SMS + email) every 5 minutes. One reminder per
|
||||
* booking per tick, newest unsigned handover's reference. Stops the moment
|
||||
* signForBooking() stamps signed_at.
|
||||
*
|
||||
* NB: runs in every API instance — keep a single instance in dev or the
|
||||
* customer is reminded once per instance per tick.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
|
||||
async remindUnsignedHandovers(): Promise<void> {
|
||||
try {
|
||||
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (booking_id)
|
||||
booking_id AS "bookingId", reference
|
||||
FROM freight.booking_handovers
|
||||
WHERE signed_at IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
ORDER BY booking_id, generated_at DESC`,
|
||||
);
|
||||
if (!rows.length) return;
|
||||
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
|
||||
for (const row of rows) {
|
||||
await this.notifySignNeeded(row.bookingId, row.reference);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||
await this.dataSource
|
||||
|
||||
@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
|
||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||
/** Sent after the transaction commits so the gateway never blocks the receive. */
|
||||
const pendingNotifications: Array<{
|
||||
owner: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
bookingReference?: string | null;
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
};
|
||||
booking: {
|
||||
companyId?: string | null;
|
||||
reference?: string | null;
|
||||
hasFirstMile?: boolean;
|
||||
hasLastMile?: boolean;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
};
|
||||
bookingId: string;
|
||||
}> = [];
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||||
// holds capacity/location locks open for the whole gateway latency.
|
||||
pendingNotifications.push({
|
||||
owner: {
|
||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
},
|
||||
booking,
|
||||
bookingId,
|
||||
});
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
// Fan out after commit, un-awaited: the receive response must not wait on the
|
||||
// SMS gateway. Both notifiers swallow their own errors.
|
||||
for (const pending of pendingNotifications) {
|
||||
void this.notifyOwnerInventoryReceived(pending.owner);
|
||||
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2326,6 +2357,27 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-haul = the customer's own truck collects the goods: either a truck
|
||||
* assigned via the portal (customer_truck_assigned_at), or a walk-in truck
|
||||
* registered at the gate on a booking with no EDR last-mile leg. EDR
|
||||
* last-mile bookings are never self-haul.
|
||||
*/
|
||||
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
const [row]: Array<{ ok: number }> = await runner.query(
|
||||
`SELECT 1 AS ok
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||||
[bookingId],
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
||||
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
@@ -2335,19 +2387,17 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||
// Leaving = gate-out captured, with either a weighed gross or an explicit
|
||||
// container weighing skip (bulk always weighs).
|
||||
const isTruckLeaving =
|
||||
Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
|
||||
if (isTruckLeaving) {
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
|
||||
if (item.bookingId) {
|
||||
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
||||
// Self-haul = customer collects: a truck assigned via the portal, OR a
|
||||
// walk-in truck registered at the gate on a booking with no EDR last mile.
|
||||
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
|
||||
// Self-haul: the handover must be signed before the exit paper is issued.
|
||||
// Prefer the structured handover record; fall back to the legacy note.
|
||||
const handoverSigned =
|
||||
@@ -2361,7 +2411,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
// Skipped when the operator chose not to weigh (containers only).
|
||||
if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
const selected = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
@@ -2431,13 +2482,10 @@ export class WarehouseInventoryService {
|
||||
[item.bookingId],
|
||||
);
|
||||
// Self-haul: generate the per-booking handover on first truck arrival
|
||||
// (idempotent). It must be signed before the truck leaves.
|
||||
const [selfHaul]: Array<{ ok: number }> = await manager.query(
|
||||
`SELECT 1 AS ok FROM freight.bookings
|
||||
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (selfHaul) {
|
||||
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
|
||||
// assigned trucks and walk-in trucks registered manually at the gate
|
||||
// (no portal assignment, no EDR last mile). Must be signed before leaving.
|
||||
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
|
||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||
}
|
||||
}
|
||||
@@ -4422,14 +4470,17 @@ export class WarehouseInventoryService {
|
||||
if (!dto.driverName?.trim()) {
|
||||
throw new BadRequestException('Driver name is required for exit inspection');
|
||||
}
|
||||
if (dto.tareWeight === undefined) {
|
||||
// Container bookings may skip the weighbridge entirely (weighingSkipped);
|
||||
// bulk always weighs.
|
||||
const weighingSkipped = dto.weighingSkipped === true;
|
||||
if (dto.tareWeight === undefined && !weighingSkipped) {
|
||||
throw new BadRequestException('Tare weight is required for truck arrival');
|
||||
}
|
||||
|
||||
const tareWeight = Number(dto.tareWeight);
|
||||
const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
|
||||
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||||
const computedNetWeight =
|
||||
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||
const submittedNetWeight =
|
||||
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
||||
|
||||
@@ -4441,7 +4492,11 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||
}
|
||||
}
|
||||
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
|
||||
if (
|
||||
!weighingSkipped &&
|
||||
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
|
||||
grossWeight == null
|
||||
) {
|
||||
throw new BadRequestException('Gross weight is required for truck exit');
|
||||
}
|
||||
|
||||
@@ -4457,7 +4512,8 @@ export class WarehouseInventoryService {
|
||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} t`,
|
||||
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||||
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
@@ -4481,6 +4537,8 @@ export class WarehouseInventoryService {
|
||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
||||
// The weigh/skip decision is made at arrival and sticks for the exit.
|
||||
weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
},
|
||||
{
|
||||
fileKey: "commercial_license",
|
||||
fileLabel: "Commercial License",
|
||||
helpText: "Verified against the government trade system during registration.",
|
||||
fileLabel: "Commercial Registration",
|
||||
helpText:
|
||||
"Verified against the government trade system during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "business_license",
|
||||
fileLabel: "Business License / Trade License",
|
||||
helpText: "Verified against the government trade system during registration.",
|
||||
helpText:
|
||||
"Verified against the government trade system during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
const CONTRACT_INTAKE_ENTITY = "contract_intake";
|
||||
|
||||
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
||||
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField(
|
||||
"commercial_framework",
|
||||
"Commercial Framework / Agreement",
|
||||
1,
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
),
|
||||
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
||||
required: false,
|
||||
}),
|
||||
@@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
export class FileUploadSettingsSeeder {
|
||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder {
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
> = [
|
||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||
...s,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
})),
|
||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description: CLEARANCE_DESCRIPTION,
|
||||
})),
|
||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||
})),
|
||||
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||
})),
|
||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Commercial/framework documents attached at contract submission.",
|
||||
})),
|
||||
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||
})),
|
||||
];
|
||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||
...s,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
})),
|
||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description: CLEARANCE_DESCRIPTION,
|
||||
})),
|
||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||
})),
|
||||
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||
})),
|
||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Commercial/framework documents attached at contract submission.",
|
||||
})),
|
||||
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||
})),
|
||||
];
|
||||
|
||||
for (const documentSetting of allSettings) {
|
||||
await settingRepository.upsert(
|
||||
@@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder {
|
||||
});
|
||||
|
||||
if (!setting) {
|
||||
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
|
||||
throw new Error(
|
||||
`file_upload_setting_seed_failed:${documentSetting.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
await fieldRepository.delete({ settingId: setting.id });
|
||||
|
||||
@@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
|
||||
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
|
||||
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
|
||||
perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'),
|
||||
];
|
||||
|
||||
// D. Finance — payments + invoices
|
||||
@@ -395,6 +396,7 @@ export const FREIGHT_PERMS = {
|
||||
update: 'edr_freight_app:customers:update',
|
||||
deactivate: 'edr_freight_app:customers:deactivate',
|
||||
verify: 'edr_freight_app:customers:verify',
|
||||
resetPassword: 'edr_freight_app:customers:reset-password',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_freight_app:payments:view',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, ResetChannel } from "@/types/customer";
|
||||
|
||||
export interface ResetPasswordActionProps {
|
||||
company: Pick<Company, "id" | "email" | "phone">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. Sends a one-time code to the customer's
|
||||
* primary contact; the customer picks their own new password. No credential is
|
||||
* ever shown to or handled by staff.
|
||||
*/
|
||||
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("phone");
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.resetPassword.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Reset code sent",
|
||||
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send reset code",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<KeyRound size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Send a password-reset code"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a one-time code to this customer's primary contact.
|
||||
They choose their own new password — you will not see it.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label="Send the code via"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
description={company.phone ?? "No phone on the company record"}
|
||||
/>
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={company.email ?? "No email on the company record"}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
The code goes to the primary contact's own email or phone, which
|
||||
may differ from the company contact details shown above.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
onClick={() => mutate({ companyId: company.id, channel })}
|
||||
>
|
||||
Send reset code
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,5 +13,9 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -250,7 +250,10 @@ const FreightSidebar = ({
|
||||
<AppShell.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
type="never"
|
||||
type="hover"
|
||||
scrollbars="y"
|
||||
scrollbarSize={6}
|
||||
scrollHideDelay={500}
|
||||
px="sm"
|
||||
pb="md"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
|
||||
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Release paper preview failed',
|
||||
description: extractErrorMessage(error),
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Handover document failed',
|
||||
description: extractErrorMessage(error),
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
|
||||
@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -900,7 +900,7 @@ function EligibleTab({
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
@@ -1697,7 +1697,6 @@ function LoadedExportTab({
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -1739,19 +1738,6 @@ function LoadedExportTab({
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
{dispatchable && (
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch([r.id])}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
|
||||
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
@@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
|
||||
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
.toFixed(3),
|
||||
);
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
// Skip is only offered for container bookings; bulk always weighs.
|
||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
!skipWeighing &&
|
||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||
|
||||
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (!gateInTime || tareWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
}
|
||||
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
weighingSkipped: skipWeighing || undefined,
|
||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
<Text size="sm" fw={600}>Weigh truck?</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
|
||||
value={weighTruck}
|
||||
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
|
||||
disabled={isEntranceLocked}
|
||||
/>
|
||||
{skipWeighing && (
|
||||
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface TruckDispatchModalProps {
|
||||
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
|
||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
|
||||
{items.map((item) => {
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
// Per-booking Load and Dispatch are retired: wagon loading happens in
|
||||
// the train flow and dispatch at the train level (which already
|
||||
// advances inventory). Only the remaining lifecycle actions render.
|
||||
const rawNextAction = getNextInventoryAction(item);
|
||||
const nextAction =
|
||||
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
|
||||
const canGenerateHandover =
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'dispatch')}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
|
||||
@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/payments/by-company/${id}/customer-view`,
|
||||
RESET_PASSWORD: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-password`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
|
||||
@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
|
||||
update: "edr_freight_app:customers:update",
|
||||
deactivate: "edr_freight_app:customers:deactivate",
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ProfileChips,
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
ResetPasswordAction,
|
||||
TableCard,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
action={<ResetPasswordAction company={company} />}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
CustomerPayment,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
} from "@/types/customer";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
@@ -2269,6 +2271,16 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||
),
|
||||
|
||||
resetPassword: endpoint<
|
||||
{ companyId: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"customers",
|
||||
"resetPassword",
|
||||
({ companyId, channel }) =>
|
||||
customersService.resetPassword(companyId, channel),
|
||||
),
|
||||
|
||||
setProfileStatus: endpoint<
|
||||
{ profileId: string; status: ProfileStatus; note?: string },
|
||||
CompanyProfile
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CustomerPayment,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
} from "@/types/customer";
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
@@ -81,6 +83,22 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a password-reset code to the company's primary contact. Staff never
|
||||
* receive a credential — the customer sets their own password from the code.
|
||||
*/
|
||||
resetPassword(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
setProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
|
||||
@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ResetPasswordResult {
|
||||
channel: ResetChannel;
|
||||
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
|
||||
maskedTarget: string;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
export interface Company {
|
||||
id: string;
|
||||
|
||||
@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. Import STORED is handled via the import queue.
|
||||
if (isImport) return null;
|
||||
// once inspection passes. An import item parked back into storage returns
|
||||
// to pickup — otherwise Store would strand it with no action.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
|
||||
grossWeight?: number;
|
||||
netWeight?: number;
|
||||
gateOutTime?: string;
|
||||
/** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
|
||||
weighingSkipped?: boolean;
|
||||
}
|
||||
|
||||
/** Import branch: proof of delivery captured on customer pickup. */
|
||||
|
||||
@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
@@ -252,6 +253,7 @@ const App = () => {
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Signup-flow pages; reached while a session already exists */}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||
|
||||
export type OtpChannel = "phone" | "email";
|
||||
|
||||
export const OTP_LENGTH = 6;
|
||||
|
||||
export interface OtpChannelSelectProps {
|
||||
value: OtpChannel;
|
||||
onChange: (channel: OtpChannel) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Phone/email toggle deciding where the verification code is sent. */
|
||||
export function OtpChannelSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label = "Send verification code via",
|
||||
}: OtpChannelSelectProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
{label}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OtpChannelStepProps {
|
||||
channel: OtpChannel;
|
||||
/** Raw email or phone the code went to; masked before display. */
|
||||
target: string;
|
||||
value: string;
|
||||
onChange: (otp: string) => void;
|
||||
onVerify: () => void;
|
||||
onBack: () => void;
|
||||
onResend: () => void;
|
||||
/** Seconds until resend is allowed; 0 enables the button. */
|
||||
resendIn: number;
|
||||
sending: boolean;
|
||||
verifying: boolean;
|
||||
error: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "enter the code we sent you" stage. Shared by signup and the
|
||||
* forgot-password flow — both send through the same `/api/otp/*` service.
|
||||
*/
|
||||
export default function OtpChannelStep({
|
||||
channel,
|
||||
target,
|
||||
value,
|
||||
onChange,
|
||||
onVerify,
|
||||
onBack,
|
||||
onResend,
|
||||
resendIn,
|
||||
sending,
|
||||
verifying,
|
||||
error,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
}: OtpChannelStepProps) {
|
||||
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
||||
const busy = sending || verifying;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
|
||||
{description ?? "Enter it to continue."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={OTP_LENGTH}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={value}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || value.trim().length !== OTP_LENGTH}
|
||||
onClick={onVerify}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || busy}
|
||||
onClick={onResend}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { passwordRequirements } from "@/utils/passwordSchema";
|
||||
|
||||
export interface PasswordChecklistProps {
|
||||
/** The current password value; the checklist hides itself when empty. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Live pass/fail list of the password rules, shown under a password field. */
|
||||
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(value);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({
|
||||
});
|
||||
}, [roles, nationality, startMutation]);
|
||||
|
||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||
const handleBackToRoles = useCallback(() => { }, []);
|
||||
// Back from the form's first step returns to nationality/role selection.
|
||||
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||
// draft, refreshes the nationality and creates only roles that don't exist yet.
|
||||
const handleBackToRoles = useCallback(() => {
|
||||
setStartError(null);
|
||||
setPhase("nationality-role");
|
||||
}, []);
|
||||
|
||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||
@@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({
|
||||
// The active step across the whole journey, driving the header + progress pill.
|
||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
console.log({ stepMeta, activeStep, STEP_META });
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
// Closing from the congratulations panel also clears the completed flag so a
|
||||
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
|
||||
onSubmit: handleSubmit,
|
||||
isPending: finishMutation.isPending,
|
||||
onBack: handleBackToRoles,
|
||||
hideFirstStepBack: true,
|
||||
initialStep: effectiveResumeStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: handleStepChange,
|
||||
|
||||
@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
|
||||
REFRESH_TOKEN: "/api/auth/refresh-token",
|
||||
LOGOUT: "/api/auth/logout",
|
||||
PROFILE: "/auth/profile",
|
||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||
},
|
||||
|
||||
USERS: {
|
||||
|
||||
24
apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
Normal file
24
apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Seconds a user must wait before another OTP can be requested. */
|
||||
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
|
||||
* than wall-clock arithmetic, so it needs no Date.now().
|
||||
*/
|
||||
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) return;
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [secondsLeft]);
|
||||
|
||||
return {
|
||||
secondsLeft,
|
||||
start: () => setSecondsLeft(seconds),
|
||||
reset: () => setSecondsLeft(0),
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({
|
||||
// Contract ready for signature → "View & sign" jumps straight to the
|
||||
// full-page contract viewer where the signature flow lives.
|
||||
const canSign = bookingIsSignable(booking);
|
||||
const canApproveDelivery = booking.status === "COMPLETED";
|
||||
// Visible from handover generation until the customer signs (flag is attached
|
||||
// by the bookings list endpoint; false again the moment it's signed).
|
||||
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||
|
||||
@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
|
||||
onBack,
|
||||
initialStep,
|
||||
resyncOpen,
|
||||
hideFirstStepBack,
|
||||
onStepChange,
|
||||
onSaveStep,
|
||||
rehydrate,
|
||||
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
|
||||
initialStep?: CompanyStep;
|
||||
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
|
||||
resyncOpen?: boolean;
|
||||
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
|
||||
hideFirstStepBack?: boolean;
|
||||
/** Reports the active step so the parent can persist resume progress. */
|
||||
onStepChange?: (step: CompanyStep) => void;
|
||||
/** Persist the current step's data before advancing; returns an error to show. */
|
||||
@@ -514,10 +511,6 @@ export default function CompanyProfileForm({
|
||||
else setStep(stepOrder[currentIdx - 1]);
|
||||
};
|
||||
|
||||
// Back is hidden on the first step during onboarding (can't return to role
|
||||
// selection); otherwise always available.
|
||||
const showBack = !(hideFirstStepBack && step === "company");
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
@@ -851,17 +844,13 @@ export default function CompanyProfileForm({
|
||||
)}
|
||||
|
||||
<Group justify="space-between" pt="xs">
|
||||
{showBack ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={prevStep}
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={prevStep}
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={nextStep}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { api } from "@/services/api";
|
||||
import type { ResetTicket } from "@/types/auth";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type Stage = "identify" | "otp" | "password";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stage, setStage] = useState<Stage>("identify");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
// The reset ticket lives in memory only — persisting it would leave a
|
||||
// password-change credential sitting in localStorage.
|
||||
const [ticket, setTicket] = useState<ResetTicket | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
/** The identifier as the API will see it — normalised once, reused everywhere. */
|
||||
const normalised = normaliseIdentifier(identifier);
|
||||
|
||||
const sendCode = async () => {
|
||||
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
|
||||
setOtpCode("");
|
||||
resendCooldown.start();
|
||||
};
|
||||
|
||||
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
|
||||
// so we always advance; a non-existent identifier simply never receives a code.
|
||||
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 — trade the code for a single-use ticket.
|
||||
const handleVerify = async () => {
|
||||
setError(null);
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await api.auth.verifyPasswordResetOtp.call({
|
||||
identifier: normalised,
|
||||
channel,
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
setTicket(result);
|
||||
setStage("password");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 — spend the ticket on IAM's set-password.
|
||||
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!ticket) {
|
||||
setError("Your reset session expired. Start again.");
|
||||
setStage("identify");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
try {
|
||||
await api.auth.resetPassword.call({
|
||||
userId: ticket.userId,
|
||||
// The API matches this against email / username / phone, so the typed
|
||||
// identifier works regardless of which one it is.
|
||||
email: normalised,
|
||||
verificationCode: ticket.verificationCode,
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identifierLabel =
|
||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Recover your account"
|
||||
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{stage === "identify" ? (
|
||||
<form onSubmit={handleIdentify} className="flex w-full flex-col">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<KeyRound size={22} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your email or phone number and we'll send you a code to
|
||||
reset it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={sending}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
label="Send the code to"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
The code goes to {identifierLabel}, which may differ from what you
|
||||
typed above.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
disabled={!identifier.trim()}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
Send code
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remembered it?{" "}
|
||||
<Link to="/login" className="font-semibold text-primary hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{stage === "otp" ? (
|
||||
<OtpChannelStep
|
||||
channel={channel}
|
||||
target={normalised}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={handleVerify}
|
||||
onBack={() => {
|
||||
setStage("identify");
|
||||
setError(null);
|
||||
}}
|
||||
onResend={handleResend}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={error}
|
||||
title="Enter your reset code"
|
||||
description="Enter it to choose a new password."
|
||||
submitLabel="Verify code"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stage === "password" ? (
|
||||
<form onSubmit={handleReset} className="flex w-full flex-col">
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Choose a new password
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Pick something strong you haven't used before.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={verifying}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<PasswordChecklist value={password} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm new password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={verifying}
|
||||
error={
|
||||
confirmPassword && confirmPassword !== password
|
||||
? "Passwords do not match"
|
||||
: undefined
|
||||
}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={
|
||||
verifying ||
|
||||
!meetsAllRequirements(password) ||
|
||||
password !== confirmPassword
|
||||
}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={verifying}
|
||||
onClick={() => {
|
||||
setStage("otp");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
function normaliseIdentifier(raw: string): string {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -80,7 +70,7 @@ export default function LoginPage() {
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||
<Link
|
||||
to="#"
|
||||
to="/forgot-password"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -8,27 +17,19 @@ import { z } from "zod";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
import {
|
||||
confirmPasswordField,
|
||||
passwordField,
|
||||
passwordRequirements,
|
||||
samePassword,
|
||||
} from "@/utils/passwordSchema";
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
password: passwordField,
|
||||
confirmPassword: confirmPasswordField,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
.refine(samePassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
@@ -54,7 +55,8 @@ export default function SetPasswordPage() {
|
||||
const password = watch("password");
|
||||
|
||||
const requirements = useMemo(
|
||||
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
|
||||
() =>
|
||||
passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
|
||||
[password],
|
||||
);
|
||||
|
||||
@@ -93,11 +95,21 @@ export default function SetPasswordPage() {
|
||||
"Secure freight operations",
|
||||
"Advanced authentication system",
|
||||
],
|
||||
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
|
||||
stats: {
|
||||
label: "Security Protection",
|
||||
value: "256-bit",
|
||||
footer: "Encrypted",
|
||||
progress: "w-[98%]",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<Box
|
||||
w={48}
|
||||
h={48}
|
||||
bg="edr-soft"
|
||||
className="flex items-center justify-center rounded-2xl"
|
||||
>
|
||||
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
@@ -1,50 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
confirmPasswordField,
|
||||
passwordField,
|
||||
samePassword,
|
||||
} from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
@@ -61,43 +49,23 @@ const userSchema = z
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
password: passwordField,
|
||||
confirmPassword: confirmPasswordField,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
.refine(samePassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
|
||||
type OtpChannel = "phone" | "email";
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
||||
// phone number before the account is actually created. The account is only
|
||||
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
|
||||
// chosen channel before the account is actually created. The account is only
|
||||
// created after the code is verified — the OTP is a hard requirement.
|
||||
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||
@@ -109,14 +77,7 @@ export default function SignupPage() {
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -170,7 +131,7 @@ export default function SignupPage() {
|
||||
setOtpChannel(channel);
|
||||
setOtpCode("");
|
||||
setOtpError(null);
|
||||
setResendIn(60);
|
||||
resendCooldown.start();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
@@ -190,7 +151,7 @@ export default function SignupPage() {
|
||||
: { phone: pendingData.phone },
|
||||
);
|
||||
setOtpCode("");
|
||||
setResendIn(60);
|
||||
resendCooldown.start();
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -202,8 +163,8 @@ export default function SignupPage() {
|
||||
const confirmOtp = async () => {
|
||||
if (!pendingData) return;
|
||||
setOtpError(null);
|
||||
if (otpCode.trim().length !== 6) {
|
||||
setOtpError("Enter the 6-digit code we sent you.");
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
@@ -298,35 +259,11 @@ export default function SignupPage() {
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
@@ -337,37 +274,7 @@ export default function SignupPage() {
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div
|
||||
key={req.label}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
|
||||
>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<PasswordChecklist value={passwordValue} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
@@ -412,87 +319,28 @@ export default function SignupPage() {
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a 6 - digit code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
.Enter it to finish creating your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{otpError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
{otpError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Verify & create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={sending || verifying}
|
||||
onClick={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || sending || verifying}
|
||||
onClick={resendOtp}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<OtpChannelStep
|
||||
channel={otpChannel}
|
||||
target={
|
||||
otpChannel === "email"
|
||||
? (pendingData?.email ?? "")
|
||||
: (pendingData?.phone ?? "")
|
||||
}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={confirmOtp}
|
||||
onBack={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
onResend={resendOtp}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={otpError}
|
||||
description="Enter it to finish creating your account."
|
||||
submitLabel="Verify & create account"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -107,10 +107,12 @@ export function ReadonlyBookingView({
|
||||
(isGeneralContract
|
||||
? status === "FULLY_EXECUTED"
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const canApproveDelivery =
|
||||
status === "COMPLETED" ||
|
||||
Boolean(booking.handoverAwaitingSignature) ||
|
||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
||||
// Approve delivery tracks the handover lifecycle exactly: the button appears
|
||||
// the moment a handover is generated (truck arrival, or an operator's
|
||||
// signature request) and disappears the moment the customer signs it. The
|
||||
// backend flag counts only unsigned SELF_HAUL handovers, so no status
|
||||
// heuristics are needed here.
|
||||
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||
const usesCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT"
|
||||
? !booking.lastMileDeliveryAddress
|
||||
|
||||
@@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map<string, string>([
|
||||
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
|
||||
// Company onboarding document codes (see file-upload-settings seeder).
|
||||
["tin_certificate", "TIN Certificate"],
|
||||
["commercial_license", "Commercial License"],
|
||||
["commercial_license", "Commercial Registration"],
|
||||
["business_license", "Business License / Trade License"],
|
||||
["investment_license", "Investment License"],
|
||||
["national_id", "National ID"],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
@@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) {
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const { company } = useAuth();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
// A contract can only be created under an approved profile — NewContractPage
|
||||
// blocks every operation whose profile isn't "active". With none approved the
|
||||
// page is reachable but unusable, so warn before sending the user there.
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const noActiveProfile =
|
||||
profiles.length > 0 && !profiles.some((p) => p.status === "active");
|
||||
|
||||
const openNewContract = () =>
|
||||
navigate("/contracts/new", { state: { fresh: true } });
|
||||
|
||||
const toggleExpanded = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const nextSet = new Set(prev);
|
||||
@@ -137,9 +153,11 @@ export default function ContractsList() {
|
||||
const stats = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
const active = items.filter((c) =>
|
||||
["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||
c.status,
|
||||
),
|
||||
[
|
||||
"CONTRACT_ACTIVE",
|
||||
"FULLY_EXECUTED",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
].includes(c.status),
|
||||
).length;
|
||||
const pending = items.filter((c) =>
|
||||
[
|
||||
@@ -158,7 +176,7 @@ export default function ContractsList() {
|
||||
return { active, pending, total };
|
||||
}, [data]);
|
||||
|
||||
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
|
||||
const total = data?.meta?.total ?? data?.items?.length ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const pageIndex = pagination.pageIndex;
|
||||
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
|
||||
@@ -175,19 +193,63 @@ export default function ContractsList() {
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Contracts
|
||||
</Title>
|
||||
<Button
|
||||
color="edr-green"
|
||||
<Popover
|
||||
opened={disclaimerOpen}
|
||||
onChange={setDisclaimerOpen}
|
||||
position="bottom-end"
|
||||
width={340}
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
shadow="md"
|
||||
withArrow
|
||||
trapFocus
|
||||
>
|
||||
New Contract
|
||||
</Button>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() =>
|
||||
noActiveProfile
|
||||
? setDisclaimerOpen((o) => !o)
|
||||
: openNewContract()
|
||||
}
|
||||
styles={{
|
||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||
}}
|
||||
>
|
||||
New Contract
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<AlertTriangle
|
||||
size={18}
|
||||
color="var(--mantine-color-edr-accent-6)"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
/>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
None of your profiles are active yet
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text fz={13} c="dimmed">
|
||||
Contracts can only be created under a profile EDR has
|
||||
approved. You can continue, but every operation stays locked
|
||||
until at least one profile is approved.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
||||
{/* Summary strip */}
|
||||
@@ -378,7 +440,11 @@ export default function ContractsList() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11}>
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||
<Inbox
|
||||
size={26}
|
||||
color={MUTED}
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
<Text fz={13} c="dimmed">
|
||||
No contracts yet. Create one from New Contract.
|
||||
</Text>
|
||||
@@ -400,154 +466,159 @@ export default function ContractsList() {
|
||||
const isOpen = expanded.has(c.id);
|
||||
return (
|
||||
<Fragment key={c.id}>
|
||||
<Table.Tr
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isOpen ? "#F4FBF8" : undefined,
|
||||
}}
|
||||
onClick={() => navigate(`/contracts/${c.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Box
|
||||
component="button"
|
||||
aria-label={isOpen ? "Hide progress" : "Show progress"}
|
||||
aria-expanded={isOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded(c.id);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${BORDER}`,
|
||||
background: isOpen ? GREEN : "#FFFFFF",
|
||||
color: isOpen ? "#FFFFFF" : MUTED,
|
||||
cursor: "pointer",
|
||||
transition: "all 140ms ease",
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(180deg)" : "none",
|
||||
transition: "transform 160ms ease",
|
||||
<Table.Tr
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isOpen ? "#F4FBF8" : undefined,
|
||||
}}
|
||||
onClick={() => navigate(`/contracts/${c.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Box
|
||||
component="button"
|
||||
aria-label={
|
||||
isOpen ? "Hide progress" : "Show progress"
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded(c.id);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{c.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{isContainer ? "Containerised" : "Bulk"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isGeneral ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{isGeneral ? "General" : "One-Time"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={7} wrap="nowrap" align="center">
|
||||
{isContainer ? (
|
||||
<Package size={15} color={MUTED} />
|
||||
) : (
|
||||
<Weight size={15} color={MUTED} />
|
||||
)}
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${BORDER}`,
|
||||
background: isOpen ? GREEN : "#FFFFFF",
|
||||
color: isOpen ? "#FFFFFF" : MUTED,
|
||||
cursor: "pointer",
|
||||
transition: "all 140ms ease",
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(180deg)" : "none",
|
||||
transition: "transform 160ms ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{c.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{origin}{" "}
|
||||
<Text span c="dimmed">
|
||||
→
|
||||
</Text>{" "}
|
||||
{destination}
|
||||
{count > 1 && (
|
||||
<Text span c="dimmed" fz={12}>
|
||||
{" "}
|
||||
+{count - 1}
|
||||
<Text fz={12} c="dimmed">
|
||||
{isContainer ? "Containerised" : "Bulk"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isGeneral ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{isGeneral ? "General" : "One-Time"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={7} wrap="nowrap" align="center">
|
||||
{isContainer ? (
|
||||
<Package size={15} color={MUTED} />
|
||||
) : (
|
||||
<Weight size={15} color={MUTED} />
|
||||
)}
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={dir ? undefined : "dimmed"}
|
||||
style={{ color: dir ? INK : undefined }}
|
||||
>
|
||||
{tradeLabel}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{c.paymentCurrency ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={c.createdAt ? undefined : "dimmed"}
|
||||
style={{ color: c.createdAt ? INK : undefined }}
|
||||
>
|
||||
{c.createdAt
|
||||
? new Date(c.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={c.contractValidUntil ? undefined : "dimmed"}
|
||||
style={{
|
||||
color: c.contractValidUntil ? INK : undefined,
|
||||
}}
|
||||
>
|
||||
{c.contractValidUntil
|
||||
? new Date(
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{origin}{" "}
|
||||
<Text span c="dimmed">
|
||||
→
|
||||
</Text>{" "}
|
||||
{destination}
|
||||
{count > 1 && (
|
||||
<Text span c="dimmed" fz={12}>
|
||||
{" "}
|
||||
+{count - 1}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={dir ? undefined : "dimmed"}
|
||||
style={{ color: dir ? INK : undefined }}
|
||||
>
|
||||
{tradeLabel}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{c.paymentCurrency ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={c.createdAt ? undefined : "dimmed"}
|
||||
style={{ color: c.createdAt ? INK : undefined }}
|
||||
>
|
||||
{c.createdAt
|
||||
? new Date(c.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={c.contractValidUntil ? undefined : "dimmed"}
|
||||
style={{
|
||||
color: c.contractValidUntil ? INK : undefined,
|
||||
}}
|
||||
>
|
||||
{c.contractValidUntil
|
||||
? new Date(
|
||||
c.contractValidUntil,
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ContractStatusBadge status={c.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||
<ContractDocButton
|
||||
contract={c}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<ContractCustomerAction
|
||||
contract={c}
|
||||
bookings={bookings}
|
||||
size="sm"
|
||||
listStyle
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr style={{ background: "#F4FBF8" }}>
|
||||
<Table.Td colSpan={11} style={{ padding: "6px 20px 18px" }}>
|
||||
<ContractStepBanner contract={c} />
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ContractStatusBadge status={c.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||
<ContractDocButton
|
||||
contract={c}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<ContractCustomerAction
|
||||
contract={c}
|
||||
bookings={bookings}
|
||||
size="sm"
|
||||
listStyle
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Table.Tr style={{ background: "#F4FBF8" }}>
|
||||
<Table.Td
|
||||
colSpan={11}
|
||||
style={{ padding: "6px 20px 18px" }}
|
||||
>
|
||||
<ContractStepBanner contract={c} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -564,7 +635,10 @@ export default function ContractsList() {
|
||||
gap="md"
|
||||
px={20}
|
||||
py={14}
|
||||
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
||||
style={{
|
||||
borderTop: `1px solid ${BORDER}`,
|
||||
background: "#FCFDFE",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<Text fz={13} c="dimmed">
|
||||
@@ -574,8 +648,7 @@ export default function ContractsList() {
|
||||
data={["10", "25", "50"]}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={(v) =>
|
||||
v &&
|
||||
setPagination({ pageIndex: 0, pageSize: Number(v) })
|
||||
v && setPagination({ pageIndex: 0, pageSize: Number(v) })
|
||||
}
|
||||
radius="md"
|
||||
size="xs"
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function NationalitySelect({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<RoleCard
|
||||
label="Ethiopian Company"
|
||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
|
||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
|
||||
icon={<MapPin size={22} />}
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
|
||||
@@ -77,6 +77,9 @@ import type {
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
ResetTicket,
|
||||
} from "@/types/auth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,6 +113,21 @@ export const api = {
|
||||
"setPassword",
|
||||
authService.setPassword,
|
||||
),
|
||||
requestPasswordReset: endpoint<ForgotPasswordRequestPayload, void>(
|
||||
"auth",
|
||||
"requestPasswordReset",
|
||||
authService.requestPasswordReset,
|
||||
),
|
||||
verifyPasswordResetOtp: endpoint<ForgotPasswordVerifyPayload, ResetTicket>(
|
||||
"auth",
|
||||
"verifyPasswordResetOtp",
|
||||
authService.verifyPasswordResetOtp,
|
||||
),
|
||||
resetPassword: endpoint<SetPasswordPayload, void>(
|
||||
"auth",
|
||||
"resetPassword",
|
||||
authService.resetPassword,
|
||||
),
|
||||
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||
"auth",
|
||||
"checkAvailability",
|
||||
|
||||
@@ -3,11 +3,14 @@ import type {
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
ResetTicket,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
@@ -53,6 +56,31 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// The three calls below drive the unauthenticated forgot-password flow.
|
||||
// Responses under /api/auth are *flattened* by the API's response
|
||||
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||
|
||||
requestPasswordReset: async (body: ForgotPasswordRequestPayload) => {
|
||||
await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body);
|
||||
},
|
||||
|
||||
verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => {
|
||||
const res = await client.post<ResetTicket>(
|
||||
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY,
|
||||
body,
|
||||
);
|
||||
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
|
||||
},
|
||||
|
||||
/**
|
||||
* Spend the reset ticket. Distinct from `setPassword` above, which the
|
||||
* authenticated post-signup flow drives through `useAuth` — this one carries
|
||||
* its own userId/verificationCode and never touches the session.
|
||||
*/
|
||||
resetPassword: async (body: SetPasswordPayload) => {
|
||||
await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body);
|
||||
},
|
||||
|
||||
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||
const res = await client.get<CheckAvailabilityResponse>(
|
||||
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||
|
||||
@@ -63,6 +63,25 @@ export interface SetPasswordPayload {
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
/** The channel a password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ForgotPasswordRequestPayload {
|
||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||
identifier: string;
|
||||
channel: ResetChannel;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface GenerateVerificationCodePayload {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
|
||||
22
apps/edr-freight-web/portal/src/utils/identifier.ts
Normal file
22
apps/edr-freight-web/portal/src/utils/identifier.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
export function normaliseIdentifier(raw: string): string {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
export const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
export const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
38
apps/edr-freight-web/portal/src/utils/passwordSchema.ts
Normal file
38
apps/edr-freight-web/portal/src/utils/passwordSchema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
|
||||
export const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
|
||||
* — a password this accepts but the API rejects surfaces as an opaque 400.
|
||||
*/
|
||||
export const passwordField = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
|
||||
|
||||
export const confirmPasswordField = z
|
||||
.string()
|
||||
.min(1, "Please confirm your password");
|
||||
|
||||
export const samePassword = (data: {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}) => data.password === data.confirmPassword;
|
||||
|
||||
/** Every requirement in {@link passwordRequirements} is satisfied. */
|
||||
export const meetsAllRequirements = (value: string) =>
|
||||
passwordRequirements.every((r) => r.test(value));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
@@ -34,6 +34,12 @@ export class AgentsController {
|
||||
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
|
||||
return this.service.updateAgent(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete agent profile' })
|
||||
deleteAgent(@Param('id') id: string) {
|
||||
return this.service.deleteAgent(id);
|
||||
}
|
||||
@Post('bookings')
|
||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||
|
||||
@@ -210,4 +210,11 @@ export class AgentsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { id } });
|
||||
if (!agent) throw new NotFoundException('Agent not found');
|
||||
await this.prisma.agent.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { ExcessBaggageService } from './excess-baggage.service';
|
||||
@@ -26,7 +26,8 @@ export class ExcessBaggageAgentController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
||||
logCharge(@Body() dto: LogExcessBaggageDto) {
|
||||
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
|
||||
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
|
||||
return this.service.logCharge(dto);
|
||||
}
|
||||
|
||||
@@ -50,24 +51,6 @@ export class ExcessBaggageAgentController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
|
||||
getCharge(@Param('id') id: string) {
|
||||
return this.service.getCharge(id);
|
||||
}
|
||||
|
||||
@Post(':id/resend')
|
||||
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
|
||||
resendLink(@Param('id') id: string) {
|
||||
return this.service.resendLink(id);
|
||||
}
|
||||
|
||||
@Patch(':id/waive')
|
||||
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
|
||||
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
|
||||
return this.service.waiveCharge(id, dto);
|
||||
}
|
||||
|
||||
@Get('allowances')
|
||||
@ApiOperation({ summary: 'List all baggage allowance rules' })
|
||||
getAllowances() {
|
||||
@@ -92,6 +75,24 @@ export class ExcessBaggageAgentController {
|
||||
return this.service.deleteAllowance(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
|
||||
getCharge(@Param('id') id: string) {
|
||||
return this.service.getCharge(id);
|
||||
}
|
||||
|
||||
@Post(':id/resend')
|
||||
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
|
||||
resendLink(@Param('id') id: string) {
|
||||
return this.service.resendLink(id);
|
||||
}
|
||||
|
||||
@Patch(':id/waive')
|
||||
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
|
||||
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
|
||||
return this.service.waiveCharge(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
||||
deleteCharge(@Param('id') id: string) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class LogExcessBaggageDto {
|
||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
||||
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
|
||||
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
|
||||
@IsOptional() @IsString() agentId?: string;
|
||||
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
|
||||
@IsInt() @IsPositive() excessWeightKg: number;
|
||||
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
|
||||
|
||||
@@ -75,7 +75,7 @@ export class ExcessBaggageService {
|
||||
const charge = await this.prisma.excessBaggageCharge.create({
|
||||
data: {
|
||||
bookingId: dto.bookingId,
|
||||
agentId: dto.agentId,
|
||||
agentId: dto.agentId ?? '',
|
||||
excessWeightKg: dto.excessWeightKg,
|
||||
feePerKgMinor,
|
||||
totalMinor,
|
||||
|
||||
@@ -154,43 +154,54 @@ export class SearchService {
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
|
||||
const now = new Date();
|
||||
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
|
||||
const daysAfter = 14 - daysBefore;
|
||||
|
||||
const windowStart = new Date(requestedDate);
|
||||
windowStart.setDate(windowStart.getDate() - daysBefore);
|
||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||
|
||||
const windowEnd = new Date(requestedDate);
|
||||
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
|
||||
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
const NEEDED = 3;
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: 'SCHEDULED',
|
||||
isPackageOnly: false,
|
||||
OR: [
|
||||
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
||||
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
||||
],
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
include: SCHEDULE_INCLUDE,
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
const baseWhere = {
|
||||
status: 'SCHEDULED',
|
||||
isPackageOnly: false,
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
} as const;
|
||||
|
||||
const results = await Promise.all(
|
||||
schedules.map(schedule =>
|
||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||
)
|
||||
);
|
||||
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
|
||||
// Fetch candidates before and after in parallel; take more than needed to
|
||||
// account for routes that don't serve the destination or have no availability.
|
||||
const FETCH_LIMIT = NEEDED * 5;
|
||||
|
||||
const [beforeCandidates, afterCandidates] = await Promise.all([
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } },
|
||||
include: SCHEDULE_INCLUDE,
|
||||
orderBy: { departureAt: 'desc' },
|
||||
take: FETCH_LIMIT,
|
||||
}),
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } },
|
||||
include: SCHEDULE_INCLUDE,
|
||||
orderBy: { departureAt: 'asc' },
|
||||
take: FETCH_LIMIT,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pickN = async (candidates: typeof beforeCandidates, limit: number) => {
|
||||
const out: NonNullable<Awaited<ReturnType<typeof this.buildScheduleResult>>>[] = [];
|
||||
for (const schedule of candidates) {
|
||||
if (out.length >= limit) break;
|
||||
const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality);
|
||||
if (r?.hasAvailability) out.push(r);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const [before, after] = await Promise.all([
|
||||
pickN(beforeCandidates, NEEDED),
|
||||
pickN(afterCandidates, NEEDED),
|
||||
]);
|
||||
|
||||
// before was fetched desc (closest first); reverse so result is chronological
|
||||
return [...before.reverse(), ...after];
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
@@ -415,7 +426,7 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality);
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
@@ -638,6 +649,10 @@ export class SearchService {
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||
|
||||
const nationalityUpper = (nationality ?? '').toUpperCase();
|
||||
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
|
||||
? 'LOCAL' : 'INTERNATIONAL';
|
||||
|
||||
// Collect seat class IDs from the schedule include for the ID set,
|
||||
// but fetch fresh records from DB so updated baseFareMinor is always current
|
||||
const seatClassIdSet = new Set<string>();
|
||||
@@ -647,7 +662,14 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
const freshSeatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
|
||||
where: {
|
||||
id: { in: Array.from(seatClassIdSet) },
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ nationalityType: null },
|
||||
{ nationalityType: nationalityType },
|
||||
],
|
||||
},
|
||||
});
|
||||
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
|
||||
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
@@ -725,6 +747,7 @@ export class SearchService {
|
||||
private buildCoachTypeDetails(
|
||||
schedule: ScheduleWithIncludes,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||
nationality?: string,
|
||||
): Array<{
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
@@ -749,8 +772,16 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
const nationalityUpper = (nationality ?? '').toUpperCase();
|
||||
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
|
||||
? 'LOCAL' : 'INTERNATIONAL';
|
||||
|
||||
const entry = coachTypeMap.get(coachType.id)!;
|
||||
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
|
||||
coachType.seatClasses?.forEach((sc: any) => {
|
||||
// Exclude classes that belong to the wrong nationality type
|
||||
if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return;
|
||||
if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name);
|
||||
});
|
||||
}
|
||||
|
||||
const result = [];
|
||||
@@ -769,6 +800,7 @@ export class SearchService {
|
||||
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
if (classes.length === 0) continue;
|
||||
result.push({
|
||||
coachTypeId: coachType.id,
|
||||
coachTypeName: coachType.name,
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Eye } from 'lucide-react';
|
||||
import { Plus, Edit, Eye, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { agentsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
@@ -48,6 +49,19 @@ export default function AgentsPage() {
|
||||
const [editingAgent, setEditingAgent] = useState<any>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null });
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => agentsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
setDeleteConfirm({ isOpen: false, agent: null });
|
||||
setDeleteError(null);
|
||||
},
|
||||
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
|
||||
onSuccess: () => {
|
||||
@@ -122,6 +136,12 @@ export default function AgentsPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -169,6 +189,18 @@ export default function AgentsPage() {
|
||||
emptyMessage="No agents found"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }}
|
||||
onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }}
|
||||
title="Delete Agent"
|
||||
message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
error={deleteError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Agent Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
||||
{selected && (() => {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Send, Trash2 } from 'lucide-react';
|
||||
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { excessBaggageApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
const STATUS_VARIANT: Record<string, any> = {
|
||||
PENDING: 'PENDING',
|
||||
@@ -22,9 +23,16 @@ export default function ExcessBaggagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [waiveModal, setWaiveModal] = useState<any>(null);
|
||||
const [waiveReason, setWaiveReason] = useState('');
|
||||
const [waiveError, setWaiveError] = useState<string | null>(null);
|
||||
const [logModal, setLogModal] = useState(false);
|
||||
const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false });
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
const [resendModal, setResendModal] = useState<any>(null);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
const [resendError, setResendError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['excess-baggage', filters],
|
||||
@@ -37,6 +45,17 @@ export default function ExcessBaggagePage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const logMutation = useMutation({
|
||||
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
|
||||
setLogModal(false);
|
||||
setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false });
|
||||
setLogError(null);
|
||||
},
|
||||
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
|
||||
});
|
||||
|
||||
const waiveMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
|
||||
@@ -51,7 +70,12 @@ export default function ExcessBaggagePage() {
|
||||
|
||||
const resendMutation = useMutation({
|
||||
mutationFn: (id: string) => excessBaggageApi.resendLink(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
|
||||
setResendSuccess(true);
|
||||
setResendError(null);
|
||||
},
|
||||
onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -115,7 +139,7 @@ export default function ExcessBaggagePage() {
|
||||
label: 'Resend Link',
|
||||
icon: Send,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => resendMutation.mutate(c.id),
|
||||
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
|
||||
show: (c: any) => c.status === 'PENDING',
|
||||
},
|
||||
{
|
||||
@@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
||||
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}>
|
||||
Log Excess Luggage
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() {
|
||||
emptyMessage="No excess baggage charges found"
|
||||
/>
|
||||
|
||||
{/* Log Excess Luggage Modal */}
|
||||
<Modal isOpen={logModal} onClose={() => setLogModal(false)} title="Log Excess Luggage" size="sm">
|
||||
<div className="space-y-4">
|
||||
{user && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Booking ID</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Booking UUID"
|
||||
value={logForm.bookingId}
|
||||
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Excess Weight (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="input"
|
||||
placeholder="e.g. 5"
|
||||
value={logForm.excessWeightKg}
|
||||
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={logForm.collectCash}
|
||||
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
|
||||
/>
|
||||
Collect cash now (no payment link sent)
|
||||
</label>
|
||||
{!logForm.collectCash && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A payment link will be sent to the passenger's email and phone on file.
|
||||
</p>
|
||||
)}
|
||||
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
|
||||
<ActionButton
|
||||
loading={logMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
|
||||
setLogError('Booking ID and excess weight are required');
|
||||
return;
|
||||
}
|
||||
logMutation.mutate({
|
||||
bookingId: logForm.bookingId.trim(),
|
||||
excessWeightKg: parseInt(logForm.excessWeightKg),
|
||||
collectCash: logForm.collectCash,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Resend Link Modal */}
|
||||
<Modal isOpen={!!resendModal} onClose={() => setResendModal(null)} title="Resend Payment Link" size="sm">
|
||||
{resendModal && (
|
||||
<div className="space-y-4">
|
||||
{resendSuccess ? (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ Payment link resent successfully. Expiry extended by 20 minutes.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Resend payment link for booking{' '}
|
||||
<span className="font-mono font-semibold text-foreground">{resendModal.booking?.bookingRef}</span>?
|
||||
</p>
|
||||
<div className="text-sm space-y-1">
|
||||
{resendModal.contactPhone && <div className="text-muted-foreground">📱 {resendModal.contactPhone}</div>}
|
||||
{resendModal.contactEmail && <div className="text-muted-foreground">✉ {resendModal.contactEmail}</div>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Amount: <span className="font-semibold">{formatCurrency(resendModal.totalMinor, resendModal.currency)}</span>. Expiry will be extended by 20 minutes.</p>
|
||||
{resendError && <p className="text-sm text-red-600 dark:text-red-400">{resendError}</p>}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => setResendModal(null)}>Close</ActionButton>
|
||||
{!resendSuccess && (
|
||||
<ActionButton icon={Send} loading={resendMutation.isPending} onClick={() => resendMutation.mutate(resendModal.id)}>
|
||||
Resend
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Waive Modal */}
|
||||
<Modal
|
||||
isOpen={!!waiveModal}
|
||||
|
||||
@@ -29,12 +29,12 @@ export default function LoginPage() {
|
||||
const [emailFocused, setEmailFocused] = useState(false);
|
||||
const [passwordFocused, setPasswordFocused] = useState(false);
|
||||
|
||||
const [view, setView] = useState<'login' | 'forgot'>('login');
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState('');
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const [forgotFocused, setForgotFocused] = useState(false);
|
||||
const [view, setView] = useState<'login' | 'forgot'>('login');
|
||||
const [forgotIdentifier, setForgotIdentifier] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState('');
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const [forgotFocused, setForgotFocused] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
@@ -66,13 +66,13 @@ export default function LoginPage() {
|
||||
setForgotLoading(true);
|
||||
setForgotError('');
|
||||
try {
|
||||
await iamAuthApi.forgotPassword(forgotEmail);
|
||||
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||
setForgotSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setForgotError(
|
||||
msg === 'user_not_found'
|
||||
? 'No account found with that email address.'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
);
|
||||
} finally {
|
||||
@@ -209,7 +209,7 @@ export default function LoginPage() {
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView('forgot'); setForgotEmail(email); setError(''); }}
|
||||
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
|
||||
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
@@ -260,7 +260,7 @@ export default function LoginPage() {
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter your email address and we'll send a reset link to the phone number on your account.
|
||||
Enter your email or phone number and we'll send a reset link to the phone number on your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -295,10 +295,10 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||
{/* Email field */}
|
||||
{/* Email or phone field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Email address
|
||||
Email or phone number
|
||||
</label>
|
||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||
forgotFocused
|
||||
@@ -306,15 +306,15 @@ export default function LoginPage() {
|
||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||
}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => { setForgotEmail(e.target.value); setForgotError(''); }}
|
||||
type="text"
|
||||
value={forgotIdentifier}
|
||||
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
|
||||
onFocus={() => setForgotFocused(true)}
|
||||
onBlur={() => setForgotFocused(false)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||
placeholder="name@edr.com"
|
||||
placeholder="name@edr.com or +251..."
|
||||
required
|
||||
autoComplete="email"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,9 +322,9 @@ export default function LoginPage() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading || !forgotEmail}
|
||||
disabled={forgotLoading || !forgotIdentifier.trim()}
|
||||
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: forgotLoading || !forgotEmail
|
||||
style={{ background: forgotLoading || !forgotIdentifier.trim()
|
||||
? 'rgb(20,113,76)'
|
||||
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||
}}
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' },
|
||||
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -440,7 +441,7 @@ export default function SchedulesPage() {
|
||||
label: 'Departure',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<span className="font-mono text-sm">{new Date(schedule.departureAt).toLocaleString()}</span>
|
||||
<span className="font-mono text-sm">{formatDateTime(schedule.departureAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -448,7 +449,7 @@ export default function SchedulesPage() {
|
||||
label: 'Arrival',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<span className="font-mono text-sm">{new Date(schedule.arrivalAt).toLocaleString()}</span>
|
||||
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -641,7 +642,7 @@ export default function SchedulesPage() {
|
||||
}
|
||||
}}
|
||||
title="Cancel Schedule"
|
||||
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
|
||||
message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`}
|
||||
confirmText="Cancel Schedule"
|
||||
isDanger={true}
|
||||
isLoading={cancelScheduleMutation.isPending}
|
||||
@@ -656,7 +657,7 @@ export default function SchedulesPage() {
|
||||
deleteConfirm.isBulk
|
||||
? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.`
|
||||
: `Are you sure you want to delete this schedule departing on ${
|
||||
deleteConfirm.item ? new Date(deleteConfirm.item.departureAt).toLocaleString() : ''
|
||||
deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : ''
|
||||
}?`
|
||||
}
|
||||
confirmText="Delete"
|
||||
|
||||
@@ -166,10 +166,13 @@ export default function TariffRatesPage() {
|
||||
},
|
||||
{
|
||||
key: 'coachType', label: 'Coach Type',
|
||||
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
|
||||
render: (c: any) => {
|
||||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'bedPosition', label: 'Berth Position',
|
||||
key: 'bedPosition', label: 'Bed Position',
|
||||
render: (c: any) => c.bedPosition
|
||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
||||
: <span className="text-muted-foreground text-xs">Standard</span>,
|
||||
@@ -215,8 +218,9 @@ export default function TariffRatesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
|
||||
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId)
|
||||
?? editingClass?.coachType;
|
||||
const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -224,7 +228,7 @@ export default function TariffRatesPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
|
||||
Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
|
||||
@@ -237,7 +241,7 @@ export default function TariffRatesPage() {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, nationality, berth position..."
|
||||
placeholder="Search by name, nationality, bed position..."
|
||||
className="input pl-10 w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -311,17 +315,17 @@ export default function TariffRatesPage() {
|
||||
|
||||
{isBedCoach && (
|
||||
<div>
|
||||
<label className="label">Berth Position *</label>
|
||||
<label className="label">Bed Position *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedBedPosition}
|
||||
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
||||
required={isBedCoach}
|
||||
>
|
||||
<option value="">Select berth position</option>
|
||||
<option value="">Select bed position</option>
|
||||
{(selectedCoachType?.code === 'HBC'
|
||||
? BED_POSITIONS
|
||||
: (['UPPER', 'LOWER'] as const)
|
||||
: (['Upper','Middle', 'Lower'] as const)
|
||||
).map((pos) => (
|
||||
<option key={pos} value={pos}>{pos}</option>
|
||||
))}
|
||||
|
||||
@@ -38,13 +38,6 @@ export default function TicketsPage() {
|
||||
const [excessError, setExcessError] = useState<string | null>(null);
|
||||
const [excessResult, setExcessResult] = useState<any>(null);
|
||||
|
||||
const { data: agentData } = useQuery({
|
||||
queryKey: ['agent-me'],
|
||||
queryFn: () => apiClient.get<any>('/agents/me'),
|
||||
enabled: !!user,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
@@ -133,11 +126,8 @@ export default function TicketsPage() {
|
||||
const handleExcessSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!excessTicket) return;
|
||||
const agentId = agentData?.id;
|
||||
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
|
||||
await excessMutation.mutateAsync({
|
||||
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
||||
agentId,
|
||||
excessWeightKg: parseInt(excessKg),
|
||||
collectCash: excessCollectCash,
|
||||
});
|
||||
@@ -353,15 +343,12 @@ export default function TicketsPage() {
|
||||
key: 'contact',
|
||||
label: 'Contact',
|
||||
render: (ticket: any) => {
|
||||
const phone = ticket.booking?.passenger?.phone || 'N/A';
|
||||
const email = ticket.booking?.passenger?.email || 'N/A';
|
||||
|
||||
const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—';
|
||||
const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—';
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">{phone}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -486,7 +473,7 @@ export default function TicketsPage() {
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Baggage',
|
||||
label: 'Luggage',
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
@@ -912,14 +899,9 @@ export default function TicketsPage() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
|
||||
</div>
|
||||
{agentData && (
|
||||
{user && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
|
||||
</div>
|
||||
)}
|
||||
{!agentData && (
|
||||
<div className="text-sm text-amber-600 dark:text-amber-400">
|
||||
⚠ No agent profile linked to your account.
|
||||
Agent: <span className="font-semibold text-foreground">{user.fullName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -4,8 +4,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
|
||||
export const iamAuthApi = {
|
||||
forgotPassword: (email: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||
// `identifier` can be an email address or a phone number — the IAM accepts
|
||||
// either in the `email` field of the forgot-password body.
|
||||
forgotPassword: (identifier: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
|
||||
|
||||
resetPassword: (data: {
|
||||
userId: string;
|
||||
|
||||
@@ -216,6 +216,7 @@ export const agentsApi = {
|
||||
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
|
||||
create: (data: any) => apiClient.post<any>('/agents', data),
|
||||
update: (id: string, data: any) => apiClient.patch<any>(`/agents/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/agents/${id}`),
|
||||
getShifts: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/shifts`),
|
||||
openShift: (agentId: string, data: any) => apiClient.post<any>(`/agents/${agentId}/shifts/open`, data),
|
||||
closeShift: (shiftId: string, data: any) => apiClient.post<any>(`/agents/shifts/${shiftId}/close`, data),
|
||||
|
||||
@@ -10,7 +10,10 @@ export const passengersApi = {
|
||||
if (filters?.role) params.append('role', filters.role);
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||
|
||||
if ((filters as any)?.gender) params.append('gender', (filters as any).gender);
|
||||
if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality);
|
||||
if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom);
|
||||
if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo);
|
||||
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
|
||||
},
|
||||
|
||||
@@ -21,4 +24,8 @@ export const passengersApi = {
|
||||
update: (id: string, data: Partial<Passenger.IPassenger>) => {
|
||||
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
|
||||
},
|
||||
|
||||
delete: (id: string, cascade = false) => {
|
||||
return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,21 +19,21 @@ export const formatDateTime = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
return format(d, 'MMM dd, yyyy h:mm a');
|
||||
};
|
||||
|
||||
export const formatDateTimeShort = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'dd MMM yy HH:mm');
|
||||
return format(d, 'dd MMM yy h:mm a');
|
||||
};
|
||||
|
||||
export const formatDateTimeLocal = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
return format(d, 'MMM dd, yyyy h:mm a');
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
|
||||
@@ -188,7 +188,7 @@ export default function ConfirmationPage() {
|
||||
if (!bookingId || !pnr) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Success Header */}
|
||||
@@ -456,26 +456,29 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||
>
|
||||
{isGeneratingVoucher ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Download Voucher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action Buttons — voucher/ticket only makes sense once the booking is
|
||||
actually confirmed; a pending-payment booking has no real ticket yet. */}
|
||||
{isConfirmed && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||
>
|
||||
{isGeneratingVoucher ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Download Voucher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Booking Button */}
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ProgressIndicator } from '@/components/ProgressIndicator';
|
||||
import { PageTransition } from '@/components/PageTransition';
|
||||
|
||||
export default function BookingLayout({
|
||||
children,
|
||||
@@ -31,7 +32,7 @@ export default function BookingLayout({
|
||||
<ProgressIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,30 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
|
||||
|
||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
const COUNTRIES = [
|
||||
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
|
||||
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
|
||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
|
||||
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
|
||||
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
|
||||
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
|
||||
'Latvia','Lebanon','Lesotho','Liberia','Libya','Liechtenstein','Lithuania','Luxembourg','Madagascar','Malawi',
|
||||
'Malaysia','Maldives','Mali','Malta','Marshall Islands','Mauritania','Mauritius','Mexico','Micronesia','Moldova',
|
||||
'Monaco','Mongolia','Montenegro','Morocco','Mozambique','Myanmar','Namibia','Nauru','Nepal','Netherlands',
|
||||
'New Zealand','Nicaragua','Niger','Nigeria','North Korea','North Macedonia','Norway','Oman','Pakistan','Palau',
|
||||
'Palestine','Panama','Papua New Guinea','Paraguay','Peru','Philippines','Poland','Portugal','Qatar','Romania',
|
||||
'Russia','Rwanda','Saint Kitts and Nevis','Saint Lucia','Saint Vincent and the Grenadines','Samoa','San Marino',
|
||||
'Sao Tome and Principe','Saudi Arabia','Senegal','Serbia','Seychelles','Sierra Leone','Singapore','Slovakia',
|
||||
'Slovenia','Solomon Islands','Somalia','South Africa','South Korea','South Sudan','Spain','Sri Lanka','Sudan',
|
||||
'Suriname','Sweden','Switzerland','Syria','Taiwan','Tajikistan','Tanzania','Thailand','Timor-Leste','Togo',
|
||||
'Tonga','Trinidad and Tobago','Tunisia','Turkey','Turkmenistan','Tuvalu','Uganda','Ukraine','United Arab Emirates',
|
||||
'United Kingdom','United States','Uruguay','Uzbekistan','Vanuatu','Vatican City','Venezuela','Vietnam',
|
||||
'Yemen','Zambia','Zimbabwe',
|
||||
] as const;
|
||||
|
||||
function daysInGCMonth(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate();
|
||||
}
|
||||
@@ -554,6 +578,12 @@ const passengerSchema = z.object({
|
||||
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||
}
|
||||
if (data.passportExpiryDate) {
|
||||
const expiry = new Date(data.passportExpiryDate);
|
||||
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passportExpiryDate'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -994,7 +1024,7 @@ export default function PassengersPage() {
|
||||
|
||||
if (!formInitialized || faydaCompleting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
||||
@@ -1013,7 +1043,7 @@ export default function PassengersPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
|
||||
<h1 className="section-title">Passenger details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
@@ -1312,11 +1342,15 @@ export default function PassengersPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
|
||||
<input
|
||||
<select
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
|
||||
placeholder="e.g., Djibouti"
|
||||
/>
|
||||
>
|
||||
<option value="">Select country</option>
|
||||
{COUNTRIES.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.passengers?.[index]?.passportCountry && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||
)}
|
||||
@@ -1336,8 +1370,12 @@ export default function PassengersPage() {
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.passportExpiryDate ? 'border-red-500' : ''}`}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportExpiryDate && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportExpiryDate?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,10 +72,10 @@ export default function PaymentPage() {
|
||||
// split equally across both legs. This guarantees leg totals are consistent with the
|
||||
// per-passenger breakdown rows and the overall reviewed total.
|
||||
const outboundBaseFare = isRoundTrip
|
||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
|
||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||
: 0;
|
||||
const inboundBaseFare = isRoundTrip
|
||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
|
||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||
: 0;
|
||||
|
||||
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
||||
@@ -188,10 +188,10 @@ export default function PaymentPage() {
|
||||
|
||||
if (!bookingId && !pnr) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
|
||||
<div className="booking-page flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading payment details...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading payment details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -307,11 +307,11 @@ export default function PaymentPage() {
|
||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Outbound</span>
|
||||
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||
<span>{formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Return</span>
|
||||
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||
<span>{formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -372,10 +372,10 @@ export default function PaymentPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
<h1 className="section-title">Complete payment</h1>
|
||||
|
||||
{/* Reservation confirmation banner */}
|
||||
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">
|
||||
|
||||
@@ -197,17 +197,12 @@ export default function ResultsPage() {
|
||||
// Alternatives are surfaced whenever a leg returns no exact-date results.
|
||||
const alternativeOutbound: Schedule[] =
|
||||
!!results && outboundSchedules.length === 0
|
||||
? results?.alternativeOutbound || []
|
||||
? results?.alternativeOutbound || results?.outboundAlternatives || []
|
||||
: [];
|
||||
const alternativeInbound: Schedule[] =
|
||||
isRoundTrip && !!results && inboundSchedules.length === 0
|
||||
? results?.alternativeInbound || []
|
||||
? results?.alternativeInbound || results?.inboundAlternatives || []
|
||||
: [];
|
||||
const requestedDate: string =
|
||||
(results && results.requestedDate) || searchData.date;
|
||||
const requestedReturnDate: string =
|
||||
(results && results.requestedReturnDate) || searchData.returnDate || "";
|
||||
|
||||
const isOneWayNoOutbound =
|
||||
!isRoundTrip && !!results && outboundSchedules.length === 0;
|
||||
// Round-trip: show results view if either leg has exact results OR alternatives.
|
||||
@@ -298,7 +293,17 @@ export default function ResultsPage() {
|
||||
|
||||
// For round trip inbound, proceed with both schedules
|
||||
if (isRoundTrip && !isOutbound) {
|
||||
setInboundSchedule(scheduleData);
|
||||
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
|
||||
// return seat selection page shows the same prices as the outbound leg.
|
||||
const inboundScheduleData = outboundScheduleData
|
||||
? {
|
||||
...scheduleData,
|
||||
baseFareAdult: outboundScheduleData.baseFareAdult,
|
||||
baseFareChild: outboundScheduleData.baseFareChild,
|
||||
coachTypes: outboundScheduleData.coachTypes,
|
||||
}
|
||||
: scheduleData;
|
||||
setInboundSchedule(inboundScheduleData);
|
||||
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||
} else {
|
||||
// For one-way
|
||||
@@ -529,7 +534,7 @@ export default function ResultsPage() {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
className="btn-primary w-full mt-3 text-sm active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
@@ -647,9 +652,9 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{schedule.departureAt
|
||||
? formatTime(schedule.departureAt)
|
||||
: "--:--"}
|
||||
@@ -664,9 +669,9 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 md:gap-2 mb-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
@@ -674,15 +679,15 @@ export default function ResultsPage() {
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<div className="flex items-center gap-1 mt-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
<MapPin className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{schedule.arrivalAt
|
||||
? formatTime(schedule.arrivalAt)
|
||||
: "--:--"}
|
||||
@@ -744,7 +749,7 @@ export default function ResultsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Progress Header */}
|
||||
@@ -943,28 +948,19 @@ export default function ResultsPage() {
|
||||
!!results &&
|
||||
outboundSchedules.length === 0 &&
|
||||
inboundSchedules.length === 0 &&
|
||||
alternativeOutbound.length === 0 &&
|
||||
alternativeInbound.length === 0;
|
||||
(results?.alternativeOutbound || []).length === 0 &&
|
||||
(results?.alternativeInbound || []).length === 0;
|
||||
if (isRoundTripNoResults) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains found for your selected dates or route.</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
No trains found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
We couldn't find any trains for your trip. Try adjusting
|
||||
your dates or route.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary"
|
||||
>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Modify search
|
||||
</button>
|
||||
</div>
|
||||
@@ -975,34 +971,20 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
if (isOneWayNoOutbound) {
|
||||
const requestedDateLabel = requestedDate
|
||||
? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||
: "your selected date";
|
||||
const hasAlternatives = alternativeOutbound.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
{renderClassModal()}
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="card text-center mb-8 max-w-3xl mx-auto">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-amber-500" />
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains available on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
No trains are available on{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{requestedDateLabel}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary"
|
||||
>
|
||||
Change travel date
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change date
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1031,24 +1013,15 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||
<span>No trains found matching your search. Try adjusting your dates or route.</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
No trains found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
We couldn't find any trains matching your search criteria.{" "}
|
||||
<br /> Try adjusting your dates or route.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary"
|
||||
>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Modify search
|
||||
</button>
|
||||
</div>
|
||||
@@ -1059,7 +1032,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{renderClassModal()}
|
||||
@@ -1172,29 +1145,13 @@ export default function ResultsPage() {
|
||||
{outboundSchedules.length === 0 &&
|
||||
alternativeOutbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
No trains are available on{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{requestedDate
|
||||
? format(
|
||||
new Date(`${requestedDate}T00:00:00`),
|
||||
"EEEE, MMMM d, yyyy",
|
||||
)
|
||||
: "your selected date"}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary"
|
||||
>
|
||||
Change travel dates
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
@@ -1272,29 +1229,13 @@ export default function ResultsPage() {
|
||||
{inboundSchedules.length === 0 &&
|
||||
alternativeInbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
No trains are available on{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{requestedReturnDate
|
||||
? format(
|
||||
new Date(`${requestedReturnDate}T00:00:00`),
|
||||
"EEEE, MMMM d, yyyy",
|
||||
)
|
||||
: "your selected return date"}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary"
|
||||
>
|
||||
Change travel dates
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -445,7 +445,9 @@ export default function ReviewPage() {
|
||||
const fareMinor = isPackageBooking
|
||||
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||
return { fareMinor, isFree: isFreeChild };
|
||||
const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
||||
const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
||||
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
|
||||
});
|
||||
setReviewedTotal(computedTotal, passengerFares);
|
||||
|
||||
@@ -560,6 +562,14 @@ export default function ReviewPage() {
|
||||
const isFreeChild = isPackageBooking
|
||||
? isPkgFreeChild(i)
|
||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||
|
||||
// Per-leg fares for round trips
|
||||
const outboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
|
||||
: null;
|
||||
const inboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
|
||||
: null;
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
const passengerTotal = isPackageBooking
|
||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||
@@ -582,6 +592,19 @@ export default function ReviewPage() {
|
||||
{formatFare(passengerTotal, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Round-trip: show outbound + inbound breakdown */}
|
||||
{isRoundTrip && !isFreeChild && (
|
||||
<div className="mt-1 space-y-0.5 pl-2">
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↗ Outbound</span>
|
||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↙ Return</span>
|
||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -613,10 +636,10 @@ export default function ReviewPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
||||
<h1 className="section-title">
|
||||
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -608,6 +608,12 @@ export default function SearchPage() {
|
||||
reValidateMode: "onSubmit",
|
||||
defaultValues: {
|
||||
tripType: "ONE_WAY",
|
||||
// originStationId/destinationStationId must default to "" rather than being omitted —
|
||||
// Zod's base string type check runs before .min(1, "..."), so an `undefined` value hits
|
||||
// its generic "Required" message instead of our custom one. An empty string is still a
|
||||
// string, so .min() (and its custom message) is what actually fires.
|
||||
originStationId: "",
|
||||
destinationStationId: "",
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
// No default nationality — the user must explicitly pick one. Left blank (not a valid
|
||||
@@ -827,19 +833,21 @@ export default function SearchPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
|
||||
{/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on desktop, which grows
|
||||
upward from its bottom-anchored position — give the hero extra height there so the
|
||||
widget's top edge doesn't creep up into the sticky header. */}
|
||||
widget's top edge doesn't creep up into the sticky header. On mobile the widget is
|
||||
in normal flow (not bottom-anchored), so this only applies at md: and up. */}
|
||||
<section
|
||||
className={`relative ${
|
||||
tripType === "ROUND_TRIP"
|
||||
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
|
||||
: "h-[94vh] min-h-[560px]"
|
||||
? "md:h-[90vh] md:min-h-[560px]"
|
||||
: "md:h-[94vh] md:min-h-[560px]"
|
||||
}`}
|
||||
>
|
||||
{/* Background image with zoom - fully isolated */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* Background image with zoom — desktop only; mobile drops the hero image entirely
|
||||
so the booking widget can sit at the top and use the available space. */}
|
||||
<div className="hidden md:block absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
||||
style={{
|
||||
@@ -852,12 +860,12 @@ export default function SearchPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
{/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */}
|
||||
<div className="hidden md:block absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
|
||||
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
|
||||
{/* Hero headline — top area */}
|
||||
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
||||
{/* Hero headline — desktop only; removed on mobile along with the banner image */}
|
||||
<div className="hidden md:block relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight drop-shadow-lg max-w-2xl animate-fade-in-up">
|
||||
Where are you
|
||||
<br className="hidden sm:block" /> headed today?
|
||||
@@ -867,14 +875,28 @@ export default function SearchPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Widget — absolutely positioned at bottom with margin ── */}
|
||||
{/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at
|
||||
bottom on desktop over the hero image. z-[35] sits above the floating
|
||||
support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it
|
||||
never covers either. Modals opened from inside the widget (date picker
|
||||
etc.) are portaled to <body> — see ModernDatePicker — so they aren't
|
||||
capped by this wrapper's own stacking context. ── */}
|
||||
<div
|
||||
className="absolute bottom-8 left-0 right-0 z-[50] px-4 md:px-6"
|
||||
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[35] px-4 md:px-6"
|
||||
ref={widgetRef}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
|
||||
<div className="md:hidden mb-3">
|
||||
<h1 className="text-2xl font-extrabold text-gray-900 dark:text-gray-100 leading-tight">
|
||||
Where are you headed today?
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Book your train journey across East Africa
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-none md:shadow-2xl border border-white/20 overflow-visible">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
|
||||
<span>⚠️</span>
|
||||
@@ -950,6 +972,11 @@ export default function SearchPage() {
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.originStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -998,6 +1025,11 @@ export default function SearchPage() {
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.destinationStationId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
@@ -1019,8 +1051,14 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.departureDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tripType === "ROUND_TRIP" && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -1047,6 +1085,7 @@ export default function SearchPage() {
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select return date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
@@ -1079,7 +1118,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg disabled:opacity-50"
|
||||
className="btn-primary w-full text-sm"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1182,6 +1221,7 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Departure"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
@@ -1219,7 +1259,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1324,6 +1364,7 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
@@ -1357,6 +1398,7 @@ export default function SearchPage() {
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
@@ -1482,7 +1524,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
|
||||
import Image from "next/image";
|
||||
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { isChild } from "@/utils/fare-utils";
|
||||
|
||||
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||
@@ -451,16 +452,15 @@ export default function SeatsPage() {
|
||||
// the new seat map data has finished loading.
|
||||
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
|
||||
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
|
||||
const firstClass = matchedType.classes?.[0];
|
||||
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
|
||||
const updatedSchedule = {
|
||||
...(currentSchedule as any),
|
||||
selectedCoachTypeId: newCoachTypeId,
|
||||
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
|
||||
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
|
||||
selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
seatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
|
||||
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
|
||||
};
|
||||
@@ -921,7 +921,7 @@ export default function SeatsPage() {
|
||||
const positionLabel = newSeat?.bedPosition
|
||||
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
|
||||
: "This seat";
|
||||
const legMultiplier = isRoundTrip ? 2 : 1;
|
||||
const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
@@ -1328,15 +1328,16 @@ export default function SeatsPage() {
|
||||
useEffect(() => {
|
||||
if (isRoundTrip) {
|
||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||
router.push("/booking/search");
|
||||
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||
}
|
||||
} else {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push("/booking/search");
|
||||
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isRoundTrip,
|
||||
isPackageBooking,
|
||||
selectedSchedule,
|
||||
outboundSchedule,
|
||||
inboundSchedule,
|
||||
@@ -1712,7 +1713,7 @@ export default function SeatsPage() {
|
||||
|
||||
if (
|
||||
isRoundTrip
|
||||
? !outboundSchedule || !inboundSchedule || !passengers.length
|
||||
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
|
||||
: !selectedSchedule || !passengers.length
|
||||
)
|
||||
return null;
|
||||
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading seat map...
|
||||
</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
||||
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
const styles = `
|
||||
.contact-hero {
|
||||
padding: 60px 20px;
|
||||
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
|
||||
.dark .contact-hero {
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@@ -370,6 +372,7 @@ export default function Contact() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
@@ -16,13 +16,13 @@ export default function ForgotPasswordPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await iamAuthApi.forgotPassword(email);
|
||||
await iamAuthApi.forgotPassword(identifier.trim());
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(
|
||||
msg === 'user_not_found'
|
||||
? 'No account found with that email address.'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
);
|
||||
} finally {
|
||||
@@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Enter your email and we'll send a reset link to the phone number on your account.
|
||||
Enter your email or phone number and we'll send a reset link to the phone number on your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email or phone number</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
placeholder="your@email.com or +251..."
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !email}>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !identifier.trim()}>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -26,19 +26,17 @@
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)];
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.1);
|
||||
|
||||
.booking-page {
|
||||
@apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.2);
|
||||
}
|
||||
|
||||
.booking-container {
|
||||
@apply container mx-auto px-4 max-w-6xl;
|
||||
}
|
||||
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
@@ -162,7 +160,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -188,6 +197,10 @@
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.35s ease-out;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSearchParams } from "next/navigation";
|
||||
* hop is browser-dependent and can be stripped) must NOT be used here.
|
||||
*
|
||||
* It fires immediately (no delay) and paints a bare white full-screen cover
|
||||
* above the sticky header (z-[60]) — no portal chrome, no text on the happy
|
||||
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
|
||||
* path. A short message shows only when the link is missing/untrusted.
|
||||
*
|
||||
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import MobileTopBar from '@/components/MobileTopBar';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||
import SupportWidget from '@/features/support/SupportWidget';
|
||||
import SupportWidget from '@/features/support/SupportWidgetLazy';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EDR Passenger Portal - Book your train journey',
|
||||
@@ -21,7 +22,7 @@ export default function RootLayout({
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
||||
<body className="font-sans antialiased">
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -47,11 +48,14 @@ export default function RootLayout({
|
||||
/>
|
||||
<Providers>
|
||||
<LoadingIndicator />
|
||||
<AppHeader />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<AppSidebar />
|
||||
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||
<MobileTopBar />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<BottomTabBar />
|
||||
</div>
|
||||
<SupportWidget />
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
coachTypeId: ct?.id ?? key,
|
||||
coachTypeName: ct?.name ?? tier.seatType,
|
||||
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
||||
coachTypeCode: ct?.code ?? '',
|
||||
coachTypeType: ct?.type ?? 'passenger',
|
||||
tiers: [],
|
||||
@@ -270,90 +270,121 @@ function PriceTiersPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Coach Type</h2>
|
||||
{groups.map((group) => {
|
||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
||||
const isSelected = selectedId === group.coachTypeId;
|
||||
return (
|
||||
<div
|
||||
key={group.coachTypeId}
|
||||
className={`rounded-xl border-2 overflow-hidden transition-colors ${
|
||||
allSoldOut
|
||||
? 'border-gray-200 dark:border-gray-700 opacity-50'
|
||||
: isSelected
|
||||
? 'border-primary'
|
||||
: 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||
>
|
||||
{/* Coach type header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/60">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected ? 'bg-primary' : 'bg-primary/10'
|
||||
}`}>
|
||||
<CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{formatCoachTypeLabel(group.coachTypeType)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
From {formatPrice(group.minPrice * priceMultiplier, group.currency)}
|
||||
{allSoldOut && <span className="ml-2 text-red-500 font-semibold">· Sold out</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">Choose Coach Type</h2>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{groups.map((group, index) => {
|
||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
||||
const isSelected = selectedId === group.coachTypeId;
|
||||
return (
|
||||
<div
|
||||
key={group.coachTypeId}
|
||||
role="button"
|
||||
tabIndex={allSoldOut ? -1 : 0}
|
||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||
onKeyDown={(e) => {
|
||||
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
setSelectedId(isSelected ? null : group.coachTypeId);
|
||||
}
|
||||
}}
|
||||
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
|
||||
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer'
|
||||
: 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer'
|
||||
}`}
|
||||
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }}
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
{!allSoldOut && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${isSelected ? 'border-primary' : 'border-gray-300 dark:border-gray-600 group-hover:border-primary/50'
|
||||
}`}>
|
||||
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All available classes for this coach type */}
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{group.tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
|
||||
<p className="text-xs">
|
||||
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
|
||||
{soldOut ? (
|
||||
<span className="ml-2 font-bold text-red-500">Sold out</span>
|
||||
) : (
|
||||
<span className="ml-2 text-gray-400">{tier.availableSeats} left</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-2 pr-2">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${isSelected
|
||||
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||
}`}>
|
||||
<CoachIcon className={`w-4 h-4 transition-colors ${isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 tracking-wider">
|
||||
{formatCoachTypeLabel(group.coachTypeType)}
|
||||
</p>
|
||||
{allSoldOut && (
|
||||
<span className="text-xs font-bold text-red-500 mt-0.5 block">Sold out</span>
|
||||
)}
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||
<span className={`text-sm font-bold tracking-tight ${isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{((group.minPrice * priceMultiplier) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{group.currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book Now — only when this group is selected */}
|
||||
{isSelected && !allSoldOut && (
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
||||
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Class options — always visible, matching results page style */}
|
||||
{group.tiers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||
<div className="space-y-1.5">
|
||||
{group.tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`flex flex-col py-1 px-1 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 ${soldOut ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</span>
|
||||
{soldOut ? (
|
||||
<span className="text-xs font-bold text-red-500 mt-0.5">Sold out</span>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1 mt-0.5">
|
||||
<span className="text-sm font-bold tabular-nums text-primary">
|
||||
{((tier.priceMinor * priceMultiplier) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">{tier.currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSelected && !allSoldOut && (
|
||||
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
|
||||
Click to select this coach
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isSelected && !allSoldOut && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -407,7 +438,7 @@ function PassengerCountModal({
|
||||
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.name ?? tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||
</div>
|
||||
|
||||
@@ -416,26 +447,26 @@ function PassengerCountModal({
|
||||
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
|
||||
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
|
||||
].map(({ label, sub, value, min, max, set }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
<p className="text-xs text-gray-400">{sub}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => set(Math.max(min, value - 1))}
|
||||
disabled={value <= min}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
<button type="button" onClick={() => set(value + 1)}
|
||||
disabled={value >= max}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
<p className="text-xs text-gray-400">{sub}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => set(Math.max(min, value - 1))}
|
||||
disabled={value <= min}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
<button type="button" onClick={() => set(value + 1)}
|
||||
disabled={value >= max}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{freeChildren > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
||||
@@ -457,15 +488,14 @@ function PassengerCountModal({
|
||||
|
||||
{/* Departure Station */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
||||
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
||||
Departure Station
|
||||
</label>
|
||||
<select
|
||||
value={departureStationId}
|
||||
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
|
||||
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${
|
||||
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<option value="">Select your boarding station</option>
|
||||
{stations.map((s) => (
|
||||
@@ -480,10 +510,10 @@ function PassengerCountModal({
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
if (!departureStationId) { setShowStationError(true); return; }
|
||||
const station = stations.find(s => s.id === departureStationId);
|
||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||
}}
|
||||
if (!departureStationId) { setShowStationError(true); return; }
|
||||
const station = stations.find(s => s.id === departureStationId);
|
||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||
}}
|
||||
disabled={loading || adultCount + childCount < 1}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||
@@ -523,8 +553,10 @@ export default function PackageDetailPage() {
|
||||
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
||||
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
||||
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
||||
// Representative tier for the modal header (cheapest available)
|
||||
const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null;
|
||||
// Representative tier for the modal: cheapest available in the selected group
|
||||
const representativeTier = selectedGroup?.tiers
|
||||
.filter((t) => t.availableSeats > 0)
|
||||
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null;
|
||||
|
||||
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
||||
|
||||
@@ -745,9 +777,9 @@ export default function PackageDetailPage() {
|
||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||
{pkg.priceTiers.length
|
||||
? formatPrice(
|
||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
||||
pkg.priceTiers[0].currency,
|
||||
)
|
||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
||||
pkg.priceTiers[0].currency,
|
||||
)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default async function Home() {
|
||||
// Below-the-fold on the landing page — code-split out of the initial bundle
|
||||
// needed to render/hydrate the (above-the-fold) search form.
|
||||
const PackagesSection = dynamic(() => import('@/components/PackagesSection'));
|
||||
|
||||
// Skeleton for the search form while stations are prefetched server-side —
|
||||
// keeps first paint on a slow connection from being a blank page, and
|
||||
// approximates the real form's height to avoid layout shift once it streams in.
|
||||
function SearchFormSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto animate-pulse">
|
||||
<div className="rounded-2xl border border-gray-100 dark:border-gray-800 p-6 space-y-4">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
</div>
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-xl w-40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates the server-side stations prefetch behind its own Suspense boundary
|
||||
// (a real async Server Component, unlike a plain `await` in the page body) so
|
||||
// Next.js can stream: the shell flushes immediately with SearchFormSkeleton,
|
||||
// then the real search form + hydrated stations data stream in once ready —
|
||||
// instead of blocking the whole page's TTFB on that one fetch.
|
||||
async function StationsPrefetch({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = new QueryClient();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -18,10 +46,18 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<SearchFormSkeleton />}>
|
||||
<StationsPrefetch>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</StationsPrefetch>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import ChangePasswordModal from "@/components/ChangePasswordModal";
|
||||
|
||||
export default function AppHeader() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const { user, isAuthenticated, initialize, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setIsDark(isDarkMode);
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDarkMode = html.classList.contains("dark");
|
||||
if (isDarkMode) {
|
||||
html.classList.remove("dark");
|
||||
setIsDark(false);
|
||||
localStorage.setItem("theme", "light");
|
||||
} else {
|
||||
html.classList.add("dark");
|
||||
setIsDark(true);
|
||||
localStorage.setItem("theme", "dark");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[60] bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-14 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link
|
||||
href="/booking/lookup"
|
||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
||||
>
|
||||
My Booking
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Help Link */}
|
||||
<Link
|
||||
href="/help"
|
||||
className="hidden sm:flex items-center justify-center p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title="Help & FAQ"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title={isDark ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Auth */}
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
|
||||
{user.fullName?.toUpperCase().charAt(0) || 'U'}
|
||||
</div>
|
||||
<ChevronDown className="hidden sm:block w-4 h-4 text-gray-100" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 z-50 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isOpen && (
|
||||
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
|
||||
<Link
|
||||
href="/booking/lookup"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
My Booking
|
||||
</Link>
|
||||
<Link
|
||||
href="/help"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Help
|
||||
</Link>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
212
apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
Normal file
212
apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Home,
|
||||
Phone,
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
Moon,
|
||||
Sun,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal';
|
||||
import { BOOKING_STEPS } from '@/components/ProgressIndicator';
|
||||
|
||||
// Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
|
||||
// get a vertical step list instead of the standard nav highlighting.
|
||||
const BOOKING_STEP_MAP: Record<string, string> = {
|
||||
'/booking/search': 'search',
|
||||
'/booking/results': 'results',
|
||||
'/booking/auth-check': 'passengers',
|
||||
'/booking/passengers': 'passengers',
|
||||
'/booking/seats': 'seats',
|
||||
'/booking/review': 'review',
|
||||
'/booking/payment': 'payment',
|
||||
'/booking/confirmation': 'confirmation',
|
||||
};
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone },
|
||||
{ href: '/help', label: 'Help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const { user, isAuthenticated, initialize, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const nextDark = !html.classList.contains('dark');
|
||||
html.classList.toggle('dark', nextDark);
|
||||
localStorage.setItem('theme', nextDark ? 'dark' : 'light');
|
||||
setIsDark(nextDark);
|
||||
};
|
||||
|
||||
const currentStepId = BOOKING_STEP_MAP[pathname ?? ''];
|
||||
const currentStepIndex = currentStepId ? BOOKING_STEPS.findIndex((s) => s.id === currentStepId) : -1;
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-64 fixed inset-y-0 left-0 z-40 flex-col bg-[rgb(20_113_76)] dark:bg-gray-900 border-r border-[rgb(16_89_60)] dark:border-gray-800">
|
||||
<Link href="/" className="flex items-center px-5 h-20 flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-16 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||||
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white'
|
||||
: 'text-gray-100 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentStepIndex >= 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-white/15">
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-200/80">
|
||||
Your booking
|
||||
</p>
|
||||
<ol className="space-y-1">
|
||||
{BOOKING_STEPS.map((step, index) => {
|
||||
const isComplete = index < currentStepIndex;
|
||||
const isCurrent = index === currentStepIndex;
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2.5 px-3 py-1.5">
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors ${
|
||||
isComplete
|
||||
? 'bg-white text-[rgb(20,113,76)]'
|
||||
: isCurrent
|
||||
? 'border-2 border-white text-white'
|
||||
: 'border border-white/30 text-white/50'
|
||||
}`}
|
||||
>
|
||||
{isComplete ? <Check className="w-3 h-3" /> : index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isCurrent ? 'text-white font-semibold' : isComplete ? 'text-gray-100' : 'text-white/50'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-white/15 p-3 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-100 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
{isDark ? 'Light mode' : 'Dark mode'}
|
||||
</button>
|
||||
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
|
||||
{user.fullName?.toUpperCase().charAt(0) || 'U'}
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-white truncate">{user.fullName}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-200 transition-transform ${showUserMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user