mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
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 { CheckAvailabilityController } from './check-availability.controller';
|
||||||
import { CheckAvailabilityService } from './check-availability.service';
|
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 { FreightMeController } from './freight-me.controller';
|
||||||
import { FreightMeService } from './freight-me.service';
|
import { FreightMeService } from './freight-me.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
imports: [
|
||||||
controllers: [FreightMeController, CheckAvailabilityController],
|
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
||||||
providers: [FreightMeService, CheckAvailabilityService],
|
OtpModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
FreightMeController,
|
||||||
|
CheckAvailabilityController,
|
||||||
|
ForgotPasswordController,
|
||||||
|
CustomerResetController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
FreightMeService,
|
||||||
|
CheckAvailabilityService,
|
||||||
|
ForgotPasswordService,
|
||||||
|
CustomerResetService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class FreightAuthModule {}
|
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(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
@@ -1135,7 +1159,7 @@ export class BookingsService {
|
|||||||
const statusFilter = this.parseStatusFilter(filter);
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||||
|
|
||||||
return this.bookingsRepository.findAllPaginated({
|
const result = await this.bookingsRepository.findAllPaginated({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
...statusFilter,
|
...statusFilter,
|
||||||
@@ -1167,6 +1191,8 @@ export class BookingsService {
|
|||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
|
await this.attachHandoverFlags(result.items ?? []);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
/** 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
|
* The company's latest "open" request — pending (locks the customer) or the
|
||||||
* most recent rejected one (drives the reapply banner + prefill). Approved
|
* most recent rejected one (drives the reapply banner + prefill).
|
||||||
* requests are terminal and ignored here.
|
*
|
||||||
|
* 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(
|
async findLatestOpenByCompanyId(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<CompanyChangeRequest | null> {
|
): Promise<CompanyChangeRequest | null> {
|
||||||
const pending = await this.findPendingByCompanyId(companyId);
|
const pending = await this.findPendingByCompanyId(companyId);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
return this.repository.findOne({
|
const latest = await this.repository.findOne({
|
||||||
where: { companyId, status: ChangeRequestStatus.Rejected },
|
where: { companyId },
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
|
return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<CompanyChangeRequest | 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 { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record<ProfileType, string> = {
|
|||||||
[ProfileType.transporter]: "TR",
|
[ProfileType.transporter]: "TR",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|
||||||
|
/** Numbers per series letter: A00001..A99999, then B00001. */
|
||||||
|
const SERIES_SIZE = 99_999;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
|||||||
const result = await this.repository.query(
|
const result = await this.repository.query(
|
||||||
`SELECT nextval('${seqName}') AS next_id`,
|
`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];
|
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[]> {
|
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
type!: ProfileType;
|
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.
|
* is approved (status → Active); pending/unapproved profiles carry NULL.
|
||||||
* The unique index tolerates this because Postgres treats NULLs as distinct.
|
* The unique index tolerates this because Postgres treats NULLs as distinct.
|
||||||
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
|
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ export class ContractTransitionService {
|
|||||||
if (!dto.otpPhone || !dto.otp) {
|
if (!dto.otpPhone || !dto.otp) {
|
||||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
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.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
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,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} 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 {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
|
|||||||
import { NotificationInboxService } from "./notification-inbox.service";
|
import { NotificationInboxService } from "./notification-inbox.service";
|
||||||
|
|
||||||
@ApiTags("notifications")
|
@ApiTags("notifications")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@Controller("notifications")
|
@Controller("notifications")
|
||||||
export class NotificationInboxController {
|
export class NotificationInboxController {
|
||||||
constructor(private readonly service: NotificationInboxService) {}
|
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
|
* Server → client push for in-app notifications. Clients only *listen* (no
|
||||||
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
|
* `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
|
||||||
* the handshake is authenticated in `handleConnection` and each socket joins a
|
* controller does not cover WebSockets; the handshake is authenticated in
|
||||||
* private `user:<id>` room the service targets.
|
* `handleConnection` and each socket joins a private `user:<id>` room the
|
||||||
|
* service targets.
|
||||||
*/
|
*/
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
namespace: NOTIFICATION_WS_NAMESPACE,
|
namespace: NOTIFICATION_WS_NAMESPACE,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
|||||||
|
|
||||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
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 {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
url,
|
url,
|
||||||
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
|||||||
callbackUrl: "",
|
callbackUrl: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
timeout,
|
||||||
headers: {
|
headers: {
|
||||||
accept: "*/*",
|
accept: "*/*",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class OtpService {
|
|||||||
await this.otpRepository.createOtp(target, otp);
|
await this.otpRepository.createOtp(target, otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A freshly issued code gets a fresh guess budget.
|
||||||
|
this.actionAttempts.delete(this.targetKey(target));
|
||||||
|
|
||||||
if (target.email) {
|
if (target.email) {
|
||||||
// send email (queued to RabbitMQ via the shared Email service)
|
// send email (queued to RabbitMQ via the shared Email service)
|
||||||
await this.emailClient.sendEmail({
|
await this.emailClient.sendEmail({
|
||||||
@@ -121,24 +124,44 @@ export class OtpService {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||||
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
// contract signature, resetting a forgotten password). Unlike verifyOtp above
|
||||||
// and leaves the code in place — this enforces a short TTL and consumes the
|
// — which marks a target verified and leaves the code in place — this enforces
|
||||||
// code on success so it can never be replayed.
|
// a TTL and consumes the code on success so it can never be replayed.
|
||||||
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
async verifyOtpForAction(phone: string, otp: string) {
|
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
|
||||||
const otpData = await this.otpRepository.findByPhone(phone);
|
// 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) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
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();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
|
|
||||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
if (ageMs > ttlMs) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
@@ -146,11 +169,24 @@ export class OtpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (otpData.otp !== otp) {
|
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");
|
throw new BadRequestException("Invalid verification code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// single-use: consume on success
|
// single-use: consume on success
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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. */
|
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||||
export class ReleaseOrderDto {
|
export class ReleaseOrderDto {
|
||||||
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
gateOutTime?: string;
|
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'],
|
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
// Reserve is retired from the operator flow — a stored export item advances
|
// Reserve is retired from the operator flow — a stored export item advances
|
||||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
// 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'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
READY_FOR_LOADING: ['LOADED'],
|
READY_FOR_LOADING: ['LOADED'],
|
||||||
LOADED: ['DISPATCHED'],
|
LOADED: ['DISPATCHED'],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
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). */
|
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||||
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
|
|||||||
@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
|
|||||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
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.dataSource.transaction(async (manager) => {
|
||||||
await this.validateLocation(manager, {
|
await this.validateLocation(manager, {
|
||||||
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
|
|||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.notifyOwnerInventoryReceived({
|
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
// holds capacity/location locks open for the whole gateway latency.
|
||||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
pendingNotifications.push({
|
||||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
owner: {
|
||||||
grnNumber,
|
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||||
direction: dto.direction,
|
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||||
warehouseId: dto.warehouseId,
|
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||||
|
grnNumber,
|
||||||
|
direction: dto.direction,
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
},
|
||||||
|
booking,
|
||||||
|
bookingId,
|
||||||
});
|
});
|
||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
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;
|
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. */
|
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
||||||
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
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) {
|
if (isTruckLeaving) {
|
||||||
await this.invoices.assertClearanceAllowed(id);
|
await this.invoices.assertClearanceAllowed(id);
|
||||||
|
|
||||||
if (item.bookingId) {
|
if (item.bookingId) {
|
||||||
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
|
// Self-haul = customer collects: a truck assigned via the portal, OR a
|
||||||
await this.dataSource.query(
|
// walk-in truck registered at the gate on a booking with no EDR last mile.
|
||||||
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
|
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
|
||||||
FROM freight.bookings
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL`,
|
|
||||||
[item.bookingId],
|
|
||||||
);
|
|
||||||
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
|
||||||
// Self-haul: the handover must be signed before the exit paper is issued.
|
// Self-haul: the handover must be signed before the exit paper is issued.
|
||||||
// Prefer the structured handover record; fall back to the legacy note.
|
// Prefer the structured handover record; fall back to the legacy note.
|
||||||
const handoverSigned =
|
const handoverSigned =
|
||||||
@@ -2361,7 +2411,8 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||||
// total VGM cargo weight of the containers selected as loaded on it.
|
// 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
|
const selected = dto.containerNumber
|
||||||
.split(/[,;\n]+/)
|
.split(/[,;\n]+/)
|
||||||
.map((n) => n.trim())
|
.map((n) => n.trim())
|
||||||
@@ -2431,13 +2482,10 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
// Self-haul: generate the per-booking handover on first truck arrival
|
// Self-haul: generate the per-booking handover on first truck arrival
|
||||||
// (idempotent). It must be signed before the truck leaves.
|
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
|
||||||
const [selfHaul]: Array<{ ok: number }> = await manager.query(
|
// assigned trucks and walk-in trucks registered manually at the gate
|
||||||
`SELECT 1 AS ok FROM freight.bookings
|
// (no portal assignment, no EDR last mile). Must be signed before leaving.
|
||||||
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
|
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
|
||||||
[item.bookingId],
|
|
||||||
);
|
|
||||||
if (selfHaul) {
|
|
||||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4422,14 +4470,17 @@ export class WarehouseInventoryService {
|
|||||||
if (!dto.driverName?.trim()) {
|
if (!dto.driverName?.trim()) {
|
||||||
throw new BadRequestException('Driver name is required for exit inspection');
|
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');
|
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 grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||||
const submittedNetWeight =
|
const submittedNetWeight =
|
||||||
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
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.');
|
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');
|
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.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : 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`,
|
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||||
@@ -4481,6 +4537,8 @@ export class WarehouseInventoryService {
|
|||||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
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",
|
fileKey: "commercial_license",
|
||||||
fileLabel: "Commercial License",
|
fileLabel: "Commercial Registration",
|
||||||
helpText: "Verified against the government trade system during registration.",
|
helpText:
|
||||||
|
"Verified against the government trade system during registration.",
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
@@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
{
|
{
|
||||||
fileKey: "business_license",
|
fileKey: "business_license",
|
||||||
fileLabel: "Business License / Trade 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,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
@@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
|||||||
const CONTRACT_INTAKE_ENTITY = "contract_intake";
|
const CONTRACT_INTAKE_ENTITY = "contract_intake";
|
||||||
|
|
||||||
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
||||||
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
|
clearanceField(
|
||||||
required: false,
|
"commercial_framework",
|
||||||
}),
|
"Commercial Framework / Agreement",
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
||||||
required: false,
|
required: false,
|
||||||
}),
|
}),
|
||||||
@@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
|||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) { }
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder {
|
|||||||
const allSettings: Array<
|
const allSettings: Array<
|
||||||
OnboardingDocumentSetting & { description: string }
|
OnboardingDocumentSetting & { description: string }
|
||||||
> = [
|
> = [
|
||||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||||
})),
|
})),
|
||||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description: CLEARANCE_DESCRIPTION,
|
description: CLEARANCE_DESCRIPTION,
|
||||||
})),
|
})),
|
||||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||||
})),
|
})),
|
||||||
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||||
})),
|
})),
|
||||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
"Commercial/framework documents attached at contract submission.",
|
"Commercial/framework documents attached at contract submission.",
|
||||||
})),
|
})),
|
||||||
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const documentSetting of allSettings) {
|
for (const documentSetting of allSettings) {
|
||||||
await settingRepository.upsert(
|
await settingRepository.upsert(
|
||||||
@@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!setting) {
|
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 });
|
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-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-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-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
|
// D. Finance — payments + invoices
|
||||||
@@ -395,6 +396,7 @@ export const FREIGHT_PERMS = {
|
|||||||
update: 'edr_freight_app:customers:update',
|
update: 'edr_freight_app:customers:update',
|
||||||
deactivate: 'edr_freight_app:customers:deactivate',
|
deactivate: 'edr_freight_app:customers:deactivate',
|
||||||
verify: 'edr_freight_app:customers:verify',
|
verify: 'edr_freight_app:customers:verify',
|
||||||
|
resetPassword: 'edr_freight_app:customers:reset-password',
|
||||||
},
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: 'edr_freight_app:payments:view',
|
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,
|
ChangeRequestReview,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
} from "./ChangeRequestReview";
|
} from "./ChangeRequestReview";
|
||||||
|
export {
|
||||||
|
default as ResetPasswordAction,
|
||||||
|
type ResetPasswordActionProps,
|
||||||
|
} from "./ResetPasswordAction";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -250,7 +250,10 @@ const FreightSidebar = ({
|
|||||||
<AppShell.Section
|
<AppShell.Section
|
||||||
grow
|
grow
|
||||||
component={ScrollArea}
|
component={ScrollArea}
|
||||||
type="never"
|
type="hover"
|
||||||
|
scrollbars="y"
|
||||||
|
scrollbarSize={6}
|
||||||
|
scrollHideDelay={500}
|
||||||
px="sm"
|
px="sm"
|
||||||
pb="md"
|
pb="md"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { warehouseService } from '@/services/warehouse.service';
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
|||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({
|
toast({
|
||||||
title: 'Gate clearance recorded',
|
title: 'Gate clearance recorded',
|
||||||
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
|
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
|||||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface InventoryWorkbenchProps {
|
interface InventoryWorkbenchProps {
|
||||||
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Release paper preview failed',
|
title: 'Release paper preview failed',
|
||||||
description: extractErrorMessage(error),
|
description: await extractDownloadErrorMessage(error),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Handover document failed',
|
title: 'Handover document failed',
|
||||||
description: extractErrorMessage(error),
|
description: await extractDownloadErrorMessage(error),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
|||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
import '@/components/overview/overview.css';
|
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' });
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -900,7 +900,7 @@ function EligibleTab({
|
|||||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
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());
|
setSelected(new Set());
|
||||||
@@ -1697,7 +1697,6 @@ function LoadedExportTab({
|
|||||||
<Table.Th>Weight</Table.Th>
|
<Table.Th>Weight</Table.Th>
|
||||||
<Table.Th>Route</Table.Th>
|
<Table.Th>Route</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -1739,19 +1738,6 @@ function LoadedExportTab({
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
handoverDocumentReference: row.handoverDocumentReference,
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
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
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
id: row.bookingId,
|
id: row.bookingId,
|
||||||
@@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
|||||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||||
netWeight: lineNumber(note, 'Net Weight'),
|
netWeight: lineNumber(note, 'Net Weight'),
|
||||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
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 [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||||
const [gateInTime, setGateInTime] = useState('');
|
const [gateInTime, setGateInTime] = useState('');
|
||||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
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 [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||||
const [gateOutTime, setGateOutTime] = useState('');
|
const [gateOutTime, setGateOutTime] = useState('');
|
||||||
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||||
setGateInTime(inspection.gateInTime);
|
setGateInTime(inspection.gateInTime);
|
||||||
setTareWeight(inspection.tareWeight);
|
setTareWeight(inspection.tareWeight);
|
||||||
|
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||||
setGrossWeight(inspection.grossWeight);
|
setGrossWeight(inspection.grossWeight);
|
||||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||||
setGateOutTime(inspection.gateOutTime);
|
setGateOutTime(inspection.gateOutTime);
|
||||||
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
}, [opened, item, truckPrefill]);
|
}, [opened, item, truckPrefill]);
|
||||||
|
|
||||||
const savedInspection = parseInspectionNote(item?.notes);
|
const savedInspection = parseInspectionNote(item?.notes);
|
||||||
const isExitStep = savedInspection.tareWeight !== '';
|
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||||
const isEntranceLocked = isExitStep;
|
const isEntranceLocked = isExitStep;
|
||||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
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)
|
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||||
.toFixed(3),
|
.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
|
const systemNetWeight = useContainerNet
|
||||||
? selectedCargoWeight
|
? selectedCargoWeight
|
||||||
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
const weightMismatch =
|
||||||
|
!skipWeighing &&
|
||||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
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' });
|
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!gateInTime || tareWeight === '') {
|
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && systemNetWeight === '') {
|
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
truckType: truckType.trim() || undefined,
|
truckType: truckType.trim() || undefined,
|
||||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||||
gateInTime: toIsoDateTime(gateInTime),
|
gateInTime: toIsoDateTime(gateInTime),
|
||||||
tareWeight: Number(tareWeight),
|
weighingSkipped: skipWeighing || undefined,
|
||||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||||
|
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : 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} />
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</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>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
<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} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||||
min={0}
|
min={0}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { warehouseService } from '@/services/warehouse.service';
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface TruckDispatchModalProps {
|
interface TruckDispatchModalProps {
|
||||||
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
|
|||||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
} catch (e) {
|
} 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,
|
type WarehouseInventoryItem,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { InventoryStatusBadge } from './badges';
|
import { InventoryStatusBadge } from './badges';
|
||||||
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface WarehouseInventoryTableProps {
|
interface WarehouseInventoryTableProps {
|
||||||
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
|||||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const kind = itemKind(item);
|
const kind = itemKind(item);
|
||||||
const busy = busyId === item.id;
|
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 =
|
const canGenerateHandover =
|
||||||
item.inspectionStatus === 'PASSED' &&
|
item.inspectionStatus === 'PASSED' &&
|
||||||
Boolean(item.bookingId) &&
|
Boolean(item.bookingId) &&
|
||||||
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'READY_FOR_PICKUP' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
size="compact-xs"
|
||||||
size="compact-xs"
|
variant="light"
|
||||||
variant="light"
|
color="blue"
|
||||||
color="blue"
|
loading={busy}
|
||||||
loading={busy}
|
onClick={() => onAdvance(item, 'store')}
|
||||||
onClick={() => onAdvance(item, 'store')}
|
>
|
||||||
>
|
Store
|
||||||
Store
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="green"
|
|
||||||
loading={busy}
|
|
||||||
onClick={() => onAdvance(item, 'dispatch')}
|
|
||||||
>
|
|
||||||
Dispatch
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{item.status !== 'DISPATCHED' && (
|
{item.status !== 'DISPATCHED' && (
|
||||||
<Tooltip label="Move" withArrow>
|
<Tooltip label="Move" withArrow>
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/bookings/by-company/${id}/customer-view`,
|
`/bookings/by-company/${id}/customer-view`,
|
||||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||||
`/payments/by-company/${id}/customer-view`,
|
`/payments/by-company/${id}/customer-view`,
|
||||||
|
RESET_PASSWORD: (companyId: string) =>
|
||||||
|
`/backoffice/customers/${companyId}/reset-password`,
|
||||||
},
|
},
|
||||||
|
|
||||||
BILLING: {
|
BILLING: {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:customers:update",
|
update: "edr_freight_app:customers:update",
|
||||||
deactivate: "edr_freight_app:customers:deactivate",
|
deactivate: "edr_freight_app:customers:deactivate",
|
||||||
verify: "edr_freight_app:customers:verify",
|
verify: "edr_freight_app:customers:verify",
|
||||||
|
resetPassword: "edr_freight_app:customers:reset-password",
|
||||||
},
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: "edr_freight_app:payments:view",
|
view: "edr_freight_app:payments:view",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
ProfileChips,
|
ProfileChips,
|
||||||
ProfileStatusBadge,
|
ProfileStatusBadge,
|
||||||
ProfileTypeBadge,
|
ProfileTypeBadge,
|
||||||
|
ResetPasswordAction,
|
||||||
TableCard,
|
TableCard,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
|
|||||||
<ChangeRequestPendingBadge companyId={company.id} />
|
<ChangeRequestPendingBadge companyId={company.id} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
|
action={<ResetPasswordAction company={company} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<Tabs defaultValue="overview">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
|
ResetChannel,
|
||||||
|
ResetPasswordResult,
|
||||||
} from "@/types/customer";
|
} from "@/types/customer";
|
||||||
import {
|
import {
|
||||||
CreateDropdownOptionDto,
|
CreateDropdownOptionDto,
|
||||||
@@ -2269,6 +2271,16 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
resetPassword: endpoint<
|
||||||
|
{ companyId: string; channel: ResetChannel },
|
||||||
|
ResetPasswordResult
|
||||||
|
>(
|
||||||
|
"customers",
|
||||||
|
"resetPassword",
|
||||||
|
({ companyId, channel }) =>
|
||||||
|
customersService.resetPassword(companyId, channel),
|
||||||
|
),
|
||||||
|
|
||||||
setProfileStatus: endpoint<
|
setProfileStatus: endpoint<
|
||||||
{ profileId: string; status: ProfileStatus; note?: string },
|
{ profileId: string; status: ProfileStatus; note?: string },
|
||||||
CompanyProfile
|
CompanyProfile
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
|
ResetChannel,
|
||||||
|
ResetPasswordResult,
|
||||||
} from "@/types/customer";
|
} from "@/types/customer";
|
||||||
|
|
||||||
const cleanParams = (params: object) =>
|
const cleanParams = (params: object) =>
|
||||||
@@ -81,6 +83,22 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.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(
|
setProfileStatus(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
|
|||||||
updatedAt: string;
|
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`). */
|
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||||
export interface Company {
|
export interface Company {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
|||||||
return 'store';
|
return 'store';
|
||||||
case 'STORED':
|
case 'STORED':
|
||||||
// Reserve is retired: a stored export item goes straight to loading prep
|
// Reserve is retired: a stored export item goes straight to loading prep
|
||||||
// once inspection passes. Import STORED is handled via the import queue.
|
// once inspection passes. An import item parked back into storage returns
|
||||||
if (isImport) return null;
|
// to pickup — otherwise Store would strand it with no action.
|
||||||
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
return inspected ? 'ready-for-loading' : null;
|
return inspected ? 'ready-for-loading' : null;
|
||||||
case 'RESERVED':
|
case 'RESERVED':
|
||||||
// Export loading is gated on a passed inspection.
|
// Export loading is gated on a passed inspection.
|
||||||
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
|
|||||||
grossWeight?: number;
|
grossWeight?: number;
|
||||||
netWeight?: number;
|
netWeight?: number;
|
||||||
gateOutTime?: string;
|
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. */
|
/** Import branch: proof of delivery captured on customer pickup. */
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
|||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import MySignaturePage from "./pages/MySignaturePage";
|
import MySignaturePage from "./pages/MySignaturePage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
||||||
import LoginPage from "./pages/accounts/LoginPage";
|
import LoginPage from "./pages/accounts/LoginPage";
|
||||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
@@ -252,6 +253,7 @@ const App = () => {
|
|||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Signup-flow pages; reached while a session already exists */}
|
{/* 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]);
|
}, [roles, nationality, startMutation]);
|
||||||
|
|
||||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
// Back from the form's first step returns to nationality/role selection.
|
||||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||||
const handleBackToRoles = useCallback(() => { }, []);
|
// 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
|
// 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).
|
// 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.
|
// The active step across the whole journey, driving the header + progress pill.
|
||||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||||
const stepMeta = STEP_META[activeStep];
|
const stepMeta = STEP_META[activeStep];
|
||||||
console.log({ stepMeta, activeStep, STEP_META });
|
|
||||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||||
|
|
||||||
// Closing from the congratulations panel also clears the completed flag so a
|
// Closing from the congratulations panel also clears the completed flag so a
|
||||||
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
|
|||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
isPending: finishMutation.isPending,
|
isPending: finishMutation.isPending,
|
||||||
onBack: handleBackToRoles,
|
onBack: handleBackToRoles,
|
||||||
hideFirstStepBack: true,
|
|
||||||
initialStep: effectiveResumeStep,
|
initialStep: effectiveResumeStep,
|
||||||
resyncOpen: opened,
|
resyncOpen: opened,
|
||||||
onStepChange: handleStepChange,
|
onStepChange: handleStepChange,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
|
|||||||
REFRESH_TOKEN: "/api/auth/refresh-token",
|
REFRESH_TOKEN: "/api/auth/refresh-token",
|
||||||
LOGOUT: "/api/auth/logout",
|
LOGOUT: "/api/auth/logout",
|
||||||
PROFILE: "/auth/profile",
|
PROFILE: "/auth/profile",
|
||||||
|
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||||
|
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||||
},
|
},
|
||||||
|
|
||||||
USERS: {
|
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
|
// Contract ready for signature → "View & sign" jumps straight to the
|
||||||
// full-page contract viewer where the signature flow lives.
|
// full-page contract viewer where the signature flow lives.
|
||||||
const canSign = bookingIsSignable(booking);
|
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 origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest =
|
const dest =
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
|
|||||||
onBack,
|
onBack,
|
||||||
initialStep,
|
initialStep,
|
||||||
resyncOpen,
|
resyncOpen,
|
||||||
hideFirstStepBack,
|
|
||||||
onStepChange,
|
onStepChange,
|
||||||
onSaveStep,
|
onSaveStep,
|
||||||
rehydrate,
|
rehydrate,
|
||||||
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
|
|||||||
initialStep?: CompanyStep;
|
initialStep?: CompanyStep;
|
||||||
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
|
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
|
||||||
resyncOpen?: boolean;
|
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. */
|
/** Reports the active step so the parent can persist resume progress. */
|
||||||
onStepChange?: (step: CompanyStep) => void;
|
onStepChange?: (step: CompanyStep) => void;
|
||||||
/** Persist the current step's data before advancing; returns an error to show. */
|
/** 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]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
@@ -851,17 +844,13 @@ export default function CompanyProfileForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="space-between" pt="xs">
|
||||||
{showBack ? (
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
variant="default"
|
onClick={prevStep}
|
||||||
onClick={prevStep}
|
leftSection={<ArrowLeft size={16} />}
|
||||||
leftSection={<ArrowLeft size={16} />}
|
>
|
||||||
>
|
Back
|
||||||
Back
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={nextStep}
|
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 useAuth from "@/hooks/useAuth";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
|
import { normaliseIdentifier } from "@/utils/identifier";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/edr-logo.png";
|
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() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -80,7 +70,7 @@ export default function LoginPage() {
|
|||||||
<div className="mb-1.5 flex items-center justify-between">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-gray-800">Password</span>
|
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/forgot-password"
|
||||||
className="text-xs font-semibold text-primary hover:underline"
|
className="text-xs font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Forgot password?
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
|
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -8,27 +17,19 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthLayout from "@/components/auth/AuthLayout";
|
||||||
|
import {
|
||||||
const passwordRequirements = [
|
confirmPasswordField,
|
||||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
passwordField,
|
||||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
passwordRequirements,
|
||||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
samePassword,
|
||||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
} from "@/utils/passwordSchema";
|
||||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
password: z
|
password: passwordField,
|
||||||
.string()
|
confirmPassword: confirmPasswordField,
|
||||||
.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"),
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine(samePassword, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
});
|
});
|
||||||
@@ -54,7 +55,8 @@ export default function SetPasswordPage() {
|
|||||||
const password = watch("password");
|
const password = watch("password");
|
||||||
|
|
||||||
const requirements = useMemo(
|
const requirements = useMemo(
|
||||||
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
|
() =>
|
||||||
|
passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
|
||||||
[password],
|
[password],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,11 +95,21 @@ export default function SetPasswordPage() {
|
|||||||
"Secure freight operations",
|
"Secure freight operations",
|
||||||
"Advanced authentication system",
|
"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">
|
<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)" />
|
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,50 +1,38 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
PinInput,
|
|
||||||
SegmentedControl,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Check,
|
|
||||||
Mail,
|
|
||||||
RotateCw,
|
|
||||||
ShieldCheck,
|
|
||||||
Smartphone,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { userType } from "@/enums/userType";
|
import { userType } from "@/enums/userType";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
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 { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import {
|
||||||
|
confirmPasswordField,
|
||||||
|
passwordField,
|
||||||
|
samePassword,
|
||||||
|
} from "@/utils/passwordSchema";
|
||||||
import { extractApiError } from "@/utils/result";
|
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
|
const userSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
@@ -61,43 +49,23 @@ const userSchema = z
|
|||||||
en: z.string().min(2, "Name is required"),
|
en: z.string().min(2, "Name is required"),
|
||||||
am: z.string().nullable(),
|
am: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
password: z
|
password: passwordField,
|
||||||
.string()
|
confirmPassword: confirmPasswordField,
|
||||||
.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"),
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine(samePassword, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof userSchema>;
|
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() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
|
||||||
// phone number before the account is actually created. The account is only
|
// chosen channel before the account is actually created. The account is only
|
||||||
// created after the code is verified — the OTP is a hard requirement.
|
// created after the code is verified — the OTP is a hard requirement.
|
||||||
const [stage, setStage] = useState<"form" | "otp">("form");
|
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||||
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||||
@@ -109,14 +77,7 @@ export default function SignupPage() {
|
|||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
const [otpError, setOtpError] = useState<string | null>(null);
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
const [resendIn, setResendIn] = useState(0);
|
const resendCooldown = useResendCooldown();
|
||||||
|
|
||||||
// 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -170,7 +131,7 @@ export default function SignupPage() {
|
|||||||
setOtpChannel(channel);
|
setOtpChannel(channel);
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
setResendIn(60);
|
resendCooldown.start();
|
||||||
setStage("otp");
|
setStage("otp");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(extractApiError(err).message);
|
setError(extractApiError(err).message);
|
||||||
@@ -190,7 +151,7 @@ export default function SignupPage() {
|
|||||||
: { phone: pendingData.phone },
|
: { phone: pendingData.phone },
|
||||||
);
|
);
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
setResendIn(60);
|
resendCooldown.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOtpError(extractApiError(err).message);
|
setOtpError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,8 +163,8 @@ export default function SignupPage() {
|
|||||||
const confirmOtp = async () => {
|
const confirmOtp = async () => {
|
||||||
if (!pendingData) return;
|
if (!pendingData) return;
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
if (otpCode.trim().length !== 6) {
|
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||||
setOtpError("Enter the 6-digit code we sent you.");
|
setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
@@ -298,35 +259,11 @@ export default function SignupPage() {
|
|||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<OtpChannelSelect
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
value={channel}
|
||||||
Send verification code via
|
onChange={setChannel}
|
||||||
</Text>
|
disabled={sending}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -337,37 +274,7 @@ export default function SignupPage() {
|
|||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
{passwordValue.length > 0 ? (
|
<PasswordChecklist value={passwordValue} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -412,87 +319,28 @@ export default function SignupPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<OtpChannelStep
|
||||||
<div className="mb-1 flex justify-center">
|
channel={otpChannel}
|
||||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
target={
|
||||||
<ShieldCheck size={22} />
|
otpChannel === "email"
|
||||||
</span>
|
? (pendingData?.email ?? "")
|
||||||
</div>
|
: (pendingData?.phone ?? "")
|
||||||
<div className="space-y-1.5 text-center">
|
}
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
value={otpCode}
|
||||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
onChange={setOtpCode}
|
||||||
</h1>
|
onVerify={confirmOtp}
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
onBack={() => {
|
||||||
We sent a 6 - digit code to{" "}
|
setStage("form");
|
||||||
<span className="font-medium text-gray-700">
|
setOtpError(null);
|
||||||
{otpChannel === "email"
|
}}
|
||||||
? maskEmail(pendingData?.email ?? "")
|
onResend={resendOtp}
|
||||||
: maskPhone(pendingData?.phone ?? "")}
|
resendIn={resendCooldown.secondsLeft}
|
||||||
</span>
|
sending={sending}
|
||||||
.Enter it to finish creating your account.
|
verifying={verifying}
|
||||||
</p>
|
error={otpError}
|
||||||
</div>
|
description="Enter it to finish creating your account."
|
||||||
|
submitLabel="Verify & create account"
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ export function ReadonlyBookingView({
|
|||||||
(isGeneralContract
|
(isGeneralContract
|
||||||
? status === "FULLY_EXECUTED"
|
? status === "FULLY_EXECUTED"
|
||||||
: status === "SELECTED_FOR_BATCH");
|
: status === "SELECTED_FOR_BATCH");
|
||||||
const canApproveDelivery =
|
// Approve delivery tracks the handover lifecycle exactly: the button appears
|
||||||
status === "COMPLETED" ||
|
// the moment a handover is generated (truck arrival, or an operator's
|
||||||
Boolean(booking.handoverAwaitingSignature) ||
|
// signature request) and disappears the moment the customer signs it. The
|
||||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
// backend flag counts only unsigned SELF_HAUL handovers, so no status
|
||||||
|
// heuristics are needed here.
|
||||||
|
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||||
const usesCustomerTruck =
|
const usesCustomerTruck =
|
||||||
booking.tradeDirection === "IMPORT"
|
booking.tradeDirection === "IMPORT"
|
||||||
? !booking.lastMileDeliveryAddress
|
? !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),
|
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
|
||||||
// Company onboarding document codes (see file-upload-settings seeder).
|
// Company onboarding document codes (see file-upload-settings seeder).
|
||||||
["tin_certificate", "TIN Certificate"],
|
["tin_certificate", "TIN Certificate"],
|
||||||
["commercial_license", "Commercial License"],
|
["commercial_license", "Commercial Registration"],
|
||||||
["business_license", "Business License / Trade License"],
|
["business_license", "Business License / Trade License"],
|
||||||
["investment_license", "Investment License"],
|
["investment_license", "Investment License"],
|
||||||
["national_id", "National ID"],
|
["national_id", "National ID"],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
|
Popover,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -31,6 +33,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
@@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) {
|
|||||||
|
|
||||||
export default function ContractsList() {
|
export default function ContractsList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { company } = useAuth();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
|
||||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
||||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||||
const [createdTo, setCreatedTo] = useState<string>("");
|
const [createdTo, setCreatedTo] = useState<string>("");
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
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) =>
|
const toggleExpanded = (id: string) =>
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const nextSet = new Set(prev);
|
const nextSet = new Set(prev);
|
||||||
@@ -137,9 +153,11 @@ export default function ContractsList() {
|
|||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
const active = items.filter((c) =>
|
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;
|
).length;
|
||||||
const pending = items.filter((c) =>
|
const pending = items.filter((c) =>
|
||||||
[
|
[
|
||||||
@@ -158,7 +176,7 @@ export default function ContractsList() {
|
|||||||
return { active, pending, total };
|
return { active, pending, total };
|
||||||
}, [data]);
|
}, [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 pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
const pageIndex = pagination.pageIndex;
|
const pageIndex = pagination.pageIndex;
|
||||||
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
|
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
|
||||||
@@ -175,19 +193,63 @@ export default function ContractsList() {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
<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
|
Contracts
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
<Popover
|
||||||
color="edr-green"
|
opened={disclaimerOpen}
|
||||||
|
onChange={setDisclaimerOpen}
|
||||||
|
position="bottom-end"
|
||||||
|
width={340}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
shadow="md"
|
||||||
leftSection={<Plus size={16} />}
|
withArrow
|
||||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
trapFocus
|
||||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
|
||||||
>
|
>
|
||||||
New Contract
|
<Popover.Target>
|
||||||
</Button>
|
<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>
|
</Group>
|
||||||
|
|
||||||
{/* Summary strip */}
|
{/* Summary strip */}
|
||||||
@@ -378,7 +440,11 @@ export default function ContractsList() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={11}>
|
<Table.Td colSpan={11}>
|
||||||
<Stack align="center" gap={8} py={48}>
|
<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">
|
<Text fz={13} c="dimmed">
|
||||||
No contracts yet. Create one from New Contract.
|
No contracts yet. Create one from New Contract.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -400,154 +466,159 @@ export default function ContractsList() {
|
|||||||
const isOpen = expanded.has(c.id);
|
const isOpen = expanded.has(c.id);
|
||||||
return (
|
return (
|
||||||
<Fragment key={c.id}>
|
<Fragment key={c.id}>
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: isOpen ? "#F4FBF8" : undefined,
|
background: isOpen ? "#F4FBF8" : undefined,
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate(`/contracts/${c.id}`)}
|
onClick={() => navigate(`/contracts/${c.id}`)}
|
||||||
>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
aria-label={isOpen ? "Hide progress" : "Show progress"}
|
aria-label={
|
||||||
aria-expanded={isOpen}
|
isOpen ? "Hide progress" : "Show progress"
|
||||||
onClick={(e) => {
|
}
|
||||||
e.stopPropagation();
|
aria-expanded={isOpen}
|
||||||
toggleExpanded(c.id);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
style={{
|
toggleExpanded(c.id);
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
/>
|
style={{
|
||||||
</Box>
|
display: "flex",
|
||||||
</Table.Td>
|
alignItems: "center",
|
||||||
<Table.Td>
|
justifyContent: "center",
|
||||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
width: 28,
|
||||||
{c.reference}
|
height: 28,
|
||||||
</Text>
|
borderRadius: 8,
|
||||||
<Text fz={12} c="dimmed">
|
border: `1px solid ${BORDER}`,
|
||||||
{isContainer ? "Containerised" : "Bulk"}
|
background: isOpen ? GREEN : "#FFFFFF",
|
||||||
</Text>
|
color: isOpen ? "#FFFFFF" : MUTED,
|
||||||
</Table.Td>
|
cursor: "pointer",
|
||||||
<Table.Td>
|
transition: "all 140ms ease",
|
||||||
<Badge
|
}}
|
||||||
variant="light"
|
>
|
||||||
color={isGeneral ? "edr-green" : "gray"}
|
<ChevronDown
|
||||||
radius="sm"
|
size={16}
|
||||||
>
|
style={{
|
||||||
{isGeneral ? "General" : "One-Time"}
|
transform: isOpen ? "rotate(180deg)" : "none",
|
||||||
</Badge>
|
transition: "transform 160ms ease",
|
||||||
</Table.Td>
|
}}
|
||||||
<Table.Td>
|
/>
|
||||||
<Group gap={7} wrap="nowrap" align="center">
|
</Box>
|
||||||
{isContainer ? (
|
</Table.Td>
|
||||||
<Package size={15} color={MUTED} />
|
<Table.Td>
|
||||||
) : (
|
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||||
<Weight size={15} color={MUTED} />
|
{c.reference}
|
||||||
)}
|
|
||||||
<Text fz={13} style={{ color: INK }}>
|
|
||||||
{isContainer ? "Container" : "Bulk"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
<Text fz={12} c="dimmed">
|
||||||
</Table.Td>
|
{isContainer ? "Containerised" : "Bulk"}
|
||||||
<Table.Td>
|
</Text>
|
||||||
<Text fz={13} style={{ color: INK }}>
|
</Table.Td>
|
||||||
{origin}{" "}
|
<Table.Td>
|
||||||
<Text span c="dimmed">
|
<Badge
|
||||||
→
|
variant="light"
|
||||||
</Text>{" "}
|
color={isGeneral ? "edr-green" : "gray"}
|
||||||
{destination}
|
radius="sm"
|
||||||
{count > 1 && (
|
>
|
||||||
<Text span c="dimmed" fz={12}>
|
{isGeneral ? "General" : "One-Time"}
|
||||||
{" "}
|
</Badge>
|
||||||
+{count - 1}
|
</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>
|
||||||
)}
|
</Group>
|
||||||
</Text>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Text fz={13} style={{ color: INK }}>
|
||||||
<Text
|
{origin}{" "}
|
||||||
fz={13}
|
<Text span c="dimmed">
|
||||||
c={dir ? undefined : "dimmed"}
|
→
|
||||||
style={{ color: dir ? INK : undefined }}
|
</Text>{" "}
|
||||||
>
|
{destination}
|
||||||
{tradeLabel}
|
{count > 1 && (
|
||||||
</Text>
|
<Text span c="dimmed" fz={12}>
|
||||||
</Table.Td>
|
{" "}
|
||||||
<Table.Td>
|
+{count - 1}
|
||||||
<Text fz={13} style={{ color: INK }}>
|
</Text>
|
||||||
{c.paymentCurrency ?? "—"}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text
|
<Text
|
||||||
fz={13}
|
fz={13}
|
||||||
c={c.createdAt ? undefined : "dimmed"}
|
c={dir ? undefined : "dimmed"}
|
||||||
style={{ color: c.createdAt ? INK : undefined }}
|
style={{ color: dir ? INK : undefined }}
|
||||||
>
|
>
|
||||||
{c.createdAt
|
{tradeLabel}
|
||||||
? new Date(c.createdAt).toLocaleDateString()
|
</Text>
|
||||||
: "—"}
|
</Table.Td>
|
||||||
</Text>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Text fz={13} style={{ color: INK }}>
|
||||||
<Table.Td>
|
{c.paymentCurrency ?? "—"}
|
||||||
<Text
|
</Text>
|
||||||
fz={13}
|
</Table.Td>
|
||||||
c={c.contractValidUntil ? undefined : "dimmed"}
|
<Table.Td>
|
||||||
style={{
|
<Text
|
||||||
color: c.contractValidUntil ? INK : undefined,
|
fz={13}
|
||||||
}}
|
c={c.createdAt ? undefined : "dimmed"}
|
||||||
>
|
style={{ color: c.createdAt ? INK : undefined }}
|
||||||
{c.contractValidUntil
|
>
|
||||||
? new Date(
|
{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,
|
c.contractValidUntil,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "—"}
|
: "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ContractStatusBadge status={c.status} />
|
<ContractStatusBadge status={c.status} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify="flex-end" gap={8} wrap="nowrap">
|
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||||
<ContractDocButton
|
<ContractDocButton
|
||||||
contract={c}
|
contract={c}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<ContractCustomerAction
|
<ContractCustomerAction
|
||||||
contract={c}
|
contract={c}
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
size="sm"
|
size="sm"
|
||||||
listStyle
|
listStyle
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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.Td>
|
||||||
</Table.Tr>
|
</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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -564,7 +635,10 @@ export default function ContractsList() {
|
|||||||
gap="md"
|
gap="md"
|
||||||
px={20}
|
px={20}
|
||||||
py={14}
|
py={14}
|
||||||
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
style={{
|
||||||
|
borderTop: `1px solid ${BORDER}`,
|
||||||
|
background: "#FCFDFE",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={10} align="center">
|
<Group gap={10} align="center">
|
||||||
<Text fz={13} c="dimmed">
|
<Text fz={13} c="dimmed">
|
||||||
@@ -574,8 +648,7 @@ export default function ContractsList() {
|
|||||||
data={["10", "25", "50"]}
|
data={["10", "25", "50"]}
|
||||||
value={String(pagination.pageSize)}
|
value={String(pagination.pageSize)}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
v &&
|
v && setPagination({ pageIndex: 0, pageSize: Number(v) })
|
||||||
setPagination({ pageIndex: 0, pageSize: Number(v) })
|
|
||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function NationalitySelect({
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<RoleCard
|
<RoleCard
|
||||||
label="Ethiopian Company"
|
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} />}
|
icon={<MapPin size={22} />}
|
||||||
selected={value === "ethiopian"}
|
selected={value === "ethiopian"}
|
||||||
onClick={() => onChange("ethiopian")}
|
onClick={() => onChange("ethiopian")}
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ import type {
|
|||||||
SetPasswordPayload,
|
SetPasswordPayload,
|
||||||
SignupPayload,
|
SignupPayload,
|
||||||
SignupResponse,
|
SignupResponse,
|
||||||
|
ForgotPasswordRequestPayload,
|
||||||
|
ForgotPasswordVerifyPayload,
|
||||||
|
ResetTicket,
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,6 +113,21 @@ export const api = {
|
|||||||
"setPassword",
|
"setPassword",
|
||||||
authService.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>(
|
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||||
"auth",
|
"auth",
|
||||||
"checkAvailability",
|
"checkAvailability",
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import type {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
CheckAvailabilityPayload,
|
CheckAvailabilityPayload,
|
||||||
CheckAvailabilityResponse,
|
CheckAvailabilityResponse,
|
||||||
|
ForgotPasswordRequestPayload,
|
||||||
|
ForgotPasswordVerifyPayload,
|
||||||
GenerateVerificationCodePayload,
|
GenerateVerificationCodePayload,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
OtpPayload,
|
OtpPayload,
|
||||||
OtpResponse,
|
OtpResponse,
|
||||||
|
ResetTicket,
|
||||||
SetPasswordPayload,
|
SetPasswordPayload,
|
||||||
SignupPayload,
|
SignupPayload,
|
||||||
SignupResponse,
|
SignupResponse,
|
||||||
@@ -53,6 +56,31 @@ export const authService = {
|
|||||||
return res.data.data;
|
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) => {
|
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||||
const res = await client.get<CheckAvailabilityResponse>(
|
const res = await client.get<CheckAvailabilityResponse>(
|
||||||
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ export interface SetPasswordPayload {
|
|||||||
verificationCode: string;
|
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 {
|
export interface GenerateVerificationCodePayload {
|
||||||
email: string;
|
email: string;
|
||||||
phoneNumber: 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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AgentsService } from './agents.service';
|
import { AgentsService } from './agents.service';
|
||||||
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
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 }) {
|
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
|
||||||
return this.service.updateAgent(id, dto);
|
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')
|
@Post('bookings')
|
||||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { IsInt, IsPositive, IsString } from 'class-validator';
|
import { IsInt, IsPositive, IsString } from 'class-validator';
|
||||||
import { ExcessBaggageService } from './excess-baggage.service';
|
import { ExcessBaggageService } from './excess-baggage.service';
|
||||||
@@ -26,7 +26,8 @@ export class ExcessBaggageAgentController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
@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);
|
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')
|
@Get('allowances')
|
||||||
@ApiOperation({ summary: 'List all baggage allowance rules' })
|
@ApiOperation({ summary: 'List all baggage allowance rules' })
|
||||||
getAllowances() {
|
getAllowances() {
|
||||||
@@ -92,6 +75,24 @@ export class ExcessBaggageAgentController {
|
|||||||
return this.service.deleteAllowance(id);
|
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')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
||||||
deleteCharge(@Param('id') id: string) {
|
deleteCharge(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
|
|
||||||
export class LogExcessBaggageDto {
|
export class LogExcessBaggageDto {
|
||||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
@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' })
|
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
|
||||||
@IsInt() @IsPositive() excessWeightKg: number;
|
@IsInt() @IsPositive() excessWeightKg: number;
|
||||||
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
|
@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({
|
const charge = await this.prisma.excessBaggageCharge.create({
|
||||||
data: {
|
data: {
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
agentId: dto.agentId,
|
agentId: dto.agentId ?? '',
|
||||||
excessWeightKg: dto.excessWeightKg,
|
excessWeightKg: dto.excessWeightKg,
|
||||||
feePerKgMinor,
|
feePerKgMinor,
|
||||||
totalMinor,
|
totalMinor,
|
||||||
|
|||||||
@@ -154,43 +154,54 @@ export class SearchService {
|
|||||||
) {
|
) {
|
||||||
const [y, m, d] = dateStr.split('-').map(Number);
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
|
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 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({
|
const baseWhere = {
|
||||||
where: {
|
status: 'SCHEDULED',
|
||||||
status: 'SCHEDULED',
|
isPackageOnly: false,
|
||||||
isPackageOnly: false,
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
OR: [
|
coachAssignments: { some: {} },
|
||||||
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
} as const;
|
||||||
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
|
||||||
],
|
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
|
||||||
coachAssignments: { some: {} },
|
|
||||||
},
|
|
||||||
include: SCHEDULE_INCLUDE,
|
|
||||||
orderBy: { departureAt: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
// Fetch candidates before and after in parallel; take more than needed to
|
||||||
schedules.map(schedule =>
|
// account for routes that don't serve the destination or have no availability.
|
||||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
const FETCH_LIMIT = NEEDED * 5;
|
||||||
)
|
|
||||||
);
|
const [beforeCandidates, afterCandidates] = await Promise.all([
|
||||||
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
|
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(
|
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 legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
@@ -638,6 +649,10 @@ export class SearchService {
|
|||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
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,
|
// Collect seat class IDs from the schedule include for the ID set,
|
||||||
// but fetch fresh records from DB so updated baseFareMinor is always current
|
// but fetch fresh records from DB so updated baseFareMinor is always current
|
||||||
const seatClassIdSet = new Set<string>();
|
const seatClassIdSet = new Set<string>();
|
||||||
@@ -647,7 +662,14 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const freshSeatClasses = await this.prisma.seatClass.findMany({
|
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 seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
|
||||||
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
@@ -725,6 +747,7 @@ export class SearchService {
|
|||||||
private buildCoachTypeDetails(
|
private buildCoachTypeDetails(
|
||||||
schedule: ScheduleWithIncludes,
|
schedule: ScheduleWithIncludes,
|
||||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||||
|
nationality?: string,
|
||||||
): Array<{
|
): Array<{
|
||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: 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)!;
|
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 = [];
|
const result = [];
|
||||||
@@ -769,6 +800,7 @@ export class SearchService {
|
|||||||
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
||||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
|
if (classes.length === 0) continue;
|
||||||
result.push({
|
result.push({
|
||||||
coachTypeId: coachType.id,
|
coachTypeId: coachType.id,
|
||||||
coachTypeName: coachType.name,
|
coachTypeName: coachType.name,
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { agentsApi, apiClient } from '@/lib/api';
|
import { agentsApi, apiClient } from '@/lib/api';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
@@ -48,6 +49,19 @@ export default function AgentsPage() {
|
|||||||
const [editingAgent, setEditingAgent] = useState<any>(null);
|
const [editingAgent, setEditingAgent] = useState<any>(null);
|
||||||
const [editError, setEditError] = useState<string | null>(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({
|
const editMutation = useMutation({
|
||||||
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
|
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -122,6 +136,12 @@ export default function AgentsPage() {
|
|||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
||||||
|
variant: 'danger' as const,
|
||||||
|
icon: Trash2,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,6 +189,18 @@ export default function AgentsPage() {
|
|||||||
emptyMessage="No agents found"
|
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 */}
|
{/* Agent Details Modal */}
|
||||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
||||||
{selected && (() => {
|
{selected && (() => {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { excessBaggageApi } from '@/lib/api';
|
import { excessBaggageApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, any> = {
|
const STATUS_VARIANT: Record<string, any> = {
|
||||||
PENDING: 'PENDING',
|
PENDING: 'PENDING',
|
||||||
@@ -22,9 +23,16 @@ export default function ExcessBaggagePage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const [waiveModal, setWaiveModal] = useState<any>(null);
|
const [waiveModal, setWaiveModal] = useState<any>(null);
|
||||||
const [waiveReason, setWaiveReason] = useState('');
|
const [waiveReason, setWaiveReason] = useState('');
|
||||||
const [waiveError, setWaiveError] = useState<string | null>(null);
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['excess-baggage', filters],
|
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({
|
const waiveMutation = useMutation({
|
||||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||||
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
|
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
|
||||||
@@ -51,7 +70,12 @@ export default function ExcessBaggagePage() {
|
|||||||
|
|
||||||
const resendMutation = useMutation({
|
const resendMutation = useMutation({
|
||||||
mutationFn: (id: string) => excessBaggageApi.resendLink(id),
|
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({
|
const deleteMutation = useMutation({
|
||||||
@@ -115,7 +139,7 @@ export default function ExcessBaggagePage() {
|
|||||||
label: 'Resend Link',
|
label: 'Resend Link',
|
||||||
icon: Send,
|
icon: Send,
|
||||||
variant: 'secondary' as const,
|
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',
|
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>
|
<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>
|
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}>
|
||||||
|
Log Excess Luggage
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() {
|
|||||||
emptyMessage="No excess baggage charges found"
|
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 */}
|
{/* Waive Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!!waiveModal}
|
isOpen={!!waiveModal}
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ export default function LoginPage() {
|
|||||||
const [emailFocused, setEmailFocused] = useState(false);
|
const [emailFocused, setEmailFocused] = useState(false);
|
||||||
const [passwordFocused, setPasswordFocused] = useState(false);
|
const [passwordFocused, setPasswordFocused] = useState(false);
|
||||||
|
|
||||||
const [view, setView] = useState<'login' | 'forgot'>('login');
|
const [view, setView] = useState<'login' | 'forgot'>('login');
|
||||||
const [forgotEmail, setForgotEmail] = useState('');
|
const [forgotIdentifier, setForgotIdentifier] = useState('');
|
||||||
const [forgotLoading, setForgotLoading] = useState(false);
|
const [forgotLoading, setForgotLoading] = useState(false);
|
||||||
const [forgotError, setForgotError] = useState('');
|
const [forgotError, setForgotError] = useState('');
|
||||||
const [forgotSent, setForgotSent] = useState(false);
|
const [forgotSent, setForgotSent] = useState(false);
|
||||||
const [forgotFocused, setForgotFocused] = useState(false);
|
const [forgotFocused, setForgotFocused] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login } = useAuthStore();
|
const { login } = useAuthStore();
|
||||||
@@ -66,13 +66,13 @@ export default function LoginPage() {
|
|||||||
setForgotLoading(true);
|
setForgotLoading(true);
|
||||||
setForgotError('');
|
setForgotError('');
|
||||||
try {
|
try {
|
||||||
await iamAuthApi.forgotPassword(forgotEmail);
|
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||||
setForgotSent(true);
|
setForgotSent(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err.response?.data?.message || err.message || '';
|
const msg = err.response?.data?.message || err.message || '';
|
||||||
setForgotError(
|
setForgotError(
|
||||||
msg === 'user_not_found'
|
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.'
|
: msg || 'Failed to send the reset link. Please try again.'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -209,7 +209,7 @@ export default function LoginPage() {
|
|||||||
<div className="flex justify-end mt-2">
|
<div className="flex justify-end mt-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
@@ -260,7 +260,7 @@ export default function LoginPage() {
|
|||||||
Reset your password
|
Reset your password
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -295,10 +295,10 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||||
{/* Email field */}
|
{/* Email or phone field */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
<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>
|
</label>
|
||||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||||
forgotFocused
|
forgotFocused
|
||||||
@@ -306,15 +306,15 @@ export default function LoginPage() {
|
|||||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||||
}`}>
|
}`}>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
value={forgotEmail}
|
value={forgotIdentifier}
|
||||||
onChange={(e) => { setForgotEmail(e.target.value); setForgotError(''); }}
|
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
|
||||||
onFocus={() => setForgotFocused(true)}
|
onFocus={() => setForgotFocused(true)}
|
||||||
onBlur={() => setForgotFocused(false)}
|
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"
|
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
|
required
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,9 +322,9 @@ export default function LoginPage() {
|
|||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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)'
|
? 'rgb(20,113,76)'
|
||||||
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</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: '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: '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' },
|
{ 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
interface Schedule {
|
interface Schedule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -440,7 +441,7 @@ export default function SchedulesPage() {
|
|||||||
label: 'Departure',
|
label: 'Departure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (schedule: Schedule) => (
|
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',
|
label: 'Arrival',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (schedule: Schedule) => (
|
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"
|
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"
|
confirmText="Cancel Schedule"
|
||||||
isDanger={true}
|
isDanger={true}
|
||||||
isLoading={cancelScheduleMutation.isPending}
|
isLoading={cancelScheduleMutation.isPending}
|
||||||
@@ -656,7 +657,7 @@ export default function SchedulesPage() {
|
|||||||
deleteConfirm.isBulk
|
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 ${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 ${
|
: `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"
|
confirmText="Delete"
|
||||||
|
|||||||
@@ -166,10 +166,13 @@ export default function TariffRatesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'coachType', label: 'Coach Type',
|
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
|
render: (c: any) => c.bedPosition
|
||||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
||||||
: <span className="text-muted-foreground text-xs">Standard</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 selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId)
|
||||||
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
|
?? editingClass?.coachType;
|
||||||
|
const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -224,7 +228,7 @@ export default function TariffRatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
|
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name, nationality, berth position..."
|
placeholder="Search by name, nationality, bed position..."
|
||||||
className="input pl-10 w-full"
|
className="input pl-10 w-full"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -311,17 +315,17 @@ export default function TariffRatesPage() {
|
|||||||
|
|
||||||
{isBedCoach && (
|
{isBedCoach && (
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Berth Position *</label>
|
<label className="label">Bed Position *</label>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={selectedBedPosition}
|
value={selectedBedPosition}
|
||||||
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
||||||
required={isBedCoach}
|
required={isBedCoach}
|
||||||
>
|
>
|
||||||
<option value="">Select berth position</option>
|
<option value="">Select bed position</option>
|
||||||
{(selectedCoachType?.code === 'HBC'
|
{(selectedCoachType?.code === 'HBC'
|
||||||
? BED_POSITIONS
|
? BED_POSITIONS
|
||||||
: (['UPPER', 'LOWER'] as const)
|
: (['Upper','Middle', 'Lower'] as const)
|
||||||
).map((pos) => (
|
).map((pos) => (
|
||||||
<option key={pos} value={pos}>{pos}</option>
|
<option key={pos} value={pos}>{pos}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -38,13 +38,6 @@ export default function TicketsPage() {
|
|||||||
const [excessError, setExcessError] = useState<string | null>(null);
|
const [excessError, setExcessError] = useState<string | null>(null);
|
||||||
const [excessResult, setExcessResult] = useState<any>(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 }) => (
|
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">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
<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) => {
|
const handleExcessSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!excessTicket) return;
|
if (!excessTicket) return;
|
||||||
const agentId = agentData?.id;
|
|
||||||
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
|
|
||||||
await excessMutation.mutateAsync({
|
await excessMutation.mutateAsync({
|
||||||
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
||||||
agentId,
|
|
||||||
excessWeightKg: parseInt(excessKg),
|
excessWeightKg: parseInt(excessKg),
|
||||||
collectCash: excessCollectCash,
|
collectCash: excessCollectCash,
|
||||||
});
|
});
|
||||||
@@ -353,15 +343,12 @@ export default function TicketsPage() {
|
|||||||
key: 'contact',
|
key: 'contact',
|
||||||
label: 'Contact',
|
label: 'Contact',
|
||||||
render: (ticket: any) => {
|
render: (ticket: any) => {
|
||||||
const phone = ticket.booking?.passenger?.phone || 'N/A';
|
const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—';
|
||||||
const email = ticket.booking?.passenger?.email || 'N/A';
|
const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{phone}</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 className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -486,7 +473,7 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Baggage',
|
label: 'Luggage',
|
||||||
onClick: openExcessModal,
|
onClick: openExcessModal,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Package,
|
icon: Package,
|
||||||
@@ -912,14 +899,9 @@ export default function TicketsPage() {
|
|||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
|
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
|
||||||
</div>
|
</div>
|
||||||
{agentData && (
|
{user && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
|
Agent: <span className="font-semibold text-foreground">{user.fullName}</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!agentData && (
|
|
||||||
<div className="text-sm text-amber-600 dark:text-amber-400">
|
|
||||||
⚠ No agent profile linked to your account.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
|||||||
|
|
||||||
|
|
||||||
export const iamAuthApi = {
|
export const iamAuthApi = {
|
||||||
forgotPassword: (email: string) =>
|
// `identifier` can be an email address or a phone number — the IAM accepts
|
||||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
// 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: {
|
resetPassword: (data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export const agentsApi = {
|
|||||||
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
|
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
|
||||||
create: (data: any) => apiClient.post<any>('/agents', data),
|
create: (data: any) => apiClient.post<any>('/agents', data),
|
||||||
update: (id: string, data: any) => apiClient.patch<any>(`/agents/${id}`, 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`),
|
getShifts: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/shifts`),
|
||||||
openShift: (agentId: string, data: any) => apiClient.post<any>(`/agents/${agentId}/shifts/open`, data),
|
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),
|
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?.role) params.append('role', filters.role);
|
||||||
if (filters?.page) params.append('page', filters.page.toString());
|
if (filters?.page) params.append('page', filters.page.toString());
|
||||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.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()}`);
|
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -21,4 +24,8 @@ export const passengersApi = {
|
|||||||
update: (id: string, data: Partial<Passenger.IPassenger>) => {
|
update: (id: string, data: Partial<Passenger.IPassenger>) => {
|
||||||
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
|
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';
|
if (!date) return 'N/A';
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
if (isNaN(d.getTime())) return 'N/A';
|
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 => {
|
export const formatDateTimeShort = (date?: string | Date | null): string => {
|
||||||
if (!date) return 'N/A';
|
if (!date) return 'N/A';
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
if (isNaN(d.getTime())) return 'N/A';
|
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 => {
|
export const formatDateTimeLocal = (date?: string | Date | null): string => {
|
||||||
if (!date) return 'N/A';
|
if (!date) return 'N/A';
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
if (isNaN(d.getTime())) return 'N/A';
|
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 => {
|
export const getStatusColor = (status: string): string => {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export default function ConfirmationPage() {
|
|||||||
if (!bookingId || !pnr) return null;
|
if (!bookingId || !pnr) return null;
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Success Header */}
|
{/* Success Header */}
|
||||||
@@ -456,26 +456,29 @@ export default function ConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons — voucher/ticket only makes sense once the booking is
|
||||||
<div className="mb-6">
|
actually confirmed; a pending-payment booking has no real ticket yet. */}
|
||||||
<button
|
{isConfirmed && (
|
||||||
onClick={handleDownloadVoucher}
|
<div className="mb-6">
|
||||||
disabled={isGeneratingVoucher}
|
<button
|
||||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
onClick={handleDownloadVoucher}
|
||||||
>
|
disabled={isGeneratingVoucher}
|
||||||
{isGeneratingVoucher ? (
|
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||||
<>
|
>
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
{isGeneratingVoucher ? (
|
||||||
<span>Generating...</span>
|
<>
|
||||||
</>
|
<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>
|
<>
|
||||||
</>
|
<FileText className="w-4 h-4" />
|
||||||
)}
|
<span>Download Voucher</span>
|
||||||
</button>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New Booking Button */}
|
{/* New Booking Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { ProgressIndicator } from '@/components/ProgressIndicator';
|
import { ProgressIndicator } from '@/components/ProgressIndicator';
|
||||||
|
import { PageTransition } from '@/components/PageTransition';
|
||||||
|
|
||||||
export default function BookingLayout({
|
export default function BookingLayout({
|
||||||
children,
|
children,
|
||||||
@@ -31,7 +32,7 @@ export default function BookingLayout({
|
|||||||
<ProgressIndicator currentStep={currentStep} />
|
<ProgressIndicator currentStep={currentStep} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
<PageTransition>{children}</PageTransition>
|
||||||
</div>
|
</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 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) {
|
function daysInGCMonth(y: number, m: number) {
|
||||||
return new Date(y, m, 0).getDate();
|
return new Date(y, m, 0).getDate();
|
||||||
}
|
}
|
||||||
@@ -554,6 +578,12 @@ const passengerSchema = z.object({
|
|||||||
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
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) {
|
if (!formInitialized || faydaCompleting) {
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-lg mx-auto text-center">
|
<div className="max-w-lg mx-auto text-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
<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="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<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">
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
@@ -1312,11 +1342,15 @@ export default function PassengersPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
|
<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`)}
|
{...register(`passengers.${index}.passportCountry`)}
|
||||||
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
|
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 && (
|
{errors.passengers?.[index]?.passportCountry && (
|
||||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1336,8 +1370,12 @@ export default function PassengersPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
{...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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export default function PaymentPage() {
|
|||||||
// split equally across both legs. This guarantees leg totals are consistent with the
|
// split equally across both legs. This guarantees leg totals are consistent with the
|
||||||
// per-passenger breakdown rows and the overall reviewed total.
|
// per-passenger breakdown rows and the overall reviewed total.
|
||||||
const outboundBaseFare = isRoundTrip
|
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;
|
: 0;
|
||||||
const inboundBaseFare = isRoundTrip
|
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;
|
: 0;
|
||||||
|
|
||||||
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
||||||
@@ -188,10 +188,10 @@ export default function PaymentPage() {
|
|||||||
|
|
||||||
if (!bookingId && !pnr) {
|
if (!bookingId && !pnr) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
<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>
|
||||||
</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="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Outbound</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Return</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -372,10 +372,10 @@ export default function PaymentPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<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 */}
|
{/* 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">
|
<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.
|
// Alternatives are surfaced whenever a leg returns no exact-date results.
|
||||||
const alternativeOutbound: Schedule[] =
|
const alternativeOutbound: Schedule[] =
|
||||||
!!results && outboundSchedules.length === 0
|
!!results && outboundSchedules.length === 0
|
||||||
? results?.alternativeOutbound || []
|
? results?.alternativeOutbound || results?.outboundAlternatives || []
|
||||||
: [];
|
: [];
|
||||||
const alternativeInbound: Schedule[] =
|
const alternativeInbound: Schedule[] =
|
||||||
isRoundTrip && !!results && inboundSchedules.length === 0
|
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 =
|
const isOneWayNoOutbound =
|
||||||
!isRoundTrip && !!results && outboundSchedules.length === 0;
|
!isRoundTrip && !!results && outboundSchedules.length === 0;
|
||||||
// Round-trip: show results view if either leg has exact results OR alternatives.
|
// 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
|
// For round trip inbound, proceed with both schedules
|
||||||
if (isRoundTrip && !isOutbound) {
|
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
|
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||||
} else {
|
} else {
|
||||||
// For one-way
|
// For one-way
|
||||||
@@ -529,7 +534,7 @@ export default function ResultsPage() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSelect(classModal, isOutbound);
|
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>
|
<span>
|
||||||
{isRoundTrip && isOutbound
|
{isRoundTrip && isOutbound
|
||||||
@@ -647,9 +652,9 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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-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
|
{schedule.departureAt
|
||||||
? formatTime(schedule.departureAt)
|
? formatTime(schedule.departureAt)
|
||||||
: "--:--"}
|
: "--:--"}
|
||||||
@@ -664,9 +669,9 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center">
|
<div className="flex-1 min-w-0 flex flex-col items-center">
|
||||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
<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-4 h-4" />
|
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||||
<span>{durationStr}</span>
|
<span>{durationStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
<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 className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
{schedule.stops && schedule.stops.length > 0 && (
|
{schedule.stops && schedule.stops.length > 0 && (
|
||||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<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-4 h-4" />
|
<MapPin className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||||
<span>{schedule.stops.length - 2} stops</span>
|
<span>{schedule.stops.length - 2} stops</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<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
|
{schedule.arrivalAt
|
||||||
? formatTime(schedule.arrivalAt)
|
? formatTime(schedule.arrivalAt)
|
||||||
: "--:--"}
|
: "--:--"}
|
||||||
@@ -744,7 +749,7 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Progress Header */}
|
{/* Progress Header */}
|
||||||
@@ -943,28 +948,19 @@ export default function ResultsPage() {
|
|||||||
!!results &&
|
!!results &&
|
||||||
outboundSchedules.length === 0 &&
|
outboundSchedules.length === 0 &&
|
||||||
inboundSchedules.length === 0 &&
|
inboundSchedules.length === 0 &&
|
||||||
alternativeOutbound.length === 0 &&
|
(results?.alternativeOutbound || []).length === 0 &&
|
||||||
alternativeInbound.length === 0;
|
(results?.alternativeInbound || []).length === 0;
|
||||||
if (isRoundTripNoResults) {
|
if (isRoundTripNoResults) {
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="card text-center">
|
<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="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>No trains found for your selected dates or route.</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
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"
|
|
||||||
>
|
|
||||||
Modify search
|
Modify search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -975,34 +971,20 @@ export default function ResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOneWayNoOutbound) {
|
if (isOneWayNoOutbound) {
|
||||||
const requestedDateLabel = requestedDate
|
|
||||||
? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
|
||||||
: "your selected date";
|
|
||||||
const hasAlternatives = alternativeOutbound.length > 0;
|
const hasAlternatives = alternativeOutbound.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="booking-page">
|
||||||
{renderClassModal()}
|
{renderClassModal()}
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="card text-center mb-8 max-w-3xl 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 mb-8">
|
||||||
<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">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<Calendar className="w-10 h-10 text-amber-500" />
|
<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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
No trains available
|
Change date
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1031,24 +1013,15 @@ export default function ResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="card text-center">
|
<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="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
<div className="flex items-center gap-2.5 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
<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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
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"
|
|
||||||
>
|
|
||||||
Modify search
|
Modify search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1059,7 +1032,7 @@ export default function ResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{renderClassModal()}
|
{renderClassModal()}
|
||||||
@@ -1172,29 +1145,13 @@ export default function ResultsPage() {
|
|||||||
{outboundSchedules.length === 0 &&
|
{outboundSchedules.length === 0 &&
|
||||||
alternativeOutbound.length > 0 && (
|
alternativeOutbound.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="card text-center mb-6 max-w-3xl 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 mb-6">
|
||||||
<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">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<Calendar className="w-8 h-8 text-amber-500" />
|
<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>
|
</div>
|
||||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
No trains available
|
Change dates
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -1272,29 +1229,13 @@ export default function ResultsPage() {
|
|||||||
{inboundSchedules.length === 0 &&
|
{inboundSchedules.length === 0 &&
|
||||||
alternativeInbound.length > 0 && (
|
alternativeInbound.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="card text-center mb-6 max-w-3xl 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 mb-6">
|
||||||
<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">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<Calendar className="w-8 h-8 text-amber-500" />
|
<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>
|
</div>
|
||||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
No trains available
|
Change dates
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|||||||
@@ -445,7 +445,9 @@ export default function ReviewPage() {
|
|||||||
const fareMinor = isPackageBooking
|
const fareMinor = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
||||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
: (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);
|
setReviewedTotal(computedTotal, passengerFares);
|
||||||
|
|
||||||
@@ -560,6 +562,14 @@ export default function ReviewPage() {
|
|||||||
const isFreeChild = isPackageBooking
|
const isFreeChild = isPackageBooking
|
||||||
? isPkgFreeChild(i)
|
? isPkgFreeChild(i)
|
||||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, 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 seatFare = getPassengerSeatFare(p);
|
||||||
const passengerTotal = isPackageBooking
|
const passengerTotal = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||||
@@ -582,6 +592,19 @@ export default function ReviewPage() {
|
|||||||
{formatFare(passengerTotal, displayCurrency)}
|
{formatFare(passengerTotal, displayCurrency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -613,10 +636,10 @@ export default function ReviewPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<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'}
|
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -608,6 +608,12 @@ export default function SearchPage() {
|
|||||||
reValidateMode: "onSubmit",
|
reValidateMode: "onSubmit",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
tripType: "ONE_WAY",
|
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,
|
adultCount: 1,
|
||||||
childCount: 0,
|
childCount: 0,
|
||||||
// No default nationality — the user must explicitly pick one. Left blank (not a valid
|
// 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 ── */}
|
{/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */}
|
||||||
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
|
{/* 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
|
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
|
<section
|
||||||
className={`relative ${
|
className={`relative ${
|
||||||
tripType === "ROUND_TRIP"
|
tripType === "ROUND_TRIP"
|
||||||
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
|
? "md:h-[90vh] md:min-h-[560px]"
|
||||||
: "h-[94vh] min-h-[560px]"
|
: "md:h-[94vh] md:min-h-[560px]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Background image with zoom - fully isolated */}
|
{/* Background image with zoom — desktop only; mobile drops the hero image entirely
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
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
|
<div
|
||||||
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
||||||
style={{
|
style={{
|
||||||
@@ -852,12 +860,12 @@ export default function SearchPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Gradient overlay */}
|
{/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */}
|
||||||
<div className="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-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" />
|
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||||
|
|
||||||
{/* Hero headline — top area */}
|
{/* Hero headline — desktop only; removed on mobile along with the banner image */}
|
||||||
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
<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">
|
<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
|
Where are you
|
||||||
<br className="hidden sm:block" /> headed today?
|
<br className="hidden sm:block" /> headed today?
|
||||||
@@ -867,14 +875,28 @@ export default function SearchPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<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}
|
ref={widgetRef}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<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)}>
|
<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 && (
|
{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">
|
<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>
|
<span>⚠️</span>
|
||||||
@@ -950,6 +972,11 @@ export default function SearchPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{hasInteracted && errors.originStationId && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.originStationId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -998,6 +1025,11 @@ export default function SearchPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{hasInteracted && errors.destinationStationId && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.destinationStationId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
@@ -1019,8 +1051,14 @@ export default function SearchPage() {
|
|||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder="Select date"
|
placeholder="Select date"
|
||||||
|
error={!!errors.departureDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.departureDate && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.departureDate.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tripType === "ROUND_TRIP" && (
|
{tripType === "ROUND_TRIP" && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1047,6 +1085,7 @@ export default function SearchPage() {
|
|||||||
: new Date()
|
: new Date()
|
||||||
}
|
}
|
||||||
placeholder="Select return date"
|
placeholder="Select return date"
|
||||||
|
error={!!errors.returnDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.returnDate && (
|
{errors.returnDate && (
|
||||||
@@ -1079,7 +1118,7 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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 className="w-5 h-5" />
|
||||||
Search
|
Search
|
||||||
@@ -1182,6 +1221,7 @@ export default function SearchPage() {
|
|||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder="Departure"
|
placeholder="Departure"
|
||||||
|
error={!!errors.departureDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.departureDate && (
|
{errors.departureDate && (
|
||||||
@@ -1219,7 +1259,7 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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 className="w-5 h-5" />
|
||||||
Search
|
Search
|
||||||
@@ -1324,6 +1364,7 @@ export default function SearchPage() {
|
|||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder="Select date"
|
placeholder="Select date"
|
||||||
|
error={!!errors.departureDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.departureDate && (
|
{errors.departureDate && (
|
||||||
@@ -1357,6 +1398,7 @@ export default function SearchPage() {
|
|||||||
: new Date()
|
: new Date()
|
||||||
}
|
}
|
||||||
placeholder="Select date"
|
placeholder="Select date"
|
||||||
|
error={!!errors.returnDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.returnDate && (
|
{errors.returnDate && (
|
||||||
@@ -1482,7 +1524,7 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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 className="w-5 h-5" />
|
||||||
Search
|
Search
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import CustomModal from "@/components/CustomModal";
|
import CustomModal from "@/components/CustomModal";
|
||||||
|
import { Skeleton } from "@/components/Skeleton";
|
||||||
import { isChild } from "@/utils/fare-utils";
|
import { isChild } from "@/utils/fare-utils";
|
||||||
|
|
||||||
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
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.
|
// the new seat map data has finished loading.
|
||||||
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
|
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
|
||||||
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
|
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
|
||||||
const firstClass = matchedType.classes?.[0];
|
|
||||||
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
|
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
|
||||||
const updatedSchedule = {
|
const updatedSchedule = {
|
||||||
...(currentSchedule as any),
|
...(currentSchedule as any),
|
||||||
selectedCoachTypeId: newCoachTypeId,
|
selectedCoachTypeId: newCoachTypeId,
|
||||||
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
|
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
|
||||||
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
|
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
|
||||||
selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
seatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
|
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
|
||||||
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
|
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
|
||||||
};
|
};
|
||||||
@@ -921,7 +921,7 @@ export default function SeatsPage() {
|
|||||||
const positionLabel = newSeat?.bedPosition
|
const positionLabel = newSeat?.bedPosition
|
||||||
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
|
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
|
||||||
: "This seat";
|
: "This seat";
|
||||||
const legMultiplier = isRoundTrip ? 2 : 1;
|
const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
|
||||||
|
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -1328,15 +1328,16 @@ export default function SeatsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRoundTrip) {
|
if (isRoundTrip) {
|
||||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||||
router.push("/booking/search");
|
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!selectedSchedule || !passengers.length) {
|
if (!selectedSchedule || !passengers.length) {
|
||||||
router.push("/booking/search");
|
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
|
isPackageBooking,
|
||||||
selectedSchedule,
|
selectedSchedule,
|
||||||
outboundSchedule,
|
outboundSchedule,
|
||||||
inboundSchedule,
|
inboundSchedule,
|
||||||
@@ -1712,7 +1713,7 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
isRoundTrip
|
isRoundTrip
|
||||||
? !outboundSchedule || !inboundSchedule || !passengers.length
|
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
|
||||||
: !selectedSchedule || !passengers.length
|
: !selectedSchedule || !passengers.length
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 space-y-4">
|
||||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<Skeleton className="h-5 w-32" />
|
||||||
Loading seat map...
|
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
||||||
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
|
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
|
|
||||||
const styles = `
|
const styles = `
|
||||||
.contact-hero {
|
.contact-hero {
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
|
background: #ffffff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .contact-hero {
|
.dark .contact-hero {
|
||||||
|
background: #111827;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +372,7 @@ export default function Contact() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react';
|
|||||||
import { iamAuthApi } from '@/lib/api/auth';
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
@@ -16,13 +16,13 @@ export default function ForgotPasswordPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await iamAuthApi.forgotPassword(email);
|
await iamAuthApi.forgotPassword(identifier.trim());
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err.response?.data?.message || err.message || '';
|
const msg = err.response?.data?.message || err.message || '';
|
||||||
setError(
|
setError(
|
||||||
msg === 'user_not_found'
|
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.'
|
: msg || 'Failed to send the reset link. Please try again.'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<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
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
value={identifier}
|
||||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com or +251..."
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'}
|
{loading ? 'Sending...' : 'Send reset link'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -26,19 +26,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.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 {
|
.booking-page {
|
||||||
background-color: rgba(20, 113, 76, 0.1);
|
@apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.booking-container {
|
||||||
.btn-ghost:hover {
|
@apply container mx-auto px-4 max-w-6xl;
|
||||||
background-color: rgba(20, 113, 76, 0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.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;
|
@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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.animate-bounce-in {
|
.animate-bounce-in {
|
||||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
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);
|
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 {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: 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.
|
* 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
|
* 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.
|
* path. A short message shows only when the link is missing/untrusted.
|
||||||
*
|
*
|
||||||
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
* `?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 { headers } from 'next/headers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
import AppHeader from '@/components/AppHeader';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import { Footer } from '@/components/Footer';
|
import MobileTopBar from '@/components/MobileTopBar';
|
||||||
|
import BottomTabBar from '@/components/BottomTabBar';
|
||||||
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||||
import SupportWidget from '@/features/support/SupportWidget';
|
import SupportWidget from '@/features/support/SupportWidgetLazy';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'EDR Passenger Portal - Book your train journey',
|
title: 'EDR Passenger Portal - Book your train journey',
|
||||||
@@ -21,7 +22,7 @@ export default function RootLayout({
|
|||||||
const nonce = headers().get('x-nonce') ?? undefined;
|
const nonce = headers().get('x-nonce') ?? undefined;
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
<body className="font-sans antialiased">
|
||||||
<script
|
<script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
@@ -47,11 +48,14 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
<Providers>
|
<Providers>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
<AppHeader />
|
<AppSidebar />
|
||||||
<main className="flex-1">
|
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||||
{children}
|
<MobileTopBar />
|
||||||
</main>
|
<main className="flex-1">
|
||||||
<Footer />
|
{children}
|
||||||
|
</main>
|
||||||
|
<BottomTabBar />
|
||||||
|
</div>
|
||||||
<SupportWidget />
|
<SupportWidget />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
|||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
coachTypeId: ct?.id ?? key,
|
coachTypeId: ct?.id ?? key,
|
||||||
coachTypeName: ct?.name ?? tier.seatType,
|
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
||||||
coachTypeCode: ct?.code ?? '',
|
coachTypeCode: ct?.code ?? '',
|
||||||
coachTypeType: ct?.type ?? 'passenger',
|
coachTypeType: ct?.type ?? 'passenger',
|
||||||
tiers: [],
|
tiers: [],
|
||||||
@@ -270,90 +270,121 @@ function PriceTiersPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
|
<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">Select Coach Type</h2>
|
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">Choose Coach Type</h2>
|
||||||
{groups.map((group) => {
|
<div className="grid grid-cols-1 gap-4">
|
||||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
{groups.map((group, index) => {
|
||||||
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||||
const isSelected = selectedId === group.coachTypeId;
|
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
||||||
return (
|
const isSelected = selectedId === group.coachTypeId;
|
||||||
<div
|
return (
|
||||||
key={group.coachTypeId}
|
<div
|
||||||
className={`rounded-xl border-2 overflow-hidden transition-colors ${
|
key={group.coachTypeId}
|
||||||
allSoldOut
|
role="button"
|
||||||
? 'border-gray-200 dark:border-gray-700 opacity-50'
|
tabIndex={allSoldOut ? -1 : 0}
|
||||||
: isSelected
|
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||||
? 'border-primary'
|
onKeyDown={(e) => {
|
||||||
: 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
|
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
}`}
|
e.preventDefault();
|
||||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
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">
|
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
|
||||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||||
isSelected ? 'bg-primary' : 'bg-primary/10'
|
: 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'
|
||||||
<CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
: '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'
|
||||||
</div>
|
}`}
|
||||||
<div className="flex-1 min-w-0">
|
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }}
|
||||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
>
|
||||||
{formatCoachTypeLabel(group.coachTypeType)}
|
{/* Radio indicator */}
|
||||||
</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>
|
|
||||||
{!allSoldOut && (
|
{!allSoldOut && (
|
||||||
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
<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 ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
|
}`}>
|
||||||
}`}>
|
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All available classes for this coach type */}
|
<div className="flex flex-col">
|
||||||
<div className="px-4 py-3 space-y-2">
|
<div className="flex items-start gap-2 pr-2">
|
||||||
{group.tiers.map((tier) => {
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${isSelected
|
||||||
const soldOut = tier.availableSeats === 0;
|
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||||
return (
|
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||||
<div
|
}`}>
|
||||||
key={tier.id}
|
<CoachIcon className={`w-4 h-4 transition-colors ${isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||||
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
|
}`} />
|
||||||
>
|
</div>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
|
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
|
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 tracking-wider">
|
||||||
<p className="text-xs">
|
{formatCoachTypeLabel(group.coachTypeType)}
|
||||||
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
|
</p>
|
||||||
{soldOut ? (
|
{allSoldOut && (
|
||||||
<span className="ml-2 font-bold text-red-500">Sold out</span>
|
<span className="text-xs font-bold text-red-500 mt-0.5 block">Sold out</span>
|
||||||
) : (
|
)}
|
||||||
<span className="ml-2 text-gray-400">{tier.availableSeats} left</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>
|
||||||
</p>
|
<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>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Now — only when this group is selected */}
|
{/* Class options — always visible, matching results page style */}
|
||||||
{isSelected && !allSoldOut && (
|
{group.tiers.length > 0 && (
|
||||||
<div className="px-4 pb-4">
|
<div className="mt-2 pt-2 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||||
<button
|
<div className="space-y-1.5">
|
||||||
type="button"
|
{group.tiers.map((tier) => {
|
||||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
const soldOut = tier.availableSeats === 0;
|
||||||
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"
|
return (
|
||||||
>
|
<div
|
||||||
Book Now <ArrowRight className="w-4 h-4" />
|
key={tier.id}
|
||||||
</button>
|
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>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -407,7 +438,7 @@ function PassengerCountModal({
|
|||||||
|
|
||||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
<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-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>
|
<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>
|
</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: "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 },
|
{ 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 }) => (
|
].map(({ label, sub, value, min, max, set }) => (
|
||||||
<div key={label} className="flex items-center justify-between">
|
<div key={label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||||
<p className="text-xs text-gray-400">{sub}</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>
|
</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 && (
|
{freeChildren > 0 && (
|
||||||
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
||||||
@@ -457,15 +488,14 @@ function PassengerCountModal({
|
|||||||
|
|
||||||
{/* Departure Station */}
|
{/* Departure Station */}
|
||||||
<div>
|
<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
|
Departure Station
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={departureStationId}
|
value={departureStationId}
|
||||||
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
|
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 ${
|
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'
|
||||||
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<option value="">Select your boarding station</option>
|
<option value="">Select your boarding station</option>
|
||||||
{stations.map((s) => (
|
{stations.map((s) => (
|
||||||
@@ -480,10 +510,10 @@ function PassengerCountModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" onClick={() => {
|
<button type="button" onClick={() => {
|
||||||
if (!departureStationId) { setShowStationError(true); return; }
|
if (!departureStationId) { setShowStationError(true); return; }
|
||||||
const station = stations.find(s => s.id === departureStationId);
|
const station = stations.find(s => s.id === departureStationId);
|
||||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||||
}}
|
}}
|
||||||
disabled={loading || adultCount + childCount < 1}
|
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">
|
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" /></>}
|
{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
|
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
||||||
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
||||||
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
||||||
// Representative tier for the modal header (cheapest available)
|
// Representative tier for the modal: cheapest available in the selected group
|
||||||
const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null;
|
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';
|
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">
|
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||||
{pkg.priceTiers.length
|
{pkg.priceTiers.length
|
||||||
? formatPrice(
|
? formatPrice(
|
||||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
||||||
pkg.priceTiers[0].currency,
|
pkg.priceTiers[0].currency,
|
||||||
)
|
)
|
||||||
: "—"}
|
: "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||||
import SearchPage from '@/app/booking/search/page';
|
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 queryClient = new QueryClient();
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
@@ -18,10 +46,18 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Suspense>
|
{children}
|
||||||
<SearchPage />
|
|
||||||
<PackagesSection />
|
|
||||||
</Suspense>
|
|
||||||
</HydrationBoundary>
|
</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