mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed
This commit is contained in:
23
.github/workflows/deploy.yml
vendored
23
.github/workflows/deploy.yml
vendored
@@ -170,13 +170,36 @@ jobs:
|
|||||||
- name: Build ${{ matrix.service }}
|
- name: Build ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
||||||
|
# Tag with git SHA for rollback capability
|
||||||
|
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
|
||||||
|
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Deploy ${{ matrix.service }}
|
- name: Deploy ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||||
|
|
||||||
|
- name: Verify deployment health
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
|
||||||
|
echo "Waiting for service to become healthy on port ${PORT}..."
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
|
||||||
|
echo "Service is healthy."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "Service failed health check after 120s — rolling back"
|
||||||
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Remove npm credentials from workspace
|
- name: Remove npm credentials from workspace
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f .npmrc .npmrc_temp
|
run: rm -f .npmrc .npmrc_temp
|
||||||
|
|||||||
@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
|
|||||||
|
|
||||||
### Runtime
|
### Runtime
|
||||||
|
|
||||||
The final image runs:
|
The final image uses Next.js `output: 'standalone'` and runs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx next start
|
node server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
|
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
|
||||||
|
|
||||||
|
## Rollback Procedure
|
||||||
|
|
||||||
|
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
|
||||||
|
|
||||||
|
### Rollback a single service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Find the last known-good image tag
|
||||||
|
docker images | grep passenger-api
|
||||||
|
|
||||||
|
# 2. Re-tag it as the current image
|
||||||
|
docker tag edr-passenger-main-passenger-api:<previous-sha> edr-passenger-main-passenger-api:latest
|
||||||
|
|
||||||
|
# 3. Restart the container from the previous image
|
||||||
|
docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback via re-run
|
||||||
|
|
||||||
|
Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit.
|
||||||
|
|
||||||
|
## Production Security Checklist
|
||||||
|
|
||||||
|
Before deploying to production, verify:
|
||||||
|
|
||||||
|
- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`)
|
||||||
|
- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10`
|
||||||
|
- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production)
|
||||||
|
- [ ] `NODE_ENV=production` is set
|
||||||
|
- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token
|
||||||
|
- [ ] No `.env` files are committed to the repository (`git status` should show none)
|
||||||
|
|
||||||
|
## Data Retention Policy
|
||||||
|
|
||||||
|
The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes:
|
||||||
|
|
||||||
|
| Table | Retention |
|
||||||
|
|---|---|
|
||||||
|
| `OtpCode` | 1 hour after expiry or verification |
|
||||||
|
| `FaydaVerificationSession` | 1 hour after expiry or completion |
|
||||||
|
| `AuditLog` | 365 days |
|
||||||
|
| `PaymentWebhookEvent` | 90 days |
|
||||||
|
| `GateValidationLog` | 180 days |
|
||||||
|
|
||||||
|
No manual intervention is required. Monitor the `TasksService` log output for purge counts.
|
||||||
|
|
||||||
## GitHub Actions Deployment Flow
|
## GitHub Actions Deployment Flow
|
||||||
|
|
||||||
@@ -147,9 +193,10 @@ For each service:
|
|||||||
- Computes branch slug and sets:
|
- Computes branch slug and sets:
|
||||||
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
||||||
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||||
- Runs:
|
- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
|
||||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
- Builds the service image and tags it with the short git SHA.
|
||||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
- Runs `docker compose up -d <service> --force-recreate`.
|
||||||
|
- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy.
|
||||||
- Cleans `.npmrc`/`.npmrc_temp`.
|
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||||
|
|
||||||
## Branch/Environment Isolation
|
## Branch/Environment Isolation
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
||||||
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
|
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
|
||||||
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
|
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
|
||||||
|
# (dev without a local broker).
|
||||||
RABBITMQ_ENABLED=false
|
RABBITMQ_ENABLED=false
|
||||||
RABBITMQ_URL=amqp://localhost:5672
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
SMS_QUEUE=sms_queue
|
SMS_QUEUE=sms_queue
|
||||||
|
EMAIL_QUEUE=email_queue
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
|||||||
|
|
||||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||||
|
|
||||||
|
/** Container allocation on a booking (allocate-containers endpoint). */
|
||||||
|
export const AllocationManage = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.allocation.manage);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support email as a second OTP channel alongside phone (e.g. signup lets the
|
||||||
|
* user choose which one to verify). `phone` becomes nullable since an
|
||||||
|
* email-channel row has none, and `email` is added as a nullable unique column
|
||||||
|
* mirroring `phone`'s shape.
|
||||||
|
*/
|
||||||
|
export class AddEmailToOtpVerifications1900000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddEmailToOtpVerifications1900000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE public.otp_verifications
|
||||||
|
ALTER COLUMN phone DROP NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE public.otp_verifications
|
||||||
|
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE public.otp_verifications
|
||||||
|
DROP COLUMN IF EXISTS email
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE public.otp_verifications
|
||||||
|
ALTER COLUMN phone SET NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplify the rate + weight-limit configuration model:
|
||||||
|
*
|
||||||
|
* 1. Drop the effective_from / effective_to validity window from both
|
||||||
|
* `rates` and `weight_limit_rules`. Rates are now activated purely by
|
||||||
|
* the approval workflow (status = LIVE) and weight limits are always
|
||||||
|
* active for their container + direction. No time-travel scheduling.
|
||||||
|
*
|
||||||
|
* 2. Enforce "one rate per pattern" with partial unique indexes so the same
|
||||||
|
* configuration (e.g. FIRST_MILE for a given container type) cannot be
|
||||||
|
* duplicated. NULL scope columns are COALESCE-normalised because Postgres
|
||||||
|
* treats NULLs as distinct in a plain unique index.
|
||||||
|
*
|
||||||
|
* This migration is destructive on the date columns — existing effective_*
|
||||||
|
* values are dropped.
|
||||||
|
*/
|
||||||
|
export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface {
|
||||||
|
name = 'SimplifyRatesAndWeightLimitRules1900000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── 1. De-duplicate existing data so the unique indexes can be created ──
|
||||||
|
// Keep the most recently-created row per pattern, soft-delete the rest.
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
rate_unit
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM freight.rates
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'
|
||||||
|
)
|
||||||
|
UPDATE freight.rates r
|
||||||
|
SET deleted_at = now()
|
||||||
|
FROM ranked
|
||||||
|
WHERE r.id = ranked.id AND ranked.rn > 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY container_type_id, trade_direction
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM freight.weight_limit_rules
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
)
|
||||||
|
UPDATE freight.weight_limit_rules w
|
||||||
|
SET deleted_at = now()
|
||||||
|
FROM ranked
|
||||||
|
WHERE w.id = ranked.id AND ranked.rn > 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── 2. Drop the effective-date indexes + columns ───────────────────────
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`);
|
||||||
|
// Indexes created by TypeORM's @Index carry generated hashed names — drop
|
||||||
|
// any index that references the effective_from column defensively.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE idx record;
|
||||||
|
BEGIN
|
||||||
|
FOR idx IN
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE schemaname = 'freight'
|
||||||
|
AND tablename IN ('rates', 'weight_limit_rules')
|
||||||
|
AND indexdef ILIKE '%effective_from%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
|
||||||
|
|
||||||
|
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
|
||||||
|
// The unit is part of the identity so a surcharge can legitimately carry two
|
||||||
|
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
|
||||||
|
// while still blocking a true duplicate (same rateType + scope + unit).
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||||
|
ON freight.rates (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
rate_unit
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern"
|
||||||
|
ON freight.weight_limit_rules (container_type_id, trade_direction)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||||
|
await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { BookingView } from "../../common/booking-guards";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@FreightAdmin()
|
@BookingView()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) { }
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
@Get("invoices")
|
@Get("invoices")
|
||||||
@ApiOperation({ summary: "List all invoices" })
|
@ApiOperation({
|
||||||
findAll() {
|
summary: "List invoices (paginated, filterable by company/status/search)",
|
||||||
return this.billingService.findAll();
|
})
|
||||||
|
findAll(@Query() query: FilterInvoiceDto) {
|
||||||
|
return this.billingService.findAllPaginated(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("invoices/:id")
|
@Get("invoices/:id")
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -134,9 +134,56 @@ export class BillingService {
|
|||||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated invoice list for the backoffice — optionally narrowed to a
|
||||||
|
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||||
|
* invoices page).
|
||||||
|
*/
|
||||||
|
async findAllPaginated(
|
||||||
|
filter: {
|
||||||
|
companyId?: string;
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ items: Invoice[]; total: number }> {
|
||||||
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.createQueryBuilder("invoice")
|
||||||
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize);
|
||||||
|
|
||||||
|
if (filter.companyId) {
|
||||||
|
qb.andWhere("invoice.companyId = :companyId", {
|
||||||
|
companyId: filter.companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filter.status) {
|
||||||
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
|
}
|
||||||
|
if (filter.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||||
|
{ search: `%${filter.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoice header plus its line items. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id);
|
const invoice = await this.invoices.findById(id, {
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
});
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
const lines = await this.invoiceLines.findAll({
|
const lines = await this.invoiceLines.findAll({
|
||||||
where: { invoiceId: id },
|
where: { invoiceId: id },
|
||||||
@@ -375,7 +422,7 @@ export class BillingService {
|
|||||||
input.dueAt ??
|
input.dueAt ??
|
||||||
new Date(
|
new Date(
|
||||||
Date.now() +
|
Date.now() +
|
||||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class FilterInvoiceDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
||||||
|
import { AllocationManage } from '../../common/booking-guards';
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags('bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -10,6 +11,7 @@ export class BookingAllocationController {
|
|||||||
constructor(private readonly bookingsService: BookingsService) {}
|
constructor(private readonly bookingsService: BookingsService) {}
|
||||||
|
|
||||||
@Post(':bookingId/allocate-containers')
|
@Post(':bookingId/allocate-containers')
|
||||||
|
@AllocationManage()
|
||||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||||
async allocateContainers(
|
async allocateContainers(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
ratesService as never,
|
ratesService as never,
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
|
|
||||||
|
export interface OverweightLine {
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComputedPriceResult {
|
export interface ComputedPriceResult {
|
||||||
lineItems: PriceLineItemDto[];
|
lineItems: PriceLineItemDto[];
|
||||||
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
|
|||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
hardBlocked: string[];
|
hardBlocked: string[];
|
||||||
|
overweightLines: OverweightLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredPricingBreakdown = {
|
type StoredPricingBreakdown = {
|
||||||
@@ -67,6 +76,7 @@ export class BookingPricingService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -94,12 +104,19 @@ export class BookingPricingService {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
|
||||||
|
// (and the overweight warning + surcharge) at the confirm step, before submit.
|
||||||
|
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
|
||||||
|
const pairing = await this.containerValidationService.validate20ftPairing(booking);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
warnings: computed.warnings,
|
warnings: computed.warnings,
|
||||||
|
overweightLines: computed.overweightLines,
|
||||||
|
pairingErrors: pairing.map((p) => p.message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +186,35 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overweight detail for the customer: map the engine's per-line results back
|
||||||
|
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||||
|
// is derived from the line total minus the excess the engine computed.
|
||||||
|
const overweightLines: OverweightLine[] = [];
|
||||||
|
const containerLines = (booking.bookingContainers ?? []).filter(
|
||||||
|
(bc) => bc.containerTypeId != null,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||||
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
|
if (!wr?.isOverweight) continue;
|
||||||
|
const line = containerLines[i];
|
||||||
|
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
||||||
|
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||||
|
let code = line?.containerSize ?? '';
|
||||||
|
if (line?.containerTypeId) {
|
||||||
|
try {
|
||||||
|
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
|
||||||
|
} catch {
|
||||||
|
// fall back to the container size label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overweightLines.push({
|
||||||
|
containerTypeCode: code,
|
||||||
|
totalVgmTons,
|
||||||
|
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
||||||
|
excessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
@@ -178,6 +224,7 @@ export class BookingPricingService {
|
|||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: ruleResult.warnings,
|
warnings: ruleResult.warnings,
|
||||||
hardBlocked: ruleResult.hardBlocked,
|
hardBlocked: ruleResult.hardBlocked,
|
||||||
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { clearanceCodesForBooking } from './clearance.util';
|
import { clearanceCodesForBooking } from './clearance.util';
|
||||||
@@ -51,6 +52,7 @@ export class BookingTransitionService {
|
|||||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -58,6 +60,19 @@ export class BookingTransitionService {
|
|||||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||||
|
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||||
|
const violations =
|
||||||
|
await this.containerValidationService.validate20ftPairing(booking);
|
||||||
|
if (violations.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
|
||||||
|
.map((v) => v.message)
|
||||||
|
.join(' ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||||
@@ -78,6 +93,11 @@ export class BookingTransitionService {
|
|||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
|
||||||
|
// If no balanced pairing exists the booking cannot proceed (overweight only
|
||||||
|
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
|
||||||
|
await this.assert20ftPairable(booking);
|
||||||
|
|
||||||
const stored = booking.pricingBreakdown as {
|
const stored = booking.pricingBreakdown as {
|
||||||
lineItems?: PriceLineItemDto[];
|
lineItems?: PriceLineItemDto[];
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
@@ -158,6 +178,7 @@ export class BookingTransitionService {
|
|||||||
hardBlocked: computed.hardBlocked,
|
hardBlocked: computed.hardBlocked,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
});
|
});
|
||||||
|
await this.assert20ftPairable(booking);
|
||||||
|
|
||||||
await this.pricingService.createPricingSnapshots(
|
await this.pricingService.createPricingSnapshots(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -993,12 +1014,14 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
||||||
// booking's day still has capacity — nothing below runs and the request stays
|
// booking's day still has capacity — nothing below runs and the request stays
|
||||||
// pending for staff to move/decline.
|
// pending for staff to move/decline. (For a consolidated pair this is a rough
|
||||||
|
// solo pre-check; the real combined-capacity reservation happens after the
|
||||||
|
// booking is FULLY_EXECUTED, once both partners are ready.)
|
||||||
const isExportTrain =
|
const isExportTrain =
|
||||||
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
||||||
const exportScheduleId = isExportTrain
|
if (isExportTrain) {
|
||||||
? await this.bookingBatchService.pickExportSchedule(booking)
|
await this.bookingBatchService.pickExportSchedule(booking);
|
||||||
: null;
|
}
|
||||||
|
|
||||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1023,11 +1046,12 @@ export class BookingTransitionService {
|
|||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (exportScheduleId) {
|
if (isExportTrain) {
|
||||||
// FCFS: reserve the slot and send the payment notification immediately;
|
// FCFS: reserve the slot and send the payment notification immediately;
|
||||||
// paid → auto-allocated by the settle/paid pipeline.
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||||
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||||
const fresh = await this.bookingsService.findById(booking.id);
|
const fresh = await this.bookingsService.findById(booking.id);
|
||||||
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
|
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||||
} else if (booking.tradeDirection === "IMPORT") {
|
} else if (booking.tradeDirection === "IMPORT") {
|
||||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||||
// after staff document review, never at accept time.
|
// after staff document review, never at accept time.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
|
|||||||
// import { PayController } from './pay.controller';
|
// import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
@@ -79,6 +80,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
|
ContainerValidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides).
|
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||||
|
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
|
||||||
|
*
|
||||||
|
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
|
||||||
|
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
|
||||||
|
* the same train is useless. The day filter is applied only when THIS booking already has
|
||||||
|
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
|
||||||
*/
|
*/
|
||||||
async findComplementaryConsolidationPartner(
|
async findComplementaryConsolidationPartner(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
): Promise<Booking | null> {
|
): Promise<Booking | null> {
|
||||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||||
|
|
||||||
return this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
.innerJoin('bc.containerType', 'ct')
|
.innerJoin('bc.containerType', 'ct')
|
||||||
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||||
quantity,
|
quantity,
|
||||||
perWagon,
|
perWagon,
|
||||||
})
|
});
|
||||||
.orderBy('b.createdAt', 'ASC')
|
|
||||||
.getOne();
|
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
|
||||||
|
// when this booking has no date yet (matched again once it picks its day).
|
||||||
|
if (booking.scheduledDate) {
|
||||||
|
qb.andWhere(
|
||||||
|
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||||
|
{ bookingDate: booking.scheduledDate },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { validate20ftWeightPairing } from './container-pairing.util';
|
||||||
|
|
||||||
|
describe('validate20ftWeightPairing', () => {
|
||||||
|
const MAX_DIFF = 10;
|
||||||
|
|
||||||
|
it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
|
||||||
|
// sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 24 },
|
||||||
|
{ label: 'B', grossWeightTons: 8 },
|
||||||
|
{ label: 'C', grossWeightTons: 18 },
|
||||||
|
{ label: 'D', grossWeightTons: 15 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a pair whose weight difference exceeds the cap', () => {
|
||||||
|
// sorted: 5, 25 → single pair diff 20 > 10.
|
||||||
|
const units = [
|
||||||
|
{ label: 'HEAVY', grossWeightTons: 25 },
|
||||||
|
{ label: 'LIGHT', grossWeightTons: 5 },
|
||||||
|
];
|
||||||
|
const result = validate20ftWeightPairing(units, MAX_DIFF);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
|
||||||
|
expect(result[0].diffTons).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
|
||||||
|
// sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 10 },
|
||||||
|
{ label: 'B', grossWeightTons: 12 },
|
||||||
|
{ label: 'C', grossWeightTons: 30 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
|
||||||
|
// Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
|
||||||
|
// but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 20 },
|
||||||
|
{ label: 'B', grossWeightTons: 12 },
|
||||||
|
{ label: 'C', grossWeightTons: 22 },
|
||||||
|
{ label: 'D', grossWeightTons: 10 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for fewer than two units', () => {
|
||||||
|
expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
|
||||||
|
expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Booking-time 20ft weight-pairing rule.
|
||||||
|
*
|
||||||
|
* A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
|
||||||
|
* same wagon their gross-weight difference must not exceed `maxPairDiffTons`
|
||||||
|
* (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
|
||||||
|
* balanced. 40ft containers occupy a whole wagon alone and never pair.
|
||||||
|
*
|
||||||
|
* At booking time the customer enters every 20ft container's weight but not its
|
||||||
|
* wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
|
||||||
|
* (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
|
||||||
|
* valid pairing exists this one finds it — a violation here means no balanced
|
||||||
|
* pairing is possible and the booking must be blocked. An odd leftover 20ft is
|
||||||
|
* fine: it has no partner in this booking and flows to consolidation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Container20ftUnit {
|
||||||
|
/** Human label for messages, e.g. the container number. */
|
||||||
|
label: string;
|
||||||
|
grossWeightTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingViolation {
|
||||||
|
message: string;
|
||||||
|
/** The two container labels whose pairing exceeds the diff cap. */
|
||||||
|
labels: [string, string];
|
||||||
|
diffTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the given 20ft units can all be paired onto wagons within the
|
||||||
|
* weight-difference cap. Returns one violation per over-cap adjacent pair (empty
|
||||||
|
* when every wagon pair is balanced or there is nothing to pair). A single
|
||||||
|
* leftover unit (odd count) is not a violation.
|
||||||
|
*/
|
||||||
|
export function validate20ftWeightPairing(
|
||||||
|
units: Container20ftUnit[],
|
||||||
|
maxPairDiffTons: number,
|
||||||
|
): PairingViolation[] {
|
||||||
|
if (units.length < 2 || maxPairDiffTons == null) return [];
|
||||||
|
|
||||||
|
// Ascending by weight: adjacent pairs have the smallest possible diffs.
|
||||||
|
const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
|
||||||
|
const violations: PairingViolation[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i + 1 < sorted.length; i += 2) {
|
||||||
|
const a = sorted[i];
|
||||||
|
const b = sorted[i + 1];
|
||||||
|
const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
|
||||||
|
if (diff > maxPairDiffTons) {
|
||||||
|
violations.push({
|
||||||
|
message:
|
||||||
|
`20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
|
||||||
|
`${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
|
||||||
|
`weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
|
||||||
|
labels: [a.label, b.label],
|
||||||
|
diffTons: round2(diff),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||||
|
import {
|
||||||
|
Container20ftUnit,
|
||||||
|
PairingViolation,
|
||||||
|
validate20ftWeightPairing,
|
||||||
|
} from './container-pairing.util';
|
||||||
|
|
||||||
|
/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
|
||||||
|
const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-time container validations that need the customer-entered per-unit
|
||||||
|
* weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
|
||||||
|
* rule engine (which works on line totals) because pairing is per physical unit.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerValidationService {
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
private async maxPairDiffTons(): Promise<number> {
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(TrainSchedulingGlobalRules)
|
||||||
|
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
const v = row?.max20ftPairWeightDiffTons;
|
||||||
|
const n = v == null ? NaN : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
|
||||||
|
private async load20ftUnits(booking: Booking): Promise<Container20ftUnit[]> {
|
||||||
|
const lines = (booking.bookingContainers ?? []).filter(
|
||||||
|
(bc) => (bc.containerSize ?? '').includes('20'),
|
||||||
|
);
|
||||||
|
if (!lines.length) return [];
|
||||||
|
|
||||||
|
const units = await this.dataSource
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.find({
|
||||||
|
where: { bookingContainerId: In(lines.map((l) => l.id)) },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return units.map((u) => ({
|
||||||
|
label: u.containerNumber || u.id.slice(0, 8),
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the 20ft weight-pairing rule for a booking. Returns one message per
|
||||||
|
* pair whose weight difference exceeds the cap; empty when all 20ft can be
|
||||||
|
* balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
|
||||||
|
* it flows to consolidation. Callers hard-block a non-empty result.
|
||||||
|
*/
|
||||||
|
async validate20ftPairing(booking: Booking): Promise<PairingViolation[]> {
|
||||||
|
// Only bookings whose 20ft lines actually carry per-unit weights can be
|
||||||
|
// checked; contract-drawdown bookings do (units are required there).
|
||||||
|
const containerLines = booking.bookingContainers ?? [];
|
||||||
|
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||||
|
if (!has20ft) return [];
|
||||||
|
|
||||||
|
const units = await this.load20ftUnits(booking);
|
||||||
|
if (units.length < 2) return [];
|
||||||
|
|
||||||
|
const maxDiff = await this.maxPairDiffTons();
|
||||||
|
return validate20ftWeightPairing(units, maxDiff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,20 @@ export class PriceLineItemDto {
|
|||||||
currency!: string;
|
currency!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OverweightLineDto {
|
||||||
|
@ApiProperty()
|
||||||
|
containerTypeCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalVgmTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
maxAllowedTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
excessTons!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class GeneratePriceResponseDto {
|
export class GeneratePriceResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
bookingId!: string;
|
bookingId!: string;
|
||||||
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [String] })
|
||||||
warnings!: string[];
|
warnings!: string[];
|
||||||
|
|
||||||
|
/** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
|
||||||
|
@ApiProperty({ type: [OverweightLineDto] })
|
||||||
|
overweightLines!: OverweightLineDto[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 20ft weight-pairing violations. Non-empty means the booking cannot be
|
||||||
|
* balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
|
||||||
|
* container weights/quantities. (Overweight, by contrast, only warns.)
|
||||||
|
*/
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
pairingErrors!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ExchangeService } from '@edr/api-common';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
@@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -60,6 +66,9 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly exchangeService: ExchangeService,
|
||||||
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -111,6 +120,20 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
|
|
||||||
|
// Booking-window gate (config-driven): an operations booking may only be
|
||||||
|
// created while the route's booking window is open — import: the day's window
|
||||||
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
|
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||||
|
// enter clearance first and are scheduled later, so they are not gated here.
|
||||||
|
if (!generalCustoms) {
|
||||||
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
|
originYardId: route?.originYardId ?? null,
|
||||||
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
scheduledDate: dto.scheduledDate ?? null,
|
||||||
|
direction: contract.tradeDirection ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
@@ -563,6 +586,145 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-create validation for the shipment form: run the overweight rule + the
|
||||||
|
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
||||||
|
* booking. The portal calls this from the price-confirm modal so the customer
|
||||||
|
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
||||||
|
* un-pairable 20ft set before the booking is created.
|
||||||
|
*/
|
||||||
|
async validateShipment(
|
||||||
|
contractId: string,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<{
|
||||||
|
overweightLines: Array<{
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}>;
|
||||||
|
overweightSurchargeAmount: number;
|
||||||
|
currency: string | null;
|
||||||
|
pairingErrors: string[];
|
||||||
|
}> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
|
const lines = dto.containers ?? [];
|
||||||
|
if (!lines.length) {
|
||||||
|
return {
|
||||||
|
overweightLines: [],
|
||||||
|
overweightSurchargeAmount: 0,
|
||||||
|
currency: null,
|
||||||
|
pairingErrors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
||||||
|
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
lines.map(async (line) => {
|
||||||
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
|
line.containerSize,
|
||||||
|
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const totalVgmTons = (line.units ?? []).reduce(
|
||||||
|
(s, u) => s + Number(u.vgmTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { line, ct, totalVgmTons };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: null,
|
||||||
|
serviceTypeId: contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: contract.isReefer ?? false,
|
||||||
|
isGovernment: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 0,
|
||||||
|
containers: resolved.map((r) => ({
|
||||||
|
containerTypeId: r.ct.id,
|
||||||
|
quantity: r.line.quantity,
|
||||||
|
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
||||||
|
totalVgmTons: r.totalVgmTons,
|
||||||
|
isReefer: r.ct.isReefer,
|
||||||
|
})),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const overweightLines: Array<{
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}> = [];
|
||||||
|
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||||
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
|
if (!wr?.isOverweight) continue;
|
||||||
|
const r = resolved[i];
|
||||||
|
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||||
|
overweightLines.push({
|
||||||
|
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
|
||||||
|
totalVgmTons: r?.totalVgmTons ?? 0,
|
||||||
|
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
|
||||||
|
excessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||||
|
const twentyFtUnits = resolved
|
||||||
|
.filter((r) => (r.line.containerSize ?? '').includes('20'))
|
||||||
|
.flatMap((r) =>
|
||||||
|
(r.line.units ?? []).map((u, idx) => ({
|
||||||
|
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const maxDiff = await this.max20ftPairDiffTons();
|
||||||
|
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
|
||||||
|
(v) => v.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Real overweight surcharge (same rate the rule engine bills at booking-create
|
||||||
|
// time) so the confirm-modal total isn't missing the charge the warning refers to.
|
||||||
|
// Rates are stored in USD; convert to the contract's payment currency the same
|
||||||
|
// way BookingPricingService does so this preview matches the eventual booking total.
|
||||||
|
const overweightModifier = ruleResult.appliedModifiers.find(
|
||||||
|
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
|
||||||
|
);
|
||||||
|
let overweightSurchargeAmount = 0;
|
||||||
|
if (overweightModifier) {
|
||||||
|
const isEtb = contract.paymentCurrency === 'ETB';
|
||||||
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
overweightSurchargeAmount = isEtb
|
||||||
|
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
|
||||||
|
: overweightModifier.calculatedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overweightLines,
|
||||||
|
overweightSurchargeAmount,
|
||||||
|
currency: overweightLines.length ? contract.paymentCurrency : null,
|
||||||
|
pairingErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async max20ftPairDiffTons(): Promise<number> {
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(TrainSchedulingGlobalRules)
|
||||||
|
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
|
||||||
|
return Number.isFinite(n) ? n : 10;
|
||||||
|
}
|
||||||
|
|
||||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||||
private async resolveContainerTypeForSize(
|
private async resolveContainerTypeForSize(
|
||||||
size: string,
|
size: string,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
|||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -63,6 +64,7 @@ export class ContractTransitionService {
|
|||||||
private readonly renderer: ContractRendererService,
|
private readonly renderer: ContractRendererService,
|
||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly otpService: OtpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -520,6 +522,12 @@ export class ContractTransitionService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BadRequestException('Customer has already signed this contract');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
|
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
||||||
|
// must be verified before the signature is applied.
|
||||||
|
if (!dto.otpPhone || !dto.otp) {
|
||||||
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||||
|
}
|
||||||
|
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
|
||||||
await this.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',
|
||||||
|
|||||||
@@ -788,6 +788,18 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/validate-shipment')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||||
|
})
|
||||||
|
validateShipment(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
|
) {
|
||||||
|
return this.contractBookingService.validateShipment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/capacity')
|
@Get(':id/capacity')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
|||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
import { ContractsController } from './contracts.controller';
|
import { ContractsController } from './contracts.controller';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -72,10 +74,15 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
|
OtpModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
|
// TrainSchedulingModule provides the config-driven booking-window gate used
|
||||||
|
// by ContractBookingService.createUnderContract. forwardRef because
|
||||||
|
// TrainSchedulingModule already imports ContractsModule.
|
||||||
|
forwardRef(() => TrainSchedulingModule),
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class SignContractDto {
|
export class SignContractDto {
|
||||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||||
@@ -26,4 +26,19 @@ export class SignContractDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
consentText?: string;
|
consentText?: string;
|
||||||
|
|
||||||
|
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
||||||
|
// SMS'd to the signer's phone, verified server-side before the signature is
|
||||||
|
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
||||||
|
// customer's registered phone).
|
||||||
|
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||||
|
otp?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
otpPhone?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class SendEmailDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Recipient email address",
|
||||||
|
example: "customer@example.com",
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
to!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Email subject",
|
||||||
|
example: "Your EDR Freight verification code",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
text?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
html?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ClientProxy } from "@nestjs/microservices";
|
||||||
|
import { SendEmailDto } from "./dtos/email.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailClientService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(EmailClientService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject("EMAIL_SERVICE")
|
||||||
|
private readonly emailClient: ClientProxy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.emailClient
|
||||||
|
.connect()
|
||||||
|
.then(() => this.logger.log("connected to Email service"))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Error happened at Email service", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||||
|
return { queued: false };
|
||||||
|
}
|
||||||
|
this.emailClient.emit("send-email", {
|
||||||
|
to: dto.to,
|
||||||
|
subject: dto.subject,
|
||||||
|
text: dto.text,
|
||||||
|
html: dto.html,
|
||||||
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
|
});
|
||||||
|
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||||
|
this.logger.log(
|
||||||
|
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||||
|
);
|
||||||
|
// Recipient + content are PII — debug only.
|
||||||
|
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
||||||
|
return { queued: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices";
|
|||||||
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
import { NotificationsService } from "./notifications.service";
|
||||||
import { SmsClientService } from "./sms-client.service";
|
import { SmsClientService } from "./sms-client.service";
|
||||||
|
import { EmailClientService } from "./email-client.service";
|
||||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
|
|
||||||
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
|
|||||||
queueOptions: { durable: true },
|
queueOptions: { durable: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "EMAIL_SERVICE",
|
||||||
|
transport: Transport.RMQ,
|
||||||
|
options: {
|
||||||
|
urls: [process.env.RABBITMQ_URL as string],
|
||||||
|
queue: process.env.EMAIL_QUEUE ?? "email_queue",
|
||||||
|
queueOptions: { durable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
|
providers: [
|
||||||
exports: [NotificationsService, SmsClientService],
|
EmailNotificationStrategy,
|
||||||
|
SmsNotificationStrategy,
|
||||||
|
NotificationsService,
|
||||||
|
SmsClientService,
|
||||||
|
EmailClientService,
|
||||||
|
],
|
||||||
|
exports: [NotificationsService, SmsClientService, EmailClientService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
// otp.controller.ts
|
// otp.controller.ts
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
|
||||||
import { OtpService } from "./otp.service";
|
import { OtpService, OtpTarget } from "./otp.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
|
// Exactly one of phone/email must be present per request — the channel the
|
||||||
|
// code is sent through / checked against.
|
||||||
|
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||||
|
if (email) return { email };
|
||||||
|
if (phone) return { phone };
|
||||||
|
throw new BadRequestException("phone or email is required");
|
||||||
|
}
|
||||||
|
|
||||||
@Controller("otp")
|
@Controller("otp")
|
||||||
@Public()
|
@Public()
|
||||||
export class OtpController {
|
export class OtpController {
|
||||||
@@ -24,9 +33,12 @@ export class OtpController {
|
|||||||
@Post("send")
|
@Post("send")
|
||||||
async sendOtp(
|
async sendOtp(
|
||||||
@Body("phone")
|
@Body("phone")
|
||||||
phone: string
|
phone?: string,
|
||||||
|
|
||||||
|
@Body("email")
|
||||||
|
email?: string
|
||||||
) {
|
) {
|
||||||
return this.otpService.sendOtp(phone);
|
return this.otpService.sendOtp(toTarget(phone, email));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -36,13 +48,16 @@ export class OtpController {
|
|||||||
@Post("verify")
|
@Post("verify")
|
||||||
async verifyOtp(
|
async verifyOtp(
|
||||||
@Body("phone")
|
@Body("phone")
|
||||||
phone: string,
|
phone: string | undefined,
|
||||||
|
|
||||||
|
@Body("email")
|
||||||
|
email: string | undefined,
|
||||||
|
|
||||||
@Body("otp")
|
@Body("otp")
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
) {
|
||||||
return this.otpService.verifyOtp(
|
return this.otpService.verifyOtp(
|
||||||
phone,
|
toTarget(phone, email),
|
||||||
otp
|
otp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
|
|||||||
name: "otp_verifications",
|
name: "otp_verifications",
|
||||||
})
|
})
|
||||||
export class OtpVerification extends BaseEntity{
|
export class OtpVerification extends BaseEntity{
|
||||||
|
// Exactly one of phone/email is set per row — the channel the code was sent
|
||||||
|
// through.
|
||||||
@Column({
|
@Column({
|
||||||
unique: true,
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
})
|
})
|
||||||
phone!: string;
|
phone?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
email?: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
otp!: string;
|
otp!: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
|||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
OtpRepository,
|
OtpRepository,
|
||||||
|
OtpService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OtpModule {}
|
export class OtpModule {}
|
||||||
@@ -31,17 +31,44 @@ export class OtpRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Find By Email
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async findByEmail(
|
||||||
|
email: string
|
||||||
|
) {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Find By Target (either channel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async findByTarget(
|
||||||
|
target: { phone?: string; email?: string }
|
||||||
|
) {
|
||||||
|
return target.email
|
||||||
|
? this.findByEmail(target.email)
|
||||||
|
: this.findByPhone(target.phone!);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Create OTP
|
// Create OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async createOtp(
|
async createOtp(
|
||||||
phone: string,
|
target: { phone?: string; email?: string },
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
) {
|
||||||
const entity =
|
const entity =
|
||||||
this.repository.create({
|
this.repository.create({
|
||||||
phone,
|
phone: target.phone,
|
||||||
|
email: target.email,
|
||||||
otp,
|
otp,
|
||||||
verified: false,
|
verified: false,
|
||||||
});
|
});
|
||||||
@@ -70,10 +97,10 @@ export class OtpRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify Phone
|
// Mark Verified
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async verifyPhone(
|
async markVerified(
|
||||||
otpVerification: OtpVerification
|
otpVerification: OtpVerification
|
||||||
) {
|
) {
|
||||||
otpVerification.verified =
|
otpVerification.verified =
|
||||||
@@ -83,4 +110,18 @@ export class OtpRepository {
|
|||||||
otpVerification
|
otpVerification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete OTP (single-use consume)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
||||||
|
// requested for the same number on the next action.
|
||||||
|
async deleteOtp(
|
||||||
|
otpVerification: OtpVerification
|
||||||
|
) {
|
||||||
|
return this.repository.remove(
|
||||||
|
otpVerification
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,80 +1,80 @@
|
|||||||
// otp.service.ts
|
// otp.service.ts
|
||||||
|
|
||||||
import {
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
|
|
||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
|
||||||
|
// Exactly one of phone/email is set — enforced by the controller before it
|
||||||
|
// reaches here.
|
||||||
|
export type OtpTarget = { phone?: string; email?: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpService {
|
export class OtpService {
|
||||||
|
logger = new Logger(OtpService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly otpRepository: OtpRepository,
|
private readonly otpRepository: OtpRepository,
|
||||||
private readonly smsClient: SmsClientService
|
private readonly smsClient: SmsClientService,
|
||||||
) {}
|
private readonly emailClient: EmailClientService,
|
||||||
|
) { }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Generate OTP
|
// Generate OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
generateOtp(): string {
|
generateOtp(): string {
|
||||||
return Math.floor(
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
100000 + Math.random() * 900000
|
|
||||||
).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Send OTP
|
// Send OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(phone: string) {
|
async sendOtp(target: OtpTarget) {
|
||||||
try {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// The verification code is generated server-side — never supplied by the
|
||||||
// caller — so the OTP stays a secret known only to the server and the
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
// recipient of the SMS.
|
// recipient of the SMS/email.
|
||||||
const otp = this.generateOtp();
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing phone
|
// find existing row for this channel
|
||||||
const existingPhone =
|
const existing = await this.otpRepository.findByTarget(target);
|
||||||
await this.otpRepository.findByPhone(
|
|
||||||
phone
|
|
||||||
);
|
|
||||||
|
|
||||||
// update existing otp
|
// update existing otp
|
||||||
if (existingPhone) {
|
if (existing) {
|
||||||
await this.otpRepository.updateOtp(
|
await this.otpRepository.updateOtp(existing, otp);
|
||||||
existingPhone,
|
|
||||||
otp
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// create new otp
|
// create new otp
|
||||||
await this.otpRepository.createOtp(
|
await this.otpRepository.createOtp(target, otp);
|
||||||
phone,
|
|
||||||
otp
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
if (target.email) {
|
||||||
await this.smsClient.sendSms({
|
// send email (queued to RabbitMQ via the shared Email service)
|
||||||
to: phone,
|
await this.emailClient.sendEmail({
|
||||||
message: `Your verification code is ${otp}`,
|
to: target.email,
|
||||||
});
|
subject: "Your EDR Freight verification code",
|
||||||
|
text: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||||
|
await this.smsClient.sendSms({
|
||||||
|
to: target.phone as string,
|
||||||
|
message: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message:
|
message: "OTP sent successfully",
|
||||||
"OTP sent successfully",
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Failed to send OTP");
|
||||||
"Failed to send OTP"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,40 +82,70 @@ export class OtpService {
|
|||||||
// Verify OTP
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async verifyOtp(
|
async verifyOtp(target: OtpTarget, otp: string) {
|
||||||
phone: string,
|
// find the channel's row
|
||||||
otp: string
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
) {
|
|
||||||
// find phone
|
|
||||||
const otpData =
|
|
||||||
await this.otpRepository.findByPhone(
|
|
||||||
phone
|
|
||||||
);
|
|
||||||
|
|
||||||
// phone not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Phone number not found"
|
target.email ? "Email address not found" : "Phone number not found",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// invalid otp
|
// invalid otp
|
||||||
if (otpData.otp !== otp) {
|
if (otpData.otp !== otp) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Invalid OTP");
|
||||||
"Invalid OTP"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify phone
|
// mark verified
|
||||||
await this.otpRepository.verifyPhone(
|
await this.otpRepository.markVerified(otpData);
|
||||||
otpData
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message:
|
message: target.email
|
||||||
"Phone verified successfully",
|
? "Email verified successfully"
|
||||||
|
: "Phone verified successfully",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verify OTP for a sensitive action (sudo mode)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||||
|
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
||||||
|
// and leaves the code in place — this enforces a short TTL and consumes the
|
||||||
|
// code on success so it can never be replayed.
|
||||||
|
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
async verifyOtpForAction(phone: string, otp: string) {
|
||||||
|
const otpData = await this.otpRepository.findByPhone(phone);
|
||||||
|
|
||||||
|
if (!otpData) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No verification code was requested for this phone",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
|
|
||||||
|
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||||
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
|
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Verification code has expired. Request a new one.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpData.otp !== otp) {
|
||||||
|
throw new BadRequestException("Invalid verification code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// single-use: consume on success
|
||||||
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Logger,
|
||||||
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
@@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service";
|
|||||||
@Public()
|
@Public()
|
||||||
@Controller("internal/payments")
|
@Controller("internal/payments")
|
||||||
export class InternalPaymentController {
|
export class InternalPaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
private readonly logger = new Logger(InternalPaymentController.name);
|
||||||
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
@Post("mark-paid")
|
@Post("mark-paid")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
summary:
|
||||||
})
|
"Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
||||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
})
|
||||||
return this.paymentService.handlePaymentEvent(event);
|
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||||
}
|
this.logger.log(`Marking payment ${event} as PAID`);
|
||||||
|
return this.paymentService.handlePaymentEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ export class PaymentRepository {
|
|||||||
p.paid_at,
|
p.paid_at,
|
||||||
p.created_at
|
p.created_at
|
||||||
FROM freight.payments p
|
FROM freight.payments p
|
||||||
JOIN freight.bookings b ON b.id = p.ref_id
|
JOIN freight.bookings b ON b.id = p.ref_id::uuid
|
||||||
WHERE b.company_id = $1
|
WHERE b.company_id = $1
|
||||||
AND p.deleted_at IS NULL
|
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
ORDER BY p.created_at DESC`,
|
ORDER BY p.created_at DESC`,
|
||||||
[companyId],
|
[companyId],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
import {
|
import {
|
||||||
RATE_APPLIES_TO,
|
RATE_APPLIES_TO,
|
||||||
RATE_TRIGGERS,
|
RATE_TRIGGERS,
|
||||||
@@ -51,15 +51,6 @@ export class CreateRateDto {
|
|||||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit!: string;
|
rateUnit!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
|
|
||||||
@IsDateString()
|
|
||||||
effectiveFrom!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
effectiveTo?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubmitRateForApprovalDto {
|
export class SubmitRateForApprovalDto {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||||
|
|
||||||
@@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
maxVgmTons!: number;
|
maxVgmTons!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
|
|
||||||
@IsDateString()
|
|
||||||
effectiveFrom!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
effectiveTo?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rate units make sense for a given rate shape. The weighting basis is
|
||||||
|
* driven by the *type* of thing being billed — a container leg bills per
|
||||||
|
* container, bulk freight per ton, an intercity move can be per-km, a
|
||||||
|
* cancellation is a flat/per-invoice fee, and overweight is always per excess
|
||||||
|
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||||
|
* only pick a unit the pricing engine knows how to apply.
|
||||||
|
*
|
||||||
|
* Returned lists are ordered with the most natural/default unit first.
|
||||||
|
*/
|
||||||
|
export function allowedRateUnits(input: {
|
||||||
|
appliesTo: RateAppliesTo;
|
||||||
|
trigger: RateTrigger;
|
||||||
|
}): RateUnit[] {
|
||||||
|
const { appliesTo, trigger } = input;
|
||||||
|
|
||||||
|
// Surcharges (Applies to = Other) are governed by their trigger.
|
||||||
|
if (appliesTo === 'OTHER') {
|
||||||
|
switch (trigger) {
|
||||||
|
case 'OVERWEIGHT':
|
||||||
|
// Overweight always bills the excess tonnage — per ton, nothing else.
|
||||||
|
return ['PER_TON'];
|
||||||
|
case 'REEFER':
|
||||||
|
case 'HAZARDOUS':
|
||||||
|
// Scale with the freight shape: per container for boxes, per ton for bulk.
|
||||||
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
|
case 'DEMURRAGE':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
|
case 'CANCELLATION':
|
||||||
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
|
case 'CONSOLIDATION':
|
||||||
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
|
case 'SHIPPING_LINE':
|
||||||
|
case 'PIL_EXTRA_FEE':
|
||||||
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
|
default:
|
||||||
|
return ['FLAT', 'PER_TON', 'PER_CONTAINER'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base freight + first/last mile scale with the cargo type.
|
||||||
|
switch (appliesTo) {
|
||||||
|
case 'CONTAINER':
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
|
case 'BULK':
|
||||||
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
|
case 'INTERCITY':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||||||
|
case 'FIRST_MILE':
|
||||||
|
case 'LAST_MILE':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
||||||
|
default:
|
||||||
|
return ['FLAT'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default (first / most natural) unit for a rate shape. */
|
||||||
|
export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit {
|
||||||
|
return allowedRateUnits(input)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `unit` is a valid weighting basis for the given rate shape. */
|
||||||
|
export function isRateUnitAllowed(input: {
|
||||||
|
appliesTo: RateAppliesTo;
|
||||||
|
trigger: RateTrigger;
|
||||||
|
unit: RateUnit;
|
||||||
|
}): boolean {
|
||||||
|
return allowedRateUnits(input).includes(input.unit);
|
||||||
|
}
|
||||||
@@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
@Entity({ schema: 'freight', name: 'rates' })
|
@Entity({ schema: 'freight', name: 'rates' })
|
||||||
@Index(['rateType'])
|
@Index(['rateType'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@Index(['effectiveFrom'])
|
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['trigger'])
|
@Index(['trigger'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@@ -131,10 +130,4 @@ export class Rate extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||||
approvedAt?: Date | null;
|
approvedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'effective_from', type: 'date' })
|
|
||||||
effectiveFrom!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
|
||||||
effectiveTo?: Date | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity';
|
|||||||
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
|
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['tradeDirection'])
|
@Index(['tradeDirection'])
|
||||||
@Index(['effectiveFrom'])
|
|
||||||
export class WeightLimitRule extends BaseEntity {
|
export class WeightLimitRule extends BaseEntity {
|
||||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||||
containerTypeId!: string;
|
containerTypeId!: string;
|
||||||
@@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
||||||
maxVgmTons!: number;
|
maxVgmTons!: number;
|
||||||
|
|
||||||
@Column({ name: 'effective_from', type: 'date', nullable: true })
|
|
||||||
effectiveFrom!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
|
||||||
effectiveTo?: Date | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity';
|
|||||||
export interface IRatesRepository {
|
export interface IRatesRepository {
|
||||||
findById(id: string): Promise<Rate | null>;
|
findById(id: string): Promise<Rate | null>;
|
||||||
findLiveRates(): Promise<Rate[]>;
|
findLiveRates(): Promise<Rate[]>;
|
||||||
|
findByPattern(pattern: {
|
||||||
|
rateType: string;
|
||||||
|
rateUnit: string;
|
||||||
|
containerTypeId?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
create(data: Partial<Rate>): Promise<Rate>;
|
create(data: Partial<Rate>): Promise<Rate>;
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository {
|
|||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
): Promise<WeightLimitRule[]>;
|
): Promise<WeightLimitRule[]>;
|
||||||
|
findByPattern(
|
||||||
|
containerTypeId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
excludeId?: string,
|
||||||
|
): Promise<WeightLimitRule | null>;
|
||||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
||||||
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
||||||
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
||||||
|
|||||||
@@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findLiveRates(): Promise<Rate[]> {
|
findLiveRates(): Promise<Rate[]> {
|
||||||
const now = new Date();
|
|
||||||
return this.repo
|
return this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
.where('rate.status = :status', { status: 'LIVE' })
|
.where('rate.status = :status', { status: 'LIVE' })
|
||||||
.andWhere('rate.effective_from <= :now', { now })
|
|
||||||
.andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now })
|
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a non-superseded rate matching an identity pattern — the same tuple the
|
||||||
|
* `UQ_rates_pattern` unique index enforces. Used to reject duplicates before
|
||||||
|
* insert so the admin gets a friendly error instead of a raw constraint fault.
|
||||||
|
* NULL scope columns are matched with IS NULL, mirroring the COALESCE index.
|
||||||
|
*/
|
||||||
|
findByPattern(pattern: {
|
||||||
|
rateType: string;
|
||||||
|
rateUnit: string;
|
||||||
|
containerTypeId?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
}): Promise<Rate | null> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('rate')
|
||||||
|
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
|
||||||
|
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
|
||||||
|
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
|
||||||
|
|
||||||
|
if (pattern.containerTypeId) {
|
||||||
|
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.container_type_id IS NULL');
|
||||||
|
}
|
||||||
|
if (pattern.cargoTypeId) {
|
||||||
|
qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.cargo_type_id IS NULL');
|
||||||
|
}
|
||||||
|
if (pattern.tradeDirection) {
|
||||||
|
qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.trade_direction IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
|
||||||
return this.repo.find(options);
|
return this.repo.find(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
|||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
): Promise<WeightLimitRule[]> {
|
): Promise<WeightLimitRule[]> {
|
||||||
const now = new Date();
|
|
||||||
return this.repo
|
return this.repo
|
||||||
.createQueryBuilder('rule')
|
.createQueryBuilder('rule')
|
||||||
.innerJoinAndSelect('rule.containerType', 'ct')
|
.innerJoinAndSelect('rule.containerType', 'ct')
|
||||||
@@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
|||||||
dir: tradeDirection,
|
dir: tradeDirection,
|
||||||
both: 'BOTH',
|
both: 'BOTH',
|
||||||
})
|
})
|
||||||
.andWhere('rule.effective_from <= :now', { now })
|
|
||||||
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
|
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a rule matching the (containerType, tradeDirection) identity — the
|
||||||
|
* tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates
|
||||||
|
* before insert. Optionally excludes a row by id so updates don't self-collide.
|
||||||
|
*/
|
||||||
|
findByPattern(
|
||||||
|
containerTypeId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
excludeId?: string,
|
||||||
|
): Promise<WeightLimitRule | null> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('rule')
|
||||||
|
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
|
||||||
|
.andWhere('rule.trade_direction = :tradeDirection', { tradeDirection });
|
||||||
|
if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId });
|
||||||
|
return qb.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
|
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
|
||||||
return this.repo.find(options);
|
return this.repo.find(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
import { deriveRateType } from '../entities/rate-type.util';
|
import { deriveRateType } from '../entities/rate-type.util';
|
||||||
|
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,7 +34,7 @@ export class RatesService {
|
|||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
order: { effectiveFrom: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
@@ -46,6 +53,50 @@ export class RatesService {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise + validate the weighting unit for a rate shape. Overweight is
|
||||||
|
* always billed per excess ton, so its unit is forced to PER_TON regardless
|
||||||
|
* of what the client sent. Every other shape must pick a unit the pricing
|
||||||
|
* engine can actually apply (see `allowedRateUnits`).
|
||||||
|
*/
|
||||||
|
private resolveRateUnit(
|
||||||
|
appliesTo: Rate['appliesTo'],
|
||||||
|
trigger: Rate['trigger'],
|
||||||
|
requestedUnit: Rate['rateUnit'],
|
||||||
|
): Rate['rateUnit'] {
|
||||||
|
// Overweight is per-ton, full stop.
|
||||||
|
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||||
|
|
||||||
|
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||||
|
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requestedUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||||
|
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||||
|
* make pricing ambiguous — so we allow exactly one per pattern.
|
||||||
|
*/
|
||||||
|
private async assertNoDuplicatePattern(pattern: {
|
||||||
|
rateType: string;
|
||||||
|
rateUnit: string;
|
||||||
|
containerTypeId: string | null;
|
||||||
|
cargoTypeId: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
ignoreId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const existing = await this.repository.findByPattern(pattern);
|
||||||
|
if (existing && existing.id !== pattern.ignoreId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a rate in DRAFT status. */
|
/** Create a rate in DRAFT status. */
|
||||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||||
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
||||||
@@ -57,25 +108,28 @@ export class RatesService {
|
|||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||||
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
|
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
|
||||||
|
|
||||||
|
const rateType = deriveRateType({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection,
|
||||||
|
isBulk: Boolean(cargoTypeId),
|
||||||
|
});
|
||||||
|
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||||
|
|
||||||
|
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
|
||||||
|
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
rateType: deriveRateType({
|
rateType,
|
||||||
appliesTo,
|
|
||||||
trigger,
|
|
||||||
tradeDirection,
|
|
||||||
isBulk: Boolean(cargoTypeId),
|
|
||||||
}),
|
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
currency: dto.currency ?? 'USD',
|
currency: dto.currency ?? 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
rateUnit,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
proposedByStaffId,
|
proposedByStaffId,
|
||||||
effectiveFrom: new Date(dto.effectiveFrom),
|
|
||||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,22 +164,35 @@ export class RatesService {
|
|||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection;
|
: existing.tradeDirection;
|
||||||
|
|
||||||
updates.containerTypeId = containerTypeId;
|
updates.containerTypeId = containerTypeId ?? null;
|
||||||
updates.cargoTypeId = cargoTypeId;
|
updates.cargoTypeId = cargoTypeId ?? null;
|
||||||
updates.tradeDirection = tradeDirection;
|
updates.tradeDirection = tradeDirection ?? null;
|
||||||
// Keep the derived rateType in sync with whatever changed.
|
// Keep the derived rateType in sync with whatever changed.
|
||||||
updates.rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: Boolean(cargoTypeId),
|
isBulk: Boolean(cargoTypeId),
|
||||||
});
|
});
|
||||||
|
updates.rateType = rateType;
|
||||||
|
|
||||||
|
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||||
|
// forced to PER_TON.
|
||||||
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||||
|
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
|
||||||
|
|
||||||
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
|
await this.assertNoDuplicatePattern({
|
||||||
|
rateType,
|
||||||
|
rateUnit: updates.rateUnit,
|
||||||
|
containerTypeId: updates.containerTypeId,
|
||||||
|
cargoTypeId: updates.cargoTypeId,
|
||||||
|
tradeDirection: updates.tradeDirection,
|
||||||
|
ignoreId: id,
|
||||||
|
});
|
||||||
|
|
||||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
|
||||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
|
||||||
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
|
|
||||||
const updated = await this.repository.update(id, updates);
|
const updated = await this.repository.update(id, updates);
|
||||||
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||||
@@ -30,7 +30,7 @@ export class WeightLimitRulesService {
|
|||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: { containerType: true },
|
relations: { containerType: true },
|
||||||
order: { effectiveFrom: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
@@ -44,26 +44,51 @@ export class WeightLimitRulesService {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a second rule for the same container + direction. One VGM limit per
|
||||||
|
* (container, direction) — otherwise the booking engine can't tell which
|
||||||
|
* applies.
|
||||||
|
*/
|
||||||
|
private async assertNoDuplicate(
|
||||||
|
containerTypeId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
ignoreId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new weight limit rule. */
|
/** Create a new weight limit rule. */
|
||||||
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
||||||
|
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
containerTypeId: dto.containerTypeId,
|
containerTypeId: dto.containerTypeId,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
maxVgmTons: dto.maxVgmTons,
|
maxVgmTons: dto.maxVgmTons,
|
||||||
effectiveFrom: new Date(dto.effectiveFrom),
|
|
||||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update an existing weight limit rule. */
|
/** Update an existing weight limit rule. */
|
||||||
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
const patch: Partial<WeightLimitRule> = {};
|
const patch: Partial<WeightLimitRule> = {};
|
||||||
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
|
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
|
||||||
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
|
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
|
||||||
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
|
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
|
||||||
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
|
|
||||||
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
|
// Re-check uniqueness when the identity (container/direction) changes.
|
||||||
|
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {
|
||||||
|
await this.assertNoDuplicate(
|
||||||
|
patch.containerTypeId ?? existing.containerTypeId,
|
||||||
|
patch.tradeDirection ?? existing.tradeDirection,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.repository.update(id, patch);
|
const updated = await this.repository.update(id, patch);
|
||||||
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
|
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
listBatchWindowsForDate,
|
listBatchWindowsForDate,
|
||||||
listBatchWindowsForBookings,
|
listBatchWindowsForBookings,
|
||||||
BATCH_WINDOW_START_HOURS,
|
BATCH_WINDOW_START_HOURS,
|
||||||
boardWindowForTimestamp,
|
listConfigBookingWindows,
|
||||||
listBoardWindowsForRange,
|
|
||||||
groupBookingsIntoBoardWindows,
|
groupBookingsIntoBoardWindows,
|
||||||
|
type BoardWindowConfig,
|
||||||
} from './batch-window.util';
|
} from './batch-window.util';
|
||||||
|
|
||||||
describe('batch-window.util', () => {
|
describe('batch-window.util', () => {
|
||||||
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||||
it('maps 04:00 EAT to the 03:00–06:00 slot', () => {
|
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
|
||||||
// 01:00 UTC = 04:00 EAT on 11 Jun
|
const cfg: BoardWindowConfig = {
|
||||||
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
|
importWindowLeadDays: 3,
|
||||||
expect(w.label).toContain('03:00');
|
windowOpenHour: 8,
|
||||||
expect(w.label).toContain('06:00');
|
windowDurationHours: 3,
|
||||||
expect(w.date).toBe('2026-06-11');
|
reopenDelayMinutes: 90,
|
||||||
expect(w.dateLabel).toContain('11 Jun');
|
exportBookingLeadHours: 24,
|
||||||
});
|
};
|
||||||
|
|
||||||
it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => {
|
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
|
||||||
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
|
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
|
||||||
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
|
|
||||||
expect(w.label).toContain('00:00');
|
|
||||||
expect(w.label).toContain('03:00');
|
|
||||||
expect(w.date).toBe('2026-06-11');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps 23:00 EAT to the final 21:00–24:00 slot', () => {
|
|
||||||
// 20:00 UTC = 23:00 EAT on 11 Jun
|
|
||||||
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
|
|
||||||
expect(w.label).toContain('21:00');
|
|
||||||
expect(w.label).toContain('24:00');
|
|
||||||
expect(w.date).toBe('2026-06-11');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists a continuous range open→departure clamped at both ends', () => {
|
|
||||||
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
|
|
||||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
|
||||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const windows = listBoardWindowsForRange(open, departure);
|
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||||
|
|
||||||
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
|
|
||||||
expect(windows).toHaveLength(6 + 8 + 8 + 5);
|
|
||||||
expect(windows[0].date).toBe('2026-06-05');
|
expect(windows[0].date).toBe('2026-06-05');
|
||||||
expect(windows[0].label).toContain('06:00');
|
expect(windows[0].label).toContain('08:00');
|
||||||
expect(windows[0].label).toContain('09:00');
|
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
|
||||||
const last = windows[windows.length - 1];
|
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
|
||||||
expect(last.date).toBe('2026-06-08');
|
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
|
||||||
expect(last.label).toContain('12:00');
|
|
||||||
expect(last.label).toContain('15:00');
|
|
||||||
// chronological + unique keys
|
|
||||||
const keys = windows.map((w) => w.key);
|
|
||||||
expect(new Set(keys).size).toBe(keys.length);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles a same-day open→departure range', () => {
|
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
|
||||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||||
const windows = listBoardWindowsForRange(open, departure);
|
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT
|
||||||
// 06,09,12 = 3 slots
|
expect(windows.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(windows).toHaveLength(3);
|
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
|
||||||
|
// all cycles stay on the same EAT booking day
|
||||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
|
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const departure = new Date('2026-06-06T11:00:00.000Z');
|
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
|
||||||
|
expect(windows).toHaveLength(1);
|
||||||
|
// 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
|
||||||
|
expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
|
||||||
|
expect(windows[0].end.toISOString()).toBe(departure.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets bookings into config cycles and keeps empty + pending windows', () => {
|
||||||
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const items = [
|
const items = [
|
||||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
|
||||||
{ id: 'b', ts: null }, // pending
|
{ id: 'b', ts: null }, // pending
|
||||||
];
|
];
|
||||||
const map = groupBookingsIntoBoardWindows(
|
const map = groupBookingsIntoBoardWindows(
|
||||||
items,
|
items,
|
||||||
(i) => i.ts,
|
(i) => i.ts,
|
||||||
open,
|
'IMPORT',
|
||||||
departure,
|
departure,
|
||||||
|
cfg,
|
||||||
'pending-contract',
|
'pending-contract',
|
||||||
);
|
);
|
||||||
const pending = map.get('pending-contract');
|
const pending = map.get('pending-contract');
|
||||||
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
||||||
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
||||||
expect(withA?.window?.date).toBe('2026-06-05');
|
expect(withA?.window?.date).toBe('2026-06-05');
|
||||||
// empty slots are retained for the UI
|
// empty cycles are retained for the UI
|
||||||
const emptyCount = [...map.values()].filter(
|
const emptyCount = [...map.values()].filter(
|
||||||
(b) => b.window && b.items.length === 0,
|
(b) => b.window && b.items.length === 0,
|
||||||
).length;
|
).length;
|
||||||
expect(emptyCount).toBeGreaterThan(0);
|
expect(emptyCount).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('attaches a booking made before the window opened to the first cycle', () => {
|
||||||
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
|
const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
|
||||||
|
const map = groupBookingsIntoBoardWindows(
|
||||||
|
items,
|
||||||
|
(i) => i.ts,
|
||||||
|
'IMPORT',
|
||||||
|
departure,
|
||||||
|
cfg,
|
||||||
|
'pending-contract',
|
||||||
|
);
|
||||||
|
const withEarly = [...map.values()].find((b) =>
|
||||||
|
b.items.some((i) => i.id === 'early'),
|
||||||
|
);
|
||||||
|
expect(withEarly?.window?.date).toBe('2026-06-05');
|
||||||
|
expect(withEarly?.window?.label).toContain('08:00');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,14 +230,13 @@ export function listBatchWindowsForBookings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Board-display windows: full-day, midnight-based 3h slots over a date range.
|
// Board-display windows: the REAL booking-window cycles derived from the
|
||||||
// These are used ONLY for the batch-board UI grouping (not persisted, and
|
// train_scheduling_global_rules config (window open hour, lead days, duration,
|
||||||
// independent of the cron intake hours above).
|
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
|
||||||
|
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
|
||||||
|
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */
|
|
||||||
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
|
|
||||||
|
|
||||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||||
export interface BoardWindow extends BatchWindow {
|
export interface BoardWindow extends BatchWindow {
|
||||||
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
||||||
@@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow {
|
|||||||
dateLabel: string;
|
dateLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Config fields the board needs to reconstruct booking-window cycles. */
|
||||||
|
export interface BoardWindowConfig {
|
||||||
|
importWindowLeadDays: number;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowDurationHours: number;
|
||||||
|
reopenDelayMinutes: number;
|
||||||
|
exportBookingLeadHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -257,119 +265,124 @@ function pad2(n: number): string {
|
|||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
|
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
|
||||||
function boardWindowFromEatStart(
|
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||||
year: number,
|
const { year, month, day } = eatParts(start);
|
||||||
month: number,
|
|
||||||
day: number,
|
|
||||||
startHour: number,
|
|
||||||
): BoardWindow {
|
|
||||||
const start = eatToUtc(year, month, day, startHour);
|
|
||||||
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
|
|
||||||
const end = eatToUtc(year, month, day, endHour);
|
|
||||||
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
|
|
||||||
return {
|
return {
|
||||||
key: start.toISOString(),
|
key: start.toISOString(),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
label: formatWindowLabel(start, end, endLabel),
|
label: formatWindowLabel(start, end),
|
||||||
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
||||||
dateLabel: dayLabelFmt.format(start),
|
dateLabel: dayLabelFmt.format(start),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which midnight-based 3h EAT slot a timestamp falls in. */
|
|
||||||
export function boardWindowForTimestamp(date: Date): BoardWindow {
|
|
||||||
const { year, month, day, hour } = eatParts(date);
|
|
||||||
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
|
|
||||||
for (const h of BOARD_WINDOW_HOURS) {
|
|
||||||
if (hour >= h) startHour = h;
|
|
||||||
}
|
|
||||||
return boardWindowFromEatStart(year, month, day, startHour);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
|
* The real booking-window cycles for a schedule, straight from config.
|
||||||
* clamped to the slot containing `openDate` on the first day and the slot
|
*
|
||||||
* containing `departureDate` on the last day. Returned in chronological order.
|
* IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
|
||||||
|
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
|
||||||
|
* after each close, on the same booking day, until departure. This mirrors
|
||||||
|
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||||
|
* exact windows the engine runs.
|
||||||
|
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
|
||||||
*/
|
*/
|
||||||
export function listBoardWindowsForRange(
|
export function listConfigBookingWindows(
|
||||||
openDate: Date,
|
direction: string | null | undefined,
|
||||||
departureDate: Date,
|
departure: Date,
|
||||||
|
cfg: BoardWindowConfig,
|
||||||
): BoardWindow[] {
|
): BoardWindow[] {
|
||||||
const startWin = boardWindowForTimestamp(openDate);
|
if (direction === 'EXPORT') {
|
||||||
const endWin = boardWindowForTimestamp(departureDate);
|
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
||||||
// Guard against an inverted range (departure before open).
|
return [boardWindowFromInterval(start, departure)];
|
||||||
if (endWin.start.getTime() < startWin.start.getTime()) {
|
|
||||||
return [startWin];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const windows: BoardWindow[] = [];
|
const windows: BoardWindow[] = [];
|
||||||
const seen = new Set<string>();
|
const durationMs = cfg.windowDurationHours * 3_600_000;
|
||||||
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
|
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||||
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
|
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||||
let cursor = new Date(eatToUtc(
|
|
||||||
Number(startWin.date.slice(0, 4)),
|
|
||||||
Number(startWin.date.slice(5, 7)),
|
|
||||||
Number(startWin.date.slice(8, 10)),
|
|
||||||
12,
|
|
||||||
));
|
|
||||||
const lastDayMs = eatToUtc(
|
|
||||||
Number(endWin.date.slice(0, 4)),
|
|
||||||
Number(endWin.date.slice(5, 7)),
|
|
||||||
Number(endWin.date.slice(8, 10)),
|
|
||||||
12,
|
|
||||||
).getTime();
|
|
||||||
|
|
||||||
while (cursor.getTime() <= lastDayMs) {
|
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||||
const { year, month, day } = eatParts(cursor);
|
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
|
||||||
for (const h of BOARD_WINDOW_HOURS) {
|
for (let cycle = 0; cycle < 12; cycle += 1) {
|
||||||
const w = boardWindowFromEatStart(year, month, day, h);
|
if (opensAt.getTime() >= departure.getTime()) break;
|
||||||
if (
|
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||||
w.start.getTime() >= startWin.start.getTime() &&
|
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||||
w.start.getTime() <= endWin.start.getTime() &&
|
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||||
!seen.has(w.key)
|
|
||||||
) {
|
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
|
||||||
seen.add(w.key);
|
if (
|
||||||
windows.push(w);
|
nextOpensAt.getTime() >= departure.getTime() ||
|
||||||
}
|
eatDay(nextOpensAt) !== eatDay(opensAt)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
opensAt = nextOpensAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
windows.sort(compareBatchWindows);
|
// Degenerate config (no window before departure) — surface a single window
|
||||||
|
// clamped to departure so the board still renders something meaningful.
|
||||||
|
if (windows.length === 0) {
|
||||||
|
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
|
||||||
|
}
|
||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
|
||||||
|
function configWindowForTimestamp(
|
||||||
|
windows: BoardWindow[],
|
||||||
|
date: Date,
|
||||||
|
): BoardWindow | null {
|
||||||
|
const ms = date.getTime();
|
||||||
|
for (const w of windows) {
|
||||||
|
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group items into board windows spanning [openDate, departureDate]. Empty
|
* Group items into the real config booking-window cycles for a schedule. Empty
|
||||||
* windows are kept so the UI shows every slot. Items whose timestamp falls
|
* windows are kept so the UI shows every cycle. Items whose timestamp falls
|
||||||
* outside the range still get their own window (nothing hidden). Items without
|
* outside every window (e.g. a booking created before the window opened) are
|
||||||
* a timestamp go to `pendingKey`.
|
* attached to the nearest window by start time so nothing is hidden. Items
|
||||||
|
* without a timestamp go to `pendingKey`.
|
||||||
*/
|
*/
|
||||||
export function groupBookingsIntoBoardWindows<T>(
|
export function groupBookingsIntoBoardWindows<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
getTimestamp: (item: T) => Date | null | undefined,
|
getTimestamp: (item: T) => Date | null | undefined,
|
||||||
openDate: Date,
|
direction: string | null | undefined,
|
||||||
departureDate: Date,
|
departure: Date,
|
||||||
|
cfg: BoardWindowConfig,
|
||||||
pendingKey = 'pending-contract',
|
pendingKey = 'pending-contract',
|
||||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||||
|
const windows = listConfigBookingWindows(direction, departure, cfg);
|
||||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||||
|
for (const w of windows) {
|
||||||
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
|
|
||||||
map.set(w.key, { window: w, items: [] });
|
map.set(w.key, { window: w, items: [] });
|
||||||
}
|
}
|
||||||
map.set(pendingKey, { window: null, items: [] });
|
map.set(pendingKey, { window: null, items: [] });
|
||||||
|
|
||||||
|
const firstWindow = windows[0] ?? null;
|
||||||
|
const lastWindow = windows[windows.length - 1] ?? null;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const ts = getTimestamp(item);
|
const ts = getTimestamp(item);
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
map.get(pendingKey)!.items.push(item);
|
map.get(pendingKey)!.items.push(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const w = boardWindowForTimestamp(ts);
|
let w = configWindowForTimestamp(windows, ts);
|
||||||
if (!map.has(w.key)) {
|
if (!w) {
|
||||||
map.set(w.key, { window: w, items: [] });
|
// Booked before the window opened → first cycle; after it closed → last cycle.
|
||||||
|
w =
|
||||||
|
firstWindow && ts.getTime() < firstWindow.start.getTime()
|
||||||
|
? firstWindow
|
||||||
|
: lastWindow;
|
||||||
|
}
|
||||||
|
if (!w) {
|
||||||
|
map.get(pendingKey)!.items.push(item);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
map.get(w.key)!.items.push(item);
|
map.get(w.key)!.items.push(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reserves both partners of a consolidated pair together on one train', async () => {
|
||||||
|
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
|
||||||
|
const consol = (id: string, partnerId: string, priority: number): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: priority,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: partnerId,
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||||
|
consol('a', 'b', 30),
|
||||||
|
consol('b', 'a', 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Both reserved on the same (first) train; neither reported unplaced.
|
||||||
|
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||||
|
expect(reservedIds.sort()).toEqual(['a', 'b']);
|
||||||
|
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
|
||||||
|
const lonely = {
|
||||||
|
id: 'a',
|
||||||
|
reference: 'a',
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 30,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: 'missing-partner',
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Never reserved — waits for its partner in a later cycle.
|
||||||
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
interface Capacity {
|
||||||
@@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
/** Set when this booking shares a wagon with a consolidation partner. */
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
@@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* fits the booking. Throws ConflictException when every train is full — the
|
* fits the booking. Throws ConflictException when every train is full — the
|
||||||
* staff accept fails and no more export bookings are taken.
|
* staff accept fails and no more export bookings are taken.
|
||||||
*/
|
*/
|
||||||
async pickExportSchedule(booking: Booking): Promise<string> {
|
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||||
if (!booking.scheduledDate) {
|
if (!booking.scheduledDate) {
|
||||||
throw new BadRequestException('Booking has no scheduled date');
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
}
|
}
|
||||||
@@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const required = need ?? this.needFor(booking, wagonLengths);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
@@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
if (this.fits(need, budget)) return schedule.id;
|
if (this.fits(required, budget)) return schedule.id;
|
||||||
}
|
}
|
||||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve an accepted export booking on its picked train and open the pay
|
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||||
* window immediately (payment notification goes out on reserve). Marks the
|
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||||
* train FULL when this reservation exhausts the wagon budget.
|
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
|
||||||
|
* against the combined shared-wagon need; the first partner's accept just waits.
|
||||||
|
* Throws ConflictException (before this booking is persisted-ready) when there is
|
||||||
|
* no export capacity for the day, so staff accept fails.
|
||||||
*/
|
*/
|
||||||
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
|
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||||
await this.reserve(booking, scheduleId);
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking);
|
||||||
|
await this.reserveOnExport([booking], scheduleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
|
||||||
|
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
|
||||||
|
// waits; the partner's later accept will reserve the pair.
|
||||||
|
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||||
|
await this.reserveOnExport([booking, partner], scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
|
||||||
|
private async reserveOnExport(
|
||||||
|
bookings: Booking[],
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||||
this.armSettle(scheduleId);
|
this.armSettle(scheduleId);
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
@@ -557,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const board: BatchBoardSchedule[] = [];
|
const board: BatchBoardSchedule[] = [];
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
|
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
|
||||||
|
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
|
||||||
|
// and domestic/legacy schedules run the legacy fill, not the window batch.
|
||||||
|
if (s.direction !== "IMPORT") continue;
|
||||||
|
|
||||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||||
@@ -597,6 +633,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
|
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
|
||||||
throw new BadRequestException("Schedule is no longer active");
|
throw new BadRequestException("Schedule is no longer active");
|
||||||
}
|
}
|
||||||
|
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
|
||||||
|
if (s.direction !== "IMPORT") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The batch board only covers import schedules",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
@@ -622,6 +664,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve consolidation-partner references for the shared-wagon badge. Most
|
||||||
|
// partners are on this same schedule; look up any that aren't in one query.
|
||||||
|
const refById = new Map(
|
||||||
|
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
|
||||||
|
);
|
||||||
|
const missingPartnerIds = [
|
||||||
|
...new Set(
|
||||||
|
bookings
|
||||||
|
.map((b) => b.consolidationPartnerId)
|
||||||
|
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (missingPartnerIds.length) {
|
||||||
|
const partners = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.find({ where: { id: In(missingPartnerIds) } });
|
||||||
|
for (const p of partners) {
|
||||||
|
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||||
const need = this.needFor(b, wagonLengths);
|
const need = this.needFor(b, wagonLengths);
|
||||||
const alloc = allocationByBooking.get(b.id);
|
const alloc = allocationByBooking.get(b.id);
|
||||||
@@ -647,20 +710,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null,
|
: null,
|
||||||
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
||||||
allocationIssue: alloc?.issue ?? null,
|
allocationIssue: alloc?.issue ?? null,
|
||||||
|
consolidationPartnerId: b.consolidationPartnerId ?? null,
|
||||||
|
consolidationPartnerRef: b.consolidationPartnerId
|
||||||
|
? (refById.get(b.consolidationPartnerId) ?? null)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = s.trainSet?.locomotive ?? null;
|
||||||
|
|
||||||
// Display windows span the whole booking window: from when it opened
|
// Display windows are the REAL booking-window cycles from the global-rules
|
||||||
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
|
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
|
||||||
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
|
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
|
||||||
|
// export: single FCFS lead window) — not a fixed clock grid.
|
||||||
|
const windowCfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||||
items,
|
items,
|
||||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||||
openDate,
|
s.direction ?? null,
|
||||||
departureDate,
|
departureDate,
|
||||||
|
windowCfg,
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyCounts = () => ({
|
const emptyCounts = () => ({
|
||||||
@@ -903,13 +973,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
if (!this.fits(need, budget)) {
|
if (!this.fits(need, budget)) {
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
budget = await this.preemptForGovernment(
|
budget = await this.preemptForGovernment(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
need,
|
need,
|
||||||
@@ -918,14 +994,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(scheduleId, booking, "gov");
|
await this.allocate(scheduleId, booking, "gov");
|
||||||
|
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, scheduleId);
|
await this.reserve(booking, scheduleId);
|
||||||
|
if (partner) await this.reserve(partner, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget = this.subtract(budget, need);
|
||||||
@@ -1013,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
destinationYardId,
|
destinationYardId,
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
|
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||||
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
// First train (earliest departure) that fits this booking as-is.
|
// First train (earliest departure) that fits this unit as-is.
|
||||||
let target = trains.find((t) => this.fits(need, t.budget));
|
let target = trains.find((t) => this.fits(need, t.budget));
|
||||||
|
|
||||||
if (!target && booking.isGovernment) {
|
if (!target && isGov) {
|
||||||
// Government booking fits nowhere on its own — try to preempt commercial
|
// Government fits nowhere on its own — try to preempt commercial
|
||||||
// on each train (earliest first) until one frees enough room.
|
// on each train (earliest first) until one frees enough room.
|
||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
t.budget = await this.preemptForGovernment(
|
t.budget = await this.preemptForGovernment(
|
||||||
@@ -1038,41 +1124,45 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
// A consolidated pair is placed whole or not at all — never split.
|
||||||
// partial-capacity offer on the train with the most free wagons: pay =
|
if (!isPair) {
|
||||||
// accept the split (remainder returns to the contract cap), no pay =
|
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||||
// booking stays whole and expires for this train.
|
// partial-capacity offer on the train with the most free wagons.
|
||||||
const partialTarget = [...trains]
|
const partialTarget = [...trains]
|
||||||
.filter((t) => t.budget.wagons >= 1)
|
.filter((t) => t.budget.wagons >= 1)
|
||||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||||
if (
|
if (
|
||||||
partialTarget &&
|
partialTarget &&
|
||||||
!booking.isGovernment &&
|
!booking.isGovernment &&
|
||||||
booking.tradeDirection === "IMPORT" &&
|
booking.tradeDirection === "IMPORT" &&
|
||||||
booking.contractKind === "GENERAL" &&
|
booking.contractKind === "GENERAL" &&
|
||||||
this.splitService
|
this.splitService
|
||||||
) {
|
) {
|
||||||
const offered = await this.tryPartialOffer(
|
const offered = await this.tryPartialOffer(
|
||||||
booking,
|
booking,
|
||||||
partialTarget.id,
|
partialTarget.id,
|
||||||
partialTarget.budget,
|
partialTarget.budget,
|
||||||
need,
|
need,
|
||||||
);
|
);
|
||||||
if (offered) {
|
if (offered) {
|
||||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||||
partialTarget.armed = true;
|
partialTarget.armed = true;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stays in the pool, retried next batch/window cycle.
|
// Stays in the pool, retried next batch/window cycle.
|
||||||
this.notifier.unplaced(booking, day);
|
this.notifier.unplaced(booking, day);
|
||||||
|
if (partner) this.notifier.unplaced(partner, day);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(target.id, booking, "gov");
|
await this.allocate(target.id, booking, "gov");
|
||||||
|
if (partner) await this.allocate(target.id, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, target.id);
|
await this.reserve(booking, target.id);
|
||||||
|
if (partner) await this.reserve(partner, target.id);
|
||||||
target.armed = true;
|
target.armed = true;
|
||||||
}
|
}
|
||||||
target.budget = this.subtract(target.budget, need);
|
target.budget = this.subtract(target.budget, need);
|
||||||
@@ -1099,6 +1189,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
need: Capacity,
|
need: Capacity,
|
||||||
): Promise<Capacity | null> {
|
): Promise<Capacity | null> {
|
||||||
if (!this.splitService) return null;
|
if (!this.splitService) return null;
|
||||||
|
// A consolidated booking is already half of a shared wagon — never split it.
|
||||||
|
if (booking.consolidationPartnerId) return null;
|
||||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||||
|
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
@@ -1143,29 +1235,69 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return capacity > 0 ? capacity : 60;
|
return capacity > 0 ? capacity : 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/**
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||||
|
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
||||||
|
* path: expire it). Consolidated pairs settle atomically: both allocate only
|
||||||
|
* when both paid; if either partner expires, both expire (a half-paid shared
|
||||||
|
* wagon must not ship). Returns whether anything changed.
|
||||||
|
*/
|
||||||
|
private async settleReserved(
|
||||||
|
scheduleId: string,
|
||||||
|
expireUnpaidUnknownDeadline: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
const reserved =
|
const reserved =
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||||
|
const done = new Set<string>();
|
||||||
let anySettled = false;
|
let anySettled = false;
|
||||||
|
|
||||||
for (const booking of reserved) {
|
const isPaid = (b: Booking) =>
|
||||||
const paid =
|
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
const isExpired = (b: Booking) =>
|
||||||
const expired = booking.paymentDeadline
|
b.paymentDeadline
|
||||||
? booking.paymentDeadline.getTime() <= now
|
? b.paymentDeadline.getTime() <= now
|
||||||
: false;
|
: expireUnpaidUnknownDeadline;
|
||||||
|
|
||||||
if (paid) {
|
for (const booking of reserved) {
|
||||||
|
if (done.has(booking.id)) continue;
|
||||||
|
const partner = booking.consolidationPartnerId
|
||||||
|
? (byId.get(booking.consolidationPartnerId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (partner) {
|
||||||
|
done.add(booking.id);
|
||||||
|
done.add(partner.id);
|
||||||
|
// Both-or-neither: allocate the shared wagon only when both partners paid;
|
||||||
|
// if either lapsed, expire both so no half-paid wagon rides.
|
||||||
|
if (isPaid(booking) && isPaid(partner)) {
|
||||||
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
|
await this.allocate(scheduleId, partner, "paid");
|
||||||
|
anySettled = true;
|
||||||
|
} else if (isExpired(booking) || isExpired(partner)) {
|
||||||
|
await this.expire(booking);
|
||||||
|
await this.expire(partner);
|
||||||
|
anySettled = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
done.add(booking.id);
|
||||||
|
if (isPaid(booking)) {
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
} else if (expired) {
|
} else if (isExpired(booking)) {
|
||||||
await this.expire(booking);
|
await this.expire(booking);
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return anySettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
|
const anySettled = await this.settleReserved(scheduleId, false);
|
||||||
if (anySettled) await this.fillSchedule(scheduleId);
|
if (anySettled) await this.fillSchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/** Allocate paid reservations, expire the rest, then top up. */
|
/** Allocate paid reservations, expire the rest, then top up. */
|
||||||
async settleBatch(scheduleId: string): Promise<void> {
|
async settleBatch(scheduleId: string): Promise<void> {
|
||||||
this.removeTimeout(scheduleId);
|
this.removeTimeout(scheduleId);
|
||||||
const reserved =
|
await this.settleReserved(scheduleId, true);
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const booking of reserved) {
|
|
||||||
const paid =
|
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
|
||||||
const expired = booking.paymentDeadline
|
|
||||||
? booking.paymentDeadline.getTime() <= now
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (paid) {
|
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
|
||||||
} else if (expired) {
|
|
||||||
await this.expire(booking);
|
|
||||||
}
|
|
||||||
// else: still within window (rare at settle) → leave for the re-armed timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.fillSchedule(scheduleId);
|
await this.fillSchedule(scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
@@ -1452,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- capacity helpers -----------------------------------------------------
|
// ---- capacity helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse consolidated partners into single pool entries so the fill treats a
|
||||||
|
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
|
||||||
|
* - no `consolidationPartnerId` → passes through as a lone booking.
|
||||||
|
* - consolidated + partner also in this pool → emitted ONCE (at the position of
|
||||||
|
* whichever partner ranks first) as a pair; the partner is not emitted again.
|
||||||
|
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
|
||||||
|
* it waits for the partner to become ready in a later cycle).
|
||||||
|
* The pool is already priority-ordered, so emitting the pair at the first-seen
|
||||||
|
* partner's slot ranks it by the stronger (max-priority) partner automatically.
|
||||||
|
*/
|
||||||
|
private groupConsolidatedPool(
|
||||||
|
pool: Booking[],
|
||||||
|
): Array<{ primary: Booking; partner: Booking | null }> {
|
||||||
|
const byId = new Map(pool.map((b) => [b.id, b]));
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
|
||||||
|
for (const booking of pool) {
|
||||||
|
if (emitted.has(booking.id)) continue;
|
||||||
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
emitted.add(booking.id);
|
||||||
|
units.push({ primary: booking, partner: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const partner = byId.get(partnerId) ?? null;
|
||||||
|
if (!partner) {
|
||||||
|
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
|
||||||
|
emitted.add(booking.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emitted.add(booking.id);
|
||||||
|
emitted.add(partner.id);
|
||||||
|
units.push({ primary: booking, partner });
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
|
||||||
|
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
|
||||||
|
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
|
||||||
|
* independently-rounded-up needs (that is the capacity consolidation saves).
|
||||||
|
*/
|
||||||
|
private combinedNeed(
|
||||||
|
primary: Booking,
|
||||||
|
partner: Booking,
|
||||||
|
wagonLengths: WagonLengths,
|
||||||
|
): Capacity {
|
||||||
|
const containers = (b: Booking): number =>
|
||||||
|
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
|
const totalContainers = containers(primary) + containers(partner);
|
||||||
|
const sharedWagons =
|
||||||
|
totalContainers > 0
|
||||||
|
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||||
|
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||||
|
const weightTons =
|
||||||
|
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||||
|
return {
|
||||||
|
wagons: sharedWagons,
|
||||||
|
weightTons,
|
||||||
|
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||||
|
container: wagonLengths.container,
|
||||||
|
bulk: wagonLengths.bulk,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private wagonsFor(booking: Booking): number {
|
private wagonsFor(booking: Booking): number {
|
||||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||||
return Math.ceil(booking.wagonsRequired);
|
return Math.ceil(booking.wagonsRequired);
|
||||||
|
|||||||
@@ -3257,6 +3257,53 @@ export class TrainSchedulingService {
|
|||||||
return days.includes(day);
|
return days.includes(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the config-driven booking window at booking-create time.
|
||||||
|
*
|
||||||
|
* A booking is only allowed when the route has an OPEN departure the customer
|
||||||
|
* can join for the requested day — which, because the window engine keeps
|
||||||
|
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
|
||||||
|
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
|
||||||
|
* `importWindowLeadDays` before departure, for `windowDurationHours`).
|
||||||
|
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
|
||||||
|
*
|
||||||
|
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
|
||||||
|
* both gates are satisfied by checking that route for open departures. When a
|
||||||
|
* specific day is requested, require an open departure on that EAT day; when no
|
||||||
|
* day is given, require at least one open departure on the route at all.
|
||||||
|
* Throws `BadRequestException` when the window is closed. No-ops when the route
|
||||||
|
* yards are unknown (nothing to gate against).
|
||||||
|
*/
|
||||||
|
async assertBookingWindowOpen(input: {
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
scheduledDate?: Date | string | null;
|
||||||
|
direction?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { originYardId, destinationYardId } = input;
|
||||||
|
if (!originYardId || !destinationYardId) return;
|
||||||
|
|
||||||
|
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||||
|
if (days.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
input.direction === 'EXPORT'
|
||||||
|
? 'The export booking window for this route is not open yet'
|
||||||
|
: 'The import booking window for this route is closed right now',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.scheduledDate) {
|
||||||
|
const day = eatDay(new Date(input.scheduledDate));
|
||||||
|
if (!days.includes(day)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
input.direction === 'EXPORT'
|
||||||
|
? 'No departure is within the export booking window on the selected day'
|
||||||
|
: 'The import booking window is not open for the selected day',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async mapScheduleDetail(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BOOKING_RULE_ENGINE_PERMISSIONS,
|
BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||||
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||||
|
POSITION_PERMISSION_PRESETS,
|
||||||
ROLE_PERMISSION_PRESETS,
|
ROLE_PERMISSION_PRESETS,
|
||||||
} from './freight-permissions.registry';
|
} from './freight-permissions.registry';
|
||||||
|
|
||||||
@@ -10,6 +11,13 @@ export type FreightSeedRole = {
|
|||||||
permissionKeys: string[];
|
permissionKeys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FreightSeedPosition = {
|
||||||
|
key: string;
|
||||||
|
name: { en: string };
|
||||||
|
rank: number;
|
||||||
|
permissionKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const IAM_PERMISSION_KEYS = {
|
const IAM_PERMISSION_KEYS = {
|
||||||
activateEmployee: "can:activateEmployee",
|
activateEmployee: "can:activateEmployee",
|
||||||
activateUser: "can:activateUser",
|
activateUser: "can:activateUser",
|
||||||
@@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
permissionKeys: [],
|
permissionKeys: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operational positions (positions-as-roles). Seeded as Position +
|
||||||
|
* PositionPermission rows (NOT Role/RolePermission). Users get their access by
|
||||||
|
* being assigned to a Position via EmployeePosition.
|
||||||
|
*/
|
||||||
|
export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
||||||
|
{ key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] },
|
||||||
|
{ key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] },
|
||||||
|
{ key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] },
|
||||||
|
{ key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] },
|
||||||
|
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
||||||
|
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
||||||
|
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||||
|
];
|
||||||
|
|||||||
@@ -3,14 +3,28 @@ import {
|
|||||||
Organization,
|
Organization,
|
||||||
OrganizationConfiguration,
|
OrganizationConfiguration,
|
||||||
Permission,
|
Permission,
|
||||||
|
Position,
|
||||||
|
PositionPermission,
|
||||||
|
PositionType,
|
||||||
Role,
|
Role,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
|
Unit,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
||||||
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
||||||
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
import {
|
||||||
|
EDR_FREIGHT_POSITIONS,
|
||||||
|
EDR_FREIGHT_ROLES,
|
||||||
|
type FreightSeedPosition,
|
||||||
|
type FreightSeedRole,
|
||||||
|
} from "./edr-freight.seed";
|
||||||
|
|
||||||
|
const EDR_UNIT_KEY = "edr_freight_hq";
|
||||||
|
const EDR_UNIT_NAME = { en: "EDR Freight HQ" };
|
||||||
|
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
|
||||||
|
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||||
@@ -40,6 +54,19 @@ export class EdrOrgSeeder {
|
|||||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
||||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
||||||
await this.ensureSuperAdminPermissions(manager);
|
await this.ensureSuperAdminPermissions(manager);
|
||||||
|
|
||||||
|
// Positions-as-roles: seed operational positions and grant their
|
||||||
|
// permissions via PositionPermission (not Role/RolePermission).
|
||||||
|
const unit = await this.ensureDefaultUnit(manager, organization.id);
|
||||||
|
const positionType = await this.ensureDefaultPositionType(manager, unit.id);
|
||||||
|
await this.ensurePositions(
|
||||||
|
manager,
|
||||||
|
organization.id,
|
||||||
|
unit.id,
|
||||||
|
positionType.id,
|
||||||
|
EDR_FREIGHT_POSITIONS,
|
||||||
|
);
|
||||||
|
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||||
@@ -205,4 +232,146 @@ export class EdrOrgSeeder {
|
|||||||
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultUnit(
|
||||||
|
manager: EntityManager,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const unitRepository = manager.getRepository(Unit);
|
||||||
|
|
||||||
|
let unit = await unitRepository.findOne({
|
||||||
|
where: { key: EDR_UNIT_KEY, organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
const insertResult = await unitRepository.insert({
|
||||||
|
key: EDR_UNIT_KEY,
|
||||||
|
name: EDR_UNIT_NAME,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`);
|
||||||
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`);
|
||||||
|
return { id: unit.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultPositionType(
|
||||||
|
manager: EntityManager,
|
||||||
|
unitId: string,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const positionTypeRepository = manager.getRepository(PositionType);
|
||||||
|
|
||||||
|
// PositionType has no unique constraint on (key, unitId); find-then-insert.
|
||||||
|
let positionType = await positionTypeRepository.findOne({
|
||||||
|
where: { key: EDR_POSITION_TYPE_KEY, unitId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!positionType) {
|
||||||
|
const insertResult = await positionTypeRepository.insert({
|
||||||
|
key: EDR_POSITION_TYPE_KEY,
|
||||||
|
name: EDR_POSITION_TYPE_NAME,
|
||||||
|
isSystem: true,
|
||||||
|
unitId,
|
||||||
|
});
|
||||||
|
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
||||||
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
||||||
|
return { id: positionType.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePositions(
|
||||||
|
manager: EntityManager,
|
||||||
|
organizationId: string,
|
||||||
|
unitId: string,
|
||||||
|
positionTypeId: string,
|
||||||
|
seedPositions: FreightSeedPosition[],
|
||||||
|
) {
|
||||||
|
await manager.getRepository(Position).upsert(
|
||||||
|
seedPositions.map(({ key, name, rank }) => ({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
rank,
|
||||||
|
organizationId,
|
||||||
|
unitId,
|
||||||
|
positionTypeId,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
conflictPaths: { key: true, unitId: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${seedPositions.length} EDR positions '${seedPositions
|
||||||
|
.map((position) => position.key)
|
||||||
|
.join("', '")}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePositionPermissions(
|
||||||
|
manager: EntityManager,
|
||||||
|
unitId: string,
|
||||||
|
seedPositions: FreightSeedPosition[],
|
||||||
|
) {
|
||||||
|
const permissionKeys = [
|
||||||
|
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!permissionKeys.length) {
|
||||||
|
this.logger.log(
|
||||||
|
"No EDR position permissions configured; skipping position-permission links",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = await manager.getRepository(Position).find({
|
||||||
|
where: { key: In(seedPositions.map((position) => position.key)), unitId },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
const seededPermissions = await manager.getRepository(Permission).find({
|
||||||
|
where: { key: In(permissionKeys) },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const positionByKey = new Map(
|
||||||
|
positions.map((position) => [position.key, position]),
|
||||||
|
);
|
||||||
|
const permissionByKey = new Map(
|
||||||
|
seededPermissions.map((permission) => [permission.key, permission]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const positionPermissions = seedPositions.flatMap((position) => {
|
||||||
|
const seededPosition = positionByKey.get(position.key);
|
||||||
|
|
||||||
|
if (!seededPosition) {
|
||||||
|
throw new Error(`missing_position:${position.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return position.permissionKeys.map((permissionKey) => {
|
||||||
|
const seededPermission = permissionByKey.get(permissionKey);
|
||||||
|
|
||||||
|
if (!seededPermission) {
|
||||||
|
throw new Error(`missing_permission:${permissionKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionId: seededPosition.id as string,
|
||||||
|
permissionId: seededPermission.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
|
||||||
|
conflictPaths: { positionId: true, permissionId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${positionPermissions.length} EDR position-permission links`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container-allocation permission for the previously-unguarded
|
||||||
|
* booking allocate-containers endpoint.
|
||||||
|
*/
|
||||||
|
export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
|
||||||
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||||
...BOOKING_PERMISSIONS,
|
...BOOKING_PERMISSIONS,
|
||||||
...CONTRACT_PERMISSIONS,
|
...CONTRACT_PERMISSIONS,
|
||||||
...RULE_ENGINE_PERMISSIONS,
|
...RULE_ENGINE_PERMISSIONS,
|
||||||
|
...GAP_CONTROLLER_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
||||||
@@ -171,6 +180,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: (slug: RuleEngineResourceSlug) =>
|
manage: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||||
},
|
},
|
||||||
|
allocation: {
|
||||||
|
manage: 'edr_freight_app:allocation:manage',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const allRuleEngineViewKeys = () =>
|
const allRuleEngineViewKeys = () =>
|
||||||
@@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position permission presets (positions-as-roles). Grants flow to users via
|
||||||
|
* Position → PositionPermission (NOT Role/RolePermission). Each reuses the
|
||||||
|
* matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the
|
||||||
|
* gap-controller keys the position needs. Deduped via Set.
|
||||||
|
*/
|
||||||
|
const dedupe = (keys: string[]): string[] => [...new Set(keys)];
|
||||||
|
|
||||||
|
export const POSITION_PERMISSION_PRESETS = {
|
||||||
|
// Chief: senior operational role — intake/line-staff approval + director
|
||||||
|
// approval + scheduling/ops, plus container allocation.
|
||||||
|
chief: dedupe([
|
||||||
|
...ROLE_PERMISSION_PRESETS.lineStaff,
|
||||||
|
...ROLE_PERMISSION_PRESETS.director,
|
||||||
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
|
FREIGHT_PERMS.allocation.manage,
|
||||||
|
]),
|
||||||
|
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
||||||
|
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||||
|
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
||||||
|
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),
|
||||||
|
marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]),
|
||||||
|
operation: dedupe([
|
||||||
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
|
FREIGHT_PERMS.allocation.manage,
|
||||||
|
]),
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
||||||
key: p.key,
|
key: p.key,
|
||||||
label: p.name.en,
|
label: p.name.en,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
|||||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||||
import {
|
import {
|
||||||
Employee,
|
Employee,
|
||||||
|
EmployeePosition,
|
||||||
Organization,
|
Organization,
|
||||||
|
Position,
|
||||||
Role,
|
Role,
|
||||||
|
Unit,
|
||||||
User,
|
User,
|
||||||
UserCredential,
|
UserCredential,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -13,13 +16,19 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
||||||
const EDR_ORG_KEY = 'edr_freight';
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
|
const EDR_UNIT_KEY = 'edr_freight_hq';
|
||||||
|
|
||||||
|
// roleKey is kept only for backwards compatibility with existing UserRole rows;
|
||||||
|
// access is granted via the assigned position (positionKey) + PositionPermission.
|
||||||
const STAFF_USERS = [
|
const STAFF_USERS = [
|
||||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' },
|
||||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
{ email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' },
|
||||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' },
|
||||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' },
|
||||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
|
{ email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' },
|
||||||
|
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
|
||||||
|
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
|
||||||
|
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder {
|
|||||||
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unit = await manager.getRepository(Unit).findOne({
|
||||||
|
where: { key: EDR_UNIT_KEY, organizationId: organization.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new Error(`missing_unit:${EDR_UNIT_KEY}`);
|
||||||
|
}
|
||||||
|
|
||||||
const roleRepository = manager.getRepository(Role);
|
const roleRepository = manager.getRepository(Role);
|
||||||
const userRepository = manager.getRepository(User);
|
const userRepository = manager.getRepository(User);
|
||||||
const userCredentialRepository = manager.getRepository(UserCredential);
|
const userCredentialRepository = manager.getRepository(UserCredential);
|
||||||
const userRoleRepository = manager.getRepository(UserRole);
|
const userRoleRepository = manager.getRepository(UserRole);
|
||||||
const employeeRepository = manager.getRepository(Employee);
|
const employeeRepository = manager.getRepository(Employee);
|
||||||
|
const positionRepository = manager.getRepository(Position);
|
||||||
|
const employeePositionRepository = manager.getRepository(EmployeePosition);
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password);
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
@@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder {
|
|||||||
{ conflictPaths: { userId: true, roleId: true } },
|
{ conflictPaths: { userId: true, roleId: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const employeeExists = await employeeRepository.exists({
|
let employee = await employeeRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeExists) {
|
if (!employee) {
|
||||||
await employeeRepository.insert({
|
employee = await employeeRepository.save(
|
||||||
userId: user.id,
|
employeeRepository.create({
|
||||||
organizationId: organization.id,
|
userId: user.id,
|
||||||
|
organizationId: organization.id,
|
||||||
|
unitId: unit.id,
|
||||||
|
isCurrent: true,
|
||||||
|
name: { en: staff.username },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access via the assigned position (positions-as-roles).
|
||||||
|
const position = await positionRepository.findOne({
|
||||||
|
where: { key: staff.positionKey, unitId: unit.id },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new Error(`missing_position:${staff.positionKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeePositionExists = await employeePositionRepository.exists({
|
||||||
|
where: {
|
||||||
|
employeeId: employee.id as string,
|
||||||
|
positionId: position.id as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employeePositionExists) {
|
||||||
|
await employeePositionRepository.insert({
|
||||||
|
employeeId: employee.id as string,
|
||||||
|
positionId: position.id as string,
|
||||||
|
unitId: unit.id,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
name: { en: staff.username },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||||
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
|
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
|
||||||
|
|
||||||
const base = new Date("2026-01-01");
|
|
||||||
|
|
||||||
const rules = [
|
const rules = [
|
||||||
{
|
{ containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 },
|
||||||
containerTypeId: twenty.id,
|
{ containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 },
|
||||||
tradeDirection: "IMPORT",
|
{ containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 },
|
||||||
maxVgmTons: 26,
|
{ containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 },
|
||||||
effectiveFrom: base,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
containerTypeId: twenty.id,
|
|
||||||
tradeDirection: "EXPORT",
|
|
||||||
maxVgmTons: 26,
|
|
||||||
effectiveFrom: base,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
containerTypeId: forty.id,
|
|
||||||
tradeDirection: "IMPORT",
|
|
||||||
maxVgmTons: 28,
|
|
||||||
effectiveFrom: base,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
containerTypeId: forty.id,
|
|
||||||
tradeDirection: "EXPORT",
|
|
||||||
maxVgmTons: 28,
|
|
||||||
effectiveFrom: base,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
@@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await wlRepo.update(existing.id, {
|
await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons });
|
||||||
maxVgmTons: rule.maxVgmTons,
|
|
||||||
effectiveFrom: rule.effectiveFrom,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await wlRepo.insert(rule);
|
await wlRepo.insert(rule);
|
||||||
}
|
}
|
||||||
@@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
ctByCode: Map<string, any>,
|
ctByCode: Map<string, any>,
|
||||||
cargoByCode: Map<string, any>,
|
cargoByCode: Map<string, any>,
|
||||||
): Promise<Rate[]> {
|
): Promise<Rate[]> {
|
||||||
const effectiveFrom = new Date("2026-01-01");
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
|
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
|
||||||
@@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
proposedByStaffId: STAFF_USER_ID,
|
proposedByStaffId: STAFF_USER_ID,
|
||||||
approvedByCeoId: CEO_USER_ID,
|
approvedByCeoId: CEO_USER_ID,
|
||||||
approvedAt: now,
|
approvedAt: now,
|
||||||
effectiveFrom,
|
|
||||||
}))
|
}))
|
||||||
.filter((d) => !existingBySignature.has(signature(d)));
|
.filter((d) => !existingBySignature.has(signature(d)));
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Receipt,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -32,7 +33,11 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import {
|
||||||
|
FreightDashboardLayout,
|
||||||
|
type SidebarItem,
|
||||||
|
type SidebarSection,
|
||||||
|
} from "@/components/layout";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
|||||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
@@ -61,7 +68,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
|||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
import {
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
hasPermission as hasFreightPermission,
|
||||||
|
isDjiboutiGl,
|
||||||
|
isEthiopianGl,
|
||||||
|
isSuperAdmin,
|
||||||
|
} from "./lib/permissions";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
@@ -144,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Wallet />,
|
icon: <Wallet />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
href: "/dashboard/invoices",
|
||||||
|
icon: <Receipt />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -159,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// label: "Shipment Requests",
|
label: "Shipment Requests",
|
||||||
// href: "/dashboard/shipment-requests",
|
href: "/dashboard/shipment-requests",
|
||||||
// icon: <Send />,
|
icon: <Send />,
|
||||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
@@ -435,12 +454,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
/** Hrefs of the two document-clearance menu items (stable identifiers). */
|
||||||
|
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||||
|
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||||
|
|
||||||
|
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
item.href === ET_CLEARANCE_HREF;
|
||||||
|
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
item.href === DJ_CLEARANCE_HREF;
|
||||||
|
const isClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
isEtClearanceItem(item) || isDjClearanceItem(item);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only items the user is permitted to see; drop now-empty sections.
|
||||||
|
*
|
||||||
|
* Position-scoped visibility (super_admin bypasses all of this):
|
||||||
|
* - Ethiopian GL → sees ONLY the ET document-clearance page.
|
||||||
|
* - Djibouti GL → sees ONLY the DJ clearance page.
|
||||||
|
* - Everyone else → sees everything they have permission for, EXCEPT the two
|
||||||
|
* clearance pages (those are GL-only).
|
||||||
|
*/
|
||||||
const filterSidebarByPermission = (
|
const filterSidebarByPermission = (
|
||||||
sections: SidebarSection[],
|
sections: SidebarSection[],
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
): SidebarSection[] => {
|
): SidebarSection[] => {
|
||||||
const itemAllowed = (item: SidebarItem): boolean => {
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const etGl = !superAdmin && isEthiopianGl(user);
|
||||||
|
const djGl = !superAdmin && isDjiboutiGl(user);
|
||||||
|
|
||||||
|
const permissionAllowed = (item: SidebarItem): boolean => {
|
||||||
if (!item.permission) return true;
|
if (!item.permission) return true;
|
||||||
const keys = Array.isArray(item.permission)
|
const keys = Array.isArray(item.permission)
|
||||||
? item.permission
|
? item.permission
|
||||||
@@ -448,6 +490,19 @@ const filterSidebarByPermission = (
|
|||||||
return keys.some((key) => hasFreightPermission(user, key));
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const itemAllowed = (item: SidebarItem): boolean => {
|
||||||
|
if (superAdmin) return true;
|
||||||
|
|
||||||
|
// GL positions are locked to their single clearance page.
|
||||||
|
if (etGl) return isEtClearanceItem(item);
|
||||||
|
if (djGl) return isDjClearanceItem(item);
|
||||||
|
|
||||||
|
// Everyone else: hide the GL-only clearance pages entirely.
|
||||||
|
if (isClearanceItem(item)) return false;
|
||||||
|
|
||||||
|
return permissionAllowed(item);
|
||||||
|
};
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
@@ -469,6 +524,22 @@ const DashboardShell = () => {
|
|||||||
);
|
);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
|
// GL positions are locked to their single clearance page: if they navigate
|
||||||
|
// (or deep-link) anywhere else, send them back to their clearance hub.
|
||||||
|
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
|
||||||
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const glClearanceHome = !superAdmin
|
||||||
|
? isEthiopianGl(user)
|
||||||
|
? ET_CLEARANCE_HREF
|
||||||
|
: isDjiboutiGl(user)
|
||||||
|
? DJ_CLEARANCE_HREF
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||||
|
return <Navigate to={glClearanceHome} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FreightDashboardLayout
|
<FreightDashboardLayout
|
||||||
sidebarSections={sidebarSections}
|
sidebarSections={sidebarSections}
|
||||||
@@ -506,7 +577,10 @@ const App = () => {
|
|||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="/health" element={<HealthCheck />} />
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={<Navigate to="/dashboard/overview" replace />}
|
||||||
|
/>
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
<Route path="profile" element={<MyProfilePage />} />
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
@@ -522,8 +596,27 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
|
<Route
|
||||||
|
path="invoices"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
|
<InvoicesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="invoices/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
|
<InvoiceDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route
|
||||||
|
path="booking-requests/:id"
|
||||||
|
element={<BookingRequestDetailPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
@@ -536,7 +629,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="clearance/:id"
|
path="clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||||
|
>
|
||||||
<DocumentClearanceDetailPage />
|
<DocumentClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -570,7 +665,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="bookings/:bookingId/clearance"
|
path="bookings/:bookingId/clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||||
|
>
|
||||||
<DocumentClearanceDetailPage />
|
<DocumentClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -578,7 +675,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="shipment-requests"
|
path="shipment-requests"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<ShipmentRequestsPage />
|
<ShipmentRequestsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -586,7 +685,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="shipment-requests/:id"
|
path="shipment-requests/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<ShipmentRequestDetailPage />
|
<ShipmentRequestDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -618,12 +719,20 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
<Route
|
||||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
path="gl-ethiopia/clearance"
|
||||||
|
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-ethiopia/clearance/:id"
|
||||||
|
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="gl-djibouti/clearance"
|
path="gl-djibouti/clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||||
|
>
|
||||||
<GlDjiboutiClearanceListPage />
|
<GlDjiboutiClearanceListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -631,7 +740,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="gl-djibouti/clearance/:id"
|
path="gl-djibouti/clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||||
|
>
|
||||||
<GlClearanceDetailPage />
|
<GlClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -644,7 +755,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contracts/:id/create-booking"
|
path="contracts/:id/create-booking"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<GlCreateBookingForm />
|
<GlCreateBookingForm />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -655,155 +768,174 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route
|
||||||
|
path="warehouse-inventory"
|
||||||
|
element={<WarehouseInventoryPage />}
|
||||||
|
/>
|
||||||
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
|
<Route
|
||||||
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
|
path="export-djibouti-unloading"
|
||||||
|
element={<ExportDjiboutiUnloadingQueuePage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="interchange-documents"
|
||||||
|
element={<InterchangeDocumentsPage />}
|
||||||
|
/>
|
||||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
<Route
|
||||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
path="warehouse-fee-invoices"
|
||||||
|
element={<WarehouseInvoicesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="warehouse-dashboard"
|
||||||
|
element={<WarehouseDashboardPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling"
|
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/batch-board"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<BatchBoardPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/first-mile"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<FirstMilePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/last-mile"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<LastMilePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/batch-board/:scheduleId"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<BatchScheduleDetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleV2ListPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2/:scheduleId"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleV2DetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleTrackPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="routes"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<RoutesPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="locomotives"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="trains"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="trains/:id"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<TrainDetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="wagons"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="containers"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="cargoes"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="vehicles"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="drivers"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/batch-board"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchBoardPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/first-mile"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<FirstMilePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/last-mile"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<LastMilePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/batch-board/:scheduleId"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchScheduleDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2ListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2DetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleTrackPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="routes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<RoutesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="locomotives"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<TrainDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagons"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="containers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="cargoes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="vehicles"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="drivers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling"
|
||||||
|
element={
|
||||||
|
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board"
|
path="operations/batch-board"
|
||||||
@@ -953,9 +1085,15 @@ const App = () => {
|
|||||||
{/* Legacy embedded user management routes */}
|
{/* Legacy embedded user management routes */}
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<Route
|
||||||
|
path="user-management/position-types"
|
||||||
|
element={<PositionTypesPage />}
|
||||||
|
/>
|
||||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
<Route
|
||||||
|
path="user-management/permissions"
|
||||||
|
element={<PermissionsPage />}
|
||||||
|
/>
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
@@ -977,7 +1115,9 @@ const App = () => {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="configuration/train-scheduling-rules"
|
path="configuration/train-scheduling-rules"
|
||||||
@@ -996,8 +1136,14 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
<Route
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
path="configuration/cargo-types/:id"
|
||||||
|
element={<CargoTypesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="configuration/:resource"
|
||||||
|
element={<RuleEngineResourcePage />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rules"
|
path="rules"
|
||||||
@@ -1007,9 +1153,14 @@ const App = () => {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rule-engine"
|
path="rule-engine"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="rule-engine/:resource"
|
||||||
|
element={<RuleEngineLegacyRedirect />}
|
||||||
/>
|
/>
|
||||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
|
||||||
|
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
@@ -1026,9 +1177,7 @@ const App = () => {
|
|||||||
/** Redirect removed milestones page to document clearance. */
|
/** Redirect removed milestones page to document clearance. */
|
||||||
function BookingMilestonesRedirect() {
|
function BookingMilestonesRedirect() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
|
||||||
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -88,7 +89,13 @@ export function ProfileChips({
|
|||||||
}) {
|
}) {
|
||||||
if (!profiles.length) {
|
if (!profiles.length) {
|
||||||
return (
|
return (
|
||||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
No profiles
|
No profiles
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -118,7 +125,13 @@ export function ProfileChips({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{extra > 0 ? (
|
{extra > 0 ? (
|
||||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
+{extra}
|
+{extra}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
|||||||
CANCELLED: "red",
|
CANCELLED: "red",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
export function BookingStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: CustomerBookingStatus;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||||
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
|||||||
refunded: "grape",
|
refunded: "grape",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
export function PaymentStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: CustomerPaymentStatus;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||||
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
ISSUED: "cyan",
|
||||||
|
PENDING: "yellow",
|
||||||
|
PARTIALLY_PAID: "orange",
|
||||||
|
PAID: "edr-green",
|
||||||
|
OVERDUE: "red",
|
||||||
|
CANCELLED: "gray",
|
||||||
|
REFUNDED: "grape",
|
||||||
|
EXPIRED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: Freight.InvoiceStatus;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline approval action buttons for a profile row.
|
* Inline approval action buttons for a profile row.
|
||||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||||
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
|
|||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const act = (next: ProfileStatus) =>
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||||
mutate({ profileId, status: next });
|
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
|
|||||||
@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
|
|||||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||||
|
|
||||||
const setField = (name: string, value: unknown) => {
|
const setField = (name: string, value: unknown) => {
|
||||||
setValues((current) => ({ ...current, [name]: value }));
|
setValues((current) => {
|
||||||
|
const next = { ...current, [name]: value };
|
||||||
|
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||||
|
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||||
|
// allowed set instead of submitting a stale, rejected unit.
|
||||||
|
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
||||||
|
next.rateUnit = "";
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (event: React.FormEvent) => {
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
|
|||||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||||
|
|
||||||
if (field.type === "select") {
|
if (field.type === "select") {
|
||||||
|
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||||
|
// the choices track the other fields the admin has picked.
|
||||||
|
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
key={field.name}
|
key={field.name}
|
||||||
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
|
|||||||
value={resolveSelectValue(field, values)}
|
value={resolveSelectValue(field, values)}
|
||||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading}
|
||||||
data={(field.options ?? [])
|
data={options
|
||||||
.filter((opt) => opt.value !== "")
|
.filter((opt) => opt.value !== "")
|
||||||
.map((opt) => ({
|
.map((opt) => ({
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
|
|||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import type { CompanyListFilter } from "@/types/customer";
|
import type { CompanyListFilter } from "@/types/customer";
|
||||||
|
import type { InvoiceListFilter } from "@/types/invoice";
|
||||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
|
|||||||
ROOT: ["file-upload-settings"] as const,
|
ROOT: ["file-upload-settings"] as const,
|
||||||
list: () => ["file-upload-settings", "list"] as const,
|
list: () => ["file-upload-settings", "list"] as const,
|
||||||
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
|
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
|
||||||
byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
|
byCode: (code: string) =>
|
||||||
|
["file-upload-settings", "by-code", code] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
DROPDOWN_SETTINGS: {
|
DROPDOWN_SETTINGS: {
|
||||||
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
|
|||||||
["customers", "list", filter ?? {}] as const,
|
["customers", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["customers", "detail", id] as const,
|
byId: (id: string) => ["customers", "detail", id] as const,
|
||||||
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
||||||
documents: (id: string) => ["customers", "detail", id, "documents"] as const,
|
documents: (id: string) =>
|
||||||
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
INVOICES: {
|
||||||
|
ROOT: ["invoices"] as const,
|
||||||
|
list: (filter?: InvoiceListFilter) =>
|
||||||
|
["invoices", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
ROOT: ["bookings"] as const,
|
ROOT: ["bookings"] as const,
|
||||||
list: (filter?: BookingListFilter) =>
|
list: (filter?: BookingListFilter) =>
|
||||||
@@ -80,7 +90,12 @@ export const QUERY_KEYS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
ROOT: ["train-scheduling"] as const,
|
ROOT: ["train-scheduling"] as const,
|
||||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
[
|
||||||
|
"train-scheduling",
|
||||||
|
"eligible-bookings",
|
||||||
|
freightType ?? "CONTAINER",
|
||||||
|
filters ?? {},
|
||||||
|
] as const,
|
||||||
locomotives: (routeId?: string) =>
|
locomotives: (routeId?: string) =>
|
||||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||||
stations: () => ["train-scheduling", "stations"] as const,
|
stations: () => ["train-scheduling", "stations"] as const,
|
||||||
@@ -100,31 +115,37 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
FLEET: {
|
FLEET: {
|
||||||
ROOT: ["fleet"] as const,
|
ROOT: ["fleet"] as const,
|
||||||
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
list: (resource: FleetResourceSlug | string) =>
|
||||||
|
["fleet", "list", resource] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
VEHICLES: {
|
VEHICLES: {
|
||||||
ROOT: ["vehicles"] as const,
|
ROOT: ["vehicles"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["vehicles", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["vehicles", "detail", id] as const,
|
byId: (id: string) => ["vehicles", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
ROOT: ["first-mile"] as const,
|
ROOT: ["first-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["first-mile", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["first-mile", "detail", id] as const,
|
byId: (id: string) => ["first-mile", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
LAST_MILE: {
|
LAST_MILE: {
|
||||||
ROOT: ["last-mile"] as const,
|
ROOT: ["last-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["last-mile", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
list: (
|
||||||
["rule-engine", "list", resource, params ?? {}] as const,
|
resource: RuleEngineResourceSlug | string,
|
||||||
|
params?: RuleEngineListParams,
|
||||||
|
) => ["rule-engine", "list", resource, params ?? {}] as const,
|
||||||
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
|
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
|
||||||
["rule-engine", "detail", resource, id] as const,
|
["rule-engine", "detail", resource, id] as const,
|
||||||
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
||||||
@@ -138,27 +159,39 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
ROOT: ["overview"] as const,
|
ROOT: ["overview"] as const,
|
||||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
dashboard: (range?: string) =>
|
||||||
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
["overview", "dashboard", range ?? "30d"] as const,
|
||||||
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
|
bookingsTab: (range?: string) =>
|
||||||
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
["overview", "bookings", range ?? "30d"] as const,
|
||||||
|
contractsTab: (range?: string) =>
|
||||||
|
["overview", "contracts", range ?? "30d"] as const,
|
||||||
|
billingTab: (range?: string) =>
|
||||||
|
["overview", "billing", range ?? "30d"] as const,
|
||||||
operationsTab: () => ["overview", "operations"] as const,
|
operationsTab: () => ["overview", "operations"] as const,
|
||||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
customersTab: (range?: string) =>
|
||||||
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
["overview", "customers", range ?? "30d"] as const,
|
||||||
|
staffTab: (range?: string) =>
|
||||||
|
["overview", "staff", range ?? "30d"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FUEL: {
|
FUEL: {
|
||||||
ROOT: ["fuel"] as const,
|
ROOT: ["fuel"] as const,
|
||||||
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
|
purchases: (vehicleId?: string) =>
|
||||||
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
|
["fuel", "purchases", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) =>
|
||||||
|
["fuel", "stats", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
MAINTENANCE: {
|
MAINTENANCE: {
|
||||||
ROOT: ["maintenance"] as const,
|
ROOT: ["maintenance"] as const,
|
||||||
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
|
schedules: (vehicleId?: string) =>
|
||||||
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||||
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
|
upcoming: (vehicleId?: string) =>
|
||||||
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
|
["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
||||||
|
history: (vehicleId?: string) =>
|
||||||
|
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) =>
|
||||||
|
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FINANCIAL_REPORTS: {
|
FINANCIAL_REPORTS: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/users",
|
BASE: "/users",
|
||||||
BY_ID: (id: string | number) => `/users/${id}`,
|
BY_ID: (id: string | number) => `/users/${id}`,
|
||||||
SET_PASSWORD: "/api/auth/set-password",
|
SET_PASSWORD: "/api/auth/set-password",
|
||||||
ME: "/api/auth/me"
|
ME: "/api/auth/me",
|
||||||
},
|
},
|
||||||
|
|
||||||
ROLES: {
|
ROLES: {
|
||||||
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
|
|||||||
STATS: "/companies/stats",
|
STATS: "/companies/stats",
|
||||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||||
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
|
PROFILE_STATUS: (profileId: string) =>
|
||||||
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
|
`/companies/company-profiles/${profileId}/status`,
|
||||||
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
|
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
`/bookings/by-company/${id}/customer-view`,
|
||||||
|
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
`/payments/by-company/${id}/customer-view`,
|
||||||
|
},
|
||||||
|
|
||||||
|
BILLING: {
|
||||||
|
INVOICES: "/billing/invoices",
|
||||||
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||||
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
||||||
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
|
CLEARANCE_DECLARATION: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/declaration`,
|
||||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||||
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
|
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||||
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
|
`/bookings/${id}/clearance/transit-permit`,
|
||||||
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
|
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||||
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
|
`/bookings/${id}/clearance/delivery-order`,
|
||||||
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
|
CLEARANCE_RELEASE_ORDER: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/release-order`,
|
||||||
|
CLEARANCE_RO_AMENDMENT: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/ro-amendment`,
|
||||||
|
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/export-release`,
|
||||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||||
},
|
},
|
||||||
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||||
`/contracts/${id}/clearance/output-documents`,
|
`/contracts/${id}/clearance/output-documents`,
|
||||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
CLEARANCE_DECLARATION: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/declaration`,
|
||||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||||
@@ -247,7 +263,7 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ROUTES: {
|
ROUTES: {
|
||||||
BASE: '/routes',
|
BASE: "/routes",
|
||||||
BY_ID: (id: string) => `/routes/${id}`,
|
BY_ID: (id: string) => `/routes/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -261,12 +277,15 @@ export const URL_CONSTANTS = {
|
|||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
`/train-scheduling/batch-board/${scheduleId}`,
|
`/train-scheduling/batch-board/${scheduleId}`,
|
||||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||||
|
RUN_ALLOCATION: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/run-allocation`,
|
||||||
DOC_REVIEW_COMPLETE: (id: string) =>
|
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
BOOKING_WINDOW: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/booking-window`,
|
||||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||||
EXPIRE_BOOKING: (bookingId: string) =>
|
EXPIRE_BOOKING: (bookingId: string) =>
|
||||||
@@ -275,12 +294,14 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||||
PREVIEW: "/train-scheduling/preview",
|
PREVIEW: "/train-scheduling/preview",
|
||||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
ASSIGN_BOOKINGS: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/assign-bookings`,
|
||||||
CONTAINER: {
|
CONTAINER: {
|
||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
|
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
|
||||||
PREVIEW: "/train-scheduling/container/preview",
|
PREVIEW: "/train-scheduling/container/preview",
|
||||||
SCHEDULES: "/train-scheduling/container/schedules",
|
SCHEDULES: "/train-scheduling/container/schedules",
|
||||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
SCHEDULE_BY_ID: (id: string) =>
|
||||||
|
`/train-scheduling/container/schedules/${id}`,
|
||||||
ASSIGN_BOOKINGS: (id: string) =>
|
ASSIGN_BOOKINGS: (id: string) =>
|
||||||
`/train-scheduling/container/schedules/${id}/assign-bookings`,
|
`/train-scheduling/container/schedules/${id}/assign-bookings`,
|
||||||
CANCEL_SCHEDULE: (id: string) =>
|
CANCEL_SCHEDULE: (id: string) =>
|
||||||
@@ -323,15 +344,18 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
CHECKPOINTS: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||||
RESCHEDULE_PREVIEW: (id: string) =>
|
RESCHEDULE_PREVIEW: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||||
RESCHEDULE_EXECUTE: (id: string) =>
|
RESCHEDULE_EXECUTE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/reschedule/execute`,
|
`/train-scheduling/schedules/${id}/reschedule/execute`,
|
||||||
MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
|
MAINTENANCE: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/maintenance`,
|
||||||
SCHEDULES: "/train-scheduling/container/schedules",
|
SCHEDULES: "/train-scheduling/container/schedules",
|
||||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
SCHEDULE_BY_ID: (id: string) =>
|
||||||
|
`/train-scheduling/container/schedules/${id}`,
|
||||||
CANCEL_SCHEDULE: (id: string) =>
|
CANCEL_SCHEDULE: (id: string) =>
|
||||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||||
@@ -379,175 +403,193 @@ export const URL_CONSTANTS = {
|
|||||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||||
},
|
},
|
||||||
RATE_MATRIX: {
|
RATE_MATRIX: {
|
||||||
BASE: '/api/rate-matrices',
|
BASE: "/api/rate-matrices",
|
||||||
DRAFT: '/api/rate-matrices/draft',
|
DRAFT: "/api/rate-matrices/draft",
|
||||||
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
|
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
|
||||||
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
|
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
|
||||||
LIST: '/api/rate-matrices',
|
LIST: "/api/rate-matrices",
|
||||||
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
|
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
|
||||||
},
|
},
|
||||||
REFERENCE: {
|
REFERENCE: {
|
||||||
PORTS: '/api/reference/ports',
|
PORTS: "/api/reference/ports",
|
||||||
CITIES: '/api/reference/cities',
|
CITIES: "/api/reference/cities",
|
||||||
CONTAINER_TYPES: '/api/reference/container-types',
|
CONTAINER_TYPES: "/api/reference/container-types",
|
||||||
CURRENCIES: '/api/reference/currencies',
|
CURRENCIES: "/api/reference/currencies",
|
||||||
},
|
},
|
||||||
|
|
||||||
FACILITIES: {
|
FACILITIES: {
|
||||||
BASE: '/facilities',
|
BASE: "/facilities",
|
||||||
BY_ID: (id: string) => `/facilities/${id}`,
|
BY_ID: (id: string) => `/facilities/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSES: {
|
WAREHOUSES: {
|
||||||
BASE: '/warehouses',
|
BASE: "/warehouses",
|
||||||
DASHBOARD: '/warehouses/dashboard',
|
DASHBOARD: "/warehouses/dashboard",
|
||||||
BY_ID: (id: string) => `/warehouses/${id}`,
|
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||||
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_YARDS: {
|
WAREHOUSE_YARDS: {
|
||||||
BASE: '/warehouse-yards',
|
BASE: "/warehouse-yards",
|
||||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_ZONES: {
|
WAREHOUSE_ZONES: {
|
||||||
BASE: '/warehouse-zones',
|
BASE: "/warehouse-zones",
|
||||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVENTORY: {
|
WAREHOUSE_INVENTORY: {
|
||||||
BASE: '/warehouse-inventory',
|
BASE: "/warehouse-inventory",
|
||||||
RECEIVE: '/warehouse-inventory/receive',
|
RECEIVE: "/warehouse-inventory/receive",
|
||||||
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
|
DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
|
||||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
|
||||||
INQUIRY: '/warehouse-inventory/inquiry',
|
INQUIRY: "/warehouse-inventory/inquiry",
|
||||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||||
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||||
RESERVE: '/warehouse-inventory/reserve',
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
UNLOAD_BOOKING: (bookingId: string) =>
|
||||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
`/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
INSPECTION_REPORTS: (inventoryId: string) =>
|
||||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
`/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||||
|
LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons",
|
||||||
|
BOOKING_SCHEDULE: (bookingId: string) =>
|
||||||
|
`/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||||
// Import branch
|
// Import branch
|
||||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
MARK_READY_PICKUP: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/ready-for-pickup`,
|
||||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
RELEASE_DOCUMENT: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/release-document`,
|
||||||
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
HANDOVER_DOCUMENT: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/handover-document`,
|
||||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||||
// Receive (Import/Export bulk)
|
// Receive (Import/Export bulk)
|
||||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||||
direction
|
direction
|
||||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||||
: `/warehouse-inventory/eligible-bookings`,
|
: `/warehouse-inventory/eligible-bookings`,
|
||||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
||||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||||
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
||||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
|
||||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
LOADED_EXPORT: "/warehouse-inventory/loaded-export",
|
||||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
|
||||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
|
||||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||||
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
IMPORT_AUTO_UNLOAD_ARRIVED:
|
||||||
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
|
"/warehouse-inventory/import/auto-unload-arrived-bookings",
|
||||||
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
|
IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
|
||||||
EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue',
|
IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue",
|
||||||
|
EXPORT_DJIBOUTI_ARRIVAL_QUEUE:
|
||||||
|
"/warehouse-inventory/export/djibouti-arrival-queue",
|
||||||
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
|
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
|
||||||
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
|
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
|
||||||
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti',
|
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI:
|
||||||
|
"/warehouse-inventory/export/auto-unload-at-djibouti",
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_LOADINGS: {
|
WAREHOUSE_LOADINGS: {
|
||||||
BASE: '/warehouse-loadings',
|
BASE: "/warehouse-loadings",
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INSPECTION: {
|
WAREHOUSE_INSPECTION: {
|
||||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
ATTACHMENTS: (id: string) =>
|
||||||
|
`/warehouse-inspection-reports/${id}/attachments`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_RULES: {
|
WAREHOUSE_RULES: {
|
||||||
ALLOCATION: '/warehouse-allocation-rules',
|
ALLOCATION: "/warehouse-allocation-rules",
|
||||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
|
||||||
FEES: '/warehouse-fee-rules',
|
FEES: "/warehouse-fee-rules",
|
||||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
FEE_PREVIEW: (inventoryId: string) =>
|
||||||
|
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
WAREHOUSE_INVOICES: {
|
||||||
BASE: '/warehouse-fee-invoices',
|
BASE: "/warehouse-fee-invoices",
|
||||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||||
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
||||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||||
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
||||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
GENERATE: (inventoryId: string) =>
|
||||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
`/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
FOR_INVENTORY: (inventoryId: string) =>
|
||||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
`/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||||
|
FOR_BOOKING: (bookingId: string) =>
|
||||||
|
`/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||||
|
GATE_CLEARANCE: (inventoryId: string) =>
|
||||||
|
`/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||||
},
|
},
|
||||||
|
|
||||||
INTERCHANGE_DOCUMENTS: {
|
INTERCHANGE_DOCUMENTS: {
|
||||||
BASE: '/interchange-documents',
|
BASE: "/interchange-documents",
|
||||||
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
||||||
GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
|
GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule",
|
||||||
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
||||||
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
||||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||||
},
|
},
|
||||||
|
|
||||||
IMPORT_OPERATIONS: {
|
IMPORT_OPERATIONS: {
|
||||||
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
|
||||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||||
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
CUSTOMS_DOCUMENTS: (bookingId: string) =>
|
||||||
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
`/import-operations/customs/${bookingId}/documents`,
|
||||||
|
CUSTOMS_DECLARATION: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/declaration`,
|
||||||
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||||
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||||
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
CUSTOMS_RISK: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/risk`,
|
||||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||||
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||||
`/import-operations/empty-container-returns/${id}/status`,
|
`/import-operations/empty-container-returns/${id}/status`,
|
||||||
},
|
},
|
||||||
|
|
||||||
VEHICLES: {
|
VEHICLES: {
|
||||||
BASE: '/vehicles',
|
BASE: "/vehicles",
|
||||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
BASE: '/first-mile',
|
BASE: "/first-mile",
|
||||||
BY_ID: (id: string) => `/first-mile/${id}`,
|
BY_ID: (id: string) => `/first-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
LAST_MILE: {
|
LAST_MILE: {
|
||||||
BASE: '/last-mile',
|
BASE: "/last-mile",
|
||||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
BASE: '/drivers',
|
BASE: "/drivers",
|
||||||
BY_ID: (id: string) => `/drivers/${id}`,
|
BY_ID: (id: string) => `/drivers/${id}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import { useCallback, useState } from "react";
|
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
// `@/hooks/useFileViewer` imports keep working.
|
||||||
|
export { useFileViewer } from "@edr/ui-common";
|
||||||
/**
|
|
||||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
|
||||||
* from any file row to open the document inline (pdf / image / video / office /
|
|
||||||
* text); render `viewer` once near the page root.
|
|
||||||
*
|
|
||||||
* const { view, viewer } = useFileViewer();
|
|
||||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
|
||||||
* {viewer}
|
|
||||||
*/
|
|
||||||
export function useFileViewer() {
|
|
||||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
|
||||||
|
|
||||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
|
||||||
const close = useCallback(() => setFile(null), []);
|
|
||||||
|
|
||||||
const viewer = (
|
|
||||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return { view, close, viewer };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||||
createBooking: "edr_freight_app:contracts:create_booking",
|
createBooking: "edr_freight_app:contracts:create_booking",
|
||||||
|
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
@@ -46,6 +47,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: "edr_freight_app:fleet:manage",
|
manage: "edr_freight_app:fleet:manage",
|
||||||
},
|
},
|
||||||
admin: "edr_freight_app:admin",
|
admin: "edr_freight_app:admin",
|
||||||
|
allocation: {
|
||||||
|
manage: "edr_freight_app:allocation:manage",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
@@ -69,6 +73,38 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
|||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||||
|
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||||
|
if (!user) return [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const emp of user.employee ?? []) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
if (pos.key) keys.add(pos.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPosition(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
positionKey: string,
|
||||||
|
): boolean {
|
||||||
|
return getPositionKeys(user).includes(positionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POSITION_KEYS = {
|
||||||
|
ethiopianGl: "ethiopian_gl",
|
||||||
|
djiboutiGl: "djibouti_gl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPosition(user, POSITION_KEYS.ethiopianGl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPosition(user, POSITION_KEYS.djiboutiGl);
|
||||||
|
}
|
||||||
|
|
||||||
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
if (user?.isSuperAdmin) return true;
|
if (user?.isSuperAdmin) return true;
|
||||||
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -17,10 +18,13 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
IdCard,
|
IdCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
|
Paperclip,
|
||||||
|
Receipt,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -30,6 +34,7 @@ import {
|
|||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
@@ -50,7 +55,13 @@ import type {
|
|||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
} from "@/types/customer";
|
} from "@/types/customer";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import type { Invoice } from "@/types/invoice";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
useFileViewer,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
|||||||
export default function CustomerDetailPage() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: company, isLoading } = useQuery(
|
const { data: company, isLoading } = useQuery(
|
||||||
api.customers.getById.queryOptions({
|
api.customers.getById.queryOptions({
|
||||||
@@ -104,9 +116,34 @@ export default function CustomerDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
||||||
|
usePagination({
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const invoiceFilter = useMemo(
|
||||||
|
() => ({
|
||||||
|
companyId: id ?? "",
|
||||||
|
page: invoicePagination.pageIndex + 1,
|
||||||
|
pageSize: invoicePagination.pageSize,
|
||||||
|
}),
|
||||||
|
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
|
||||||
|
);
|
||||||
|
const invoicesQuery = useQuery(
|
||||||
|
api.invoices.list.queryOptions({
|
||||||
|
input: { filter: invoiceFilter },
|
||||||
|
enabled: Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const bookings = bookingsQuery.data ?? [];
|
const bookings = bookingsQuery.data ?? [];
|
||||||
const documents = documentsQuery.data ?? [];
|
const documents = documentsQuery.data ?? [];
|
||||||
const payments = paymentsQuery.data ?? [];
|
const payments = paymentsQuery.data ?? [];
|
||||||
|
const invoices = invoicesQuery.data?.items ?? [];
|
||||||
|
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
||||||
|
const invoicePageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(invoiceTotal / invoicePagination.pageSize),
|
||||||
|
);
|
||||||
|
|
||||||
const totalPaid = useMemo(
|
const totalPaid = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
|
|||||||
header: "",
|
header: "",
|
||||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
component="a"
|
<ActionIcon
|
||||||
href={fileViewUrl(row.original.id, true)}
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
aria-label="View"
|
||||||
aria-label="Download"
|
data-stop-row-click
|
||||||
data-stop-row-click
|
onClick={() =>
|
||||||
>
|
view({
|
||||||
<Download size={16} />
|
name: row.original.name,
|
||||||
</ActionIcon>
|
url: fileViewUrl(row.original.id),
|
||||||
|
mimeType: row.original.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(row.original.id, true)}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download"
|
||||||
|
data-stop-row-click
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||||
@@ -358,6 +412,59 @@ export default function CustomerDetailPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "invoiceNumber",
|
||||||
|
header: "Invoice",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: "Source",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.source)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dueAt",
|
||||||
|
header: "Due",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.dueAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||||
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center mih="60vh">
|
<Center mih="60vh">
|
||||||
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
subtitle={`TIN ${company.tin}${
|
||||||
}`}
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
|
|||||||
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
||||||
Payments
|
Payments
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||||
|
Invoices
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
{/* DOCUMENTS */}
|
{/* DOCUMENTS */}
|
||||||
<Tabs.Panel value="documents" pt="lg">
|
<Tabs.Panel value="documents" pt="lg">
|
||||||
<TableCard minWidth={760}>
|
<Stack gap="lg">
|
||||||
<DataTable
|
<TableCard minWidth={760}>
|
||||||
columns={documentColumns}
|
<DataTable
|
||||||
data={documents}
|
columns={documentColumns}
|
||||||
status={tableStatus(documentsQuery)}
|
data={documents}
|
||||||
emptyMessage="No documents uploaded."
|
status={tableStatus(documentsQuery)}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
emptyMessage="No documents uploaded."
|
||||||
error={
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
documentsQuery.isError
|
error={
|
||||||
? {
|
documentsQuery.isError
|
||||||
message: "Failed to load documents.",
|
? {
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
message: "Failed to load documents.",
|
||||||
}
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
/>
|
}
|
||||||
</TableCard>
|
/>
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
{licenseProfiles.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Business licenses
|
||||||
|
</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{licenseProfiles.map((p) => (
|
||||||
|
<Stack key={p.id} gap={4}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{humanize(p.type)} · {p.reference}
|
||||||
|
</Text>
|
||||||
|
{(p.licenseFiles ?? []).map((f) => (
|
||||||
|
<Group key={f.url} gap={6} wrap="nowrap">
|
||||||
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: f.name,
|
||||||
|
url: f.url,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* PAYMENTS */}
|
{/* PAYMENTS */}
|
||||||
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCard>
|
</TableCard>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* INVOICES */}
|
||||||
|
<Tabs.Panel value="invoices" pt="lg">
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={860}>
|
||||||
|
<DataTable
|
||||||
|
columns={invoiceColumns}
|
||||||
|
data={invoices}
|
||||||
|
status={tableStatus(invoicesQuery)}
|
||||||
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
|
emptyMessage="No invoices for this customer."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
error={
|
||||||
|
invoicesQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load invoices.",
|
||||||
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: invoicePagination.pageIndex,
|
||||||
|
pageSize: invoicePagination.pageSize,
|
||||||
|
pageCount: invoicePageCount,
|
||||||
|
totalCount: invoiceTotal,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination: invoicePagination },
|
||||||
|
onPaginationChange: setInvoicePagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: invoicePageCount,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
|
|
||||||
|
function openPdfBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const opened = window.open(url, "_blank");
|
||||||
|
if (!opened) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.04em" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{value && value.trim() ? value : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
const { data: invoice, isLoading } = useQuery(
|
||||||
|
api.invoices.getById.queryOptions({
|
||||||
|
input: { id: id ?? "" },
|
||||||
|
enabled: Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadDocument = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await invoicesService.downloadDocument(id);
|
||||||
|
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih="60vh">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<Container size="sm" py="xl">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Text fw={700}>Invoice not found</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/dashboard/invoices")}
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Invoices", href: "/dashboard/invoices" },
|
||||||
|
{ label: invoice.invoiceNumber },
|
||||||
|
]}
|
||||||
|
backTo="/dashboard/invoices"
|
||||||
|
title={invoice.invoiceNumber}
|
||||||
|
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
||||||
|
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Download invoice"
|
||||||
|
loading={downloading}
|
||||||
|
onClick={() => void downloadDocument()}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||||
|
<InfoField label="Billed to" value={invoice.company?.name} />
|
||||||
|
<InfoField
|
||||||
|
label="Profile"
|
||||||
|
value={invoice.companyProfile?.reference}
|
||||||
|
/>
|
||||||
|
<InfoField label="Type" value={humanize(invoice.type)} />
|
||||||
|
<InfoField label="Currency" value={invoice.currency} />
|
||||||
|
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||||
|
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||||
|
<InfoField
|
||||||
|
label="Total"
|
||||||
|
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Balance"
|
||||||
|
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Line items
|
||||||
|
</Text>
|
||||||
|
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Charge type</Table.Th>
|
||||||
|
<Table.Th ta="right">Quantity</Table.Th>
|
||||||
|
<Table.Th ta="right">Unit rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(invoice.lines ?? []).map((line) => (
|
||||||
|
<Table.Tr key={line.id}>
|
||||||
|
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(line.chargeType)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.unitRate, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.amount, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(invoice.lines ?? []).length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
No line items.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap="xl"
|
||||||
|
pt="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Paid
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, Search, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { Invoice } from "@/types/invoice";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
export default function InvoicesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
search: debouncedQuery,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
}),
|
||||||
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||||
|
api.invoices.list.queryOptions({ input: { filter } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "invoiceNumber",
|
||||||
|
header: "Invoice",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "billedTo",
|
||||||
|
header: "Billed to",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{row.original.company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: "Source",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.source)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "balance",
|
||||||
|
header: "Balance",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatMoney(row.original.balanceAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dueAt",
|
||||||
|
header: "Due",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.dueAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Invoices"
|
||||||
|
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Refresh"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card p={0}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by invoice number…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={statusFilter || "all"}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(
|
||||||
|
v === "all" ? "" : (v as Freight.InvoiceStatus),
|
||||||
|
);
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Pending", value: "PENDING" },
|
||||||
|
{ label: "Paid", value: "PAID" },
|
||||||
|
{ label: "Overdue", value: "OVERDUE" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={920}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
|
emptyMessage={
|
||||||
|
debouncedQuery
|
||||||
|
? "No invoices match your search."
|
||||||
|
: "No invoices yet."
|
||||||
|
}
|
||||||
|
error={
|
||||||
|
isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load invoices.",
|
||||||
|
onRetry: () => void refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@ export interface FormFieldDef {
|
|||||||
* showWhen and not match hideWhen.
|
* showWhen and not match hideWhen.
|
||||||
*/
|
*/
|
||||||
showWhen?: { field: string; equals: string[] };
|
showWhen?: { field: string; equals: string[] };
|
||||||
|
/**
|
||||||
|
* Select options computed from other fields' current values. When set, the
|
||||||
|
* form resolves the option list at render time from the live form state
|
||||||
|
* instead of the static `options` list. Used for the rate unit selector,
|
||||||
|
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
|
||||||
|
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
|
||||||
|
*/
|
||||||
|
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleEngineOrderConfig {
|
export interface RuleEngineOrderConfig {
|
||||||
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
|
|||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
(v) => ({
|
|
||||||
label: v.replace(/_/g, " "),
|
/**
|
||||||
value: v,
|
* Valid weighting units for a rate shape — mirrors the API's
|
||||||
}),
|
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
|
||||||
);
|
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||||
|
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||||
|
*/
|
||||||
|
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||||
|
if (appliesTo === "OTHER") {
|
||||||
|
switch (trigger) {
|
||||||
|
case "OVERWEIGHT":
|
||||||
|
return ["PER_TON"];
|
||||||
|
case "REEFER":
|
||||||
|
case "HAZARDOUS":
|
||||||
|
case "DEMURRAGE":
|
||||||
|
return ["PER_CONTAINER", "PER_TON"];
|
||||||
|
case "CANCELLATION":
|
||||||
|
return ["FLAT", "PER_INVOICE"];
|
||||||
|
case "CONSOLIDATION":
|
||||||
|
case "SHIPPING_LINE":
|
||||||
|
case "PIL_EXTRA_FEE":
|
||||||
|
return ["PER_CONTAINER", "FLAT"];
|
||||||
|
default:
|
||||||
|
return ["FLAT", "PER_TON", "PER_CONTAINER"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (appliesTo) {
|
||||||
|
case "CONTAINER":
|
||||||
|
return ["PER_CONTAINER", "PER_WAGON"];
|
||||||
|
case "BULK":
|
||||||
|
return ["PER_TON", "PER_WAGON"];
|
||||||
|
case "INTERCITY":
|
||||||
|
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||||
|
case "FIRST_MILE":
|
||||||
|
case "LAST_MILE":
|
||||||
|
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||||
|
default:
|
||||||
|
return ["FLAT"];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||||
|
const appliesTo = String(values.appliesTo ?? "");
|
||||||
|
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||||
|
if (!appliesTo) return [];
|
||||||
|
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
||||||
|
};
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
{ label: "USD", value: "USD" },
|
{ label: "USD", value: "USD" },
|
||||||
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
},
|
},
|
||||||
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||||
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
|
||||||
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
|
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{
|
{
|
||||||
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
options: TRADE_DIRECTIONS,
|
options: TRADE_DIRECTIONS,
|
||||||
},
|
},
|
||||||
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
|
||||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{
|
{
|
||||||
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||||
},
|
},
|
||||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
// is always per excess ton, so the unit field is hidden for it — the API
|
||||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
// forces PER_TON regardless.
|
||||||
|
{
|
||||||
|
name: "rateUnit",
|
||||||
|
label: "Rate unit",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
optionsFromValues: rateUnitOptions,
|
||||||
|
description: "Weighting basis — options depend on what the rate applies to.",
|
||||||
|
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowLeftRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
|||||||
Gov
|
Gov
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{b.consolidationPartnerRef ? (
|
||||||
|
<Tooltip
|
||||||
|
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||||||
|
withArrow
|
||||||
|
multiline
|
||||||
|
maw={260}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ArrowLeftRight size={10} />}
|
||||||
|
style={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
shared wagon · {b.consolidationPartnerRef}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
|
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
|
||||||
|
const [form, setForm] = useState<
|
||||||
|
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
|
||||||
|
>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Sum of all wagon lengths must not exceed this"
|
description="Sum of all wagon lengths must not exceed this"
|
||||||
value={form.maxTrainLengthMeters ?? ""}
|
value={form.maxTrainLengthMeters ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
|
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Total container and bulk cargo weight must not exceed this"
|
description="Total container and bulk cargo weight must not exceed this"
|
||||||
value={form.maxTrainWeightTons ?? ""}
|
value={form.maxTrainWeightTons ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
|
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
label="Max wagons per train"
|
label="Max wagons per train"
|
||||||
value={form.maxWagonsPerTrain ?? ""}
|
value={form.maxWagonsPerTrain ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
|
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
max20ftContainerWeightTons: Number(value),
|
max20ftContainerWeightTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
min={0.001}
|
min={0.001}
|
||||||
@@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
max20ftPairWeightDiffTons: Number(value),
|
max20ftPairWeightDiffTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="The single booking day opens this many days before departure"
|
description="The single booking day opens this many days before departure"
|
||||||
value={form.importWindowLeadDays ?? ""}
|
value={form.importWindowLeadDays ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -139,7 +142,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||||
value={form.exportBookingLeadHours ?? ""}
|
value={form.exportBookingLeadHours ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -149,7 +152,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||||
value={form.windowOpenHour ?? ""}
|
value={form.windowOpenHour ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
max={23}
|
max={23}
|
||||||
@@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
label="Window duration (hours)"
|
label="Window duration (hours)"
|
||||||
value={form.windowDurationHours ?? ""}
|
value={form.windowDurationHours ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||||
}
|
}
|
||||||
min={0.25}
|
min={0.25}
|
||||||
max={12}
|
max={12}
|
||||||
@@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Max staff time to accept booking documents after the window closes"
|
description="Max staff time to accept booking documents after the window closes"
|
||||||
value={form.docReviewMinutes ?? ""}
|
value={form.docReviewMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Time a selected customer has to pay before the slot expires"
|
description="Time a selected customer has to pay before the slot expires"
|
||||||
value={form.paymentWindowMinutes ?? ""}
|
value={form.paymentWindowMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -191,7 +194,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||||
value={form.reopenDelayMinutes ?? ""}
|
value={form.reopenDelayMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import type {
|
|||||||
UpdateFileUploadFieldDto,
|
UpdateFileUploadFieldDto,
|
||||||
UpdateFileUploadSettingDto,
|
UpdateFileUploadSettingDto,
|
||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceListFilter,
|
||||||
|
PaginatedInvoices,
|
||||||
|
} from "@/types/invoice";
|
||||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
import {
|
import {
|
||||||
RuleEngineListResult,
|
RuleEngineListResult,
|
||||||
@@ -130,6 +135,7 @@ import {
|
|||||||
import { containerTypesService } from "./container-types.service";
|
import { containerTypesService } from "./container-types.service";
|
||||||
import { containerService, type Container } from "./containerService";
|
import { containerService, type Container } from "./containerService";
|
||||||
import { customersService } from "./customers.service";
|
import { customersService } from "./customers.service";
|
||||||
|
import { invoicesService } from "./invoices.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
import {
|
import {
|
||||||
@@ -199,7 +205,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
|||||||
export const api = {
|
export const api = {
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
// ── Queries ────────────────────────────────────────────────────────────
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>(
|
scheduleList: endpoint<
|
||||||
|
{ freightType?: FreightType },
|
||||||
|
TrainScheduleListItem[]
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"schedules",
|
"schedules",
|
||||||
({ freightType }) => trainSchedulingService.listSchedules(freightType),
|
({ freightType }) => trainSchedulingService.listSchedules(freightType),
|
||||||
@@ -213,11 +222,16 @@ export const api = {
|
|||||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||||
),
|
),
|
||||||
|
|
||||||
batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>(
|
batchBoardDetail: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
BatchBoardScheduleDetail
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"batch-board-detail",
|
"batch-board-detail",
|
||||||
({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId),
|
({ scheduleId }) =>
|
||||||
({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
trainSchedulingService.getBatchBoardDetail(scheduleId),
|
||||||
|
({ scheduleId }) =>
|
||||||
|
QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||||
),
|
),
|
||||||
|
|
||||||
scheduleDetail: endpoint<
|
scheduleDetail: endpoint<
|
||||||
@@ -365,7 +379,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────
|
||||||
runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>(
|
runAllocation: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
WagonAllocationAttemptResult
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"run-allocation",
|
"run-allocation",
|
||||||
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
|
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
|
||||||
@@ -483,7 +500,10 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>(
|
pinWagons: endpoint<
|
||||||
|
{ id: string; payload: PinWagonsPayload },
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"pin-wagons",
|
"pin-wagons",
|
||||||
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
|
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
|
||||||
@@ -579,8 +599,10 @@ export const api = {
|
|||||||
({ id }) => warehouseService.getById(id).then((r) => r.data),
|
({ id }) => warehouseService.getById(id).then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
dashboard: endpoint<void, WarehouseDashboard>("warehouses", "dashboard", () =>
|
dashboard: endpoint<void, WarehouseDashboard>(
|
||||||
warehouseService.dashboard().then((r) => r.data),
|
"warehouses",
|
||||||
|
"dashboard",
|
||||||
|
() => warehouseService.dashboard().then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<SaveWarehousePayload, Warehouse>(
|
create: endpoint<SaveWarehousePayload, Warehouse>(
|
||||||
@@ -671,10 +693,8 @@ export const api = {
|
|||||||
listInventory: endpoint<
|
listInventory: endpoint<
|
||||||
{ filter?: InventoryFilter },
|
{ filter?: InventoryFilter },
|
||||||
WarehouseInventoryItem[]
|
WarehouseInventoryItem[]
|
||||||
>(
|
>("warehouse-inventory", "list", ({ filter }) =>
|
||||||
"warehouse-inventory",
|
warehouseService.listInventory(filter).then((r) => r.data),
|
||||||
"list",
|
|
||||||
({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
inquiry: endpoint<
|
inquiry: endpoint<
|
||||||
@@ -687,11 +707,19 @@ export const api = {
|
|||||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||||
),
|
),
|
||||||
|
|
||||||
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
eligibleBookings: endpoint<
|
||||||
|
{ direction?: "IMPORT" | "EXPORT" } | void,
|
||||||
|
EligibleBooking[]
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"eligible-bookings",
|
"eligible-bookings",
|
||||||
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
(input) =>
|
||||||
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||||
|
(input) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
"eligible-bookings",
|
||||||
|
input?.direction ?? "ALL",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||||
@@ -727,7 +755,11 @@ export const api = {
|
|||||||
"import-train-items",
|
"import-train-items",
|
||||||
({ scheduleId }) =>
|
({ scheduleId }) =>
|
||||||
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
|
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
|
||||||
({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId],
|
({ scheduleId }) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
"import-train-items",
|
||||||
|
scheduleId,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
|
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
|
||||||
@@ -795,8 +827,11 @@ export const api = {
|
|||||||
"inspection-reports",
|
"inspection-reports",
|
||||||
({ inventoryId }) =>
|
({ inventoryId }) =>
|
||||||
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
|
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
|
||||||
({ inventoryId }) =>
|
({ inventoryId }) => [
|
||||||
["warehouse-inventory", inventoryId, "inspection-reports"],
|
"warehouse-inventory",
|
||||||
|
inventoryId,
|
||||||
|
"inspection-reports",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
allocationRules: endpoint<void, AllocationRule[]>(
|
allocationRules: endpoint<void, AllocationRule[]>(
|
||||||
@@ -813,15 +848,28 @@ export const api = {
|
|||||||
() => ["warehouse-fee-rules"],
|
() => ["warehouse-fee-rules"],
|
||||||
),
|
),
|
||||||
|
|
||||||
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
|
feePreview: endpoint<
|
||||||
|
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
|
||||||
|
FeePreview[]
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"fee-preview",
|
"fee-preview",
|
||||||
({ inventoryId, billingCurrency }) =>
|
({ inventoryId, billingCurrency }) =>
|
||||||
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
|
warehouseService
|
||||||
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
|
.feePreview(inventoryId, billingCurrency)
|
||||||
|
.then((r) => r.data),
|
||||||
|
({ inventoryId, billingCurrency }) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
inventoryId,
|
||||||
|
"fee-preview",
|
||||||
|
billingCurrency ?? "USD",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
|
invoices: endpoint<
|
||||||
|
{ filter?: WarehouseInvoiceFilter },
|
||||||
|
WarehouseFeeInvoice[]
|
||||||
|
>(
|
||||||
"warehouse-fee-invoices",
|
"warehouse-fee-invoices",
|
||||||
"list",
|
"list",
|
||||||
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
|
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||||
@@ -850,7 +898,8 @@ export const api = {
|
|||||||
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
|
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"receive",
|
"receive",
|
||||||
(payload) => warehouseService.receiveInventory(payload).then((r) => r.data),
|
(payload) =>
|
||||||
|
warehouseService.receiveInventory(payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["warehouse-inventory"], ["warehouses"]],
|
() => [["warehouse-inventory"], ["warehouses"]],
|
||||||
),
|
),
|
||||||
@@ -885,7 +934,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"load",
|
"load",
|
||||||
({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data),
|
({ id, payload }) =>
|
||||||
|
warehouseService.load(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -904,7 +954,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"move",
|
"move",
|
||||||
({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data),
|
({ id, payload }) =>
|
||||||
|
warehouseService.move(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -960,7 +1011,8 @@ export const api = {
|
|||||||
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"bulk-mark-inspected",
|
"bulk-mark-inspected",
|
||||||
(payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
(payload) =>
|
||||||
|
warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -978,7 +1030,12 @@ export const api = {
|
|||||||
{
|
{
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
assignments?: {
|
||||||
|
bookingId: string;
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
}[];
|
||||||
},
|
},
|
||||||
AutoUnloadArrivedResult
|
AutoUnloadArrivedResult
|
||||||
>(
|
>(
|
||||||
@@ -1050,11 +1107,11 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── Allocation + fee rules ─────────────────────────────────────────────
|
// ── Allocation + fee rules ─────────────────────────────────────────────
|
||||||
previewAllocation: endpoint<AllocationCriteria, AllocationPreviewResult | null>(
|
previewAllocation: endpoint<
|
||||||
"warehouse-allocation-rules",
|
AllocationCriteria,
|
||||||
"preview",
|
AllocationPreviewResult | null
|
||||||
(criteria) =>
|
>("warehouse-allocation-rules", "preview", (criteria) =>
|
||||||
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
|
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
|
||||||
@@ -1116,7 +1173,11 @@ export const api = {
|
|||||||
|
|
||||||
// ── Invoices ───────────────────────────────────────────────────────────
|
// ── Invoices ───────────────────────────────────────────────────────────
|
||||||
generateInvoice: endpoint<
|
generateInvoice: endpoint<
|
||||||
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
|
{
|
||||||
|
inventoryId: string;
|
||||||
|
confirmZero?: boolean;
|
||||||
|
billingCurrency?: "ETB" | "USD";
|
||||||
|
},
|
||||||
WarehouseFeeInvoice
|
WarehouseFeeInvoice
|
||||||
>(
|
>(
|
||||||
"warehouse-fee-invoices",
|
"warehouse-fee-invoices",
|
||||||
@@ -1172,7 +1233,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
|
list: endpoint<
|
||||||
|
{ status?: import("./routes.service").RouteStatus } | void,
|
||||||
|
RouteRecord[]
|
||||||
|
>(
|
||||||
"routes",
|
"routes",
|
||||||
"list",
|
"list",
|
||||||
(input) =>
|
(input) =>
|
||||||
@@ -1197,7 +1261,10 @@ export const api = {
|
|||||||
() => [["routes"]],
|
() => [["routes"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
update: endpoint<{ id: string; data: Partial<SaveRoutePayload> }, RouteRecord>(
|
update: endpoint<
|
||||||
|
{ id: string; data: Partial<SaveRoutePayload> },
|
||||||
|
RouteRecord
|
||||||
|
>(
|
||||||
"routes",
|
"routes",
|
||||||
"update",
|
"update",
|
||||||
({ id, data }) => routesService.update(id, data).then((r) => r.data),
|
({ id, data }) => routesService.update(id, data).then((r) => r.data),
|
||||||
@@ -1310,10 +1377,8 @@ export const api = {
|
|||||||
({ trainId }) => ["wagons", "train", trainId],
|
({ trainId }) => ["wagons", "train", trainId],
|
||||||
),
|
),
|
||||||
|
|
||||||
getById: endpoint<{ id: string }, Wagon>(
|
getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
|
||||||
"wagons",
|
wagonService.getById(id).then((r) => r.data),
|
||||||
"getById",
|
|
||||||
({ id }) => wagonService.getById(id).then((r) => r.data),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
assignToTrain: endpoint<
|
assignToTrain: endpoint<
|
||||||
@@ -1692,7 +1757,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"file-upload-settings",
|
"file-upload-settings",
|
||||||
"addField",
|
"addField",
|
||||||
({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto),
|
({ settingId, dto }) =>
|
||||||
|
fileUploadSettingsService.addField(settingId, dto),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["file-upload-settings"]],
|
() => [["file-upload-settings"]],
|
||||||
),
|
),
|
||||||
@@ -1791,7 +1857,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"dropdown-settings",
|
"dropdown-settings",
|
||||||
"updateOption",
|
"updateOption",
|
||||||
({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto),
|
({ optionId, dto }) =>
|
||||||
|
dropdownSettingsService.updateOption(optionId, dto),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["dropdown-settings"]],
|
() => [["dropdown-settings"]],
|
||||||
),
|
),
|
||||||
@@ -1844,11 +1911,10 @@ export const api = {
|
|||||||
ruleEngineService.update(resource, id, payload),
|
ruleEngineService.update(resource, id, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
remove: endpoint<
|
remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
|
||||||
{ resource: RuleEngineResourceSlug; id: string },
|
"rule-engine",
|
||||||
void
|
"remove",
|
||||||
>("rule-engine", "remove", ({ resource, id }) =>
|
({ resource, id }) => ruleEngineService.remove(resource, id),
|
||||||
ruleEngineService.remove(resource, id),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
||||||
@@ -1871,14 +1937,21 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
reorder: endpoint<
|
reorder: endpoint<
|
||||||
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
|
{
|
||||||
|
resource: RuleEngineResourceSlug;
|
||||||
|
payload: { ids: string[]; requiresDirectorApproval?: boolean };
|
||||||
|
},
|
||||||
void
|
void
|
||||||
>("rule-engine", "reorder", ({ resource, payload }) =>
|
>("rule-engine", "reorder", ({ resource, payload }) =>
|
||||||
ruleEngineService.reorder(resource, payload),
|
ruleEngineService.reorder(resource, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
moveOrder: endpoint<
|
moveOrder: endpoint<
|
||||||
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
|
{
|
||||||
|
resource: RuleEngineResourceSlug;
|
||||||
|
id: string;
|
||||||
|
direction: "up" | "down";
|
||||||
|
},
|
||||||
void
|
void
|
||||||
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
||||||
ruleEngineService.moveOrder(resource, id, direction),
|
ruleEngineService.moveOrder(resource, id, direction),
|
||||||
@@ -1900,10 +1973,8 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
remove: endpoint<{ id: string }, void>(
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
"bookings",
|
bookingsService.remove(id),
|
||||||
"remove",
|
|
||||||
({ id }) => bookingsService.remove(id),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
||||||
@@ -1953,10 +2024,11 @@ export const api = {
|
|||||||
({ id }) => bookingsService.generateContract(id),
|
({ id }) => bookingsService.generateContract(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
|
getContractView: endpoint<
|
||||||
"bookings",
|
{ id: string },
|
||||||
"getContractView",
|
import("./bookings.service").ContractView
|
||||||
({ id }) => bookingsService.getContractView(id),
|
>("bookings", "getContractView", ({ id }) =>
|
||||||
|
bookingsService.getContractView(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
signContract: endpoint<
|
signContract: endpoint<
|
||||||
@@ -2040,7 +2112,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"customers",
|
"customers",
|
||||||
"setProfileStatus",
|
"setProfileStatus",
|
||||||
({ profileId, status }) => customersService.setProfileStatus(profileId, status),
|
({ profileId, status }) =>
|
||||||
|
customersService.setProfileStatus(profileId, status),
|
||||||
undefined,
|
undefined,
|
||||||
(_input, data) => [
|
(_input, data) => [
|
||||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
@@ -2061,6 +2134,22 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invoices: {
|
||||||
|
list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>(
|
||||||
|
"invoices",
|
||||||
|
"list",
|
||||||
|
({ filter }) => invoicesService.list(filter),
|
||||||
|
({ filter }) => QUERY_KEYS.INVOICES.list(filter),
|
||||||
|
),
|
||||||
|
|
||||||
|
getById: endpoint<{ id: string }, Invoice>(
|
||||||
|
"invoices",
|
||||||
|
"getById",
|
||||||
|
({ id }) => invoicesService.getById(id),
|
||||||
|
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
||||||
"overview",
|
"overview",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { api as apiClient } from "@/auth/http";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceListFilter,
|
||||||
|
PaginatedInvoices,
|
||||||
|
} from "@/types/invoice";
|
||||||
|
|
||||||
|
const cleanParams = (params: object) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(params).filter(
|
||||||
|
([, value]) => value !== undefined && value !== "" && value !== null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invoicesService = {
|
||||||
|
list(filter: InvoiceListFilter): Promise<PaginatedInvoices> {
|
||||||
|
return apiClient
|
||||||
|
.get<PaginatedInvoices>(URL_CONSTANTS.BILLING.INVOICES, {
|
||||||
|
params: cleanParams(filter),
|
||||||
|
})
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id: string): Promise<Invoice> {
|
||||||
|
return apiClient
|
||||||
|
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadDocument(id: string) {
|
||||||
|
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,8 +15,6 @@ export interface Rate {
|
|||||||
proposedByStaffId: string;
|
proposedByStaffId: string;
|
||||||
approvedByCeoId: string | null;
|
approvedByCeoId: string | null;
|
||||||
approvedAt: string | null;
|
approvedAt: string | null;
|
||||||
effectiveFrom: string;
|
|
||||||
effectiveTo: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export type ProfileType =
|
|||||||
/** Mirrors backend `ProfileStatus`. */
|
/** Mirrors backend `ProfileStatus`. */
|
||||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||||
|
|
||||||
|
/** A business-license document uploaded for a company profile. */
|
||||||
|
export interface LicenseFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single role a company is registered for, with its reference code. */
|
/** A single role a company is registered for, with its reference code. */
|
||||||
export interface CompanyProfile {
|
export interface CompanyProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +47,10 @@ export interface CompanyProfile {
|
|||||||
type: ProfileType;
|
type: ProfileType;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: ProfileStatus;
|
status: ProfileStatus;
|
||||||
|
/** @deprecated Superseded by licenseFiles (file model). */
|
||||||
businessLicense?: string | null;
|
businessLicense?: string | null;
|
||||||
|
/** Business-license documents uploaded for this profile. */
|
||||||
|
licenseFiles?: LicenseFile[];
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
|
||||||
|
export interface Invoice extends Freight.IInvoice {
|
||||||
|
subtotalAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query parameters for the invoice list. */
|
||||||
|
export interface InvoiceListFilter {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
companyId?: string;
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
||||||
|
export interface PaginatedInvoices {
|
||||||
|
items: Invoice[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@@ -277,6 +277,8 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
@@ -42,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
|
|||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
/** Form steps rendered by CompanyProfileForm. */
|
/** Form steps rendered by CompanyProfileForm. */
|
||||||
type FormStep =
|
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
|
||||||
| "company"
|
|
||||||
| "personnel"
|
|
||||||
| "contact"
|
|
||||||
| "verify"
|
|
||||||
| "poa"
|
|
||||||
| "documents"
|
|
||||||
| "additional";
|
|
||||||
const FORM_STEPS: FormStep[] = [
|
const FORM_STEPS: FormStep[] = [
|
||||||
"company",
|
"company",
|
||||||
"personnel",
|
"personnel",
|
||||||
"contact",
|
"contact",
|
||||||
"verify",
|
|
||||||
"poa",
|
"poa",
|
||||||
"documents",
|
"documents",
|
||||||
"additional",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||||
type WizardStep = "nationality" | "role" | FormStep;
|
type WizardStep = "nationality-role" | FormStep;
|
||||||
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
|
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
|
||||||
|
|
||||||
/** Icon + title + description shown in the global dialog header per step. */
|
/** Icon + title + description shown in the global dialog header per step. */
|
||||||
const STEP_META: Record<
|
const STEP_META: Record<
|
||||||
WizardStep,
|
WizardStep,
|
||||||
{ icon: ReactNode; title: string; description: string }
|
{ icon: ReactNode; title: string; description: string }
|
||||||
> = {
|
> = {
|
||||||
nationality: {
|
"nationality-role": {
|
||||||
icon: <Globe2 size={20} />,
|
icon: <Globe2 size={20} />,
|
||||||
title: "Where is your company registered?",
|
title: "Tell us about your company",
|
||||||
description: "This determines the documents we'll ask you to provide.",
|
description: "This determines the documents we'll ask you to provide.",
|
||||||
},
|
},
|
||||||
role: {
|
|
||||||
icon: <Building2 size={20} />,
|
|
||||||
title: "What does your company do?",
|
|
||||||
description:
|
|
||||||
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
|
|
||||||
},
|
|
||||||
company: {
|
company: {
|
||||||
icon: <Building2 size={20} />,
|
icon: <Building2 size={20} />,
|
||||||
title: "Company Information",
|
title: "Company Information",
|
||||||
@@ -95,11 +78,6 @@ const STEP_META: Record<
|
|||||||
title: "Contact Person",
|
title: "Contact Person",
|
||||||
description: "Who should we reach out to about this account?",
|
description: "Who should we reach out to about this account?",
|
||||||
},
|
},
|
||||||
verify: {
|
|
||||||
icon: <ShieldCheck size={20} />,
|
|
||||||
title: "Verify Contact Person",
|
|
||||||
description: "Confirm the contact phone with a one-time SMS code.",
|
|
||||||
},
|
|
||||||
poa: {
|
poa: {
|
||||||
icon: <FileText size={20} />,
|
icon: <FileText size={20} />,
|
||||||
title: "Power of Attorney",
|
title: "Power of Attorney",
|
||||||
@@ -110,11 +88,6 @@ const STEP_META: Record<
|
|||||||
title: "Upload Documents",
|
title: "Upload Documents",
|
||||||
description: "Provide the required company documents.",
|
description: "Provide the required company documents.",
|
||||||
},
|
},
|
||||||
additional: {
|
|
||||||
icon: <CheckCircle2 size={20} />,
|
|
||||||
title: "Business License",
|
|
||||||
description: "Upload a business license for each operational profile.",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface OnboardingWizardDialogProps {
|
interface OnboardingWizardDialogProps {
|
||||||
@@ -172,12 +145,8 @@ export default function OnboardingWizardDialog({
|
|||||||
|
|
||||||
// Phases: nationality → role → form. If a draft already exists, resume
|
// Phases: nationality → role → form. If a draft already exists, resume
|
||||||
// straight into the form with nationality + roles pre-selected.
|
// straight into the form with nationality + roles pre-selected.
|
||||||
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
|
const [phase, setPhase] = useState<"nationality-role" | "form">(
|
||||||
companyAlreadyStarted
|
companyAlreadyStarted ? "form" : "nationality-role",
|
||||||
? hasOperationalProfiles
|
|
||||||
? "form"
|
|
||||||
: "role"
|
|
||||||
: "nationality",
|
|
||||||
);
|
);
|
||||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||||
savedNationality,
|
savedNationality,
|
||||||
@@ -302,16 +271,12 @@ export default function OnboardingWizardDialog({
|
|||||||
setNationality(savedNationality);
|
setNationality(savedNationality);
|
||||||
// Resume into the form only when profiles exist; otherwise send the user to
|
// Resume into the form only when profiles exist; otherwise send the user to
|
||||||
// role selection so the missing operational profiles get created.
|
// role selection so the missing operational profiles get created.
|
||||||
setPhase(hasOperationalProfiles ? "form" : "role");
|
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [companyAlreadyStarted, resumeFormStep]);
|
}, [companyAlreadyStarted, resumeFormStep]);
|
||||||
|
|
||||||
const handleNationalityContinue = useCallback(() => {
|
|
||||||
if (nationality) setPhase("role");
|
|
||||||
}, [nationality]);
|
|
||||||
|
|
||||||
const handleRolesContinue = useCallback(() => {
|
const handleRolesContinue = useCallback(() => {
|
||||||
setStartError(null);
|
setStartError(null);
|
||||||
startMutation.mutate({
|
startMutation.mutate({
|
||||||
@@ -394,6 +359,7 @@ 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
|
||||||
@@ -425,7 +391,7 @@ export default function OnboardingWizardDialog({
|
|||||||
);
|
);
|
||||||
const effectiveResumeStep: FormStep =
|
const effectiveResumeStep: FormStep =
|
||||||
requiredDocsMissing &&
|
requiredDocsMissing &&
|
||||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||||
? "documents"
|
? "documents"
|
||||||
: resumeFormStep;
|
: resumeFormStep;
|
||||||
|
|
||||||
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
|
|||||||
<OnboardingCompletePanel onClose={handleClose} />
|
<OnboardingCompletePanel onClose={handleClose} />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
{phase === "nationality" ? (
|
{phase === "nationality-role" ? (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<Text fw={600} size="lg" c="edr-text">
|
||||||
|
Where is your company registered?
|
||||||
|
</Text>
|
||||||
<NationalitySelect
|
<NationalitySelect
|
||||||
value={nationality}
|
value={nationality}
|
||||||
onChange={setNationality}
|
onChange={setNationality}
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end" pt="xs">
|
<Text fw={600} size="lg" c="edr-text">
|
||||||
<Button
|
What does your company do?(multiple)
|
||||||
color="edr-green"
|
</Text>
|
||||||
onClick={handleNationalityContinue}
|
|
||||||
disabled={!nationality}
|
|
||||||
rightSection={<ArrowRight size={16} />}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
) : phase === "role" ? (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<OnboardingRoleSelect
|
<OnboardingRoleSelect
|
||||||
value={roles}
|
value={roles}
|
||||||
onChange={setRoles}
|
onChange={setRoles}
|
||||||
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
|
|||||||
{startError}
|
{startError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="flex-end" pt="xs">
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<ArrowLeft size={16} />}
|
|
||||||
onClick={() => setPhase("nationality")}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={handleRolesContinue}
|
onClick={handleRolesContinue}
|
||||||
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||||
Go to my dashboard
|
Continue to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||||
|
VALIDATE_SHIPMENT: (id: string) =>
|
||||||
|
`/api/contracts/${id}/validate-shipment`,
|
||||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import { useCallback, useState } from "react";
|
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
// `@/hooks/useFileViewer` imports keep working.
|
||||||
|
export { useFileViewer } from "@edr/ui-common";
|
||||||
/**
|
|
||||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
|
||||||
* from any file row to open the document inline (pdf / image / video / office /
|
|
||||||
* text); render `viewer` once near the page root.
|
|
||||||
*
|
|
||||||
* const { view, viewer } = useFileViewer();
|
|
||||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
|
||||||
* {viewer}
|
|
||||||
*/
|
|
||||||
export function useFileViewer() {
|
|
||||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
|
||||||
|
|
||||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
|
||||||
const close = useCallback(() => setFile(null), []);
|
|
||||||
|
|
||||||
const viewer = (
|
|
||||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return { view, close, viewer };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
PinInput,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,15 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
CheckCircle2,
|
|
||||||
RotateCw,
|
|
||||||
Smartphone,
|
|
||||||
UserCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
@@ -35,7 +26,6 @@ import RoleLicenseStep, {
|
|||||||
type RoleLicenseProfile,
|
type RoleLicenseProfile,
|
||||||
} from "@/components/onboarding/RoleLicenseStep";
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||||
import { extractApiError } from "@/utils/result";
|
|
||||||
import {
|
import {
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
@@ -44,8 +34,6 @@ import {
|
|||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
import {
|
import {
|
||||||
buildPayload,
|
buildPayload,
|
||||||
maskPhone,
|
|
||||||
samePhone,
|
|
||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
@@ -295,6 +283,7 @@ export default function CompanyProfileForm({
|
|||||||
const useOwnerAsManager = () => {
|
const useOwnerAsManager = () => {
|
||||||
if (!etradeOwner) return;
|
if (!etradeOwner) return;
|
||||||
setValue("generalManagerName", etradeOwner.name);
|
setValue("generalManagerName", etradeOwner.name);
|
||||||
|
setValue("generalManagerEmail", user.email);
|
||||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
@@ -350,85 +339,6 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
|
||||||
// The phone we verify is the contact-person phone, normalised to E.164 so it
|
|
||||||
// matches what the backend persists as `contactVerifiedPhone`.
|
|
||||||
const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? "");
|
|
||||||
// Source of truth for "already verified" comes from the onboarding/profile
|
|
||||||
// info (rehydrate) — so a refresh resumes the verify step's "done" state.
|
|
||||||
const [verifiedPhone, setVerifiedPhone] = useState<string | null>(
|
|
||||||
rehydrate?.contactVerifiedPhone ?? null,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (rehydrate?.contactVerifiedPhone) {
|
|
||||||
setVerifiedPhone(rehydrate.contactVerifiedPhone);
|
|
||||||
}
|
|
||||||
}, [rehydrate?.contactVerifiedPhone]);
|
|
||||||
const phoneVerified = samePhone(verifiedPhone, contactPhoneE164);
|
|
||||||
|
|
||||||
const [otpSent, setOtpSent] = useState(false);
|
|
||||||
const [otpCode, setOtpCode] = useState("");
|
|
||||||
const [sendingOtp, setSendingOtp] = useState(false);
|
|
||||||
const [verifyingOtp, setVerifyingOtp] = useState(false);
|
|
||||||
const [otpError, setOtpError] = useState<string | null>(null);
|
|
||||||
const [resendIn, setResendIn] = useState(0);
|
|
||||||
|
|
||||||
// Resend cooldown countdown (no Date.now needed — pure setTimeout ticks).
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendIn <= 0) return;
|
|
||||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [resendIn]);
|
|
||||||
|
|
||||||
// A changed contact phone invalidates any in-flight code entry (the previous
|
|
||||||
// code was for a different number). Verified state is handled separately via
|
|
||||||
// the phone comparison, so this only resets the send/enter UI.
|
|
||||||
useEffect(() => {
|
|
||||||
setOtpSent(false);
|
|
||||||
setOtpCode("");
|
|
||||||
setOtpError(null);
|
|
||||||
}, [contactPhoneE164]);
|
|
||||||
|
|
||||||
const sendContactOtp = async () => {
|
|
||||||
setOtpError(null);
|
|
||||||
if (!contactPhoneE164) {
|
|
||||||
setOtpError("Enter a valid contact phone number first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSendingOtp(true);
|
|
||||||
try {
|
|
||||||
await api.auth.sendOTP.call({ phone: contactPhoneE164 });
|
|
||||||
setOtpSent(true);
|
|
||||||
setOtpCode("");
|
|
||||||
setResendIn(60);
|
|
||||||
} catch (err) {
|
|
||||||
setOtpError(extractApiError(err).message);
|
|
||||||
} finally {
|
|
||||||
setSendingOtp(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyContactOtp = async () => {
|
|
||||||
setOtpError(null);
|
|
||||||
if (otpCode.length !== 6) {
|
|
||||||
setOtpError("Enter the 6-digit code we sent you.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVerifyingOtp(true);
|
|
||||||
try {
|
|
||||||
await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode });
|
|
||||||
setVerifiedPhone(contactPhoneE164);
|
|
||||||
setOtpSent(false);
|
|
||||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
|
||||||
// (best-effort — the OTP itself already succeeded server-side).
|
|
||||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
|
||||||
} catch (err) {
|
|
||||||
setOtpError(extractApiError(err).message);
|
|
||||||
} finally {
|
|
||||||
setVerifyingOtp(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||||
|
|
||||||
// The registration/license details come straight from the eTrade lookup and
|
// The registration/license details come straight from the eTrade lookup and
|
||||||
@@ -451,10 +361,8 @@ export default function CompanyProfileForm({
|
|||||||
"company",
|
"company",
|
||||||
"personnel",
|
"personnel",
|
||||||
"contact",
|
"contact",
|
||||||
"verify",
|
|
||||||
"poa",
|
"poa",
|
||||||
"documents",
|
"documents",
|
||||||
"additional",
|
|
||||||
];
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
@@ -485,30 +393,6 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
userNavigatedRef.current = true;
|
userNavigatedRef.current = true;
|
||||||
if (step === "additional") {
|
|
||||||
if (!licenseComplete) {
|
|
||||||
setSaveError(
|
|
||||||
"Please upload a business license for each of your operational profiles.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Contact-phone verification gates advancing past the verify step. The
|
|
||||||
// verified phone is already persisted (on verify success), so there's
|
|
||||||
// nothing extra to save here.
|
|
||||||
if (step === "verify") {
|
|
||||||
if (!phoneVerified) {
|
|
||||||
setSaveError(
|
|
||||||
"Please verify the contact person's phone number to continue.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveError(null);
|
|
||||||
setStep(stepOrder[currentIdx + 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The documents step auto-uploads whatever the user selected as they
|
// The documents step auto-uploads whatever the user selected as they
|
||||||
// continue (partial uploads are allowed — required-doc completeness is
|
// continue (partial uploads are allowed — required-doc completeness is
|
||||||
// re-checked on resume). A failed upload holds them on the step.
|
// re-checked on resume). A failed upload holds them on the step.
|
||||||
@@ -525,8 +409,15 @@ export default function CompanyProfileForm({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!licenseComplete) {
|
||||||
|
setSaveError(
|
||||||
|
"Please upload a business license for each of your operational profiles.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setStep(stepOrder[currentIdx + 1]);
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Field steps validate + save before advancing.
|
// Field steps validate + save before advancing.
|
||||||
@@ -551,10 +442,7 @@ export default function CompanyProfileForm({
|
|||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<>
|
<Stack gap="sm">
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
|
||||||
Enter your TIN to auto-fill company information from eTrade
|
|
||||||
</Text>
|
|
||||||
<ETradeInfo
|
<ETradeInfo
|
||||||
tin={watch("tinNumber")}
|
tin={watch("tinNumber")}
|
||||||
register={register("tinNumber")}
|
register={register("tinNumber")}
|
||||||
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
|
|||||||
{...register("houseNo")}
|
{...register("houseNo")}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "personnel" && (
|
{step === "personnel" && (
|
||||||
@@ -783,107 +671,6 @@ export default function CompanyProfileForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "verify" && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text size="sm" c="edr-muted">
|
|
||||||
We'll text a one-time code to the contact person's phone to
|
|
||||||
confirm it's reachable. This is required before you continue.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{!contactPhoneE164 ? (
|
|
||||||
<Alert
|
|
||||||
color="yellow"
|
|
||||||
variant="light"
|
|
||||||
icon={<AlertCircle size={18} />}
|
|
||||||
>
|
|
||||||
Add a valid contact phone number on the previous step first.
|
|
||||||
</Alert>
|
|
||||||
) : phoneVerified ? (
|
|
||||||
<Alert
|
|
||||||
color="edr-green"
|
|
||||||
variant="light"
|
|
||||||
icon={<CheckCircle2 size={18} />}
|
|
||||||
title="Phone verified"
|
|
||||||
>
|
|
||||||
{maskPhone(contactPhoneE164)} has been verified.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="xs" align="center">
|
|
||||||
<Smartphone
|
|
||||||
size={16}
|
|
||||||
className="text-[var(--mantine-color-edr-muted)]"
|
|
||||||
/>
|
|
||||||
<Text size="sm" c="edr-text">
|
|
||||||
{maskPhone(contactPhoneE164)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!otpSent ? (
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
variant="light"
|
|
||||||
onClick={sendContactOtp}
|
|
||||||
loading={sendingOtp}
|
|
||||||
leftSection={<Smartphone size={16} />}
|
|
||||||
style={{ alignSelf: "flex-start" }}
|
|
||||||
>
|
|
||||||
Send code via SMS
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
oneTimeCode
|
|
||||||
value={otpCode}
|
|
||||||
placeholder="0"
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onChange={setOtpCode}
|
|
||||||
/>
|
|
||||||
<Group gap="sm">
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
onClick={verifyContactOtp}
|
|
||||||
loading={verifyingOtp}
|
|
||||||
disabled={otpCode.length !== 6}
|
|
||||||
>
|
|
||||||
Verify
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="edr-green"
|
|
||||||
onClick={sendContactOtp}
|
|
||||||
loading={sendingOtp}
|
|
||||||
disabled={resendIn > 0 || sendingOtp}
|
|
||||||
leftSection={<RotateCw size={14} />}
|
|
||||||
>
|
|
||||||
{resendIn > 0
|
|
||||||
? `Resend in ${resendIn}s`
|
|
||||||
: "Resend code"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{otpError && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
icon={<AlertCircle size={18} />}
|
|
||||||
>
|
|
||||||
{otpError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
|
|||||||
onChange={setDocumentFiles}
|
onChange={setDocumentFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "additional" && (
|
<RoleLicenseStep
|
||||||
<RoleLicenseStep
|
profiles={roleProfiles ?? []}
|
||||||
profiles={roleProfiles ?? []}
|
value={licenseFiles ?? {}}
|
||||||
value={licenseFiles ?? {}}
|
onChange={onLicenseChange ?? (() => { })}
|
||||||
onChange={onLicenseChange ?? (() => { })}
|
/>
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
|
|||||||
color="red"
|
color="red"
|
||||||
variant="light"
|
variant="light"
|
||||||
icon={<AlertCircle size={18} />}
|
icon={<AlertCircle size={18} />}
|
||||||
title={
|
title={"Couldn't save this step"}
|
||||||
step === "additional"
|
|
||||||
? "Business license required"
|
|
||||||
: "Couldn't save this step"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{saveError}
|
{saveError}
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
|
|||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
leftSection={<ArrowLeft size={16} />}
|
leftSection={<ArrowLeft size={16} />}
|
||||||
>
|
>
|
||||||
{step === "additional" ? "Back to Documents" : "Back"}
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@@ -1009,17 +790,14 @@ export default function CompanyProfileForm({
|
|||||||
disabled={
|
disabled={
|
||||||
isPending ||
|
isPending ||
|
||||||
saving ||
|
saving ||
|
||||||
(step === "documents" && !hasDocuments && loadingDocuments) ||
|
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||||
(step === "verify" && !phoneVerified)
|
|
||||||
}
|
}
|
||||||
loading={isPending || saving}
|
loading={isPending || saving}
|
||||||
rightSection={
|
rightSection={
|
||||||
!isPending && !saving && step !== "additional" ? (
|
!isPending && !saving ? <ArrowRight size={16} /> : undefined
|
||||||
<ArrowRight size={16} />
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{step === "additional" ? "Submit for review" : "Continue"}
|
{step === "documents" ? "Submit for review" : "Continue"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
import {
|
||||||
import { Controller, useForm } from "react-hook-form";
|
Alert,
|
||||||
|
Button,
|
||||||
|
PasswordInput,
|
||||||
|
PinInput,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
Mail,
|
||||||
|
RotateCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Smartphone,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { 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 RPNInput from "react-phone-number-input";
|
|
||||||
import "react-phone-number-input/style.css";
|
|
||||||
|
|
||||||
import { userType } from "@/enums/userType";
|
import { userType } from "@/enums/userType";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
import { isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import "@/components/phone-field.css";
|
import { api } from "@/services/api";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/edr-logo.png";
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
@@ -50,16 +70,46 @@ const userSchema = z
|
|||||||
|
|
||||||
type FormData = z.infer<typeof userSchema>;
|
type FormData = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
const errorText = (msg?: string) =>
|
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
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);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
// phone number before the account is actually created. The account is only
|
||||||
|
// created after the code is verified — the OTP is a hard requirement.
|
||||||
|
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||||
|
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||||
|
// Which contact method the code was sent to — chosen on the form, locked in
|
||||||
|
// once the challenge is sent.
|
||||||
|
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||||
|
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
|
const [resendIn, setResendIn] = useState(0);
|
||||||
|
|
||||||
|
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendIn <= 0) return;
|
||||||
|
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [resendIn]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -80,226 +130,329 @@ export default function SignupPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const passwordValue = watch("password") ?? "";
|
||||||
|
|
||||||
|
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
||||||
|
// move to the OTP challenge.
|
||||||
|
const requestOtp = async (data: FormData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
|
await api.auth.sendOTP.call(
|
||||||
|
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||||
|
);
|
||||||
|
setPendingData(data);
|
||||||
|
setOtpChannel(channel);
|
||||||
|
setOtpCode("");
|
||||||
|
setOtpError(null);
|
||||||
|
setResendIn(60);
|
||||||
|
setStage("otp");
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOtp = async () => {
|
||||||
|
if (!pendingData) return;
|
||||||
|
setOtpError(null);
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.auth.sendOTP.call(
|
||||||
|
otpChannel === "email"
|
||||||
|
? { email: pendingData.email }
|
||||||
|
: { phone: pendingData.phone },
|
||||||
|
);
|
||||||
|
setOtpCode("");
|
||||||
|
setResendIn(60);
|
||||||
|
} catch (err) {
|
||||||
|
setOtpError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2 — verify the code, then (only on success) create the account.
|
||||||
|
const confirmOtp = async () => {
|
||||||
|
if (!pendingData) return;
|
||||||
|
setOtpError(null);
|
||||||
|
if (otpCode.trim().length !== 6) {
|
||||||
|
setOtpError("Enter the 6-digit code we sent you.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
await api.auth.verifyOTP.call({
|
||||||
|
...(otpChannel === "email"
|
||||||
|
? { email: pendingData.email }
|
||||||
|
: { phone: pendingData.phone }),
|
||||||
|
otp: otpCode.trim(),
|
||||||
|
});
|
||||||
const payload: SignupPayload = {
|
const payload: SignupPayload = {
|
||||||
email: data.email,
|
email: pendingData.email,
|
||||||
username: data.email,
|
username: pendingData.email,
|
||||||
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
||||||
phoneNumber: data.phone,
|
phoneNumber: pendingData.phone,
|
||||||
userType: data.userType,
|
userType: pendingData.userType,
|
||||||
name: {
|
name: {
|
||||||
en: `${data.firstName.en} ${data.lastName.en}`,
|
en: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||||
am: `${data.firstName.en} ${data.lastName.en}`,
|
am: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||||
},
|
},
|
||||||
password: data.password,
|
password: pendingData.password,
|
||||||
confirmPassword: data.confirmPassword,
|
confirmPassword: pendingData.confirmPassword,
|
||||||
};
|
};
|
||||||
const result = await signup(payload);
|
const result = await signup(payload);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate("/portal");
|
navigate("/portal");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error.message);
|
setOtpError(result.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("An unexpected error occurred");
|
setOtpError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setVerifying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const passwordValue = watch("password") ?? "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Smart Freight Operations"
|
tagline="Smart Freight Operations"
|
||||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||||
>
|
>
|
||||||
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
<div className="flex w-full flex-col">
|
||||||
<div className="mb-4 flex justify-center sm:mb-6">
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
{stage === "form" ? (
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||||
Create account
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
</h1>
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
Create account
|
||||||
Register to access EDR Freight services.
|
</h1>
|
||||||
</p>
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
</div>
|
Register to access EDR Freight services.
|
||||||
|
</p>
|
||||||
<div className="flex w-full flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
|
||||||
First name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
placeholder="John"
|
|
||||||
disabled={loading}
|
|
||||||
className={fieldClass}
|
|
||||||
{...register("firstName.en")}
|
|
||||||
/>
|
|
||||||
{errorText(errors.firstName?.en?.message)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<Stack gap="md">
|
||||||
Last name <span className="text-red-500">*</span>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
</label>
|
<TextInput
|
||||||
<input
|
label="First name"
|
||||||
placeholder="Doe"
|
placeholder="John"
|
||||||
disabled={loading}
|
required
|
||||||
className={fieldClass}
|
disabled={sending}
|
||||||
{...register("lastName.en")}
|
error={errors.firstName?.en?.message}
|
||||||
|
{...register("firstName.en")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Last name"
|
||||||
|
placeholder="Doe"
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
error={errors.lastName?.en?.message}
|
||||||
|
{...register("lastName.en")}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
{errorText(errors.lastName?.en?.message)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<ControlledPhoneField
|
||||||
<label className="text-sm font-medium text-gray-800">
|
control={control}
|
||||||
Email <span className="text-red-500">*</span>
|
name="phone"
|
||||||
</label>
|
label="Phone"
|
||||||
<input
|
required
|
||||||
type="email"
|
disabled={sending}
|
||||||
placeholder="john@example.com"
|
|
||||||
disabled={loading}
|
|
||||||
className={fieldClass}
|
|
||||||
{...register("email")}
|
|
||||||
/>
|
|
||||||
{errorText(errors.email?.message)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
|
||||||
Phone <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="phone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div
|
|
||||||
className={`edr-phone-wrapper${
|
|
||||||
errors.phone ? " edr-phone-wrapper--error" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<RPNInput
|
|
||||||
international
|
|
||||||
defaultCountry="ET"
|
|
||||||
countryCallingCodeEditable={false}
|
|
||||||
addInternationalOption
|
|
||||||
id="signup-phone"
|
|
||||||
placeholder="912 345 678"
|
|
||||||
disabled={loading}
|
|
||||||
value={field.value || undefined}
|
|
||||||
onChange={(v) => field.onChange(v ?? "")}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{errorText(errors.phone?.message)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
|
||||||
Password <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="Create a strong password"
|
|
||||||
disabled={loading}
|
|
||||||
className={`${fieldClass} pr-11`}
|
|
||||||
{...register("password")}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="space-y-1.5">
|
||||||
onClick={() => setShowPassword((current) => !current)}
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
Send verification code via
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
</Text>
|
||||||
>
|
<SegmentedControl
|
||||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
fullWidth
|
||||||
</button>
|
disabled={sending}
|
||||||
</div>
|
value={channel}
|
||||||
{errorText(errors.password?.message)}
|
onChange={(v) => setChannel(v as OtpChannel)}
|
||||||
{passwordValue.length > 0 ? (
|
data={[
|
||||||
<div className="mt-2 space-y-1">
|
{
|
||||||
{passwordRequirements.map((req) => {
|
value: "phone",
|
||||||
const met = req.test(passwordValue);
|
label: (
|
||||||
return (
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<div key={req.label} className="flex items-center gap-2">
|
<Smartphone size={14} /> Phone
|
||||||
<span
|
</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"
|
},
|
||||||
}`}
|
{
|
||||||
>
|
value: "email",
|
||||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
label: (
|
||||||
</span>
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
<Mail size={14} /> Email
|
||||||
{req.label}
|
</span>
|
||||||
</span>
|
),
|
||||||
</div>
|
},
|
||||||
);
|
]}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<PasswordInput
|
||||||
Confirm password <span className="text-red-500">*</span>
|
label="Password"
|
||||||
</label>
|
placeholder="Create a strong password"
|
||||||
<div className="relative">
|
required
|
||||||
<input
|
disabled={sending}
|
||||||
type={showConfirm ? "text" : "password"}
|
error={errors.password?.message}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{passwordValue.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{passwordRequirements.map((req) => {
|
||||||
|
const met = req.test(passwordValue);
|
||||||
|
return (
|
||||||
|
<div key={req.label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||||
|
{req.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm password"
|
||||||
placeholder="Re-enter your password"
|
placeholder="Re-enter your password"
|
||||||
disabled={loading}
|
required
|
||||||
className={`${fieldClass} pr-11`}
|
disabled={sending}
|
||||||
|
error={errors.confirmPassword?.message}
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
{error ? (
|
||||||
onClick={() => setShowConfirm((current) => !current)}
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
{error}
|
||||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
loading={sending}
|
||||||
|
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||||
>
|
>
|
||||||
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
Continue
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className="mb-1 flex justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{errorText(errors.confirmPassword?.message)}
|
<div className="space-y-1.5 text-center">
|
||||||
</div>
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||||
{error ? (
|
</h1>
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
{error}
|
We sent a 6-digit code to{" "}
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{otpChannel === "email"
|
||||||
|
? maskEmail(pendingData?.email ?? "")
|
||||||
|
: maskPhone(pendingData?.phone ?? "")}
|
||||||
|
</span>
|
||||||
|
. Enter it to finish creating your account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
{otpError ? (
|
||||||
type="submit"
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
disabled={loading}
|
{otpError}
|
||||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
</Alert>
|
||||||
>
|
) : null}
|
||||||
{loading ? "Creating account..." : "Create Account"}
|
|
||||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
<Stack gap={6} align="center">
|
||||||
Already have an account?{" "}
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
<button
|
Verification code
|
||||||
type="button"
|
</Text>
|
||||||
onClick={() => navigate("/login")}
|
<PinInput
|
||||||
className="font-semibold text-primary hover:underline"
|
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}
|
||||||
>
|
>
|
||||||
Sign In
|
Verify & create account
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
</form>
|
<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>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export type CompanyStep =
|
|||||||
| "company"
|
| "company"
|
||||||
| "personnel"
|
| "personnel"
|
||||||
| "contact"
|
| "contact"
|
||||||
| "verify"
|
|
||||||
| "poa"
|
| "poa"
|
||||||
| "documents"
|
| "documents"
|
||||||
| "additional";
|
| "additional";
|
||||||
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
verify: [],
|
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function InvoiceDetailPage() {
|
|||||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
||||||
"TELEBIRR",
|
"TELEBIRR",
|
||||||
);
|
);
|
||||||
|
console.log(paymentMethod)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: invoice,
|
data: invoice,
|
||||||
@@ -127,7 +128,7 @@ export default function InvoiceDetailPage() {
|
|||||||
|
|
||||||
const payable = isPayable(invoice.status);
|
const payable = isPayable(invoice.status);
|
||||||
const lines = invoice.lines ?? [];
|
const lines = invoice.lines ?? [];
|
||||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
// const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||||
|
|
||||||
const handlePay = () => {
|
const handlePay = () => {
|
||||||
setPayModalOpen(true);
|
setPayModalOpen(true);
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ export function BookingPaymentPanel({
|
|||||||
: priceTotal(pricing);
|
: priceTotal(pricing);
|
||||||
const items = priceLineItems(pricing);
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
|
// Consolidation: this shipment shares a wagon with a partner booking, and the
|
||||||
|
// wagon is only scheduled once both partners have paid. Surface a note while
|
||||||
|
// payment is still pending (pay-window open, or a deadline set and not paid).
|
||||||
|
const showConsolidationNote =
|
||||||
|
!paid &&
|
||||||
|
Boolean(booking.consolidationPartnerId) &&
|
||||||
|
(booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
|
Boolean(booking.paymentDeadline));
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", booking.id],
|
queryKey: ["booking-invoices", booking.id],
|
||||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||||
@@ -268,6 +277,12 @@ export function BookingPaymentPanel({
|
|||||||
onPay={onPay}
|
onPay={onPay}
|
||||||
paying={paying}
|
paying={paying}
|
||||||
/>
|
/>
|
||||||
|
{showConsolidationNote && (
|
||||||
|
<Text mt={12} fz="12px" c="#9AA8B5" lh={1.5}>
|
||||||
|
This shipment shares a wagon with a consolidation partner — both
|
||||||
|
shipments must be paid for the wagon to be scheduled.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -147,6 +147,31 @@ async function searchPlaces(
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the address label for a picked place.
|
||||||
|
*
|
||||||
|
* For an establishment / POI (e.g. "Bole Medhanialem") Google's
|
||||||
|
* `formatted_address` is the *postal* address, which for many Ethiopian places
|
||||||
|
* collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim
|
||||||
|
* silently replaces the specific place the user picked with a broad city. The
|
||||||
|
* place `name` carries the specific label, so we lead with it and only append
|
||||||
|
* the formatted address for context when it doesn't already contain the name.
|
||||||
|
* Falls back to the prediction's own description (what the user saw and clicked).
|
||||||
|
*/
|
||||||
|
function placeDisplayName(
|
||||||
|
place: google.maps.places.PlaceResult | null,
|
||||||
|
prediction: PlacePrediction,
|
||||||
|
): string {
|
||||||
|
const name = place?.name?.trim();
|
||||||
|
const formatted = place?.formatted_address?.trim();
|
||||||
|
if (name && formatted) {
|
||||||
|
return formatted.toLowerCase().includes(name.toLowerCase())
|
||||||
|
? formatted
|
||||||
|
: `${name}, ${formatted}`;
|
||||||
|
}
|
||||||
|
return name || formatted || prediction.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
||||||
* per selection (closes the Autocomplete session), so billing stays on the
|
* per selection (closes the Autocomplete session), so billing stays on the
|
||||||
@@ -174,10 +199,7 @@ async function resolvePrediction(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
displayName:
|
displayName: placeDisplayName(place, prediction),
|
||||||
place?.formatted_address ||
|
|
||||||
place?.name ||
|
|
||||||
prediction.displayName,
|
|
||||||
lat: loc.lat(),
|
lat: loc.lat(),
|
||||||
lng: loc.lng(),
|
lng: loc.lng(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,17 +11,27 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
PinInput,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
FileSignature,
|
||||||
|
Printer,
|
||||||
|
RotateCw,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const CONSENT_TEXT =
|
const CONSENT_TEXT =
|
||||||
"I have read the entire contract and agree to its terms.";
|
"I have read the entire contract and agree to its terms.";
|
||||||
@@ -34,9 +44,13 @@ export default function ContractViewPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
|
const [otpOpen, setOtpOpen] = useState(false);
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
const [successOpen, setSuccessOpen] = useState(false);
|
const [successOpen, setSuccessOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
@@ -44,6 +58,15 @@ export default function ContractViewPage() {
|
|||||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
|
// The signed-in customer's registered phone — where the sudo-mode OTP is sent.
|
||||||
|
const customerPhone = user?.phoneNumber ?? "";
|
||||||
|
const maskedPhone =
|
||||||
|
customerPhone.length > 4
|
||||||
|
? `${customerPhone.slice(0, 4)}${"*".repeat(
|
||||||
|
Math.max(customerPhone.length - 6, 0),
|
||||||
|
)}${customerPhone.slice(-2)}`
|
||||||
|
: customerPhone;
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["contract-view", id],
|
queryKey: ["contract-view", id],
|
||||||
queryFn: () => contractsService.getContractView(id!),
|
queryFn: () => contractsService.getContractView(id!),
|
||||||
@@ -95,6 +118,18 @@ export default function ContractViewPage() {
|
|||||||
};
|
};
|
||||||
}, [checkScrollBottom]);
|
}, [checkScrollBottom]);
|
||||||
|
|
||||||
|
// Send (or resend) the fresh OTP challenge to the customer's phone. On success
|
||||||
|
// we swap the signature modal for the OTP entry modal.
|
||||||
|
const sendOtpMutation = useMutation({
|
||||||
|
mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSignOpen(false);
|
||||||
|
setOtpError(null);
|
||||||
|
setOtpOpen(true);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to send verification code"),
|
||||||
|
});
|
||||||
|
|
||||||
const signMutation = useMutation({
|
const signMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
contractsService.signContract(id!, {
|
contractsService.signContract(id!, {
|
||||||
@@ -104,16 +139,22 @@ export default function ContractViewPage() {
|
|||||||
: (signatureData as string),
|
: (signatureData as string),
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: CONSENT_TEXT,
|
consentText: CONSENT_TEXT,
|
||||||
|
otp: otpCode.trim(),
|
||||||
|
otpPhone: customerPhone,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSignOpen(false);
|
setOtpOpen(false);
|
||||||
|
setOtpCode("");
|
||||||
setSuccessOpen(true);
|
setSuccessOpen(true);
|
||||||
void refetch();
|
void refetch();
|
||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to sign contract"),
|
onError: (err) =>
|
||||||
|
setOtpError(
|
||||||
|
extractApiError(err).message ?? "Failed to verify code and sign",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
@@ -128,6 +169,17 @@ export default function ContractViewPage() {
|
|||||||
if (!signerName.trim()) return;
|
if (!signerName.trim()) return;
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
if (!customerPhone) {
|
||||||
|
toast.error("No phone number on file to verify your signature.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOtpCode("");
|
||||||
|
sendOtpMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmOtp = () => {
|
||||||
|
if (otpCode.trim().length !== 6) return;
|
||||||
|
setOtpError(null);
|
||||||
signMutation.mutate();
|
signMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,20 +367,114 @@ export default function ContractViewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={signMutation.isPending}
|
loading={sendOtpMutation.isPending}
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
sendOtpMutation.isPending ||
|
||||||
!signerName.trim() ||
|
!signerName.trim() ||
|
||||||
(!usingSaved && !signatureData)
|
(!usingSaved && !signatureData)
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
>
|
>
|
||||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
Continue to verification
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={otpOpen}
|
||||||
|
onClose={() => setOtpOpen(false)}
|
||||||
|
title="Verify it's you"
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={40}
|
||||||
|
h={40}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-edr-green-0)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck
|
||||||
|
size={20}
|
||||||
|
color="var(--mantine-color-edr-green-6)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
For security, enter the 6-digit code we sent by SMS to{" "}
|
||||||
|
<Text span fw={600} c="edr-text">
|
||||||
|
{maskedPhone}
|
||||||
|
</Text>{" "}
|
||||||
|
to confirm and apply your signature.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{otpError && (
|
||||||
|
<Alert color="red" variant="light" radius="md">
|
||||||
|
{otpError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
|
Verification code
|
||||||
|
</Text>
|
||||||
|
<PinInput
|
||||||
|
length={6}
|
||||||
|
type="number"
|
||||||
|
oneTimeCode
|
||||||
|
value={otpCode}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={signMutation.isPending}
|
||||||
|
styles={{ input: { textAlign: "center" } }}
|
||||||
|
onChange={setOtpCode}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="space-between" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<RotateCw size={14} />}
|
||||||
|
loading={sendOtpMutation.isPending}
|
||||||
|
disabled={sendOtpMutation.isPending || signMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setOtpError(null);
|
||||||
|
sendOtpMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</Button>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setOtpOpen(false)}
|
||||||
|
disabled={signMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<FileSignature size={16} />}
|
||||||
|
loading={signMutation.isPending}
|
||||||
|
disabled={signMutation.isPending || otpCode.trim().length !== 6}
|
||||||
|
onClick={confirmOtp}
|
||||||
|
>
|
||||||
|
Verify & sign
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ContractSignSuccessModal
|
<ContractSignSuccessModal
|
||||||
opened={successOpen}
|
opened={successOpen}
|
||||||
reference={data.reference}
|
reference={data.reference}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { OperationDatePicker } from "@edr/ui-common";
|
import { OperationDatePicker } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { ShipmentValidation } from "@/services/contracts.service";
|
||||||
import {
|
import {
|
||||||
SelectField,
|
SelectField,
|
||||||
StepCard,
|
StepCard,
|
||||||
@@ -149,6 +151,8 @@ function NewShipmentBookingForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isContainerContract = contract.freightType === "CONTAINER";
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||||
@@ -161,6 +165,14 @@ function NewShipmentBookingForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-submit validation (container contracts only): warns on overweight
|
||||||
|
// containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
|
||||||
|
// price modal opens so re-reviewing after an edit re-checks.
|
||||||
|
const validateMutation = useMutation({
|
||||||
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
|
api.contracts.validateShipment.call({ id: contractId, dto }),
|
||||||
|
});
|
||||||
|
|
||||||
function buildDto(
|
function buildDto(
|
||||||
values: ShipmentFormValues,
|
values: ShipmentFormValues,
|
||||||
): Freight.CreateBookingUnderContractDto {
|
): Freight.CreateBookingUnderContractDto {
|
||||||
@@ -207,13 +219,22 @@ function NewShipmentBookingForm({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit validates the whole form, then opens the price modal for confirmation.
|
// Submit validates the whole form, then opens the price modal for
|
||||||
|
// confirmation. For container contracts we also run the server-side shipment
|
||||||
|
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
|
||||||
|
// can surface them before the booking is created.
|
||||||
const handleReview = form.handleSubmit((values) => {
|
const handleReview = form.handleSubmit((values) => {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
|
if (isContainerContract) {
|
||||||
|
validateMutation.reset();
|
||||||
|
validateMutation.mutate(buildDto(values));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (!pendingValues) return;
|
if (!pendingValues) return;
|
||||||
|
// Guard: never let a booking with unresolved 20ft pairing errors submit.
|
||||||
|
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
|
||||||
submitMutation.mutate(buildDto(pendingValues));
|
submitMutation.mutate(buildDto(pendingValues));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +242,7 @@ function NewShipmentBookingForm({
|
|||||||
const handleReject = () => {
|
const handleReject = () => {
|
||||||
if (submitMutation.isPending) return;
|
if (submitMutation.isPending) return;
|
||||||
setPendingValues(null);
|
setPendingValues(null);
|
||||||
|
validateMutation.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
@@ -323,6 +345,8 @@ function NewShipmentBookingForm({
|
|||||||
contract={contract}
|
contract={contract}
|
||||||
values={pendingValues}
|
values={pendingValues}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
|
validation={validateMutation.data ?? null}
|
||||||
|
validationLoading={validateMutation.isPending}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
/>
|
/>
|
||||||
@@ -334,20 +358,54 @@ function PriceConfirmModal({
|
|||||||
contract,
|
contract,
|
||||||
values,
|
values,
|
||||||
loading,
|
loading,
|
||||||
|
validation,
|
||||||
|
validationLoading,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onReject,
|
onReject,
|
||||||
}: {
|
}: {
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
values: ShipmentFormValues | null;
|
values: ShipmentFormValues | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
validation: ShipmentValidation | null;
|
||||||
|
validationLoading: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
}) {
|
}) {
|
||||||
const total = useMemo(
|
const baseTotal = useMemo(
|
||||||
() => (values ? computeShipmentTotal(contract, values) : null),
|
() => (values ? computeShipmentTotal(contract, values) : null),
|
||||||
[contract, values],
|
[contract, values],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const overweightLines = validation?.overweightLines ?? [];
|
||||||
|
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
|
||||||
|
const pairingErrors = validation?.pairingErrors ?? [];
|
||||||
|
const hasPairingBlock = pairingErrors.length > 0;
|
||||||
|
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||||
|
|
||||||
|
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
|
||||||
|
// overweight line — that surcharge only exists in the live rule engine. Fold
|
||||||
|
// the real amount from validateShipment into the displayed total so the
|
||||||
|
// customer sees the actual charge the overweight warning refers to, not just
|
||||||
|
// the warning text.
|
||||||
|
const total = useMemo(() => {
|
||||||
|
if (!baseTotal) return null;
|
||||||
|
if (!(overweightSurchargeAmount > 0)) return baseTotal;
|
||||||
|
return {
|
||||||
|
...baseTotal,
|
||||||
|
lines: [
|
||||||
|
...baseTotal.lines,
|
||||||
|
{
|
||||||
|
label: "Overweight surcharge",
|
||||||
|
unitPrice: overweightSurchargeAmount,
|
||||||
|
unit: "flat" as const,
|
||||||
|
quantity: 1,
|
||||||
|
amount: overweightSurchargeAmount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: baseTotal.total + overweightSurchargeAmount,
|
||||||
|
};
|
||||||
|
}, [baseTotal, overweightSurchargeAmount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(values)}
|
opened={Boolean(values)}
|
||||||
@@ -376,6 +434,63 @@ function PriceConfirmModal({
|
|||||||
>
|
>
|
||||||
{total ? (
|
{total ? (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{validationLoading && (
|
||||||
|
<Group gap={8} c="dimmed">
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Checking container weights and wagon pairing…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPairingBlock && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title="Cannot create booking — 20ft wagon pairing"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{pairingErrors.map((msg, i) => (
|
||||||
|
<Text key={i} fz="sm" c="red.8">
|
||||||
|
{msg}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="red.7" mt={2}>
|
||||||
|
Adjust the 20ft container weights or quantities so pairs differ
|
||||||
|
by no more than 10 tons.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overweightLines.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Overweight containers"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{overweightLines.map((line, i) => (
|
||||||
|
<Text key={i} fz="sm" c="#9A5B00">
|
||||||
|
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||||
|
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||||
|
{overweightSurchargeAmount > 0
|
||||||
|
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
|
||||||
|
validation?.currency ?? total?.currency ?? ""
|
||||||
|
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
||||||
|
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{total.lines.map((line, i) => (
|
{total.lines.map((line, i) => (
|
||||||
@@ -442,6 +557,7 @@ function PriceConfirmModal({
|
|||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={confirmDisabled}
|
||||||
>
|
>
|
||||||
Confirm & book
|
Confirm & book
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { companiesService } from "@/services/companies.service";
|
import { companiesService } from "@/services/companies.service";
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
|
// Anchor,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -17,10 +20,19 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Paperclip,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
importer: "Importer",
|
||||||
|
exporter: "Exporter",
|
||||||
|
freight_forwarder: "Freight Forwarder",
|
||||||
|
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||||
|
transporter: "Transporter",
|
||||||
|
};
|
||||||
|
|
||||||
interface TabDocumentsProps {
|
interface TabDocumentsProps {
|
||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
@@ -28,21 +40,63 @@ interface TabDocumentsProps {
|
|||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
|
function documentSettingCode(nationality: string | null | undefined): string {
|
||||||
|
return nationality === "foreign"
|
||||||
|
? "company_onboarding_documents_foreign"
|
||||||
|
: "company_onboarding_documents_ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabDocuments({
|
||||||
|
profile,
|
||||||
|
mode = "edit",
|
||||||
|
onContinue,
|
||||||
|
}: TabDocumentsProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
const { view, viewer } = useFileViewer();
|
||||||
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: "customer_file_documents" },
|
input: { code: documentSettingCode(profile.nationality) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const docsQuery = useQuery(
|
||||||
|
api.companies.documents.queryOptions({
|
||||||
|
input: { companyId: profile.companyId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedKeys = useMemo(
|
||||||
|
() => (docsQuery.data ?? []).map((d) => d.code),
|
||||||
|
[docsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingFilesByKey = useMemo(() => {
|
||||||
|
const map: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
||||||
|
> = {};
|
||||||
|
for (const doc of docsQuery.data ?? []) {
|
||||||
|
(map[doc.code] ??= []).push({
|
||||||
|
name: doc.name,
|
||||||
|
url: fileViewUrl(doc.id),
|
||||||
|
size: doc.size,
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [docsQuery.data]);
|
||||||
|
|
||||||
const docUploadMutation = useMutation({
|
const docUploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
companiesService.uploadDocuments(profile.companyId, files),
|
companiesService.uploadDocuments(profile.companyId, files),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
for (const field of docSettingQuery.data?.fields ?? []) {
|
for (const field of docSettingQuery.data?.fields ?? []) {
|
||||||
const min = getMinFiles(field);
|
const min = getMinFiles(field);
|
||||||
if (min <= 0) continue;
|
if (min <= 0) continue;
|
||||||
|
if (uploadedKeys.includes(field.fileKey)) continue;
|
||||||
const v = documentFiles[field.fileKey];
|
const v = documentFiles[field.fileKey];
|
||||||
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
||||||
if (count < min) {
|
if (count < min) {
|
||||||
@@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
return errs;
|
return errs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const licenseProfiles = profile.companyProfiles.filter(
|
||||||
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<>
|
||||||
<Group gap="sm" mb="xs">
|
<Card padding="lg">
|
||||||
<FileCheck size={20} />
|
<Group gap="sm" mb="xs">
|
||||||
<Title order={3}>Documents</Title>
|
<FileCheck size={20} />
|
||||||
</Group>
|
<Title order={3}>Documents</Title>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
</Group>
|
||||||
Upload and manage required business documents
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
</Text>
|
Upload and manage required business documents
|
||||||
|
|
||||||
{docSettingQuery.isLoading ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader2 size={24} className="animate-spin" />
|
|
||||||
</Center>
|
|
||||||
) : !docSettingQuery.data ? (
|
|
||||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
|
||||||
No document requirements configured for your account.
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
|
||||||
<SmartFileInput
|
|
||||||
file={docSettingQuery.data}
|
|
||||||
value={documentFiles}
|
|
||||||
onChange={handleFilesChange}
|
|
||||||
errors={fieldErrors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{docSettingQuery.data && (
|
{docSettingQuery.isLoading ? (
|
||||||
<Group
|
<Center py="xl">
|
||||||
justify="space-between"
|
<Loader2 size={24} className="animate-spin" />
|
||||||
mt="lg"
|
</Center>
|
||||||
pt="md"
|
) : !docSettingQuery.data ? (
|
||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||||
>
|
No document requirements configured for your account.
|
||||||
<Group gap="xs">
|
</Text>
|
||||||
{docUploadMutation.isSuccess && (
|
) : (
|
||||||
<Group gap={6} c="green">
|
<SmartFileInput
|
||||||
<CheckCircle2 size={16} />
|
file={docSettingQuery.data}
|
||||||
<Text size="sm" fw={500}>
|
value={documentFiles}
|
||||||
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
|
onChange={handleFilesChange}
|
||||||
</Text>
|
errors={fieldErrors}
|
||||||
</Group>
|
uploadedKeys={uploadedKeys}
|
||||||
)}
|
existingFiles={existingFilesByKey}
|
||||||
{docUploadMutation.isError && (
|
onViewFile={view}
|
||||||
<Group gap={6} c="red">
|
/>
|
||||||
<XCircle size={16} />
|
)}
|
||||||
<Text size="sm" fw={500}>Upload failed</Text>
|
|
||||||
</Group>
|
{docSettingQuery.data && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="lg"
|
||||||
|
pt="md"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
{docUploadMutation.isSuccess && (
|
||||||
|
<Group gap={6} c="green">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{mode === "onboarding"
|
||||||
|
? "Saved successfully"
|
||||||
|
: "Documents uploaded successfully"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{docUploadMutation.isError && (
|
||||||
|
<Group gap={6} c="red">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Upload failed
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{mode === "onboarding" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
leftSection={<ArrowRight size={16} />}
|
||||||
|
loading={docUploadMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const validationErrors = validateRequired();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setFieldErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasFiles) {
|
||||||
|
docUploadMutation.mutate(documentFiles, {
|
||||||
|
onSuccess: () => onContinue?.(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onContinue?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
leftSection={<UploadCloud size={16} />}
|
||||||
|
loading={docUploadMutation.isPending}
|
||||||
|
disabled={!hasFiles}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasFiles) return;
|
||||||
|
docUploadMutation.mutate(documentFiles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload Documents
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{mode === "onboarding" ? (
|
)}
|
||||||
<Button
|
</Card>
|
||||||
type="button"
|
|
||||||
leftSection={<ArrowRight size={16} />}
|
{licenseProfiles.length > 0 && (
|
||||||
loading={docUploadMutation.isPending}
|
<Card padding="lg" mt="lg">
|
||||||
onClick={() => {
|
<Group gap="sm" mb="xs">
|
||||||
const validationErrors = validateRequired();
|
<Paperclip size={20} />
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
<Title order={3}>Business licenses</Title>
|
||||||
setFieldErrors(validationErrors);
|
</Group>
|
||||||
return;
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
}
|
License documents uploaded per operational profile
|
||||||
if (hasFiles) {
|
</Text>
|
||||||
docUploadMutation.mutate(documentFiles, {
|
|
||||||
onSuccess: () => onContinue?.(),
|
<Stack gap="md">
|
||||||
});
|
{licenseProfiles.map((p) => (
|
||||||
} else {
|
<Stack key={p.id} gap={4}>
|
||||||
onContinue?.();
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
}
|
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
|
||||||
}}
|
</Text>
|
||||||
>
|
{p.licenseFiles.map((f) => (
|
||||||
Continue
|
<Group key={f.url} gap={6} wrap="nowrap">
|
||||||
</Button>
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
) : (
|
<Text component="button" type="button" size="xs">
|
||||||
<Button
|
{f.name}
|
||||||
type="button"
|
</Text>
|
||||||
leftSection={<UploadCloud size={16} />}
|
</Group>
|
||||||
loading={docUploadMutation.isPending}
|
))}
|
||||||
disabled={!hasFiles}
|
</Stack>
|
||||||
onClick={() => {
|
))}
|
||||||
if (!hasFiles) return;
|
</Stack>
|
||||||
docUploadMutation.mutate(documentFiles);
|
</Card>
|
||||||
}}
|
|
||||||
>
|
|
||||||
Upload Documents
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ContractDocuments,
|
ContractDocuments,
|
||||||
GenerateContractPriceResponse,
|
GenerateContractPriceResponse,
|
||||||
SubmitContractResponse,
|
SubmitContractResponse,
|
||||||
|
ShipmentValidation,
|
||||||
} from "./contracts.service";
|
} from "./contracts.service";
|
||||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
UpdateDropdownSettingDto,
|
UpdateDropdownSettingDto,
|
||||||
} from "@/types/dropdownSettings";
|
} from "@/types/dropdownSettings";
|
||||||
import type {
|
import type {
|
||||||
|
CompanyDocument,
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
@@ -158,7 +160,11 @@ export const api = {
|
|||||||
createCompanyProfile: endpoint<
|
createCompanyProfile: endpoint<
|
||||||
{ type: ProfileTypeValue; businessLicense?: string },
|
{ type: ProfileTypeValue; businessLicense?: string },
|
||||||
CompanyProfileResponse
|
CompanyProfileResponse
|
||||||
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
|
>(
|
||||||
|
"companies",
|
||||||
|
"createCompanyProfile",
|
||||||
|
companiesService.createCompanyProfile,
|
||||||
|
),
|
||||||
|
|
||||||
startOnboarding: endpoint<
|
startOnboarding: endpoint<
|
||||||
{
|
{
|
||||||
@@ -192,6 +198,12 @@ export const api = {
|
|||||||
"onboardingRequirements",
|
"onboardingRequirements",
|
||||||
companiesService.getOnboardingRequirements,
|
companiesService.getOnboardingRequirements,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
|
||||||
|
"companies",
|
||||||
|
"documents",
|
||||||
|
({ companyId }) => companiesService.getDocuments(companyId),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
@@ -228,7 +240,8 @@ export const api = {
|
|||||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"downloadHandoverDocument",
|
"downloadHandoverDocument",
|
||||||
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
|
({ inventoryId }) =>
|
||||||
|
bookingsService.downloadHandoverDocument(inventoryId),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<
|
create: endpoint<
|
||||||
@@ -312,11 +325,8 @@ export const api = {
|
|||||||
proceedToOperation: endpoint<
|
proceedToOperation: endpoint<
|
||||||
{ id: string; scheduledDate: string },
|
{ id: string; scheduledDate: string },
|
||||||
Freight.IBooking
|
Freight.IBooking
|
||||||
>(
|
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
||||||
"bookings",
|
bookingsService.proceedToOperation(id, scheduledDate),
|
||||||
"proceedToOperation",
|
|
||||||
({ id, scheduledDate }) =>
|
|
||||||
bookingsService.proceedToOperation(id, scheduledDate),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
@@ -354,10 +364,11 @@ export const api = {
|
|||||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||||
),
|
),
|
||||||
|
|
||||||
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
|
getAvailableDaysForCargo: endpoint<
|
||||||
"train-scheduling",
|
Freight.AvailableDaysForCargoQuery,
|
||||||
"availableDaysForCargo",
|
string[]
|
||||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
>("train-scheduling", "availableDaysForCargo", (input) =>
|
||||||
|
bookingsService.getAvailableDaysForCargo(input),
|
||||||
),
|
),
|
||||||
|
|
||||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
@@ -462,6 +473,13 @@ export const api = {
|
|||||||
contractsService.createBookingUnderContract(id, dto),
|
contractsService.createBookingUnderContract(id, dto),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
validateShipment: endpoint<
|
||||||
|
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||||
|
ShipmentValidation
|
||||||
|
>("contracts", "validateShipment", ({ id, dto }) =>
|
||||||
|
contractsService.validateShipment(id, dto),
|
||||||
|
),
|
||||||
|
|
||||||
getContractMilestones: endpoint<
|
getContractMilestones: endpoint<
|
||||||
{ id: string },
|
{ id: string },
|
||||||
Freight.IClearanceMilestone[]
|
Freight.IClearanceMilestone[]
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export interface SignContractPayload {
|
|||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
consentText?: string;
|
consentText?: string;
|
||||||
|
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
||||||
|
otp?: string;
|
||||||
|
/** Phone the OTP was sent to; required when role=CUSTOMER. */
|
||||||
|
otpPhone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApproveDeliveryResponse {
|
export interface ApproveDeliveryResponse {
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single company-level document uploaded against a `file_upload_settings` field. */
|
||||||
|
export interface CompanyDocument {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** The `fileKey` of the setting field it was uploaded against. */
|
||||||
|
code: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single onboarding document field, as resolved and described by the backend. */
|
/** A single onboarding document field, as resolved and described by the backend. */
|
||||||
export interface OnboardingDocumentField {
|
export interface OnboardingDocumentField {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyProfileInput {
|
export interface CompanyProfileInput {
|
||||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
type:
|
||||||
|
| "importer"
|
||||||
|
| "exporter"
|
||||||
|
| "freight_forwarder"
|
||||||
|
| "dj_freight_forwarder"
|
||||||
|
| "transporter";
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +197,9 @@ export const companiesService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
|
create: async (
|
||||||
|
payload: CreateCompanyPayload,
|
||||||
|
): Promise<CompanyInfoResponse> => {
|
||||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.CREATE,
|
URL_CONSTANTS.COMPANIES_API.CREATE,
|
||||||
payload,
|
payload,
|
||||||
@@ -195,7 +214,9 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
|
updateProfile: async (
|
||||||
|
payload: UpdateProfilePayload,
|
||||||
|
): Promise<ProfileResponse> => {
|
||||||
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
||||||
payload,
|
payload,
|
||||||
@@ -293,7 +314,18 @@ export const companiesService = {
|
|||||||
formData.append(fieldName, fileOrFiles);
|
formData.append(fieldName, fileOrFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
|
await client.post(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** List documents already uploaded for a company (settings-driven, by fileKey). */
|
||||||
|
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
|
||||||
|
const response = await client.get<ApiResponse<CompanyDocument[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Upload business-license document(s) for a company profile (multi-file). */
|
/** Upload business-license document(s) for a company profile (multi-file). */
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ export interface SubmitContractResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A container line whose total VGM exceeds the weight-limit rule. */
|
||||||
|
export interface OverweightLine {
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-submit validation for a shipment booking under a CONTAINER contract.
|
||||||
|
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||||
|
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||||
|
* that cannot be balanced onto wagons) and must prevent booking.
|
||||||
|
* `overweightSurchargeAmount` is the real overweight charge (same rate the
|
||||||
|
* booking is billed at on submit) so the confirm-modal total can include it.
|
||||||
|
*/
|
||||||
|
export interface ShipmentValidation {
|
||||||
|
overweightLines: OverweightLine[];
|
||||||
|
overweightSurchargeAmount: number;
|
||||||
|
currency: string | null;
|
||||||
|
pairingErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContractListFilter {
|
export interface ContractListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
statuses?: string;
|
statuses?: string;
|
||||||
@@ -285,6 +308,20 @@ export const contractsService = {
|
|||||||
return data.data.booking ?? data.data;
|
return data.data.booking ?? data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-submit validation of a shipment booking (same DTO as
|
||||||
|
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||||
|
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
|
||||||
|
* booking is created.
|
||||||
|
*/
|
||||||
|
validateShipment: async (
|
||||||
|
id: string,
|
||||||
|
dto: Freight.CreateBookingUnderContractDto,
|
||||||
|
): Promise<ShipmentValidation> => {
|
||||||
|
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Milestones ──
|
// ── Milestones ──
|
||||||
getContractMilestones: async (
|
getContractMilestones: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export interface SignupResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OtpPayload {
|
export interface OtpPayload {
|
||||||
phone: string;
|
/** Exactly one of phone/email — the channel the code is sent through. */
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
/** Required on verify; omitted on send (the server generates the code). */
|
/** Required on verify; omitted on send (the server generates the code). */
|
||||||
otp?: string;
|
otp?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop
|
||||||
|
ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RouteCoachTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"routeId" TEXT NOT NULL,
|
||||||
|
"coachId" TEXT NOT NULL,
|
||||||
|
"positionNumber" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT,
|
||||||
|
ADD COLUMN "nationalityType" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition");
|
||||||
@@ -88,7 +88,9 @@ model SeatClass {
|
|||||||
coachTypeId String
|
coachTypeId String
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
baseFareMinor Int @default(0) // per-km rate
|
nationalityType String? // 'LOCAL' | 'INTERNATIONAL'
|
||||||
|
bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat
|
||||||
|
baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000)
|
||||||
premiumMinor Int @default(0) // flat fee per passenger
|
premiumMinor Int @default(0) // flat fee per passenger
|
||||||
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
@@ -100,6 +102,7 @@ model SeatClass {
|
|||||||
segmentFares SegmentFareRule[]
|
segmentFares SegmentFareRule[]
|
||||||
@@unique([coachTypeId, name])
|
@@unique([coachTypeId, name])
|
||||||
@@index([coachTypeId])
|
@@index([coachTypeId])
|
||||||
|
@@index([coachTypeId, nationalityType, bedPosition])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,9 +423,10 @@ model Coach {
|
|||||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||||
seats Seat[]
|
seats Seat[]
|
||||||
assignments CoachAssignment[]
|
assignments CoachAssignment[]
|
||||||
|
routeTemplates RouteCoachTemplate[]
|
||||||
@@index([coachTypeId])
|
@@index([coachTypeId])
|
||||||
@@index([sequence])
|
@@index([sequence])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -998,6 +1002,7 @@ model Route {
|
|||||||
fareRules RouteFareRule[]
|
fareRules RouteFareRule[]
|
||||||
segmentFares SegmentFareRule[]
|
segmentFares SegmentFareRule[]
|
||||||
schedules TrainSchedule[]
|
schedules TrainSchedule[]
|
||||||
|
coachTemplates RouteCoachTemplate[]
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,6 +1020,20 @@ model RouteStop {
|
|||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RouteCoachTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
routeId String
|
||||||
|
coachId String
|
||||||
|
positionNumber Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||||
|
coach Coach @relation(fields: [coachId], references: [id])
|
||||||
|
|
||||||
|
@@unique([routeId, positionNumber])
|
||||||
|
@@index([routeId])
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
model RouteFareRule {
|
model RouteFareRule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
routeId String
|
routeId String
|
||||||
|
|||||||
@@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tariff rates: baseFareMinor = tariff_decimal × 100000
|
||||||
|
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
|
||||||
|
// LOCAL = Ethiopian or Djiboutian nationals
|
||||||
|
// INTERNATIONAL = all other nationalities
|
||||||
const seatClasses = [
|
const seatClasses = [
|
||||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
// LOCAL rates
|
||||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
// INTERNATIONAL rates
|
||||||
|
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
|
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sc of seatClasses) {
|
for (const sc of seatClasses) {
|
||||||
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
||||||
await prisma.seatClass.upsert({
|
await prisma.seatClass.upsert({
|
||||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||||
update: {},
|
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
|
||||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
create: {
|
||||||
|
coachTypeId: ct!.id,
|
||||||
|
name: sc.name,
|
||||||
|
nationalityType: sc.nationalityType,
|
||||||
|
bedPosition: sc.bedPosition,
|
||||||
|
baseFareMinor: sc.baseFareMinor,
|
||||||
|
premiumMinor: sc.premiumMinor,
|
||||||
|
insuranceFeeMinor: sc.insuranceFeeMinor,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||||
@@ -238,6 +258,58 @@ async function seedRoute() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||||
|
|
||||||
|
// Full cross-border route: Sebeta → Nagad (all 15 stations)
|
||||||
|
const fullRoute = await prisma.route.upsert({
|
||||||
|
where: { code: 'Route-201' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'Route-201',
|
||||||
|
name: 'Sebeta - Nagad (Full Cross-Border)',
|
||||||
|
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
|
||||||
|
effectiveFrom: new Date('2026-01-01'),
|
||||||
|
effectiveUntil: new Date('2034-12-31'),
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cumulative distances from Sebeta (km) for all 15 stations
|
||||||
|
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
|
||||||
|
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
|
||||||
|
for (let i = 0; i < fullStationCodes.length; i++) {
|
||||||
|
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
|
||||||
|
await prisma.routeStop.upsert({
|
||||||
|
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
|
||||||
|
update: { distanceKm: fullDistancesKm[i] },
|
||||||
|
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full cross-border return route: Nagad → Sebeta
|
||||||
|
const fullReturnRoute = await prisma.route.upsert({
|
||||||
|
where: { code: 'Route-202' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'Route-202',
|
||||||
|
name: 'Nagad - Sebeta (Full Cross-Border Return)',
|
||||||
|
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
|
||||||
|
effectiveFrom: new Date('2026-01-01'),
|
||||||
|
effectiveUntil: new Date('2034-12-31'),
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||||
|
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
|
||||||
|
for (let i = 0; i < fullReturnStationCodes.length; i++) {
|
||||||
|
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
|
||||||
|
await prisma.routeStop.upsert({
|
||||||
|
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
|
||||||
|
update: { distanceKm: fullReturnDistancesKm[i] },
|
||||||
|
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedCoaches() {
|
async function seedCoaches() {
|
||||||
@@ -431,34 +503,61 @@ async function seedTrips() {
|
|||||||
async function seedFareRules() {
|
async function seedFareRules() {
|
||||||
console.log('\n💰 Seeding fare rules...');
|
console.log('\n💰 Seeding fare rules...');
|
||||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||||
|
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||||
const seatClasses = await prisma.seatClass.findMany();
|
const seatClasses = await prisma.seatClass.findMany();
|
||||||
const validFrom = new Date('2024-01-01');
|
const validFrom = new Date('2024-01-01');
|
||||||
|
|
||||||
const fareRules = [];
|
// Delete existing FareRule rows so re-seed is idempotent
|
||||||
for (const sc of seatClasses) {
|
await prisma.fareRule.deleteMany({});
|
||||||
fareRules.push({
|
|
||||||
routeId: route!.id,
|
const fareRules: any[] = [];
|
||||||
seatClassId: sc.id,
|
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
|
||||||
passengerCategory: 'ADULT' as const,
|
for (const sc of seatClasses) {
|
||||||
baseFareMinor: sc.baseFareMinor,
|
fareRules.push({
|
||||||
currency: 'ETB',
|
route: route.code,
|
||||||
validFrom,
|
seatClassId: sc.id,
|
||||||
});
|
baseFareMinor: sc.baseFareMinor,
|
||||||
fareRules.push({
|
currency: 'ETB',
|
||||||
routeId: route!.id,
|
validFrom,
|
||||||
seatClassId: sc.id,
|
});
|
||||||
passengerCategory: 'CHILD' as const,
|
}
|
||||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
|
||||||
discountPercent: 10,
|
|
||||||
currency: 'ETB',
|
|
||||||
validFrom,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
|
||||||
);
|
);
|
||||||
console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`);
|
console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`);
|
||||||
|
|
||||||
|
const allRoutes = await prisma.route.findMany({
|
||||||
|
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
|
||||||
|
});
|
||||||
|
const routeFareRules: any[] = [];
|
||||||
|
for (const r of allRoutes) {
|
||||||
|
for (const sc of seatClasses) {
|
||||||
|
routeFareRules.push({
|
||||||
|
routeId: r.id,
|
||||||
|
seatClassId: sc.id,
|
||||||
|
passengerCategory: 'ADULT' as const,
|
||||||
|
baseFareMinor: sc.baseFareMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
validFrom,
|
||||||
|
});
|
||||||
|
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
|
||||||
|
// at booking time (first child free, subsequent children full fare).
|
||||||
|
routeFareRules.push({
|
||||||
|
routeId: r.id,
|
||||||
|
seatClassId: sc.id,
|
||||||
|
passengerCategory: 'CHILD' as const,
|
||||||
|
baseFareMinor: sc.baseFareMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
validFrom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
|
||||||
|
);
|
||||||
|
console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedCurrency() {
|
async function seedCurrency() {
|
||||||
@@ -759,6 +858,22 @@ async function main() {
|
|||||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||||
|
|
||||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||||
|
['System Users', seedSystemUsers],
|
||||||
|
['Stations', seedStations],
|
||||||
|
['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||||
|
['Route', seedRoute],
|
||||||
|
['Coaches', seedCoaches],
|
||||||
|
['Trips', seedTrips],
|
||||||
|
['Fare Rules', seedFareRules],
|
||||||
|
['Currency', seedCurrency],
|
||||||
|
['Payment Methods', seedPaymentMethods],
|
||||||
|
['Segment Fares', seedSegmentFares],
|
||||||
|
['Notification Templates', seedNotificationTemplates],
|
||||||
|
['Menu & Food', seedMenuAndFood],
|
||||||
|
['Promotions', seedPromotions],
|
||||||
|
['FAQ', seedFAQ],
|
||||||
|
['Fraud Rules', seedFraudRules],
|
||||||
|
['Kulubbi Package', seedKulubbiPackage],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user