mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' into passenger/feat/iam
This commit is contained in:
@@ -32,7 +32,8 @@
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -84,13 +85,15 @@
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/vorpal": "^1.12.8",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vorpal": "^1.12.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
|
||||
* window closes before settlement (e.g. a booking whose `paymentDeadline`
|
||||
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
|
||||
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
|
||||
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
|
||||
* `OVERDUE` (still payable).
|
||||
*
|
||||
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
|
||||
* not referenced in this same transaction, so it is PG 12+ safe.
|
||||
*/
|
||||
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
|
||||
name = "AddExpiredInvoiceStatus1830000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop individual enum values; EXPIRED is left on
|
||||
// freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
@@ -21,4 +22,26 @@ export class BillingController {
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/receipt")
|
||||
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
|
||||
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.receipt(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream a generated PDF as a file download. */
|
||||
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@@ -151,16 +151,22 @@ export class BillingService {
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE"));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException("A receipt is available only after payment is recorded.");
|
||||
throw new BadRequestException(
|
||||
"A receipt is available only after payment is recorded.",
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT"));
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
@@ -177,7 +183,11 @@ export class BillingService {
|
||||
if (Number(invoice.taxAmount) > 0) {
|
||||
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
||||
}
|
||||
totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true });
|
||||
totals.push({
|
||||
label: "Total",
|
||||
amount: Number(invoice.totalAmount),
|
||||
grand: true,
|
||||
});
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
@@ -193,8 +203,18 @@ export class BillingService {
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{ label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null },
|
||||
{ label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
],
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
@@ -221,19 +241,33 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||
/**
|
||||
* Every invoice billed to a company, newest first, with billing relations.
|
||||
* Optionally narrow to a single source record (e.g. a booking's invoices) via
|
||||
* `{ source, sourceId }`.
|
||||
*/
|
||||
findByCompany(
|
||||
companyId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: { companyId },
|
||||
where: {
|
||||
companyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(userId: string): Promise<Invoice[]> {
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId) : [];
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
@@ -267,11 +301,32 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.document(id);
|
||||
}
|
||||
|
||||
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async receiptForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.receipt(id);
|
||||
}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,8 +374,7 @@ export class BillingService {
|
||||
input.subtotalAmount ??
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
const taxAmount = input.taxAmount ?? 0;
|
||||
const totalAmount =
|
||||
input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
@@ -406,7 +460,9 @@ export class BillingService {
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice> {
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException("Payment amount must be greater than zero.");
|
||||
throw new BadRequestException(
|
||||
"Payment amount must be greater than zero.",
|
||||
);
|
||||
}
|
||||
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
@@ -441,17 +497,13 @@ export class BillingService {
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
} as never,
|
||||
);
|
||||
await mg.update(Invoice, { id: invoice.id }, {
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
} as never);
|
||||
|
||||
const updated = {
|
||||
...invoice,
|
||||
@@ -459,7 +511,7 @@ export class BillingService {
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
} as Invoice;
|
||||
|
||||
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
|
||||
@@ -628,6 +680,72 @@ export class BillingService {
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
*/
|
||||
async expirePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.transition(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { status });
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
@@ -654,7 +772,9 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
throw new NotFoundException(
|
||||
`No open invoice to charge for ${source}:${sourceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
|
||||
@@ -5,14 +5,18 @@ import {
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { sendPdf } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
@@ -29,8 +33,15 @@ export class PortalBillingController {
|
||||
|
||||
@Get("my-invoices")
|
||||
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||
findMine(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query("source") source?: string,
|
||||
@Query("sourceId") sourceId?: string,
|
||||
) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user), {
|
||||
source,
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id")
|
||||
@@ -42,6 +53,34 @@ export class PortalBillingController {
|
||||
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id/document")
|
||||
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
|
||||
async document(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.billingService.documentForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id/receipt")
|
||||
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
|
||||
async receipt(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.billingService.receiptForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/pay")
|
||||
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||
pay(
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
interface StoredPricingBreakdown {
|
||||
@@ -23,6 +23,12 @@ interface StoredPricingBreakdown {
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceOptions {
|
||||
dueDate?: Date;
|
||||
invoiceType?: string;
|
||||
invoiceStatus?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -56,11 +62,14 @@ export class BookingInvoiceService {
|
||||
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||
* FK requires), or no priced amount.
|
||||
*/
|
||||
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
|
||||
async ensureInvoiceForBooking(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
Freight.InvoiceType.Prepaid,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -68,16 +77,9 @@ export class BookingInvoiceService {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = this.buildInput(booking);
|
||||
if (!input) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const input = this.buildInput(booking, invoiceOptions);
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
@@ -87,10 +89,10 @@ export class BookingInvoiceService {
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
* the booking its own way. Only PREPAID exists today.
|
||||
*/
|
||||
@OnEvent('booking.invoice.paid')
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
switch (payload.type) {
|
||||
case Freight.InvoiceType.Prepaid:
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
@@ -100,6 +102,8 @@ export class BookingInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus = this.billing.updateStatus;
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -114,16 +118,18 @@ export class BookingInvoiceService {
|
||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||
this.logger.warn(
|
||||
`Cannot advance unknown booking ${bookingId} on payment.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === 'PAID') return;
|
||||
if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
@@ -138,9 +144,13 @@ export class BookingInvoiceService {
|
||||
}
|
||||
|
||||
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
||||
private buildInput(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): GenerateInvoiceInput {
|
||||
const breakdown = (booking.pricingBreakdown ??
|
||||
{}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
||||
|
||||
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||
chargeType: l.code,
|
||||
@@ -155,10 +165,10 @@ export class BookingInvoiceService {
|
||||
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||
if (lines.length === 0) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price");
|
||||
lines.push({
|
||||
chargeType: 'FREIGHT',
|
||||
description: 'Rail freight',
|
||||
chargeType: "FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
@@ -166,7 +176,9 @@ export class BookingInvoiceService {
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
||||
const subtotal = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0),
|
||||
);
|
||||
let totalAmount = subtotal;
|
||||
|
||||
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||
@@ -176,8 +188,8 @@ export class BookingInvoiceService {
|
||||
const delta = round2(Number(adjusted) - subtotal);
|
||||
if (delta !== 0) {
|
||||
lines.push({
|
||||
chargeType: 'ADJUSTMENT',
|
||||
description: 'Staff price adjustment',
|
||||
chargeType: "ADJUSTMENT",
|
||||
description: "Staff price adjustment",
|
||||
quantity: 1,
|
||||
unitRate: delta,
|
||||
amount: delta,
|
||||
@@ -190,12 +202,14 @@ export class BookingInvoiceService {
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: booking.id,
|
||||
type: Freight.InvoiceType.Prepaid,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency,
|
||||
lines,
|
||||
totalAmount,
|
||||
dueAt: invoiceOptions.dueDate,
|
||||
type: invoiceOptions.invoiceType ?? "PREPAID",
|
||||
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,53 +4,56 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from './road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import { isRoadService } from "./road.util";
|
||||
import { RuleEngineService } from "../rule-engine/rule-engine.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { assertBookingStatus } from "./booking-status.util";
|
||||
import { clearanceCodesForBooking } from "./clearance.util";
|
||||
import {
|
||||
computeNextStep,
|
||||
type BookingNextStep,
|
||||
} from "./booking-next-step.util";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
private readonly logger = new Logger(BookingTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
) { }
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate a price before submitting (POST /bookings/:id/generate-price)',
|
||||
"Generate a price before submitting (POST /bookings/:id/generate-price)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +72,8 @@ export class BookingTransitionService {
|
||||
totalAmount?: number;
|
||||
} | null;
|
||||
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const priorityScore =
|
||||
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
|
||||
if (unchanged) {
|
||||
await this.pricingService.createPricingSnapshots(
|
||||
@@ -79,7 +83,7 @@ export class BookingTransitionService {
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
status: "SUBMITTED",
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
@@ -109,7 +113,7 @@ export class BookingTransitionService {
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
status: 'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
status: "PRICE_CHANGED_PENDING_CONFIRM",
|
||||
} as never);
|
||||
|
||||
const updatedBooking = await this.bookingsService.findById(bookingId);
|
||||
@@ -121,16 +125,17 @@ export class BookingTransitionService {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Price has changed since preview. Confirm to submit with the updated price.',
|
||||
message:
|
||||
"Price has changed since preview. Confirm to submit with the updated price.",
|
||||
};
|
||||
}
|
||||
|
||||
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException('No price to confirm');
|
||||
throw new BadRequestException("No price to confirm");
|
||||
}
|
||||
|
||||
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||
@@ -149,9 +154,10 @@ export class BookingTransitionService {
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const priorityScore =
|
||||
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
status: "SUBMITTED",
|
||||
priorityScore,
|
||||
totalAmount: computed.totalAmount,
|
||||
pricingBreakdown: {
|
||||
@@ -173,7 +179,7 @@ export class BookingTransitionService {
|
||||
totalAmount: Number(finalBooking.totalAmount),
|
||||
currency: finalBooking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Booking submitted with confirmed price.',
|
||||
message: "Booking submitted with confirmed price.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,17 +189,17 @@ export class BookingTransitionService {
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CHANGES_REQUESTED',
|
||||
status: "CHANGES_REQUESTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -203,7 +209,7 @@ export class BookingTransitionService {
|
||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
}
|
||||
@@ -217,14 +223,14 @@ export class BookingTransitionService {
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||
|
||||
// The backoffice must define how long the accepted contract stays valid.
|
||||
// Without a window the contract has no end date and cannot be relied on, so
|
||||
// accept is blocked until a positive number of days is supplied.
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
throw new BadRequestException(
|
||||
'A contract validity (in days) is required to accept this booking.',
|
||||
"A contract validity (in days) is required to accept this booking.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,12 +240,12 @@ export class BookingTransitionService {
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
status: "PENDING_APPROVAL",
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
@@ -255,17 +261,17 @@ export class BookingTransitionService {
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -283,8 +289,8 @@ export class BookingTransitionService {
|
||||
|
||||
let booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||
@@ -296,14 +302,17 @@ export class BookingTransitionService {
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
if (!step || step.status !== "PENDING") {
|
||||
throw new BadRequestException(
|
||||
"Approval step not found or already actioned",
|
||||
);
|
||||
}
|
||||
|
||||
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
const next =
|
||||
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
'Approval steps must be completed in order',
|
||||
"Approval steps must be completed in order",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,29 +324,36 @@ export class BookingTransitionService {
|
||||
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
throw new BadRequestException(
|
||||
`Role ${requiredRole} is blocked for this step`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
"APPROVED",
|
||||
);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
||||
if (requiredRole === "LINE_STAFF") {
|
||||
updates.status = "APPROVED_PENDING_SIGNATURE";
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
} else if (requiredRole === "DIRECTOR") {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
} else if (requiredRole === "CEO") {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
const allDone =
|
||||
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
if (allDone) {
|
||||
updates.status = 'APPROVED';
|
||||
updates.status = "APPROVED";
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -359,90 +375,64 @@ export class BookingTransitionService {
|
||||
reason: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step) throw new BadRequestException('Approval step not found');
|
||||
if (!step) throw new BadRequestException("Approval step not found");
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
'REJECTED',
|
||||
"REJECTED",
|
||||
reason,
|
||||
);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async customerSign(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
status: "SIGNED_CUSTOMER",
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: new Date(),
|
||||
marketingApprovedById: actorId,
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const executed = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Billable state reached — generate the invoice payment will settle.
|
||||
// Non-blocking: a billing hiccup must not undo the execution.
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(executed)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for booking ${executed.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PAID']);
|
||||
assertBookingStatus(booking, ["PAID"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'IN_TRANSIT',
|
||||
status: "IN_TRANSIT",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['IN_TRANSIT']);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'COMPLETED',
|
||||
status: "COMPLETED",
|
||||
endDate: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
@@ -451,22 +441,22 @@ export class BookingTransitionService {
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'CONTRACT_READY',
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CANCELLED',
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -479,20 +469,20 @@ export class BookingTransitionService {
|
||||
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'PENDING_CONSOLIDATION',
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"PENDING_CONSOLIDATION",
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason?.trim() || 'Customer rejected the price estimate.',
|
||||
'REJECTION',
|
||||
reason?.trim() || "Customer rejected the price estimate.",
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -513,10 +503,10 @@ export class BookingTransitionService {
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
uploadedBy: "customer" | "gl";
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
@@ -525,20 +515,21 @@ export class BookingTransitionService {
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const files = await this.filesService.findByResource(bookingId, "bookings");
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviews =
|
||||
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(
|
||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||
);
|
||||
|
||||
const documents: Awaited<
|
||||
ReturnType<BookingTransitionService['getClearanceView']>
|
||||
>['documents'] = [];
|
||||
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||
>["documents"] = [];
|
||||
|
||||
const pushSetting = async (
|
||||
code: string | null,
|
||||
uploadedBy: 'customer' | 'gl',
|
||||
uploadedBy: "customer" | "gl",
|
||||
) => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
@@ -556,28 +547,26 @@ export class BookingTransitionService {
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file
|
||||
? { id: file.id, name: file.name, url: file.url }
|
||||
: null,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
await pushSetting(inputCode, "customer");
|
||||
await pushSetting(outputCode, "gl");
|
||||
|
||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
if (!f.code?.startsWith("custom_")) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
uploadedBy: "customer",
|
||||
settingCode: "custom",
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
@@ -611,13 +600,15 @@ export class BookingTransitionService {
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(
|
||||
booking.id,
|
||||
);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
r.status === "APPROVED",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -632,33 +623,38 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
]);
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException('This booking has no document-clearance step');
|
||||
throw new BadRequestException(
|
||||
"This booking has no document-clearance step",
|
||||
);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
throw new BadRequestException("No documents uploaded");
|
||||
}
|
||||
|
||||
// First submission (nothing in review yet): every required input field must
|
||||
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
||||
// is only fixing queried/pending docs, so the already-uploaded required docs
|
||||
// stay in place and we don't re-gate on the full required set.
|
||||
if (booking.status === 'AWAITING_DOCUMENTS') {
|
||||
if (booking.status === "AWAITING_DOCUMENTS") {
|
||||
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
resource: "bookings",
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||
const settingCode = file.fieldname.startsWith('custom_')
|
||||
? 'custom'
|
||||
const settingCode = file.fieldname.startsWith("custom_")
|
||||
? "custom"
|
||||
: inputCode;
|
||||
await this.bookingsRepository.upsertDocumentReviewPending({
|
||||
bookingId,
|
||||
@@ -669,7 +665,7 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
@@ -694,7 +690,10 @@ export class BookingTransitionService {
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return;
|
||||
|
||||
const existing = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const existing = await this.filesService.findByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const presentKeys = new Set<string>([
|
||||
...existing.map((f) => f.code),
|
||||
...files.map((f) => f.fieldname),
|
||||
@@ -702,7 +701,7 @@ export class BookingTransitionService {
|
||||
|
||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||
const labels = missing.map((f) => f.fileLabel).join(", ");
|
||||
throw new BadRequestException(
|
||||
`Please upload all required documents before submitting: ${labels}`,
|
||||
);
|
||||
@@ -713,22 +712,27 @@ export class BookingTransitionService {
|
||||
async reviewDocument(
|
||||
bookingId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
status: "APPROVED" | "QUERIED",
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||
|
||||
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const existing =
|
||||
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const match = existing.find((r) => r.fileKey === fileKey);
|
||||
const settingCode =
|
||||
match?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
(fileKey.startsWith("custom_")
|
||||
? "custom"
|
||||
: (inputCode ?? outputCode ?? "custom"));
|
||||
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
if (status === "QUERIED" && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
@@ -739,11 +743,11 @@ export class BookingTransitionService {
|
||||
staffId,
|
||||
note,
|
||||
);
|
||||
if (status === 'QUERIED') {
|
||||
if (status === "QUERIED") {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
}
|
||||
@@ -756,18 +760,20 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This booking has no customs output documents');
|
||||
throw new BadRequestException(
|
||||
"This booking has no customs output documents",
|
||||
);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
throw new BadRequestException("No documents uploaded");
|
||||
}
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
resource: "bookings",
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
@@ -781,19 +787,23 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
if (!approved) {
|
||||
throw new BadRequestException(
|
||||
'All required documents must be approved before clearance can be finalized',
|
||||
"All required documents must be approved before clearance can be finalized",
|
||||
);
|
||||
}
|
||||
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const setting =
|
||||
await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const uploaded = new Set(files.map((f) => f.code));
|
||||
const missing = (setting.fields ?? []).filter(
|
||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||
@@ -802,13 +812,13 @@ export class BookingTransitionService {
|
||||
throw new BadRequestException(
|
||||
`Upload all required customs output documents first: ${missing
|
||||
.map((m) => m.fileLabel)
|
||||
.join(', ')}`,
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CLEARANCE_READY',
|
||||
status: "CLEARANCE_READY",
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
@@ -827,11 +837,14 @@ export class BookingTransitionService {
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
|
||||
assertBookingStatus(booking, [
|
||||
"CLEARANCE_READY",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException('A valid schedule date is required');
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
@@ -844,12 +857,12 @@ export class BookingTransitionService {
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
scheduledDate: date,
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
@@ -865,27 +878,27 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async reviewOperationRequest(
|
||||
bookingId: string,
|
||||
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES",
|
||||
actorId: string,
|
||||
options: { note?: string } = {},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
|
||||
|
||||
if (decision === 'REQUEST_CHANGES') {
|
||||
if (decision === "REQUEST_CHANGES") {
|
||||
if (!options.note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'A note is required when requesting changes',
|
||||
"A note is required when requesting changes",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
options.note,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
actorId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
@@ -907,9 +920,17 @@ export class BookingTransitionService {
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||
);
|
||||
await this.invoiceService.updateStatus(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
);
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'ROAD_DISPATCH_PENDING',
|
||||
status: "ROAD_DISPATCH_PENDING",
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
@@ -917,7 +938,7 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
status: "FULLY_EXECUTED",
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
@@ -932,21 +953,23 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}> {
|
||||
async enrichBookingResponse(booking: Booking): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}
|
||||
> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const summary =
|
||||
booking.contractSummary ??
|
||||
this.contractService.buildContractSummary(booking);
|
||||
const nextPending =
|
||||
booking.status === 'PENDING_APPROVAL' ||
|
||||
booking.status === 'APPROVED_PENDING_SIGNATURE'
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
const nextStep = computeNextStep(booking, nextPending);
|
||||
@@ -957,4 +980,4 @@ export class BookingTransitionService {
|
||||
nextStep,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -27,20 +27,20 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveStepDto,
|
||||
@@ -52,18 +52,21 @@ import {
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
} from "./dto/request-changes.dto";
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { SignContractDto } from "./dto/sign-contract.dto";
|
||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(
|
||||
@@ -72,12 +75,12 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@@ -87,15 +90,24 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(
|
||||
dto,
|
||||
files ?? [],
|
||||
user?.id,
|
||||
);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
const isStaff = hasFreightPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(
|
||||
result.booking.id,
|
||||
);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
@@ -105,16 +117,16 @@ export class BookingsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Patch(":id")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
summary: "Update booking",
|
||||
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
@@ -122,7 +134,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -139,7 +151,7 @@ export class BookingsController {
|
||||
return this.bookingsService.findClearanceQueue(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
if (!userId) throw new UnauthorizedException("Authentication required");
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
@@ -165,27 +177,29 @@ export class BookingsController {
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('by-company/:companyId/customer-view')
|
||||
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
findByCompanyCustomerView(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
return this.bookingsService.findCustomerBookings(companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@Get("list-summary")
|
||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@Get("my")
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
||||
"Bookings owned by the authenticated user's company that are payable " +
|
||||
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
|
||||
})
|
||||
findMyPayable(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@@ -194,32 +208,32 @@ export class BookingsController {
|
||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@Get("queues/:queue")
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||
summary: "List bookings for a dashboard queue",
|
||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||
})
|
||||
findQueue(
|
||||
@Param('queue') queue: string,
|
||||
@Param("queue") queue: string,
|
||||
@Query() filter: FilterBookingDto,
|
||||
@Query('excludeBulk') excludeBulk?: string,
|
||||
@Query("excludeBulk") excludeBulk?: string,
|
||||
) {
|
||||
return this.bookingsService.findQueue(queue, filter, {
|
||||
excludeBulk: excludeBulk === 'true',
|
||||
excludeBulk: excludeBulk === "true",
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reference-data')
|
||||
@ApiOperation({ summary: 'Booking form catalog' })
|
||||
@Get("reference-data")
|
||||
@ApiOperation({ summary: "Booking form catalog" })
|
||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
return this.bookingReferenceDataService.getReferenceData();
|
||||
}
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
@Get("by-reference/:reference")
|
||||
@ApiOperation({ summary: "Get booking by reference" })
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@Param("reference") reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
@@ -233,10 +247,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get booking by ID" })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -254,15 +268,15 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@Get(":id/tracking")
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
"tracking events. Scoped to the customer's own company.",
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -276,66 +290,66 @@ export class BookingsController {
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@Post(":id/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||||
async uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@Post(":id/generate-price")
|
||||
@ApiOperation({
|
||||
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
|
||||
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||||
description:
|
||||
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
|
||||
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
|
||||
})
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@Post(":id/submit")
|
||||
@ApiOperation({
|
||||
summary: 'Customer submit booking',
|
||||
summary: "Customer submit booking",
|
||||
description:
|
||||
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
|
||||
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
submit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@Post(":id/confirm-submit")
|
||||
@ApiOperation({
|
||||
summary: 'Confirm submit after price change',
|
||||
summary: "Confirm submit after price change",
|
||||
description:
|
||||
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
|
||||
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@Post(":id/reject")
|
||||
@ApiOperation({
|
||||
summary: 'Customer reject price estimate',
|
||||
summary: "Customer reject price estimate",
|
||||
description:
|
||||
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
|
||||
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
|
||||
})
|
||||
async reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.reject(id, dto.reason);
|
||||
@@ -344,22 +358,23 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get(':id/clearance')
|
||||
@Get(":id/clearance")
|
||||
@ApiOperation({
|
||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@Post(":id/clearance/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
||||
summary: "Customer uploads clearance documents (fieldname = document key)",
|
||||
})
|
||||
async submitClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.submitClearanceDocuments(
|
||||
@@ -369,14 +384,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/proceed')
|
||||
@Post(":id/clearance/proceed")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests operation with a schedule day ' +
|
||||
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
|
||||
"Customer requests operation with a schedule day " +
|
||||
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||||
})
|
||||
async proceedToOperation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestOperationDto,
|
||||
) {
|
||||
const booking = await this.transitionService.requestOperation(
|
||||
@@ -386,15 +401,15 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/review')
|
||||
@Post(":id/operation/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
|
||||
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
|
||||
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
|
||||
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
|
||||
})
|
||||
async reviewOperationRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: OperationReviewDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -407,11 +422,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@Post(":id/clearance/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
||||
@ApiOperation({
|
||||
summary: "GL reviews a clearance document (Approve | Query)",
|
||||
})
|
||||
async reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -425,13 +442,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
|
||||
async uploadClearanceOutput(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||
@@ -441,21 +458,22 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@Post(":id/clearance/finalize")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||
@ApiOperation({
|
||||
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
||||
summary:
|
||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||
})
|
||||
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.finalizeClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@Post(":id/staff/request-changes")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||||
async requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -467,14 +485,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@Post(":id/staff/accept")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff accept intake → set contract validity window + start approval chain',
|
||||
"Staff accept intake → set contract validity window + start approval chain",
|
||||
})
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptIntakeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -486,11 +504,11 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@Post(":id/staff/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||
@ApiOperation({ summary: 'Staff final reject' })
|
||||
@ApiOperation({ summary: "Staff final reject" })
|
||||
async staffReject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: StaffRejectDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -502,11 +520,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@Post(":id/government-expedite")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
@ApiOperation({
|
||||
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||||
})
|
||||
async governmentExpedite(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingsService.governmentExpedite(
|
||||
@@ -516,16 +536,16 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@Post(":id/approval-steps/:stepId/approve")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||
async approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
@@ -539,12 +559,12 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@Post(":id/approval-steps/:stepId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: 'Reject at approval step' })
|
||||
@ApiOperation({ summary: "Reject at approval step" })
|
||||
async rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -557,53 +577,53 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@Post(":id/contract/generate")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.contractService.generateContract(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@Get(":id/contract/view")
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
@Get(":id/contract/document")
|
||||
@ApiOperation({ summary: "Download contract PDF" })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||
@Get(":id/contract")
|
||||
@ApiOperation({ summary: "Download contract file (alias)" })
|
||||
async downloadContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
return this.downloadContractDocument(id, res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
||||
@Post(":id/contract/sign")
|
||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
@@ -615,28 +635,28 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/signatures')
|
||||
@ApiOperation({ summary: 'List contract signatures' })
|
||||
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/contract/signatures")
|
||||
@ApiOperation({ summary: "List contract signatures" })
|
||||
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSignatures(id);
|
||||
}
|
||||
|
||||
@Get(':id/summary')
|
||||
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
||||
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/summary")
|
||||
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||||
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSummary(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer/sign')
|
||||
@Post(":id/customer/sign")
|
||||
@ApiOperation({
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||||
})
|
||||
async customerSign(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
||||
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: req.user?.id ?? req.user?.sub,
|
||||
ipAddress: req.ip,
|
||||
@@ -644,20 +664,21 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/marketing/approve')
|
||||
@Post(":id/marketing/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
summary:
|
||||
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
|
||||
})
|
||||
async marketingApprove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Request() req: { ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = {
|
||||
...dto,
|
||||
role: 'STAFF',
|
||||
role: "STAFF",
|
||||
};
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: resolveAuthUserId(user),
|
||||
@@ -666,48 +687,48 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/start-transit')
|
||||
@Post(":id/operations/start-transit")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark in transit' })
|
||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark in transit" })
|
||||
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.startTransit(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/complete')
|
||||
@Post(":id/operations/complete")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark completed' })
|
||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark completed" })
|
||||
async complete(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.complete(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@Post(":id/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Request freight consolidation' })
|
||||
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Post(":id/consolidation")
|
||||
@ApiOperation({ summary: "Request freight consolidation" })
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
}
|
||||
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({ summary: "Get consolidation details" })
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPaymentController } from './booking-payment.controller';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { SignaturesModule } from "../signatures/signatures.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { BookingPaymentController } from "./booking-payment.controller";
|
||||
import { BookingPaymentService } from "./booking-payment.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingsController } from "./bookings.controller";
|
||||
import { PayController } from "./pay.controller";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { ConsolidationService } from "./consolidation.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingApprovalStep } from "./entities/booking-approval-step.entity";
|
||||
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity";
|
||||
import { BookingDocumentReview } from "./entities/booking-document-review.entity";
|
||||
import { BookingContainer } from "./entities/booking-container.entity";
|
||||
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity";
|
||||
import { BookingContractSignature } from "./entities/booking-contract-signature.entity";
|
||||
import { BookingReviewNote } from "./entities/booking-review-note.entity";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPdfService } from "../../contracts/contract-pdf.service";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -66,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
@@ -86,6 +86,11 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||
exports: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,24 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { Cron, SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { DataSource } from "typeorm";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { Locomotive } from "../locomotives/entities/locomotive.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
||||
import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
|
||||
import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
|
||||
import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
|
||||
import { BookingNotifierService } from "./booking-notifier.service";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -27,13 +29,13 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from './booking-batch.constants';
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
} from "./train-capacity.util";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -53,12 +55,12 @@ interface RouteDayGroup {
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| 'ALLOCATED'
|
||||
| 'SELECTED_FOR_BATCH'
|
||||
| 'READY'
|
||||
| 'WAITING'
|
||||
| 'PENDING_CONTRACT'
|
||||
| 'EXPIRED';
|
||||
| "ALLOCATED"
|
||||
| "SELECTED_FOR_BATCH"
|
||||
| "READY"
|
||||
| "WAITING"
|
||||
| "PENDING_CONTRACT"
|
||||
| "EXPIRED";
|
||||
|
||||
export interface BatchBoardBooking {
|
||||
id: string;
|
||||
@@ -73,10 +75,10 @@ export interface BatchBoardBooking {
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| 'NOT_ATTEMPTED'
|
||||
| 'ASSIGNED'
|
||||
| 'DEFERRED'
|
||||
| 'FAILED';
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
| "DEFERRED"
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
@@ -114,9 +116,9 @@ export interface BatchBoardScheduleDetail {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule['locomotive'];
|
||||
capacity: BatchBoardSchedule['capacity'];
|
||||
counts: BatchBoardSchedule['counts'];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
@@ -179,7 +181,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -195,10 +198,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||
.createQueryBuilder("b")
|
||||
.select("DISTINCT b.train_schedule_id", "scheduleId")
|
||||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||
.andWhere("b.train_schedule_id IS NOT NULL")
|
||||
.getRawMany<{ scheduleId: string }>();
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
@@ -276,7 +279,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
});
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
@@ -310,25 +313,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!booking?.trainScheduleId) return;
|
||||
|
||||
const isBatchPaid =
|
||||
booking.status === 'SELECTED_FOR_BATCH' ||
|
||||
booking.status === 'AWAITING_PAYMENT' ||
|
||||
booking.status === 'PAID' ||
|
||||
booking.paymentStatus === 'PAID';
|
||||
booking.status === "SELECTED_FOR_BATCH" ||
|
||||
booking.status === "AWAITING_PAYMENT" ||
|
||||
booking.status === "PAID" ||
|
||||
booking.paymentStatus === "PAID";
|
||||
if (!isBatchPaid) return;
|
||||
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
if (
|
||||
booking.status === "SELECTED_FOR_BATCH" ||
|
||||
booking.status === "AWAITING_PAYMENT"
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
|
||||
} else if (booking.paymentStatus !== 'PAID') {
|
||||
.update(bookingId, { paymentStatus: "PAID", status: "PAID" });
|
||||
} else if (booking.paymentStatus !== "PAID") {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID' });
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
}
|
||||
|
||||
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
await this.allocate(booking.trainScheduleId, booking, 'paid');
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||
);
|
||||
@@ -338,7 +345,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
}
|
||||
|
||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||
@@ -349,7 +356,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
|
||||
);
|
||||
}
|
||||
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
|
||||
if (
|
||||
result.issues.some(
|
||||
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
|
||||
)
|
||||
) {
|
||||
const issue = result.issues.find((i) => i.bookingId === bookingId);
|
||||
this.logger.warn(
|
||||
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
||||
@@ -364,9 +375,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
const unlinked =
|
||||
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
for (const booking of unlinked) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
);
|
||||
@@ -375,7 +387,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
|
||||
async runBatchFill(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
@@ -405,7 +417,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationStation: true,
|
||||
route: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
@@ -413,7 +425,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue;
|
||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
|
||||
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
@@ -425,13 +437,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
? (b.governmentInstitution ?? 'Government')
|
||||
: (b.company?.name ?? '—'),
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—"),
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
paymentDeadline: b.paymentDeadline
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
};
|
||||
});
|
||||
@@ -442,11 +456,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
|
||||
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Schedule is no longer active');
|
||||
async getBatchBoardDetail(
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> {
|
||||
const s =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!s)
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
|
||||
throw new BadRequestException("Schedule is no longer active");
|
||||
}
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
@@ -456,12 +474,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
|
||||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||||
>;
|
||||
try {
|
||||
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
allocationPreview =
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
} catch {
|
||||
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
|
||||
allocationPreview = {
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
};
|
||||
}
|
||||
const allocationByBooking = new Map(
|
||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||
@@ -474,17 +498,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
? (b.governmentInstitution ?? 'Government')
|
||||
: (b.company?.name ?? '—'),
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—"),
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
paymentDeadline: b.paymentDeadline
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
|
||||
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
|
||||
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
|
||||
fullyExecutedAt: b.fullyExecutedAt
|
||||
? b.fullyExecutedAt.toISOString()
|
||||
: null,
|
||||
selectedForBatchAt: b.selectedForBatchAt
|
||||
? b.selectedForBatchAt.toISOString()
|
||||
: null,
|
||||
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
||||
allocationIssue: alloc?.issue ?? null,
|
||||
};
|
||||
});
|
||||
@@ -514,11 +544,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
if (b.state === 'ALLOCATED') counts.allocated += 1;
|
||||
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
|
||||
else if (b.state === 'READY') counts.ready += 1;
|
||||
else if (b.state === 'WAITING') counts.waiting += 1;
|
||||
else if (b.state === 'EXPIRED') counts.expired += 1;
|
||||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||
else if (b.state === "READY") counts.ready += 1;
|
||||
else if (b.state === "WAITING") counts.waiting += 1;
|
||||
else if (b.state === "EXPIRED") counts.expired += 1;
|
||||
else counts.pendingContract += 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -526,7 +556,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === 'pending-contract' || !bucket.window) continue;
|
||||
if (key === "pending-contract" || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
@@ -539,44 +569,51 @@ export class BookingBatchService implements OnModuleInit {
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
windows.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
|
||||
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
|
||||
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate
|
||||
? s.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
.length,
|
||||
ready: items.filter((i) => i.state === "READY").length,
|
||||
waiting: items.filter((i) => i.state === "WAITING").length,
|
||||
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
|
||||
.length,
|
||||
expired: items.filter((i) => i.state === "EXPIRED").length,
|
||||
},
|
||||
windows,
|
||||
pendingContract: {
|
||||
key: 'pending-contract',
|
||||
label: 'Pending contract',
|
||||
date: '',
|
||||
dateLabel: '',
|
||||
start: '',
|
||||
end: '',
|
||||
key: "pending-contract",
|
||||
label: "Pending contract",
|
||||
date: "",
|
||||
dateLabel: "",
|
||||
start: "",
|
||||
end: "",
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
@@ -597,17 +634,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
): BatchBoardSchedule['capacity'] {
|
||||
const allocated = items.filter((i) => i.state === 'ALLOCATED');
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
|
||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||
);
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters:
|
||||
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
|
||||
Math.round(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
|
||||
) / 100,
|
||||
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
|
||||
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
|
||||
usedWeightTons:
|
||||
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
|
||||
100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
};
|
||||
}
|
||||
@@ -623,51 +664,66 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate
|
||||
? s.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
.length,
|
||||
ready: items.filter((i) => i.state === "READY").length,
|
||||
waiting: items.filter((i) => i.state === "WAITING").length,
|
||||
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
|
||||
.length,
|
||||
expired: items.filter((i) => i.state === "EXPIRED").length,
|
||||
},
|
||||
bookings: items.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
|
||||
if (linked) return 'ALLOCATED';
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return 'SELECTED_FOR_BATCH';
|
||||
private boardState(
|
||||
booking: Booking,
|
||||
linked: boolean,
|
||||
): BatchBoardBookingState {
|
||||
if (linked) return "ALLOCATED";
|
||||
if (
|
||||
booking.status === "SELECTED_FOR_BATCH" ||
|
||||
booking.status === "AWAITING_PAYMENT"
|
||||
) {
|
||||
return "SELECTED_FOR_BATCH";
|
||||
}
|
||||
if (booking.status === 'EXPIRED') return 'EXPIRED';
|
||||
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
|
||||
if (booking.status === 'PAID') return 'WAITING';
|
||||
return 'PENDING_CONTRACT';
|
||||
if (booking.status === "EXPIRED") return "EXPIRED";
|
||||
if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
|
||||
return "READY";
|
||||
if (booking.status === "PAID") return "WAITING";
|
||||
return "PENDING_CONTRACT";
|
||||
}
|
||||
|
||||
// ---- core fill ------------------------------------------------------------
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -677,7 +733,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -689,7 +745,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
if (booking.isGovernment) {
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
|
||||
budget = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
need,
|
||||
budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
||||
@@ -697,7 +758,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
armed = true;
|
||||
@@ -706,7 +767,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
||||
}
|
||||
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
@@ -732,13 +793,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const scheduleIds = bookable
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === 'OPEN' &&
|
||||
s.bookingWindowStatus === "OPEN" &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||
new Date(a.scheduleDate).getTime() -
|
||||
new Date(b.scheduleDate).getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
@@ -750,15 +812,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||
this.logger.warn(
|
||||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingCapacity(
|
||||
schedule,
|
||||
limits,
|
||||
wagonLengths,
|
||||
);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
@@ -779,7 +848,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||
t.budget = await this.preemptForGovernment(
|
||||
t.id,
|
||||
need,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (this.fits(need, t.budget)) {
|
||||
target = t;
|
||||
break;
|
||||
@@ -794,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(target.id, booking, 'gov');
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
target.armed = true;
|
||||
@@ -803,7 +877,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
@@ -813,18 +887,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const reserved =
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
let anySettled = false;
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
const paid =
|
||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: false;
|
||||
|
||||
if (paid) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
anySettled = true;
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
@@ -840,17 +916,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Allocate paid reservations, expire the rest, then top up. */
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const reserved =
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
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');
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
}
|
||||
@@ -862,11 +940,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private triggerWagonAllocation(scheduleId: string): void {
|
||||
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
|
||||
this.logger.warn(
|
||||
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
void this.trainSchedulingService
|
||||
.tryAutoWagonAllocation(scheduleId)
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- staff override actions ----------------------------------------------
|
||||
@@ -878,18 +958,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (!booking.trainScheduleId) {
|
||||
throw new BadRequestException('Booking has no target schedule to allocate to');
|
||||
throw new BadRequestException(
|
||||
"Booking has no target schedule to allocate to",
|
||||
);
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID' });
|
||||
await this.allocate(booking.trainScheduleId, booking, 'paid');
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
}
|
||||
@@ -898,7 +980,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
|
||||
* Used for EXPIRED or full-schedule bookings — no re-approval.
|
||||
*/
|
||||
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> {
|
||||
async moveToSchedule(
|
||||
bookingId: string,
|
||||
newScheduleId: string,
|
||||
): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
@@ -907,15 +992,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: newScheduleId } });
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Target schedule is not accepting bookings');
|
||||
if (!schedule)
|
||||
throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
|
||||
if (schedule.bookingWindowStatus !== "OPEN") {
|
||||
throw new BadRequestException(
|
||||
"Target schedule is not accepting bookings",
|
||||
);
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== booking.originYardId ||
|
||||
schedule.destinationStationId !== booking.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Target schedule is not on the booking route');
|
||||
throw new BadRequestException(
|
||||
"Target schedule is not on the booking route",
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -927,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
const restoredStatus =
|
||||
booking.status === 'EXPIRED'
|
||||
booking.status === "EXPIRED"
|
||||
? booking.isGovernment
|
||||
? 'APPROVED'
|
||||
: 'FULLY_EXECUTED'
|
||||
? "APPROVED"
|
||||
: "FULLY_EXECUTED"
|
||||
: booking.status;
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
status: restoredStatus,
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
@@ -949,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId);
|
||||
if (booking.trainScheduleId)
|
||||
await this.fillSchedule(booking.trainScheduleId);
|
||||
}
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
@@ -967,11 +1058,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
// The invoice was generated at booking creation/approval, before this pay
|
||||
// window opened — refresh its printed due date to the real deadline.
|
||||
await this.billing.syncPayableDueDate(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
deadline,
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -979,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
reason: 'paid' | 'gov',
|
||||
reason: "paid" | "gov",
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const exists = await this.trainScheduleBookingsRepository.existsForBooking(
|
||||
booking.id,
|
||||
manager,
|
||||
);
|
||||
const exists =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(
|
||||
booking.id,
|
||||
manager,
|
||||
);
|
||||
if (!exists) {
|
||||
await this.trainScheduleBookingsRepository.createMany(
|
||||
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
|
||||
@@ -993,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
status: reason === 'paid' ? 'PAID' : booking.status,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
status: reason === "paid" ? "PAID" : booking.status,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
scheduledAt: new Date(),
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
@@ -1012,12 +1111,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// Pay window closed before settlement → expire the booking's open invoice too
|
||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||
// source-agnostic.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
@@ -1035,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
const allocatedCommercial =
|
||||
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId);
|
||||
await this.bookingsRepository.findAllocatedCommercialForSchedule(
|
||||
scheduleId,
|
||||
);
|
||||
|
||||
// lowest priority first; reserved are cheaper to free than allocated
|
||||
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
|
||||
@@ -1052,11 +1157,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
manager,
|
||||
);
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
// Displaced → EXPIRED: close its open invoice too, so a dead booking
|
||||
// can't still be paid (mirrors `expire()`; enlisted in this txn).
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
@@ -1074,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
|
||||
return Math.max(
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
|
||||
);
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
@@ -1161,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
@@ -1172,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
private async loadWagonLengths(): Promise<WagonLengths> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
);
|
||||
return {
|
||||
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
container:
|
||||
byCode.get("NW5")?.lengthMeters ??
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
||||
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
|
||||
return this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.findOne({ where: {} });
|
||||
}
|
||||
|
||||
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
|
||||
@@ -1194,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
@@ -1207,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const used =
|
||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
||||
@@ -1216,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
private async setWindow(
|
||||
scheduleId: string,
|
||||
status: 'OPEN' | 'FULL' | 'CLOSED',
|
||||
status: "OPEN" | "FULL" | "CLOSED",
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -1233,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`),
|
||||
this.logger.error(
|
||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}, PAYMENT_WINDOW_MS);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
@@ -1242,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
const name = this.timeoutName(scheduleId);
|
||||
try {
|
||||
if (this.scheduler.doesExist('timeout', name)) {
|
||||
if (this.scheduler.doesExist("timeout", name)) {
|
||||
this.scheduler.deleteTimeout(name);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
@@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
ImportDjiboutiOperation,
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
|
||||
12
apps/edr-freight-api/src/scripts/cmds/index.ts
Normal file
12
apps/edr-freight-api/src/scripts/cmds/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type Vorpal from "vorpal";
|
||||
import type { CommandContext } from "./types";
|
||||
|
||||
import { registerSeedTestContracts } from "./seed-test-contracts.cmd";
|
||||
import { registerSeedTestSchedules } from "./seed-test-schedules.cmd";
|
||||
import { registerSeedTestCompany } from "./seed-test-company.cmd";
|
||||
|
||||
export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void {
|
||||
registerSeedTestContracts(vorpal, ctx);
|
||||
registerSeedTestSchedules(vorpal, ctx);
|
||||
registerSeedTestCompany(vorpal, ctx);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type Vorpal from "vorpal";
|
||||
import { DataSource } from "typeorm";
|
||||
import type { CommandContext } from "./types";
|
||||
import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity";
|
||||
import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity";
|
||||
import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity";
|
||||
|
||||
export function registerSeedTestCompany(
|
||||
vorpal: Vorpal,
|
||||
ctx: CommandContext,
|
||||
): void {
|
||||
vorpal
|
||||
.command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user")
|
||||
.option("--name <name>", "Company name (default: Test Company)")
|
||||
.option("--email <email>", "Company email (default: company@test.com)")
|
||||
.option("--tin <tin>", "Tax ID (default: auto-generated TSTxxxxx)")
|
||||
.action(async function (this: any, args: any) {
|
||||
const { app } = ctx;
|
||||
const ds = app.get(DataSource);
|
||||
|
||||
const raw = await ds.query(
|
||||
`SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`,
|
||||
);
|
||||
let nextTinNum = 1;
|
||||
if (raw.length > 0) {
|
||||
const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10);
|
||||
if (!isNaN(num)) nextTinNum = num + 1;
|
||||
}
|
||||
|
||||
const name = args.options?.name ?? "Test Company";
|
||||
const email = args.options?.email ?? "company@test.com";
|
||||
const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`;
|
||||
const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`;
|
||||
|
||||
const existing = await ds.getRepository(Company).findOne({ where: { tin } });
|
||||
if (existing) {
|
||||
this.log(`Company with TIN ${tin} already exists (${existing.name})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const company = await ds.getRepository(Company).save(
|
||||
ds.getRepository(Company).create({
|
||||
name,
|
||||
type: CompanyType.Customer,
|
||||
kind: CompanyKind.Commercial,
|
||||
status: CompanyStatus.Active,
|
||||
tin,
|
||||
country: "Ethiopia",
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
email,
|
||||
phone: "+251911000000",
|
||||
address: "Test Address",
|
||||
}),
|
||||
);
|
||||
this.log(` Created company: ${company.name} (TIN: ${tin})`);
|
||||
|
||||
for (const type of [ProfileType.importer, ProfileType.exporter]) {
|
||||
await ds.getRepository(CompanyProfile).save(
|
||||
ds.getRepository(CompanyProfile).create({
|
||||
companyId: company.id,
|
||||
type,
|
||||
reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`,
|
||||
status: ProfileStatus.Active,
|
||||
}),
|
||||
);
|
||||
this.log(` Created ${type} profile (approved)`);
|
||||
}
|
||||
|
||||
await ds.getRepository(ExternalProfile).save(
|
||||
ds.getRepository(ExternalProfile).create({
|
||||
userId,
|
||||
companyId: company.id,
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
isPrimaryContact: true,
|
||||
activeProfileType: ProfileType.importer,
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "done",
|
||||
}),
|
||||
);
|
||||
this.log(` Created external profile: Test User (userId: ${userId})`);
|
||||
|
||||
this.log(`\nDone — login with email "${email}" and password "password"`);
|
||||
});
|
||||
}
|
||||
330
apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts
Normal file
330
apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type Vorpal from "vorpal";
|
||||
import { DataSource } from "typeorm";
|
||||
import type { CommandContext } from "./types";
|
||||
import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity";
|
||||
import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity";
|
||||
import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity";
|
||||
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
|
||||
import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity";
|
||||
import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity";
|
||||
import { Rate } from "../../modules/rule-engine/entities/rate.entity";
|
||||
import { Contract } from "../../modules/contracts/entities/contract.entity";
|
||||
import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity";
|
||||
import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity";
|
||||
import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity";
|
||||
|
||||
export function registerSeedTestContracts(
|
||||
vorpal: Vorpal,
|
||||
ctx: CommandContext,
|
||||
): void {
|
||||
vorpal
|
||||
.command("seed:test-contracts", "Generate test contracts with companies and all deps")
|
||||
.option("-n, --count <n>", "Number of contracts to create (default: 4)")
|
||||
.option("--status <statuses>", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)")
|
||||
.option("--freight <types>", "Freight types: CONTAINER,BULK (default: both)")
|
||||
.option("--direction <dirs>", "Trade directions: IMPORT,EXPORT (default: both)")
|
||||
.option("--company <name>", "Only create contracts for company matching name/TIN")
|
||||
.action(async function (this: any, args: any) {
|
||||
const { app } = ctx;
|
||||
const ds = app.get(DataSource);
|
||||
|
||||
const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10)));
|
||||
const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE")
|
||||
.split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||
const freightList = (args.options?.freight ?? "CONTAINER,BULK")
|
||||
.split(",").map((s: string) => s.toUpperCase().trim())
|
||||
.filter((s: string) => s === "CONTAINER" || s === "BULK");
|
||||
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
|
||||
.split(",").map((s: string) => s.toUpperCase().trim())
|
||||
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
|
||||
const companyFilter = args.options?.company as string | undefined;
|
||||
|
||||
if (freightList.length === 0 || directionList.length === 0) {
|
||||
this.log("error: at least one freight type and trade direction required");
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`);
|
||||
|
||||
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
|
||||
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
||||
const djibouti = yardByCode.get("DJIBOUTI");
|
||||
const addis = yardByCode.get("ADDIS_ABABA");
|
||||
if (!djibouti || !addis) {
|
||||
this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded");
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceTypes = await ds
|
||||
.getRepository(ServiceType)
|
||||
.find({ where: { isActive: true } });
|
||||
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
|
||||
const railContainer = stByCode.get("RAIL_CONTAINER");
|
||||
const railBulk = stByCode.get("RAIL_BULK");
|
||||
if (!railContainer && !railBulk) {
|
||||
this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded");
|
||||
return;
|
||||
}
|
||||
|
||||
const cargoTypes = await ds
|
||||
.getRepository(CargoType)
|
||||
.find({ where: { isActive: true } });
|
||||
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
|
||||
const grain = cargoByCode.get("GRAIN");
|
||||
const sugar = cargoByCode.get("SUGAR");
|
||||
const fertilizer = cargoByCode.get("FERTILIZER");
|
||||
|
||||
const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } });
|
||||
|
||||
const companyRepo = ds.getRepository(Company);
|
||||
let companies = await companyRepo.find({});
|
||||
|
||||
if (companyFilter) {
|
||||
companies = companies.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(companyFilter.toLowerCase()) ||
|
||||
c.tin.includes(companyFilter),
|
||||
);
|
||||
}
|
||||
|
||||
if (companies.length === 0) {
|
||||
this.log("No existing companies found — seeding test companies...");
|
||||
companies = await seedTestCompanies(ds, (msg) => this.log(msg));
|
||||
} else {
|
||||
this.log(`Using ${companies.length} existing companies from DB`);
|
||||
}
|
||||
|
||||
const contractRepo = ds.getRepository(Contract);
|
||||
|
||||
const maxRaw = await ds.query(
|
||||
`SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`,
|
||||
);
|
||||
let nextRef = 1;
|
||||
if (maxRaw.length > 0) {
|
||||
const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10);
|
||||
if (!isNaN(num)) nextRef = num + 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const statusIdx = i % statusList.length;
|
||||
const ftIdx = i % freightList.length;
|
||||
const dirIdx = i % directionList.length;
|
||||
const companyIdx = i % companies.length;
|
||||
|
||||
const status = statusList[statusIdx];
|
||||
const freightType = freightList[ftIdx];
|
||||
const direction = directionList[dirIdx];
|
||||
const company = companies[companyIdx];
|
||||
|
||||
const profile = await ds.getRepository(CompanyProfile).findOne({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter,
|
||||
},
|
||||
});
|
||||
if (!profile) continue;
|
||||
|
||||
const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`;
|
||||
|
||||
const serviceTypeId =
|
||||
freightType === "BULK" && railBulk
|
||||
? railBulk.id
|
||||
: railContainer
|
||||
? railContainer.id
|
||||
: serviceTypes[0].id;
|
||||
|
||||
const originId = direction === "IMPORT" ? djibouti.id : addis.id;
|
||||
const destId = direction === "IMPORT" ? addis.id : djibouti.id;
|
||||
|
||||
const contract = contractRepo.create({
|
||||
reference: ref,
|
||||
companyId: company.id,
|
||||
companyProfileId: profile.id,
|
||||
contractKind: "ONE_TIME" as const,
|
||||
tradeDirection: direction,
|
||||
freightType,
|
||||
serviceTypeId,
|
||||
paymentCurrency: "USD",
|
||||
customsClearingEnabled: false,
|
||||
equipmentReturn: "without_return",
|
||||
status,
|
||||
versionNumber: 1,
|
||||
});
|
||||
|
||||
const saved = await contractRepo.save(contract);
|
||||
|
||||
await ds.getRepository(ContractRoute).save(
|
||||
ds.getRepository(ContractRoute).create({
|
||||
contractId: saved.id,
|
||||
originYardId: originId,
|
||||
destinationYardId: destId,
|
||||
sortOrder: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (freightType === "CONTAINER") {
|
||||
for (const size of ["20FT", "40FT"] as const) {
|
||||
await ds.getRepository(ContractCargoScope).save(
|
||||
ds.getRepository(ContractCargoScope).create({
|
||||
contractId: saved.id,
|
||||
containerSize: size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const bulkCargo = grain || sugar || fertilizer;
|
||||
if (bulkCargo) {
|
||||
await ds.getRepository(ContractCargoScope).save(
|
||||
ds.getRepository(ContractCargoScope).create({
|
||||
contractId: saved.id,
|
||||
cargoTypeId: bulkCargo.id,
|
||||
quantityCap: 10000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const matchingRates = rates.filter((r) => {
|
||||
if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false;
|
||||
if (r.appliesTo === "BULK" && freightType !== "BULK") return false;
|
||||
if (r.tradeDirection && r.tradeDirection !== direction) return false;
|
||||
return r.status === "LIVE" && r.trigger === "ALWAYS";
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const rate of matchingRates.slice(0, 3)) {
|
||||
const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
|
||||
await ds.getRepository(ContractRateSnapshot).save(
|
||||
ds.getRepository(ContractRateSnapshot).create({
|
||||
contractId: saved.id,
|
||||
rateId: rate.id,
|
||||
rateCode: rate.rateType,
|
||||
unitPrice: Number(rate.rateValue),
|
||||
unitOfMeasure: rate.rateUnit,
|
||||
currency: rate.currency ?? "USD",
|
||||
containerSize: freightType === "CONTAINER" ? "20FT" : null,
|
||||
isSurcharge: rate.trigger !== "ALWAYS",
|
||||
conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`);
|
||||
}
|
||||
|
||||
this.log(`Done — ${count} new contracts created`);
|
||||
});
|
||||
}
|
||||
|
||||
interface CompanySeed {
|
||||
name: string;
|
||||
tin: string;
|
||||
profiles: Array<{ type: ProfileType; reference: string }>;
|
||||
externalProfile: { userId: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
const TEST_COMPANIES: CompanySeed[] = [
|
||||
{
|
||||
name: "Test Importer Co.", tin: "TST000001",
|
||||
profiles: [
|
||||
{ type: ProfileType.importer, reference: "TST-IM-001" },
|
||||
{ type: ProfileType.exporter, reference: "TST-EX-001" },
|
||||
],
|
||||
externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" },
|
||||
},
|
||||
{
|
||||
name: "Test Exporter Ltd.", tin: "TST000002",
|
||||
profiles: [
|
||||
{ type: ProfileType.importer, reference: "TST-IM-002" },
|
||||
{ type: ProfileType.exporter, reference: "TST-EX-002" },
|
||||
],
|
||||
externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" },
|
||||
},
|
||||
{
|
||||
name: "Bulk Commodities PLC", tin: "TST000003",
|
||||
profiles: [
|
||||
{ type: ProfileType.importer, reference: "TST-IM-003" },
|
||||
{ type: ProfileType.exporter, reference: "TST-EX-003" },
|
||||
],
|
||||
externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" },
|
||||
},
|
||||
{
|
||||
name: "Hazardous Logistics Inc.", tin: "TST000004",
|
||||
profiles: [
|
||||
{ type: ProfileType.importer, reference: "TST-IM-004" },
|
||||
{ type: ProfileType.exporter, reference: "TST-EX-004" },
|
||||
],
|
||||
externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" },
|
||||
},
|
||||
];
|
||||
|
||||
async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise<Company[]> {
|
||||
const companyRepo = ds.getRepository(Company);
|
||||
const profileRepo = ds.getRepository(CompanyProfile);
|
||||
const extProfileRepo = ds.getRepository(ExternalProfile);
|
||||
const result: Company[] = [];
|
||||
|
||||
for (const seed of TEST_COMPANIES) {
|
||||
let company = await companyRepo.findOne({ where: { tin: seed.tin } });
|
||||
if (!company) {
|
||||
company = await companyRepo.save(
|
||||
companyRepo.create({
|
||||
name: seed.name,
|
||||
type: CompanyType.Customer,
|
||||
kind: CompanyKind.Commercial,
|
||||
status: CompanyStatus.Active,
|
||||
tin: seed.tin,
|
||||
country: "Ethiopia",
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`,
|
||||
phone: "+251911000001",
|
||||
}),
|
||||
);
|
||||
log(` Created company: ${seed.name}`);
|
||||
} else {
|
||||
log(` Company already exists: ${seed.name}`);
|
||||
}
|
||||
|
||||
for (const p of seed.profiles) {
|
||||
const existing = await profileRepo.findOne({
|
||||
where: { companyId: company.id, type: p.type },
|
||||
});
|
||||
if (!existing) {
|
||||
await profileRepo.save(
|
||||
profileRepo.create({
|
||||
companyId: company.id,
|
||||
type: p.type,
|
||||
reference: p.reference,
|
||||
status: ProfileStatus.Active,
|
||||
}),
|
||||
);
|
||||
log(` Created ${p.type} profile: ${p.reference}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ext = seed.externalProfile;
|
||||
const existingExt = await extProfileRepo.findOne({
|
||||
where: { companyId: company.id, userId: ext.userId },
|
||||
});
|
||||
if (!existingExt) {
|
||||
await extProfileRepo.save(
|
||||
extProfileRepo.create({
|
||||
userId: ext.userId,
|
||||
companyId: company.id,
|
||||
firstName: ext.firstName,
|
||||
lastName: ext.lastName,
|
||||
isPrimaryContact: true,
|
||||
onboardingCompleted: true,
|
||||
}),
|
||||
);
|
||||
log(` Created external profile: ${ext.firstName} ${ext.lastName}`);
|
||||
}
|
||||
|
||||
result.push(company);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
243
apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts
Normal file
243
apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type Vorpal from "vorpal";
|
||||
import { DataSource } from "typeorm";
|
||||
import { WagonStatus } from "@edr/types";
|
||||
import type { CommandContext } from "./types";
|
||||
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
|
||||
import { Route } from "../../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity";
|
||||
import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity";
|
||||
import { Wagon } from "../../modules/wagons/entities/wagon.entity";
|
||||
import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity";
|
||||
import { TrainSet } from "../../modules/train-sets/entities/train-set.entity";
|
||||
import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity";
|
||||
import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity";
|
||||
import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity";
|
||||
|
||||
async function nextSequence(ds: DataSource, pattern: string): Promise<number> {
|
||||
const like = pattern.replace(/\*/g, "%");
|
||||
const raw = await ds.query(
|
||||
`SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`,
|
||||
[like.replace(/%/g, "") + "%"],
|
||||
);
|
||||
if (raw.length === 0) return 1;
|
||||
const ref: string = raw[0].train_number;
|
||||
const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10);
|
||||
return isNaN(num) ? 1 : num + 1;
|
||||
}
|
||||
|
||||
async function nextRouteSeq(ds: DataSource, prefix: string): Promise<number> {
|
||||
const raw = await ds.query(
|
||||
`SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`,
|
||||
[prefix + "%"],
|
||||
);
|
||||
if (raw.length === 0) return 1;
|
||||
const num = parseInt(raw[0].name.replace(prefix, ""), 10);
|
||||
return isNaN(num) ? 1 : num + 1;
|
||||
}
|
||||
|
||||
async function nextWagonSeq(ds: DataSource, prefix: string): Promise<number> {
|
||||
const raw = await ds.query(
|
||||
`SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`,
|
||||
[prefix + "%"],
|
||||
);
|
||||
if (raw.length === 0) return 1;
|
||||
const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10);
|
||||
return isNaN(num) ? 1 : num + 1;
|
||||
}
|
||||
|
||||
export function registerSeedTestSchedules(
|
||||
vorpal: Vorpal,
|
||||
ctx: CommandContext,
|
||||
): void {
|
||||
vorpal
|
||||
.command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking")
|
||||
.option("-n, --count <n>", "Number of schedules to create (default: 3)")
|
||||
.option("--direction <dirs>", "IMPORT,EXPORT (default: both)")
|
||||
.option("--status <statuses>", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)")
|
||||
.option("--days-ahead <n>", "Days from now for departure (default: 3)")
|
||||
.action(async function (this: any, args: any) {
|
||||
const { app } = ctx;
|
||||
const ds = app.get(DataSource);
|
||||
|
||||
const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10)));
|
||||
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
|
||||
.split(",").map((s: string) => s.toUpperCase().trim())
|
||||
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
|
||||
const statusList = (args.options?.status ?? "SCHEDULED")
|
||||
.split(",").map((s: string) => s.toUpperCase().trim())
|
||||
.filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED");
|
||||
const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10));
|
||||
|
||||
if (directionList.length === 0 || statusList.length === 0) {
|
||||
this.log("error: at least one direction and status required");
|
||||
return;
|
||||
}
|
||||
|
||||
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
|
||||
const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y]));
|
||||
const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti");
|
||||
const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia");
|
||||
|
||||
if (!djibouti || !addis) {
|
||||
this.log("error: need at least one Djibouti and one Ethiopia yard");
|
||||
return;
|
||||
}
|
||||
|
||||
const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } });
|
||||
if (wagonTypes.length === 0) {
|
||||
this.log("error: no wagon types found — seed reference data first");
|
||||
return;
|
||||
}
|
||||
|
||||
const wagonType = wagonTypes[0];
|
||||
const wagonCapacity = Number(wagonType.capacityTons) || 70;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const tareWeight = Number(wagonType.tareWeightTons) || 14;
|
||||
|
||||
const locomotiveRepo = ds.getRepository(Locomotive);
|
||||
const scheduleRepo = ds.getRepository(TrainSchedule);
|
||||
const trainSetRepo = ds.getRepository(TrainSet);
|
||||
const wagonRepo = ds.getRepository(Wagon);
|
||||
const routeRepo = ds.getRepository(Route);
|
||||
const milestoneRepo = ds.getRepository(RouteMilestone);
|
||||
|
||||
let nextTrainNum = await nextSequence(ds, "TST-SCH-*");
|
||||
const routePrefix = "TST-RTE-";
|
||||
let nextRouteNum = await nextRouteSeq(ds, routePrefix);
|
||||
|
||||
const now = new Date();
|
||||
const travelHours = 11;
|
||||
const intermediateYards = yards.filter(
|
||||
(y) => y.id !== djibouti.id && y.id !== addis.id,
|
||||
);
|
||||
|
||||
let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } });
|
||||
if (!loco) {
|
||||
loco = await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: "TST-LOCO-01",
|
||||
name: "Test Locomotive",
|
||||
locomotiveType: "DIESEL",
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: "AVAILABLE",
|
||||
currentYardId: djibouti.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const seq = nextTrainNum + i;
|
||||
const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`;
|
||||
const dir = directionList[i % directionList.length];
|
||||
const status = statusList[i % statusList.length];
|
||||
const isDispatched = status === "DISPATCHED";
|
||||
const originYard = dir === "IMPORT" ? djibouti : addis;
|
||||
const destYard = dir === "IMPORT" ? addis : djibouti;
|
||||
const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`;
|
||||
|
||||
const departure = new Date(now);
|
||||
departure.setDate(departure.getDate() + daysAhead + i);
|
||||
departure.setHours(7, 0, 0, 0);
|
||||
const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000);
|
||||
|
||||
const route = await routeRepo.save(
|
||||
routeRepo.create({
|
||||
name: routeName,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destYard.id,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await milestoneRepo.save(
|
||||
milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }),
|
||||
);
|
||||
for (const [mi, y] of intermediateYards.entries()) {
|
||||
await milestoneRepo.save(
|
||||
milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }),
|
||||
);
|
||||
}
|
||||
await milestoneRepo.save(
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: destYard.id,
|
||||
sequenceNo: (intermediateYards.length + 1) * 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const totalWagonWeight = 4 * (tareWeight + 20);
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: loco.id,
|
||||
totalWeightTons: totalWagonWeight,
|
||||
totalLengthMeters: wagonLength * 4,
|
||||
wagonCount: 4,
|
||||
status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED",
|
||||
}),
|
||||
);
|
||||
|
||||
await ds.getRepository(TrainSetLocomotive).save(
|
||||
ds.getRepository(TrainSetLocomotive).create({
|
||||
trainSetId: trainSet.id,
|
||||
locomotiveId: loco.id,
|
||||
sequenceNo: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: originYard.id,
|
||||
destinationStationId: destYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: isDispatched ? departure : null,
|
||||
status,
|
||||
trainNumber,
|
||||
direction: dir,
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN",
|
||||
}),
|
||||
);
|
||||
|
||||
const wagonPrefix = `${trainNumber}-W`;
|
||||
let nextWagon = await nextWagonSeq(ds, wagonPrefix);
|
||||
for (let w = 0; w < 4; w++) {
|
||||
const ws = nextWagon + w;
|
||||
const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`;
|
||||
|
||||
const wagon = wagonRepo.create({
|
||||
wagonNumber,
|
||||
wagonTypeId: wagonType.id,
|
||||
currentYardId: originYard.id,
|
||||
currentTrainScheduleId: schedule.id,
|
||||
tareWeight,
|
||||
maxPayloadWeight: wagonCapacity,
|
||||
status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
notes: "Test seed wagon",
|
||||
});
|
||||
const saved = await wagonRepo.save(wagon as any);
|
||||
const physicalWagon = Array.isArray(saved) ? saved[0] : saved;
|
||||
|
||||
await ds.getRepository(TrainSetWagon).save(
|
||||
ds.getRepository(TrainSetWagon).create({
|
||||
trainSetId: trainSet.id,
|
||||
wagonTypeId: wagonType.id,
|
||||
physicalWagonId: physicalWagon.id,
|
||||
sequenceNo: w + 1,
|
||||
capacityTons: wagonCapacity,
|
||||
lengthMeters: wagonLength,
|
||||
assignedWeightTons: 20,
|
||||
status: isDispatched ? "DEPARTED" : "PLANNED",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`);
|
||||
}
|
||||
|
||||
this.log(`Done — ${count} new train schedules created`);
|
||||
});
|
||||
}
|
||||
5
apps/edr-freight-api/src/scripts/cmds/types.ts
Normal file
5
apps/edr-freight-api/src/scripts/cmds/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { INestApplicationContext } from "@nestjs/common";
|
||||
|
||||
export type CommandContext = {
|
||||
app: INestApplicationContext;
|
||||
};
|
||||
36
apps/edr-freight-api/src/scripts/main.ts
Normal file
36
apps/edr-freight-api/src/scripts/main.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import "reflect-metadata";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
import Vorpal from "vorpal";
|
||||
import { registerCommands } from "./cmds/index";
|
||||
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "../app.module";
|
||||
|
||||
const vorpal = new Vorpal();
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: false,
|
||||
});
|
||||
|
||||
try {
|
||||
registerCommands(vorpal, { app });
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 0) {
|
||||
await vorpal.exec(args.join(" "));
|
||||
} else {
|
||||
vorpal.parse(process.argv);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Script failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
|
||||
const escapePdfText = (value: string) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const money = (amount: unknown, currency = 'USD') =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
const fmtDate = (value: unknown) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value as string | Date);
|
||||
@@ -96,12 +93,6 @@ const textOp = (
|
||||
color = '0 0 0',
|
||||
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
|
||||
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
|
||||
buildCircularSeal(452, 155, label),
|
||||
];
|
||||
|
||||
const buildWarehouseOfficerSealBand = () => [
|
||||
lineOp(60, 218, 535, 218),
|
||||
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
|
||||
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
|
||||
return new Blob([pdf], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
|
||||
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
|
||||
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
|
||||
const bookingReference = firstText(invoice.bookingReference);
|
||||
const customerName = firstText(invoice.customerName);
|
||||
const inventoryReference = firstText(invoice.inventoryReference);
|
||||
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
|
||||
const clearanceStatus = firstText(
|
||||
invoice.clearanceStatus,
|
||||
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
|
||||
);
|
||||
const lines: PdfLine[] = [
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
|
||||
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
|
||||
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
|
||||
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
|
||||
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
...(invoice.items ?? []).flatMap((item) => [
|
||||
{ text: item.description, bold: true, align: 'center' as const },
|
||||
{
|
||||
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
|
||||
yGap: 13,
|
||||
align: 'center' as const,
|
||||
},
|
||||
]),
|
||||
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
];
|
||||
const authorizationOps = [
|
||||
...buildAuthorizationBand('PAID'),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
const invoiceOps = [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
|
||||
}
|
||||
|
||||
const firstText = (...values: Array<unknown>) => {
|
||||
for (const value of values) {
|
||||
if (value !== null && value !== undefined && String(value).trim()) return String(value);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
* bytes through `GET /api/files/:id` (served from MinIO with backend
|
||||
|
||||
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||
* window so it still lands on sections that mount after an async fetch (there is
|
||||
* no router-level hash handling). Deep-link targets give a card an `id`.
|
||||
*/
|
||||
export function useScrollToHash(): void {
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const id = decodeURIComponent(hash.slice(1));
|
||||
let tries = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
@@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
||||
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
|
||||
|
||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: inv, isLoading } = useQuery(
|
||||
api.warehouses.invoice.queryOptions({
|
||||
input: { id: id ?? '' },
|
||||
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
|
||||
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Download failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
|
||||
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Download failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
||||
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{inv.bookingId && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View booking
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
|
||||
@@ -147,6 +147,16 @@ export const URL_CONSTANTS = {
|
||||
BILLING: {
|
||||
MY_INVOICES: "/api/billing/my-invoices",
|
||||
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
FOR_BOOKING: (bookingId: string) =>
|
||||
`/api/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
|
||||
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||
* window so it still lands on sections that mount after an async fetch (the app
|
||||
* has no router-level hash handling). Deep-link targets give a card an `id`.
|
||||
*/
|
||||
export function useScrollToHash(): void {
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const id = decodeURIComponent(hash.slice(1));
|
||||
let tries = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -15,9 +15,16 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
||||
import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import {
|
||||
@@ -49,14 +56,29 @@ export default function InvoiceDetailPage() {
|
||||
api.invoices.get.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
const payMutation = useMutation(
|
||||
api.invoices.pay.mutationOptions({
|
||||
onSuccess: (res) => {
|
||||
const url = res.clientAction?.url;
|
||||
if (url) window.location.href = url;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
|
||||
// Extracted for payMutation callbacks — guaranteed defined when they run
|
||||
// (guarded by the early return below).
|
||||
const invSource = invoice?.source;
|
||||
const invSourceId = invoice?.sourceId;
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: async (method: PaymentMethod) => {
|
||||
const bookingId =
|
||||
invSource === "warehouse"
|
||||
? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId!
|
||||
: invSourceId!;
|
||||
return api.payments.initiate.call({ bookingId, method });
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId: invSourceId!, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -90,9 +112,55 @@ export default function InvoiceDetailPage() {
|
||||
const lines = invoice.lines ?? [];
|
||||
|
||||
const handlePay = () => {
|
||||
const returnUrl = `${window.location.origin}/payment/success`;
|
||||
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||
setPayModalOpen(true);
|
||||
};
|
||||
|
||||
const hasReceipt = Number(invoice.paidAmount) > 0;
|
||||
const canViewSource =
|
||||
invoice.source === "booking" || invoice.source === "warehouse";
|
||||
|
||||
const downloadInvoice = async () => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadDocument(id),
|
||||
`invoice-${invoice.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async () => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadReceipt(id),
|
||||
`receipt-${invoice.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
// The source link: a booking invoice goes straight to the booking; a warehouse
|
||||
// fee invoice resolves its booking (via the warehouse view) and deep-links to
|
||||
// that booking's warehouse-payments section.
|
||||
const viewSource = async () => {
|
||||
if (invoice.source === "booking") {
|
||||
navigate(`/bookings/${invoice.sourceId}`);
|
||||
return;
|
||||
}
|
||||
if (invoice.source === "warehouse") {
|
||||
try {
|
||||
const wh = await warehouseInvoicesService.get(invoice.id);
|
||||
if (wh?.bookingId) {
|
||||
navigate(`/bookings/${wh.bookingId}#warehouse-payments`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the toast below */
|
||||
}
|
||||
toast.error("This invoice's source isn't linked to a booking.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -117,27 +185,58 @@ export default function InvoiceDetailPage() {
|
||||
</Title>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</Group>
|
||||
{payable && (
|
||||
<Group gap={8} wrap="wrap">
|
||||
{canViewSource && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
onClick={viewSource}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
View source
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={downloadInvoice}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{hasReceipt && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={downloadReceipt}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
{payable && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{payMutation.isError && (
|
||||
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
|
||||
Please try again, or contact support if the problem persists.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
@@ -241,6 +340,27 @@ export default function InvoiceDetailPage() {
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
currency={invoice.currency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ const STATUS_STYLE: Record<
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||
[Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" },
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||
|
||||
@@ -24,19 +24,22 @@ import {
|
||||
ConsolidationPairedNotice,
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { PaymentCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
|
||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
const navigate = useNavigate();
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { view, viewer } = useFileViewer();
|
||||
@@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{showCountdown && (
|
||||
<PaymentDeadlineCard
|
||||
paymentDeadline={booking.paymentDeadline!}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
<PaymentCard booking={booking} pricing={pricing} />
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Download,
|
||||
FileText,
|
||||
Receipt,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||
|
||||
// ── Pay-window countdown ─────────────────────────────────────────────────────
|
||||
|
||||
interface Remaining {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
function getRemaining(deadlineMs: number): Remaining {
|
||||
const diff = deadlineMs - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||
const total = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(total / 86400),
|
||||
hours: Math.floor((total % 86400) / 3600),
|
||||
minutes: Math.floor((total % 3600) / 60),
|
||||
seconds: total % 60,
|
||||
expired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Segment({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||
<Text fz="26px" fw={800} c="#10202F" lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.6px" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Countdown({
|
||||
deadline,
|
||||
onPay,
|
||||
paying,
|
||||
}: {
|
||||
deadline: string;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
}) {
|
||||
const deadlineMs = new Date(deadline).getTime();
|
||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getRemaining(deadlineMs));
|
||||
const interval = setInterval(() => {
|
||||
const next = getRemaining(deadlineMs);
|
||||
setRemaining(next);
|
||||
if (next.expired) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
if (remaining.expired) {
|
||||
return (
|
||||
<Text fz="13.5px" c="#6B7C8E">
|
||||
The payment window has closed. Move this booking to another schedule or
|
||||
contact support.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" px={4}>
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hrs" />
|
||||
<Segment value={remaining.minutes} label="Min" />
|
||||
<Segment value={remaining.seconds} label="Sec" />
|
||||
</Group>
|
||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||
Deadline:{" "}
|
||||
{new Date(deadline).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={14}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={17} />}
|
||||
onClick={onPay}
|
||||
loading={paying}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One card covering the whole payment story for a booking: the live pay-window
|
||||
* countdown (when open), the price breakdown, and the invoice(s) — each with a
|
||||
* link to its detail page and a download. Replaces the separate deadline +
|
||||
* breakdown cards.
|
||||
*/
|
||||
export function BookingPaymentPanel({
|
||||
booking,
|
||||
pricing,
|
||||
onPay,
|
||||
paying,
|
||||
showCountdown,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
pricing: Pricing;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
showCountdown?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const items = priceLineItems(pricing);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
// The invoice worth a prominent "Download" — the first issued one, else any.
|
||||
const primary =
|
||||
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
||||
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
||||
|
||||
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadDocument(inv.id),
|
||||
`invoice-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadReceipt(inv.id),
|
||||
`receipt-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: paid ? "#ECF6F1" : showCountdown ? "#FEF6E6" : "#FDF3E0",
|
||||
color: paid ? "#0A6F4D" : showCountdown ? "#B07D14" : "#9A5B00",
|
||||
border: paid ? "1px solid #CDEBDD" : undefined,
|
||||
}}
|
||||
>
|
||||
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||
{paid
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
onPay={onPay}
|
||||
paying={paying}
|
||||
/>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box mt={showCountdown ? 0 : 12}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Box
|
||||
component="span"
|
||||
mt={6}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#EAF1FB",
|
||||
padding: "3px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
Adjusted by EDR
|
||||
</Box>
|
||||
)}
|
||||
{isAdjusted && booking.adjustmentReason && (
|
||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||
{booking.adjustmentReason}
|
||||
</Text>
|
||||
)}
|
||||
{paid && (
|
||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||
Paid · {fmtDate(booking.updatedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={11}>
|
||||
{items.map((it) => (
|
||||
<Group key={it.label} justify="space-between" wrap="nowrap">
|
||||
<Text fz="13px" c="#6B7C8E">
|
||||
{it.label}
|
||||
</Text>
|
||||
<Text fz="13px" fw={600} c="#10202F">
|
||||
{it.value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{isAdjusted ? "Adjusted total" : "Total"}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" align="center" mb={10}>
|
||||
<CardTitle>Invoices</CardTitle>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{invoices.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={10}>
|
||||
{invoices.map((inv) => (
|
||||
<Group key={inv.id} justify="space-between" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fz="13px"
|
||||
fw={700}
|
||||
c="#10202F"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{titleCase(inv.type)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(primary)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{primary && primaryPaid && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(primary)}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { CreditCard, Timer } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
interface Remaining {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
function getRemaining(deadlineMs: number): Remaining {
|
||||
const diff = deadlineMs - Date.now();
|
||||
if (diff <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||
}
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86400),
|
||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
expired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Segment({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||
<Text
|
||||
fz="28px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
lh={1}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentDeadlineCard({
|
||||
paymentDeadline,
|
||||
onPay,
|
||||
paying,
|
||||
}: {
|
||||
/** ISO timestamp marking the end of the pay window. */
|
||||
paymentDeadline: string;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
}) {
|
||||
const deadlineMs = new Date(paymentDeadline).getTime();
|
||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getRemaining(deadlineMs));
|
||||
const interval = setInterval(() => {
|
||||
const next = getRemaining(deadlineMs);
|
||||
setRemaining(next);
|
||||
if (next.expired) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
p={22}
|
||||
style={
|
||||
remaining.expired
|
||||
? undefined
|
||||
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment deadline</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: accentBg,
|
||||
color: accentFg,
|
||||
}}
|
||||
>
|
||||
<Timer size={13} />
|
||||
{remaining.expired ? "Expired" : "Pay window open"}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{remaining.expired ? (
|
||||
<Text mt={14} fz="13.5px" c="#6B7C8E">
|
||||
The payment window has closed. Move this booking to another schedule or
|
||||
contact support.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hrs" />
|
||||
<Segment value={remaining.minutes} label="Min" />
|
||||
<Segment value={remaining.seconds} label="Sec" />
|
||||
</Group>
|
||||
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
|
||||
Complete payment before the window closes to secure your slot.
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={17} />}
|
||||
onClick={onPay}
|
||||
loading={paying}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
|
||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||
Deadline:{" "}
|
||||
{new Date(paymentDeadline).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, Receipt } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
warehouseInvoicesService,
|
||||
type PortalWarehouseInvoice,
|
||||
} from "@/services/warehouse-invoices.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||
|
||||
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
||||
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||
ISSUED: { bg: "#FEF3E2", fg: "#B45309" },
|
||||
PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" },
|
||||
PAID: { bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
CANCELLED: { bg: "#EEF2F6", fg: "#64748B" },
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" };
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "3px 9px",
|
||||
borderRadius: 999,
|
||||
background: s.bg,
|
||||
color: s.fg,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
||||
* Paying them online is tracked separately (in-system demurrage/storage
|
||||
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
||||
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
||||
*/
|
||||
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-warehouse-invoices", bookingId],
|
||||
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||
});
|
||||
|
||||
if (invoices.length === 0) return null;
|
||||
|
||||
const download = async (inv: PortalWarehouseInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await warehouseInvoicesService.downloadDocument(inv.id),
|
||||
`warehouse-invoice-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Warehouse invoice PDF isn't ready yet.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async (inv: PortalWarehouseInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await warehouseInvoicesService.downloadReceipt(inv.id),
|
||||
`warehouse-receipt-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard id="warehouse-payments">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Warehouse payments</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{invoices.length} {invoices.length === 1 ? "invoice" : "invoices"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={12}>
|
||||
{invoices.map((inv) => {
|
||||
const detail = [
|
||||
inv.invoiceType?.replace(/_/g, " "),
|
||||
inv.cargoDescription ??
|
||||
inv.containerNumber ??
|
||||
inv.inventoryReference ??
|
||||
undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Group
|
||||
key={inv.id}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
border: "1px solid #EEF2F6",
|
||||
borderRadius: 12,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
<StatusPill status={inv.status} />
|
||||
</Group>
|
||||
{detail && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={4}>
|
||||
Total {money(inv.totalAmount, inv.currency)} · Balance{" "}
|
||||
{money(inv.balanceAmount, inv.currency)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock } from "lucide-react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function LineItems({ pricing }: { pricing: Pricing }) {
|
||||
@@ -107,113 +105,6 @@ export function EstimateCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentCard({
|
||||
booking,
|
||||
pricing,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
pricing: Pricing;
|
||||
}) {
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
// Customer sees the grand total plus the price breakdown that makes it up.
|
||||
// A staff adjustment, when present, overrides the computed total and is
|
||||
// flagged with an "Adjusted by EDR" badge.
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const hasItems = priceLineItems(pricing).length > 0;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
|
||||
color: paid ? "#0A6F4D" : "#9A5B00",
|
||||
border: paid ? "1px solid #CDEBDD" : undefined,
|
||||
}}
|
||||
>
|
||||
{paid && <CheckCircle2 size={13} />}
|
||||
{paid
|
||||
? "Paid"
|
||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box mt={12}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Box
|
||||
component="span"
|
||||
mt={6}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#EAF1FB",
|
||||
padding: "3px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
Adjusted by EDR
|
||||
</Box>
|
||||
)}
|
||||
{isAdjusted && booking.adjustmentReason && (
|
||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||
{booking.adjustmentReason}
|
||||
</Text>
|
||||
)}
|
||||
{paid && (
|
||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||
Paid · {fmtDate(booking.updatedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{hasItems && (
|
||||
<>
|
||||
<Divider />
|
||||
<LineItems pricing={pricing} />
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{isAdjusted ? "Adjusted total" : "Total"}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
{/* variant="default" */}
|
||||
{/* radius={10} */}
|
||||
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
||||
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
||||
{/* > */}
|
||||
{/* Download invoice */}
|
||||
{/* </Button> */}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
// The booking payment card (countdown + breakdown + invoices + download) now
|
||||
// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and
|
||||
// changes-requested views, which only show an estimate.
|
||||
|
||||
@@ -29,12 +29,39 @@ export const invoicesService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The customer's invoices for one source record (e.g. a booking). */
|
||||
listForSource: async (
|
||||
source: string,
|
||||
sourceId: string,
|
||||
): Promise<PortalInvoice[]> => {
|
||||
const { data } = await client.get(B.MY_INVOICES, {
|
||||
params: { source, sourceId },
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** One of the customer's invoices, with its line items. */
|
||||
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The sealed invoice PDF for one of the customer's invoices. */
|
||||
downloadDocument: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** The sealed payment-receipt PDF (available once paid). */
|
||||
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||
pay: async (
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||
|
||||
/**
|
||||
* A warehouse fee invoice as the freight API projects it for the customer
|
||||
* (the historical `WarehouseFeeInvoice` view shape — a subset is used here).
|
||||
*/
|
||||
export interface PortalWarehouseInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoiceType: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: number | string;
|
||||
paidAmount: number | string;
|
||||
balanceAmount: number | string;
|
||||
issuedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
paidAt?: string | null;
|
||||
bookingId?: string | null;
|
||||
inventoryId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
inventoryReference?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
export const warehouseInvoicesService = {
|
||||
/** Warehouse fee invoices linked to a booking (via its inventory items). */
|
||||
listForBooking: async (bookingId: string): Promise<PortalWarehouseInvoice[]> => {
|
||||
const { data } = await client.get(W.FOR_BOOKING(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** A single warehouse fee invoice (carries `bookingId` for source linking). */
|
||||
get: async (id: string): Promise<PortalWarehouseInvoice> => {
|
||||
const { data } = await client.get(W.BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The sealed warehouse fee invoice PDF. */
|
||||
downloadDocument: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" });
|
||||
return data;
|
||||
},
|
||||
|
||||
/** The sealed warehouse fee payment receipt PDF (available once paid). */
|
||||
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Trigger a browser download of a Blob under `filename`. */
|
||||
export function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
# Database (Prisma) — owns the `passenger` schema in edr_database
|
||||
# Production: append ?sslmode=require&connection_limit=10&pool_timeout=20 to enforce SSL and connection pooling
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
|
||||
|
||||
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
|
||||
@@ -32,14 +33,14 @@ FRONTEND_URL=http://localhost:5174
|
||||
BACK_OFFICE_URL=http://localhost:5184
|
||||
|
||||
# JWT (legacy passenger auth — being replaced by IAM)
|
||||
JWT_SECRET=edr-platform-secret-change-in-production
|
||||
# REQUIRED in production — use a random 32+ character string (e.g. openssl rand -hex 32)
|
||||
JWT_SECRET=<change-me-min-32-chars>
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
|
||||
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
|
||||
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
|
||||
# @tria-plc IAM token contract — REQUIRED in production. MUST match the IAM issuer's secret.
|
||||
JWT_ACCESS_TOKEN_SECRET=<change-me-min-32-chars>
|
||||
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
|
||||
JWT_REFRESH_TOKEN_SECRET=<change-me-min-32-chars>
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
# SendGrid
|
||||
@@ -157,7 +158,7 @@ FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
FAYDA_CLAIMS_LOCALES=en am
|
||||
FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
@@ -34,7 +35,7 @@ export class AuditService {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
this.logger.error('Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break main operations
|
||||
}
|
||||
}
|
||||
@@ -74,11 +75,20 @@ export class AuditService {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
const limit = Math.min(filters.limit ?? 50, 200);
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, limit, offset };
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
|
||||
@@ -41,9 +41,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status}`,
|
||||
exception instanceof Error ? exception.stack : JSON.stringify(exception),
|
||||
);
|
||||
console.error('Full error details:', exception);
|
||||
} else {
|
||||
); } else {
|
||||
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -6,6 +6,7 @@ type TranslationMap = Record<string, any>;
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private readonly logger = new Logger(I18nService.name);
|
||||
private translations: Map<string, TranslationMap> = new Map();
|
||||
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
|
||||
private readonly defaultLocale = 'en';
|
||||
@@ -21,7 +22,7 @@ export class I18nService {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.translations.set(locale, JSON.parse(content));
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load translation file for locale: ${locale}`);
|
||||
this.logger.warn(`Failed to load translation file for locale: ${locale}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export class PassengerAuthService {
|
||||
|
||||
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]);
|
||||
} catch (err) {
|
||||
console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message);
|
||||
this.logger.error(`[PassengerAuthService] IAM compensating cleanup failed for ${email}`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTrainDto {
|
||||
@ApiProperty({ example: '301', description: 'Unique train service number' }) @IsString() number: string;
|
||||
@@ -32,7 +32,7 @@ export class CreateCoachDto {
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||
export class UpdateCoachDto extends PartialType(CreateCoachDto) {
|
||||
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering' })
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,10 @@ export class FleetService {
|
||||
}
|
||||
|
||||
getTrains() {
|
||||
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
|
||||
return this.prisma.train.findMany({
|
||||
where: { isActive: true },
|
||||
include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
createTrain(dto: CreateTrainDto) {
|
||||
@@ -462,6 +465,7 @@ export class FleetService {
|
||||
return this.prisma.coach.update({
|
||||
where: { id },
|
||||
data: {
|
||||
number: dto.number,
|
||||
arrangement: dto.arrangement,
|
||||
capacity: dto.capacity,
|
||||
status: dto.status,
|
||||
@@ -540,6 +544,7 @@ export class FleetService {
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active');
|
||||
return this.prisma.coachAssignment.create({ data: dto });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Response } from 'express';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
@@ -20,17 +21,17 @@ export class HealthController {
|
||||
@Get('ready')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
|
||||
async readiness() {
|
||||
async readiness(@Res() res: Response) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
return res.status(HttpStatus.OK).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: { database: { status: 'ok', latencyMs: Date.now() - start } },
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
return res.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
@@ -40,7 +41,7 @@ export class HealthController {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.log("connected to SMS service");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error happened at SMS service", err);
|
||||
this.logger.error('Error happened at SMS service', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -25,6 +25,7 @@ type IamUserRow = {
|
||||
|
||||
@Injectable()
|
||||
export class PassengersService {
|
||||
private readonly logger = new Logger(PassengersService.name);
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@@ -373,7 +374,7 @@ export class PassengersService {
|
||||
verifiedData = verification.passengerData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fayda verification failed, using manual data:', error);
|
||||
this.logger.warn('Fayda verification failed, using manual data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -6,6 +6,7 @@ import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@@ -60,8 +61,6 @@ export class ReportsService {
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
|
||||
@@ -42,4 +42,5 @@ export class UpdateRouteDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ export class RoutesService {
|
||||
async updateRoute(id: string, dto: UpdateRouteDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return this.prisma.route.update({
|
||||
|
||||
await this.prisma.route.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
@@ -89,6 +90,22 @@ export class RoutesService {
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.stops && dto.stops.length >= 2) {
|
||||
await this.prisma.routeStop.deleteMany({ where: { routeId: id } });
|
||||
await this.prisma.routeStop.createMany({
|
||||
data: dto.stops.map(s => ({
|
||||
routeId: id,
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
}
|
||||
@@ -128,6 +145,7 @@ export class RoutesService {
|
||||
|
||||
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
|
||||
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
|
||||
if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`);
|
||||
|
||||
const existing = await this.prisma.routeStop.findUnique({
|
||||
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
|
||||
|
||||
@@ -100,10 +100,15 @@ export class SchedulesService {
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (!train.isActive) throw new BadRequestException('Train is not active');
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
@@ -247,10 +252,15 @@ export class SchedulesService {
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (!train.isActive) throw new BadRequestException('Train is not active');
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
@@ -540,6 +550,8 @@ export class SchedulesService {
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE');
|
||||
if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`);
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ export class SeatClassesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listSeatClasses() {
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
return this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
if (!sc.isActive) throw new NotFoundException('SeatClass is not active');
|
||||
return sc;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
@@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
@Injectable()
|
||||
export class TripProgressService {
|
||||
private readonly logger = new Logger(TripProgressService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private enhancedSeatsService: EnhancedSeatsService,
|
||||
@@ -154,10 +155,10 @@ export class TripProgressService {
|
||||
try {
|
||||
const result = await this.enhancedSeatsService.expireHolds();
|
||||
if (result.expiredHolds > 0) {
|
||||
console.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`);
|
||||
this.logger.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error expiring holds:', error);
|
||||
this.logger.error('Error expiring holds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ export class StationsService {
|
||||
where.countryCode = filters.country;
|
||||
}
|
||||
|
||||
// Default to operational stations only; allow explicit override (e.g. back-office)
|
||||
if (filters.operational !== undefined && filters.operational !== '') {
|
||||
where.isOperational = filters.operational === 'true';
|
||||
} else {
|
||||
where.isOperational = true;
|
||||
}
|
||||
|
||||
return this.prisma.station.findMany({
|
||||
@@ -46,6 +49,7 @@ export class StationsService {
|
||||
async findOne(id: string) {
|
||||
const s = await this.prisma.station.findUnique({ where: { id } });
|
||||
if (!s) throw new NotFoundException('Station not found');
|
||||
if (!s.isOperational) throw new NotFoundException('Station is not operational');
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,18 +99,11 @@ export class TicketsService {
|
||||
let guestEmail = null;
|
||||
const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName);
|
||||
|
||||
// DEBUG: Log to see what we're getting
|
||||
this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`);
|
||||
if (matchingProfile) {
|
||||
this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`);
|
||||
}
|
||||
|
||||
if (matchingProfile?.notes) {
|
||||
try {
|
||||
const notesData = JSON.parse(matchingProfile.notes);
|
||||
guestPhone = notesData.phone || null;
|
||||
guestEmail = notesData.email || null;
|
||||
this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to parse notes JSON: ${err}`);
|
||||
}
|
||||
@@ -120,13 +113,11 @@ export class TicketsService {
|
||||
if (!guestPhone) guestPhone = t.booking?.contactPhone;
|
||||
if (!guestEmail) guestEmail = t.booking?.contactEmail;
|
||||
|
||||
this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`);
|
||||
|
||||
const passengerInfo = iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||
|
||||
this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -596,8 +587,7 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
|
||||
// TODO: Implement notification logic
|
||||
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
this.logger.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -687,4 +677,4 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
NEXT_PUBLIC_IAM_ENABLED=false
|
||||
NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
# GitHub Packages Token (required to install @tria-plc/* private packages)
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
|
||||
@@ -24,8 +24,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
setIsInitializing(true);
|
||||
setCameraError(null);
|
||||
|
||||
console.log('Starting camera...');
|
||||
|
||||
// Check if mediaDevices is supported
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
|
||||
@@ -45,7 +43,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
let mediaStream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
console.log('Requesting back camera...');
|
||||
// Try with environment (back) camera first
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
@@ -55,18 +52,14 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
console.log('Back camera acquired');
|
||||
} catch (err) {
|
||||
console.warn('Back camera not available, trying default camera:', err);
|
||||
} catch {
|
||||
// Fallback to any available camera with simple constraints
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: false
|
||||
});
|
||||
console.log('Default camera acquired');
|
||||
} catch (fallbackErr) {
|
||||
console.error('All camera attempts failed:', fallbackErr);
|
||||
throw fallbackErr;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +72,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
throw new Error('Video element not found');
|
||||
}
|
||||
|
||||
console.log('Setting video source...');
|
||||
const video = videoRef.current;
|
||||
video.srcObject = mediaStream;
|
||||
|
||||
@@ -98,29 +90,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
console.log('Video ready!');
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
console.log('Metadata loaded');
|
||||
finishResolve();
|
||||
};
|
||||
const onLoadedMetadata = () => finishResolve();
|
||||
const onLoadedData = () => finishResolve();
|
||||
const onCanPlay = () => finishResolve();
|
||||
|
||||
const onLoadedData = () => {
|
||||
console.log('Data loaded');
|
||||
finishResolve();
|
||||
};
|
||||
|
||||
const onCanPlay = () => {
|
||||
console.log('Can play');
|
||||
finishResolve();
|
||||
};
|
||||
|
||||
const onVideoError = (e: Event) => {
|
||||
const onVideoError = (_e: Event) => {
|
||||
cleanup();
|
||||
console.error('Video error:', e);
|
||||
reject(new Error('Video failed to load'));
|
||||
};
|
||||
|
||||
@@ -130,40 +109,22 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
video.addEventListener('error', onVideoError);
|
||||
|
||||
// Fallback timeout - but shorter since we have multiple events
|
||||
setTimeout(() => {
|
||||
console.log('Video load timeout, proceeding anyway');
|
||||
finishResolve();
|
||||
}, 2000);
|
||||
setTimeout(() => finishResolve(), 2000);
|
||||
});
|
||||
|
||||
// Play the video
|
||||
console.log('Playing video...');
|
||||
try {
|
||||
await video.play();
|
||||
console.log('Video playing');
|
||||
} catch (playError) {
|
||||
console.warn('Play attempt 1 failed, retrying:', playError);
|
||||
// Retry play after a short delay
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
try {
|
||||
await video.play();
|
||||
console.log('Video playing (retry succeeded)');
|
||||
} catch (retryError) {
|
||||
console.warn('Play retry also failed (continuing anyway):', retryError);
|
||||
}
|
||||
try { await video.play(); } catch { /* continue */ }
|
||||
}
|
||||
|
||||
// Set state to show video
|
||||
setStream(mediaStream);
|
||||
setIsScanning(true);
|
||||
setIsInitializing(false);
|
||||
console.log('Camera started successfully');
|
||||
console.log('isScanning state set to:', true);
|
||||
console.log('isInitializing state set to:', false);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Camera start error:', error);
|
||||
|
||||
let errorMsg = 'Camera access failed. Please check permissions and try again.';
|
||||
|
||||
@@ -224,21 +185,17 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
try {
|
||||
// Try to use jsqr if available
|
||||
const jsQR = (window as any).jsQR;
|
||||
if (jsQR) {
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert',
|
||||
});
|
||||
|
||||
if (code) {
|
||||
onScan(code.data);
|
||||
stopCamera();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('QR scan error:', err);
|
||||
}
|
||||
} catch { /* ignore scan errors */ }
|
||||
}
|
||||
}, [isScanning, onScan, stopCamera]);
|
||||
|
||||
@@ -269,11 +226,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Debug info */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
Debug: isScanning={String(isScanning)}, isInitializing={String(isInitializing)}, stream={stream ? 'active' : 'null'}
|
||||
</div>
|
||||
|
||||
{/* Video viewer - always rendered, visibility controlled by display style */}
|
||||
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
|
||||
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
|
||||
@@ -337,9 +289,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
|
||||
Please allow camera access when prompted by your browser
|
||||
</p>
|
||||
<p className="text-blue-500 dark:text-blue-500 text-xs text-center">
|
||||
Check console (F12) for detailed camera logs if this takes too long
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -144,11 +144,10 @@ export default function CoachesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [seatMapPreview, setSeatMapPreview] = useState<any>(null);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -221,14 +220,6 @@ export default function CoachesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const generateSeatMapMutation = useMutation({
|
||||
mutationFn: fleetApi.generateSeatMap,
|
||||
onSuccess: (data) => {
|
||||
setSeatMapPreview(data);
|
||||
setShowPreviewModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -275,27 +266,6 @@ export default function CoachesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewSeatMap = async () => {
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const bedCategory = formData.get('bedCategory') as string;
|
||||
const capacity = parseInt(formData.get('capacity') as string);
|
||||
|
||||
if (!bedCategory || !capacity) {
|
||||
alert('Please select a bed category and enter capacity to preview seat map');
|
||||
return;
|
||||
}
|
||||
|
||||
const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6;
|
||||
const roomsPerCoach = Math.ceil(capacity / bedsPerRoom);
|
||||
|
||||
await generateSeatMapMutation.mutateAsync({
|
||||
coachCount: 1,
|
||||
roomsPerCoach,
|
||||
roomType: bedCategory,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (item: any, isCoachType: boolean) => {
|
||||
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
|
||||
};
|
||||
@@ -779,23 +749,25 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conditionally show bed fields only for Economy and Regular coach types */}
|
||||
{(() => {
|
||||
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
|
||||
const isEconomyOrRegular = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('regular') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('regular'));
|
||||
|
||||
return isEconomyOrRegular ? (
|
||||
const isBedType = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('bed') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('sleeper') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('sleeper'));
|
||||
|
||||
const derivedBedCategory = editingItem?.isCoach && selectedCoachType
|
||||
? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED')
|
||||
: (editingItem?.bedCategory || '');
|
||||
|
||||
return isBedType ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Bed Category</label>
|
||||
<select
|
||||
name="bedCategory"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedCategory || ''}
|
||||
defaultValue={derivedBedCategory}
|
||||
>
|
||||
<option value="">Select bed category</option>
|
||||
<option value="ECONOMY_BED">Economy Bed</option>
|
||||
@@ -832,9 +804,9 @@ export default function CoachesPage() {
|
||||
type="text"
|
||||
name="arrangement"
|
||||
className="input"
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
|
||||
required
|
||||
placeholder="e.g., 2+2, 3+2"
|
||||
placeholder="e.g., 3+2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
For regular seats: columns separated by +
|
||||
@@ -850,7 +822,7 @@ export default function CoachesPage() {
|
||||
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
|
||||
required
|
||||
min="1"
|
||||
placeholder="60"
|
||||
placeholder="e.g., 60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -860,10 +832,10 @@ export default function CoachesPage() {
|
||||
type="number"
|
||||
name="sequence"
|
||||
className="input"
|
||||
defaultValue={editingItem?.sequence || 1}
|
||||
defaultValue={editingItem?.sequence }
|
||||
min="1"
|
||||
required
|
||||
placeholder="1"
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Position in train consist
|
||||
@@ -885,14 +857,6 @@ export default function CoachesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handlePreviewSeatMap}
|
||||
loading={generateSeatMapMutation.isPending}
|
||||
>
|
||||
Preview Bed Layout
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -915,57 +879,7 @@ export default function CoachesPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Seat Map Preview Modal */}
|
||||
<Modal
|
||||
isOpen={showPreviewModal}
|
||||
onClose={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
title="Bed Layout Preview"
|
||||
size="lg"
|
||||
>
|
||||
{seatMapPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Configuration</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>Room Type: <span className="font-medium">{seatMapPreview.roomType}</span></div>
|
||||
<div>Rooms per Coach: <span className="font-medium">{seatMapPreview.roomsPerCoach}</span></div>
|
||||
<div>Beds per Room: <span className="font-medium">{seatMapPreview.bedsPerRoom}</span></div>
|
||||
<div>Total Beds: <span className="font-medium">{seatMapPreview.totalBeds}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">Bed Layout Sample (First Few Rooms)</h4>
|
||||
<div className="bg-gray-50 p-4 rounded border max-h-64 overflow-y-auto">
|
||||
{seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => (
|
||||
<div key={idx} className="text-xs mb-1 font-mono">
|
||||
{seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type}
|
||||
</div>
|
||||
))}
|
||||
{seatMapPreview.seats?.length > 24 && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
... and {seatMapPreview.seats.length - 24} more beds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react';
|
||||
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
@@ -18,7 +18,6 @@ const MOCK_STATS = {
|
||||
totalBookings: 1247,
|
||||
totalRevenue: 892450,
|
||||
totalPassengers: 2156,
|
||||
occupancyRate: 78
|
||||
};
|
||||
|
||||
const MOCK_RECENT_BOOKINGS = [
|
||||
@@ -50,30 +49,12 @@ function DashboardPageContent() {
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
|
||||
const { data: revenueData, isLoading: revenueLoading } = useQuery({
|
||||
queryKey: ['revenue-chart'],
|
||||
queryFn: () => dashboardApi.getRevenueChart(30),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
|
||||
queryKey: ['recent-bookings'],
|
||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: topAgents, isLoading: agentsLoading } = useQuery({
|
||||
queryKey: ['top-agents'],
|
||||
queryFn: () => dashboardApi.getTopAgents(5),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
|
||||
queryKey: ['occupancy-trend'],
|
||||
queryFn: () => dashboardApi.getOccupancyTrend(7),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
||||
queryKey: ['upcoming-trips'],
|
||||
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
||||
@@ -122,13 +103,6 @@ function DashboardPageContent() {
|
||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
||||
];
|
||||
|
||||
const agentColumns = [
|
||||
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
|
||||
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
|
||||
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
|
||||
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
|
||||
];
|
||||
|
||||
const tripColumns = [
|
||||
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
||||
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
||||
@@ -170,7 +144,7 @@ function DashboardPageContent() {
|
||||
)}
|
||||
|
||||
{/* Primary Metrics */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
title="Total Bookings"
|
||||
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
|
||||
@@ -189,75 +163,6 @@ function DashboardPageContent() {
|
||||
icon={Users}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Occupancy Rate"
|
||||
value={statsLoading ? '...' : `${displayStats?.occupancyRate || 0}%`}
|
||||
icon={Percent}
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Revenue Trend (Last 30 Days)
|
||||
</h2>
|
||||
{revenueLoading ? (
|
||||
<div className="flex h-[300px] items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : revenueData && revenueData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={revenueData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No revenue data available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Occupancy Trend */}
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Percent className="h-5 w-5" />
|
||||
Occupancy Trend (Last 7 Days)
|
||||
</h2>
|
||||
{occupancyLoading ? (
|
||||
<div className="flex h-[300px] items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : occupancyTrend && occupancyTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={occupancyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<Tooltip formatter={(value: number) => `${value}%`} />
|
||||
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Percent className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No occupancy data available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Methods Distribution */}
|
||||
@@ -313,19 +218,6 @@ function DashboardPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Agents */}
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Top Performing Agents
|
||||
</h2>
|
||||
<DataTable
|
||||
data={topAgents || []}
|
||||
columns={agentColumns}
|
||||
loading={agentsLoading}
|
||||
emptyMessage="No agent performance data available"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function OperationalReportsPage() {
|
||||
refetch();
|
||||
setShowGenerateModal(false);
|
||||
} catch (error) {
|
||||
console.error('Error generating report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export default function PaymentMethodsPage() {
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update failed:', error);
|
||||
setSuccessMessage('Failed to update payment method');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
@@ -131,8 +130,6 @@ export default function PaymentMethodsPage() {
|
||||
processingTime: formData.processingTime,
|
||||
};
|
||||
|
||||
console.log('Submitting data:', submitData);
|
||||
|
||||
if (selectedMethod) {
|
||||
updateMutation.mutate({ id: selectedMethod.id, ...submitData });
|
||||
} else {
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function RoutesPage() {
|
||||
queryKey: ['routes'],
|
||||
queryFn: async () => {
|
||||
const result = await routesApi.getAll();
|
||||
console.log('Routes query result:', result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -71,7 +70,7 @@ export default function RoutesPage() {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
|
||||
if (!originStationId || !destinationStationId) {
|
||||
alert('Please select origin and destination stations');
|
||||
return;
|
||||
@@ -112,9 +111,7 @@ export default function RoutesPage() {
|
||||
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
||||
stops: stopsArray,
|
||||
};
|
||||
|
||||
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
|
||||
|
||||
|
||||
if (editingRoute) {
|
||||
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
|
||||
} else {
|
||||
@@ -189,8 +186,8 @@ export default function RoutesPage() {
|
||||
{ key: 'code', label: 'Route Code', sortable: true },
|
||||
{ key: 'name', label: 'Route Name', sortable: true },
|
||||
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
||||
{
|
||||
key: 'active',
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Status',
|
||||
render: (route: any) => (
|
||||
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
@@ -220,15 +217,20 @@ export default function RoutesPage() {
|
||||
if (routeStops.length >= 2) {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// Last stop's distanceKm is already cumulative from origin
|
||||
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||
// Last stop's distanceKm is segment distance from previous stop, so accumulate
|
||||
let cumulative = 0;
|
||||
const allStops = routeStops.map((stop: any) => {
|
||||
cumulative += stop.distanceKm || 0;
|
||||
return { ...stop, _cumulative: cumulative };
|
||||
});
|
||||
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: stop.distanceKm || 0,
|
||||
distanceFromOrigin: allStops[idx + 1]._cumulative,
|
||||
}));
|
||||
setStops(middleStops);
|
||||
}
|
||||
@@ -392,7 +394,7 @@ export default function RoutesPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
@@ -441,7 +443,7 @@ export default function RoutesPage() {
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
@@ -483,8 +485,8 @@ export default function RoutesPage() {
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.filter((s: any) =>
|
||||
s.id !== originStationId &&
|
||||
{stations?.items?.filter((s: any) =>
|
||||
s.id !== originStationId &&
|
||||
s.id !== destinationStationId &&
|
||||
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
||||
).map((station: any) => (
|
||||
@@ -561,7 +563,7 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
|
||||
@@ -53,8 +53,6 @@ export default function SettingsPage() {
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'integrations', label: 'Integrations' },
|
||||
{ id: 'configurations', label: 'Configurations' },
|
||||
];
|
||||
|
||||
@@ -109,81 +107,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Payment Providers</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Telebirr</p>
|
||||
<p className="text-sm text-muted-foreground">Mobile payment provider</p>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" className="peer sr-only" defaultChecked />
|
||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">CBE Birr</p>
|
||||
<p className="text-sm text-muted-foreground">Bank payment provider</p>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" className="peer sr-only" defaultChecked />
|
||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Verifayda 2.0 Integration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">API URL</label>
|
||||
<input type="text" className="input" defaultValue="https://api.verifayda.gov.et/v2" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Key</label>
|
||||
<input type="password" className="input" defaultValue="••••••••••••" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="verifayda-enabled" defaultChecked />
|
||||
<label htmlFor="verifayda-enabled" className="text-sm text-foreground">
|
||||
Enable Verifayda verification
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Corporate IAM Integration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">IAM API URL</label>
|
||||
<input type="text" className="input" defaultValue="https://iam.tria-plc.com/api" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Key</label>
|
||||
<input type="password" className="input" defaultValue="••••••••••••" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="iam-enabled" />
|
||||
<label htmlFor="iam-enabled" className="text-sm text-foreground">
|
||||
Enable IAM authentication
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{activeTab === 'configurations' && (
|
||||
<div className="card space-y-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
|
||||
|
||||
@@ -49,8 +49,7 @@ export default function ActionButton({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await onClick();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ export const dashboardApi = {
|
||||
const bookingsTotal = bookingsRes?.meta?.total || 0;
|
||||
const passengersTotal = passengersRes?.meta?.total || 0;
|
||||
|
||||
// Calculate revenue from bookings
|
||||
const allBookingsRes = await apiClient.get<any>('/bookings?pageSize=100');
|
||||
const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || [];
|
||||
const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
// Calculate revenue from confirmed bookings only
|
||||
const confirmedRes = await apiClient.get<any>('/bookings?pageSize=1000&status=CONFIRMED');
|
||||
const boardedRes = await apiClient.get<any>('/bookings?pageSize=1000&status=BOARDED');
|
||||
const paidBookings = [
|
||||
...(Array.isArray(confirmedRes) ? confirmedRes : confirmedRes?.items || []),
|
||||
...(Array.isArray(boardedRes) ? boardedRes : boardedRes?.items || []),
|
||||
];
|
||||
const totalRevenue = paidBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
|
||||
// Calculate average occupancy (placeholder - would need dedicated endpoint)
|
||||
const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data
|
||||
@@ -29,10 +33,9 @@ export const dashboardApi = {
|
||||
totalTripsToday: 0,
|
||||
activeTrips: 0,
|
||||
cancelledBookings: 0,
|
||||
averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0,
|
||||
averageTicketPrice: paidBookings.length > 0 ? Math.round(totalRevenue / paidBookings.length) : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error);
|
||||
return {
|
||||
totalBookings: 0,
|
||||
totalRevenue: 0,
|
||||
@@ -51,7 +54,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch revenue chart:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -82,7 +84,6 @@ export const dashboardApi = {
|
||||
paymentIntent: booking.paymentIntent,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent bookings:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -92,7 +93,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/agents/top?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch top agents:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -102,7 +102,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/dashboard/occupancy?days=${days}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch occupancy trend:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -112,7 +111,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/schedules/upcoming?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch upcoming trips:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -122,7 +120,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>('/dashboard/payment-methods');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch payment methods:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -137,7 +134,6 @@ export const dashboardApi = {
|
||||
loyaltyPoints: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger stats:', error);
|
||||
return {
|
||||
totalPassengers: 0,
|
||||
newPassengersToday: 0,
|
||||
@@ -157,7 +153,6 @@ export const dashboardApi = {
|
||||
totalAmount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transaction summary:', error);
|
||||
return {
|
||||
totalTransactions: 0,
|
||||
successfulTransactions: 0,
|
||||
@@ -176,7 +171,6 @@ export const dashboardApi = {
|
||||
activePayments: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch live metrics:', error);
|
||||
return {
|
||||
onlineUsers: 0,
|
||||
activeBookings: 0,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { PaginatedResponse } from '@edr/types';
|
||||
export const routesApi = {
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get<any>('/routes');
|
||||
console.log('Routes API response:', response);
|
||||
// Handle both direct array and wrapped response
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response };
|
||||
@@ -24,7 +23,6 @@ export const routesApi = {
|
||||
},
|
||||
|
||||
create: (data: any) => {
|
||||
console.log('Creating route with data:', JSON.stringify(data, null, 2));
|
||||
return apiClient.post<Route>('/routes', data);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
# GitHub Packages Token (required to install @tria-plc/* private packages)
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
BIN
apps/edr-passenger-web/portal/public/packages/kulubi.jpeg
Normal file
BIN
apps/edr-passenger-web/portal/public/packages/kulubi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -681,7 +681,7 @@ export default function PassengersPage() {
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: index === 0 && isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -1532,107 +1532,6 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promotions Section */}
|
||||
<div className="bg-white dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-lg">🎁</span>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Offers & Promotions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Wide promo card */}
|
||||
<div className="md:col-span-2 relative rounded-2xl overflow-hidden min-h-[220px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)]" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(255,255,255,0.3) 20px, rgba(255,255,255,0.3) 21px)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent" />
|
||||
<div className="relative z-10 p-7 flex flex-col justify-between h-full min-h-[220px]">
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full mb-3 backdrop-blur-sm">
|
||||
Limited Time
|
||||
</span>
|
||||
<h3 className="text-2xl font-extrabold text-white leading-tight mb-2">
|
||||
20% Off Weekend
|
||||
<br />
|
||||
Travel
|
||||
</h3>
|
||||
<p className="text-white/70 text-sm max-w-xs">
|
||||
Book any weekend journey and save 20%. Valid for all seat
|
||||
classes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-white/60 text-xs">
|
||||
Valid until 31 Dec 2024
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-white text-sm font-semibold group-hover:gap-3 transition-all">
|
||||
Book now <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Narrow promo cards */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 to-orange-600" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
|
||||
New
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">
|
||||
Family Package
|
||||
</h3>
|
||||
<p className="text-white/75 text-xs mt-1">
|
||||
4 tickets for the price of 3
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-700" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
|
||||
Student
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">
|
||||
Student Discount
|
||||
</h3>
|
||||
<p className="text-white/75 text-xs mt-1">
|
||||
15% off with valid student ID
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
|
||||
100
apps/edr-passenger-web/portal/src/app/go/page.tsx
Normal file
100
apps/edr-passenger-web/portal/src/app/go/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2, ShieldAlert, ExternalLink } from "lucide-react";
|
||||
|
||||
|
||||
|
||||
const ALLOWED_HOSTS = (
|
||||
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj"
|
||||
)
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted host. */
|
||||
function isTrustedDMoneyUrl(raw: string | null): raw is string {
|
||||
if (!raw) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return ALLOWED_HOSTS.some(
|
||||
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
|
||||
);
|
||||
}
|
||||
|
||||
const REDIRECT_DELAY_MS = 1000;
|
||||
|
||||
function RedirectView() {
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("url");
|
||||
const target = useMemo(() => (isTrustedDMoneyUrl(raw) ? raw : null), [raw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
window.location.replace(target);
|
||||
}, REDIRECT_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [target]);
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<ShieldAlert className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
||||
Can't continue
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
This link is missing a valid D-Money checkout address or points to an
|
||||
untrusted destination. Please start the payment again from the app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
||||
Redirecting to D-Money
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Taking you to the secure D-Money checkout to complete your payment…
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={target}
|
||||
className="mt-6 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Continue to D-Money
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoPage() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white px-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-label="Loading" />
|
||||
}
|
||||
>
|
||||
<RedirectView />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
913
apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
Normal file
913
apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
Clock,
|
||||
Train,
|
||||
Bus,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
Tag,
|
||||
Shield,
|
||||
X,
|
||||
CheckCircle2,
|
||||
Phone,
|
||||
Mail,
|
||||
User,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Station {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
city: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
interface TrainInfo {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
operatorName: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
durationMinutes: number;
|
||||
status: string;
|
||||
stopsCount: number;
|
||||
originStation: Station;
|
||||
destinationStation: Station;
|
||||
train: TrainInfo;
|
||||
}
|
||||
|
||||
interface PriceTier {
|
||||
id: string;
|
||||
packageId: string;
|
||||
seatType: string;
|
||||
label: string;
|
||||
priceMinor: number;
|
||||
currency: string;
|
||||
availableSeats: number;
|
||||
bookedSeats: number;
|
||||
}
|
||||
|
||||
interface PackageDetail {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
boardingTime: string;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
totalCapacity: number;
|
||||
bookedCount: number;
|
||||
includedServices: string[];
|
||||
coachConfiguration: string;
|
||||
busTransferIncluded: boolean;
|
||||
busTransferRoute: string | null;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
priceTiers: PriceTier[];
|
||||
outboundSchedule: Schedule;
|
||||
returnSchedule: Schedule | null;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", opts ?? {
|
||||
weekday: "short", month: "short", day: "numeric",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function durationLabel(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function stripBullet(s: string): string {
|
||||
return s.replace(/^[••\-\t\s]+/, "").trim();
|
||||
}
|
||||
|
||||
function formatPrice(minor: number, currency: string): string {
|
||||
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
// ─── Inquiry form ────────────────────────────────────────────────────────────
|
||||
|
||||
const inquirySchema = z.object({
|
||||
travelerCount: z
|
||||
.number({ invalid_type_error: "Enter a valid number" })
|
||||
.int("Must be a whole number")
|
||||
.min(1, "At least 1 traveler required")
|
||||
.max(50, "Maximum 50 travelers per booking"),
|
||||
contactName: z
|
||||
.string()
|
||||
.min(2, "Name must be at least 2 characters")
|
||||
.max(100),
|
||||
contactEmail: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Enter a valid email address"),
|
||||
contactPhone: z
|
||||
.string()
|
||||
.min(7, "Enter a valid phone number")
|
||||
.max(20, "Phone number too long")
|
||||
.regex(/^[+\d\s\-()\\.]+$/, "Invalid phone number format"),
|
||||
notes: z.string().max(500, "Notes must be under 500 characters").optional(),
|
||||
});
|
||||
|
||||
type InquiryForm = z.infer<typeof inquirySchema>;
|
||||
|
||||
interface InquiryModalProps {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
tier: PriceTier;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function InquiryModal({ packageId, packageName, tier, onClose }: InquiryModalProps) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<InquiryForm>({
|
||||
resolver: zodResolver(inquirySchema as any),
|
||||
defaultValues: { travelerCount: 1 },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: InquiryForm) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await apiClient.post("/packages/inquiries", {
|
||||
packageId,
|
||||
priceTierId: tier.id,
|
||||
travelerCount: data.travelerCount,
|
||||
contactName: data.contactName,
|
||||
contactEmail: data.contactEmail,
|
||||
contactPhone: data.contactPhone,
|
||||
notes: data.notes ?? "",
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setSubmitError(
|
||||
err?.response?.data?.message ||
|
||||
"Something went wrong. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div className="w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Book Package
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
|
||||
{packageName.trim()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
{submitted ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="w-16 h-16 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
Inquiry Submitted!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
|
||||
We've received your booking inquiry. Our team will contact you
|
||||
shortly to confirm your reservation.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-6 px-6 py-2.5 bg-primary text-white text-sm font-semibold rounded-xl hover:bg-[rgb(16,89,60)] transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Selected tier summary */}
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
Selected seat type
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">
|
||||
{tier.label.trim()}{" "}
|
||||
<span className="text-[10px] font-normal text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded ml-1">
|
||||
{tier.seatType.trim()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
Per person
|
||||
</p>
|
||||
<p className="text-base font-extrabold text-primary mt-0.5">
|
||||
{formatPrice(tier.priceMinor, tier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex-1 overflow-y-auto scrollbar-hide px-6 py-5 space-y-4"
|
||||
>
|
||||
{/* Traveler count */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Number of Travelers <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Users className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
{...register("travelerCount", { valueAsNumber: true })}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.travelerCount
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.travelerCount && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.travelerCount.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact name */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
{...register("contactName")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactName
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactName && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...register("contactEmail")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactEmail
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Phone Number <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="+251 912 345 678"
|
||||
{...register("contactPhone")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactPhone
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Additional Notes{" "}
|
||||
<span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3.5 top-3.5 w-4 h-4 text-gray-400" />
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Any special requirements or questions..."
|
||||
{...register("notes")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none transition-colors ${
|
||||
errors.notes
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.notes && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.notes.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API error */}
|
||||
{submitError && (
|
||||
<div className="flex items-start gap-2.5 p-3.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="pt-1 pb-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Submit Inquiry <ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 text-center mt-2">
|
||||
Our team will contact you shortly to confirm.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Journey Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) {
|
||||
const dep = new Date(schedule.departureAt);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
const isSameDay = dep.toDateString() === arr.toDateString();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800">
|
||||
<Train className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-bold text-gray-800 dark:text-white">{label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{schedule.train.number}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Route row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Origin */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">From</p>
|
||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||
{schedule.originStation.code}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{schedule.originStation.name.trim()}</p>
|
||||
</div>
|
||||
|
||||
{/* Center: duration + arrow */}
|
||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{durationLabel(schedule.durationMinutes)}
|
||||
</span>
|
||||
<div className="relative w-16 flex items-center">
|
||||
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{schedule.stopsCount} stops</span>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="flex-1 min-w-0 text-right">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">To</p>
|
||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||
{schedule.destinationStation.code}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{schedule.destinationStation.name.trim()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Times */}
|
||||
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-primary">{fmtTime(schedule.departureAt)}</p>
|
||||
<p className="text-xs text-gray-400">{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{fmtTime(schedule.arrivalAt)}
|
||||
{!isSameDay && <sup className="text-[10px] text-orange-400 ml-0.5">+1</sup>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-400">{schedule.train.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Price Tiers Panel ────────────────────────────────────────────────────────
|
||||
|
||||
function PriceTiersPanel({
|
||||
tiers,
|
||||
selectedTierId,
|
||||
onSelect,
|
||||
selectedTier,
|
||||
onBookNow,
|
||||
}: {
|
||||
tiers: PriceTier[];
|
||||
selectedTierId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
selectedTier?: PriceTier;
|
||||
onBookNow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Select Seat Type
|
||||
</h2>
|
||||
|
||||
{!tiers?.length ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
No price tiers available
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
const selected = tier.id === selectedTierId;
|
||||
return (
|
||||
<button
|
||||
key={tier.id}
|
||||
type="button"
|
||||
disabled={soldOut}
|
||||
onClick={() => onSelect(tier.id)}
|
||||
className={`w-full text-left rounded-xl border-2 p-3.5 transition-all ${
|
||||
soldOut
|
||||
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
|
||||
: selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{/* Row 1: radio + full label */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center mt-0.5 transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
|
||||
{tier.label.trim()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 2: seatType badge + seats + price */}
|
||||
<div className="flex items-center justify-between mt-2 pl-[26px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
|
||||
{tier.seatType.trim()}
|
||||
</span>
|
||||
{soldOut ? (
|
||||
<span className="text-[10px] font-bold text-red-500 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded">
|
||||
SOLD OUT
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{tier.availableSeats} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-extrabold text-primary">
|
||||
{formatPrice(tier.priceMinor, tier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary + CTA */}
|
||||
{selectedTier && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-primary/30 shadow-sm">
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Seat type</p>
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{selectedTier.label.trim()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Price per person</p>
|
||||
<p className="text-base font-extrabold text-primary">
|
||||
{formatPrice(selectedTier.priceMinor, selectedTier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBookNow}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PackageDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params?.id as string;
|
||||
|
||||
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
|
||||
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||
|
||||
const { data: pkg, isLoading, isError } = useQuery<PackageDetail>({
|
||||
queryKey: ["package", id],
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading package details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !pkg) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 mb-4">Unable to load package details.</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const destination = pkg.outboundSchedule?.destinationStation;
|
||||
const availableSeats = pkg.totalCapacity - pkg.bookedCount;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Inquiry modal */}
|
||||
{inquiryOpen && selectedTier && (
|
||||
<InquiryModal
|
||||
packageId={pkg.id}
|
||||
packageName={pkg.name}
|
||||
tier={selectedTier}
|
||||
onClose={() => setInquiryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-4 left-4 flex items-center gap-1.5 text-white/90 hover:text-white bg-black/25 backdrop-blur-sm px-3 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-block bg-white/20 text-white text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm">
|
||||
{pkg.code}
|
||||
</span>
|
||||
{pkg.status === "ACTIVE" && (
|
||||
<span className="inline-block bg-green-500/90 text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white leading-tight">
|
||||
{pkg.name.trim()}
|
||||
</h1>
|
||||
{origin && destination && (
|
||||
<div className="flex items-center gap-1.5 text-white/75 text-sm mt-2">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
{origin.name.trim()}
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{destination.name.trim()}
|
||||
{pkg.returnSchedule && (
|
||||
<span className="text-white/50 text-xs ml-1">· Round Trip</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* ── Main content ── */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
|
||||
{/* Quick info strip */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Calendar className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Departure</p>
|
||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||
{fmt(pkg.departureTime, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Users className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Available</p>
|
||||
<p className={`text-xs font-bold mt-0.5 ${availableSeats <= 20 ? "text-orange-500" : "text-gray-800 dark:text-white"}`}>
|
||||
{availableSeats} seats
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Tag className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">From</p>
|
||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||
{pkg.priceTiers.length
|
||||
? formatPrice(
|
||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)),
|
||||
pkg.priceTiers[0].currency,
|
||||
)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{pkg.description && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-3">
|
||||
About this Package
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{pkg.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound journey */}
|
||||
{pkg.outboundSchedule && (
|
||||
<JourneyCard schedule={pkg.outboundSchedule} label="Outbound Journey" />
|
||||
)}
|
||||
|
||||
{/* Return journey */}
|
||||
{pkg.returnSchedule && (
|
||||
<JourneyCard schedule={pkg.returnSchedule} label="Return Journey" />
|
||||
)}
|
||||
|
||||
{/* Bus transfer */}
|
||||
{pkg.busTransferIncluded && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-50 dark:bg-amber-900/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Bus className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
Bus Transfer Included
|
||||
</p>
|
||||
{pkg.busTransferRoute && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{pkg.busTransferRoute.trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-green-500 ml-auto flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Travel info */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Travel Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InfoRow icon={<Clock className="w-4 h-4 text-primary" />} label="Boarding Time">
|
||||
{fmtTime(pkg.boardingTime)} · {fmt(pkg.boardingTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Calendar className="w-4 h-4 text-primary" />} label="Departure Time">
|
||||
{fmtTime(pkg.departureTime)} · {fmt(pkg.departureTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<MapPin className="w-4 h-4 text-primary" />} label="Arrival">
|
||||
{fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Users className="w-4 h-4 text-primary" />} label="Total Capacity">
|
||||
{pkg.totalCapacity} seats ({pkg.bookedCount} booked)
|
||||
</InfoRow>
|
||||
{pkg.coachConfiguration && (
|
||||
<InfoRow icon={<Train className="w-4 h-4 text-primary" />} label="Coach Config">
|
||||
{pkg.coachConfiguration.trim()}
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow icon={<Shield className="w-4 h-4 text-primary" />} label="Valid Period">
|
||||
{fmt(pkg.validFrom, { month: "short", day: "numeric" })} –{" "}
|
||||
{fmt(pkg.validUntil, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Included services */}
|
||||
{pkg.includedServices?.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Included Services
|
||||
</h2>
|
||||
<ul className="space-y-2.5">
|
||||
{pkg.includedServices.map((svc, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2.5 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{stripBullet(svc)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price tiers — mobile */}
|
||||
<div className="lg:hidden">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
selectedTier={selectedTier}
|
||||
onBookNow={() => setInquiryOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar — desktop ── */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-20">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
selectedTier={selectedTier}
|
||||
onBookNow={() => setInquiryOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">{icon}</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mt-0.5">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
544
apps/edr-passenger-web/portal/src/components/PackagesSection.tsx
Normal file
544
apps/edr-passenger-web/portal/src/components/PackagesSection.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
Shield,
|
||||
Train,
|
||||
Bus,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PriceTier {
|
||||
id: string;
|
||||
priceMinor: number;
|
||||
currency: string;
|
||||
availableSeats: number;
|
||||
}
|
||||
|
||||
interface Station {
|
||||
name: string;
|
||||
city: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
originStation: Station;
|
||||
destinationStation: Station;
|
||||
departureAt: string;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
interface HolidayPackage {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
departureTime: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
totalCapacity: number;
|
||||
bookedCount: number;
|
||||
includedServices?: string[];
|
||||
busTransferIncluded?: boolean;
|
||||
busTransferRoute?: string | null;
|
||||
returnSchedule?: Schedule | null;
|
||||
outboundSchedule?: Schedule;
|
||||
priceTiers: PriceTier[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(
|
||||
"en-US",
|
||||
opts ?? { month: "short", day: "numeric" },
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function validityRange(from: string, until: string): string {
|
||||
return `${fmtDate(from, { month: "short", day: "numeric" })} – ${fmtDate(until, { month: "short", day: "numeric", year: "numeric" })}`;
|
||||
}
|
||||
|
||||
function daysUntil(iso: string): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor((new Date(iso).getTime() - Date.now()) / 86400000),
|
||||
);
|
||||
}
|
||||
|
||||
function minPrice(
|
||||
tiers: PriceTier[],
|
||||
): { amount: number; currency: string } | null {
|
||||
if (!tiers?.length) return null;
|
||||
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
|
||||
return { amount: min.priceMinor / 100, currency: min.currency };
|
||||
}
|
||||
|
||||
function fmtPrice(minor: number, currency: string): string {
|
||||
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function stripBullet(s: string): string {
|
||||
return s.replace(/^[•\-\t\s]+/, "").trim();
|
||||
}
|
||||
|
||||
// ─── Urgency badge ────────────────────────────────────────────────────────────
|
||||
|
||||
function UrgencyBadge({ until }: { until: string }) {
|
||||
const days = daysUntil(until);
|
||||
if (days > 14) return null;
|
||||
return (
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-1 rounded-full ${
|
||||
days <= 3
|
||||
? "bg-red-500 text-white animate-pulse"
|
||||
: "bg-orange-400 text-white"
|
||||
}`}
|
||||
>
|
||||
{days === 0 ? "Last day!" : `Closes in ${days}d`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Availability bar ─────────────────────────────────────────────────────────
|
||||
|
||||
function AvailBar({ booked, total }: { booked: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round(((total - booked) / total) * 100) : 100;
|
||||
const low = pct <= 20;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
|
||||
<span>
|
||||
{total - booked} of {total} seats
|
||||
</span>
|
||||
<span className={low ? "text-orange-500 font-semibold" : ""}>
|
||||
{pct}% available
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${low ? "bg-orange-400" : "bg-primary"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Featured Card (first package — full-width, image left) ───────────────────
|
||||
|
||||
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const price = minPrice(pkg.priceTiers);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
const days = daysUntil(pkg.validUntil);
|
||||
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block">
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-2xl transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row min-h-[340px]">
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
sizes="(max-width: 768px) 100vw, 46vw"
|
||||
priority
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent md:bg-gradient-to-r md:from-transparent md:via-transparent md:to-black/30" />
|
||||
|
||||
{/* Top-left badges */}
|
||||
<div className="absolute top-4 left-4 flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 bg-primary text-white text-[11px] font-bold px-3 py-1 rounded-full shadow">
|
||||
✦ Featured Package
|
||||
</span>
|
||||
<UrgencyBadge until={pkg.validUntil} />
|
||||
</div>
|
||||
|
||||
{/* Bottom-left: round trip tag */}
|
||||
{pkg.returnSchedule && (
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="flex items-center gap-1.5 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[11px] font-bold px-3 py-1.5 rounded-full shadow">
|
||||
<ArrowRight className="w-3 h-3 rotate-0" />
|
||||
<ArrowRight className="w-3 h-3 rotate-180 -ml-2" />
|
||||
Round Trip
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right: Content ── */}
|
||||
<div className="flex-1 flex flex-col justify-between p-7 md:p-8">
|
||||
{/* Top section */}
|
||||
<div>
|
||||
{/* Status */}
|
||||
{pkg.status === "ACTIVE" && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400 px-2.5 py-1 rounded-full mb-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
Booking Open
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-xl md:text-2xl font-extrabold text-gray-900 dark:text-white leading-tight mb-1.5">
|
||||
{pkg.name.trim()}
|
||||
</h3>
|
||||
|
||||
{/* Route */}
|
||||
{origin && dest && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
<Train className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{origin.name.trim()}
|
||||
</span>
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{dest.name.trim()}
|
||||
</span>
|
||||
{pkg.busTransferIncluded && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">
|
||||
+
|
||||
</span>
|
||||
<Bus className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
{pkg.busTransferRoute?.trim() ?? "Bus transfer"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||||
<InfoPill
|
||||
icon={<Shield className="w-3.5 h-3.5 text-primary" />}
|
||||
label="Validity"
|
||||
>
|
||||
{validityRange(pkg.validFrom, pkg.validUntil)}
|
||||
</InfoPill>
|
||||
<InfoPill
|
||||
icon={<Clock className="w-3.5 h-3.5 text-primary" />}
|
||||
label="Booking closes"
|
||||
>
|
||||
<span
|
||||
className={days <= 7 ? "text-orange-500 font-semibold" : ""}
|
||||
>
|
||||
{fmtDate(pkg.validUntil, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{days <= 14 && ` (${days}d left)`}
|
||||
</span>
|
||||
</InfoPill>
|
||||
</div>
|
||||
|
||||
{/* Service chips */}
|
||||
{pkg.includedServices && pkg.includedServices.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{pkg.includedServices.slice(0, 4).map((svc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||||
{stripBullet(svc).split(/\s+/).slice(0, 5).join(" ")}
|
||||
</span>
|
||||
))}
|
||||
{pkg.includedServices.length > 4 && (
|
||||
<span className="text-[10px] text-gray-400 flex items-center px-1">
|
||||
+{pkg.includedServices.length - 4} more included
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: price + CTA */}
|
||||
<div className="flex items-end justify-between pt-5 border-t border-gray-100 dark:border-gray-800">
|
||||
{price ? (
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
|
||||
Starting from
|
||||
</p>
|
||||
<p className="text-3xl font-extrabold text-primary leading-none mt-1">
|
||||
{price.currency}{" "}
|
||||
<span className="text-2xl">
|
||||
{price.amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
{pkg.priceTiers.length} seat class
|
||||
{pkg.priceTiers.length !== 1 ? "es" : ""} available
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<span className="inline-flex items-center gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-6 py-3.5 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3">
|
||||
View Package
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Regular Package Card ─────────────────────────────────────────────────────
|
||||
|
||||
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const price = minPrice(pkg.priceTiers);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* Image / Gradient */}
|
||||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||
<UrgencyBadge until={pkg.validUntil} />
|
||||
</div>
|
||||
|
||||
{pkg.returnSchedule && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[10px] font-bold px-2.5 py-1 rounded-full">
|
||||
↔ Round Trip
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white text-sm leading-snug mb-2.5 line-clamp-2">
|
||||
{pkg.name.trim()}
|
||||
</h3>
|
||||
|
||||
{/* Route */}
|
||||
{origin && dest && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<MapPin className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<span className="truncate">{origin.name.trim()}</span>
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0 text-gray-300" />
|
||||
<span className="truncate">{dest.name.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validity */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<Shield className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span>{validityRange(pkg.validFrom, pkg.validUntil)}</span>
|
||||
</div>
|
||||
|
||||
{/* Departure */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span>
|
||||
Departs{" "}
|
||||
{fmtDate(pkg.departureTime, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Availability bar */}
|
||||
<div className="mb-4">
|
||||
<AvailBar booked={pkg.bookedCount} total={pkg.totalCapacity} />
|
||||
</div>
|
||||
|
||||
{/* Price + CTA */}
|
||||
<div className="mt-auto pt-3.5 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
{price ? (
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
|
||||
From
|
||||
</p>
|
||||
<p className="text-base font-extrabold text-primary">
|
||||
{fmtPrice(price.amount * 100, price.currency)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<span className="text-xs text-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all">
|
||||
View <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeletons ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturedSkeleton() {
|
||||
return (
|
||||
<div className="rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse flex flex-col md:flex-row min-h-[340px]">
|
||||
<div className="md:w-[46%] h-64 md:h-auto bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
<div className="flex-1 p-8 space-y-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
|
||||
<div className="h-7 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse">
|
||||
<div className="h-44 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
|
||||
<div className="h-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Info Pill (for featured card) ────────────────────────────────────────────
|
||||
|
||||
function InfoPill({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mt-0.5 leading-snug">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PackagesSection() {
|
||||
const {
|
||||
data: packages,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<HolidayPackage[]>({
|
||||
queryKey: ["packages"],
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<HolidayPackage[]>("/packages")) as HolidayPackage[],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isError) return null;
|
||||
|
||||
const [featured, ...rest] = packages ?? [];
|
||||
|
||||
return (
|
||||
<section className="bg-gray-50 dark:bg-gray-950 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xl">🏖️</span>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Holiday Packages
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured card */}
|
||||
{isLoading ? (
|
||||
<FeaturedSkeleton />
|
||||
) : featured ? (
|
||||
<FeaturedCard pkg={featured} />
|
||||
) : null}
|
||||
|
||||
{/* Rest grid — 3 col desktop / 2 col tablet / 1 col mobile */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : rest.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{rest.map((pkg) => (
|
||||
<PackageCard key={pkg.id} pkg={pkg} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && !packages?.length && (
|
||||
<div className="py-16 text-center">
|
||||
<span className="text-5xl block mb-3">🏖️</span>
|
||||
<p className="text-sm text-gray-400">
|
||||
No holiday packages available right now. Check back soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,12 @@ export class DMoneyWebhookService {
|
||||
) {}
|
||||
|
||||
async handle(payload: DMoneyWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
// TODO: re-enable D-Money public-key signature verification — skipped for now.
|
||||
// D-Money's onboarding pack only provides the merchant keypair (3072-bit); callbacks
|
||||
// are signed with their separate platform notification key (4096-bit), which we don't
|
||||
// have yet, so verifyWebhookSignature can never pass. Re-enable once that key is supplied.
|
||||
const signatureValid = true;
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
const providerTxnId = payload.transId ?? payload.payment_order_id;
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: "WebCheckout" as const,
|
||||
business_type: "OnlineMerchant" as const,
|
||||
title: `${input.orderRef}`,
|
||||
title: "EDR booking payment",
|
||||
total_amount: totalAmount,
|
||||
// Charge the currency the caller already converted to; never relabel it provider-side.
|
||||
trans_currency: input.currency,
|
||||
|
||||
@@ -140,6 +140,8 @@ export enum InvoiceStatus {
|
||||
Overdue = "OVERDUE",
|
||||
Cancelled = "CANCELLED",
|
||||
Refunded = "REFUNDED",
|
||||
/** Pay window closed before settlement; terminal, cannot be paid. */
|
||||
Expired = "EXPIRED",
|
||||
}
|
||||
|
||||
/** Originating subsystem an invoice bills for; namespaces invoice events. */
|
||||
@@ -149,14 +151,6 @@ export enum InvoiceSource {
|
||||
Demurrage = "demurrage",
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invoice bills for within its source — the discriminator when one
|
||||
* entity carries several invoices (e.g. a booking's up-front vs final charge).
|
||||
*/
|
||||
export enum InvoiceType {
|
||||
Prepaid = "PREPAID",
|
||||
}
|
||||
|
||||
export enum SchedulingStatus {
|
||||
NotScheduled = "NOT_SCHEDULED",
|
||||
Holding = "HOLDING",
|
||||
|
||||
1020
pnpm-lock.yaml
generated
1020
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user