mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'freight/develop' into freight/feat/company-profile
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -69,8 +69,8 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
|
||||
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
|
||||
# Portal pages the payment provider redirects the browser to after payment.
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
|
||||
@@ -43,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
@@ -52,13 +51,14 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { FacilitiesModule } from "./modules/facilities/facilities.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -117,12 +117,12 @@ import { OverviewModule } from "./modules/overview/overview.module";
|
||||
FacilitiesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
DemoBookingsSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
@@ -137,11 +137,6 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -152,13 +147,11 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.demoBookingsSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plate_number VARCHAR NOT NULL UNIQUE,
|
||||
registration_number VARCHAR NOT NULL UNIQUE,
|
||||
vehicle_type VARCHAR NOT NULL,
|
||||
manufacturer VARCHAR NOT NULL,
|
||||
model VARCHAR NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
fuel_type VARCHAR NOT NULL,
|
||||
capacity NUMERIC NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
|
||||
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
|
||||
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
|
||||
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
|
||||
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -69,7 +68,12 @@ export class BookingTransitionService {
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
||||
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
||||
// arrives. The returned status reflects that outcome.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -143,7 +147,10 @@ export class BookingTransitionService {
|
||||
},
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Same consolidation treatment as the direct submit path.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -188,18 +195,11 @@ export class BookingTransitionService {
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
// 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']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,8 +118,22 @@ export class BookingsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||
// force-scoped to their own company, regardless of any companyId they pass.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
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).
|
||||
if (!companyId) return { items: [], total: 0 };
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@@ -166,18 +181,60 @@ export class BookingsController {
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
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.',
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
|
||||
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||
// pairing never prematurely submits an unfinished/unpriced draft.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -52,6 +56,8 @@ export class BookingsService {
|
||||
private readonly minioService: MinioService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -146,13 +152,17 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||
*
|
||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||
* whole wagons (where consolidation is moot anyway).
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (explicit === false) return false;
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
@@ -193,10 +203,11 @@ export class BookingsService {
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
if (booking.status === 'DRAFT') {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
// No partner yet — park the booking so it waits. Applies both pre-submit
|
||||
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
|
||||
// reach this method.
|
||||
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
}
|
||||
|
||||
const pending = await this.findById(booking.id);
|
||||
@@ -205,45 +216,24 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
|
||||
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
|
||||
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
|
||||
* waits for a later complementary booking to complete the wagon.
|
||||
*
|
||||
* Returns the re-fetched booking, so callers can reflect the resulting status
|
||||
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
// Already paired (e.g. a partner submitted first) — nothing to do.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
return booking;
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
return result.booking;
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
@@ -575,6 +565,7 @@ export class BookingsService {
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -587,7 +578,9 @@ export class BookingsService {
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
// A forced company scope (portal/customer) overrides any caller-provided
|
||||
// companyId so a customer can only ever see their own company's bookings.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
@@ -631,6 +624,109 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company a customer user belongs to, for scoping their own
|
||||
* bookings. Returns null when no profile/company is linked yet.
|
||||
*/
|
||||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
* to the company the authenticated user is linked to — otherwise it is hidden
|
||||
* behind a NotFound so booking IDs can't be probed.
|
||||
*/
|
||||
async assertCustomerCanAccessBooking(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||
if (!companyId || booking.companyId !== companyId) {
|
||||
// Don't reveal that the booking exists for another company.
|
||||
throw new NotFoundException(`Booking ${booking.id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
* responsible for authorizing access to the booking first.
|
||||
*
|
||||
* When the booking has not been assigned to a train yet, returns a valid
|
||||
* "no schedule" payload so the UI can show a pre-dispatch state.
|
||||
*/
|
||||
async getBookingTracking(
|
||||
bookingId: string,
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
const empty: Freight.IBookingTracking = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: false,
|
||||
scheduleId: null,
|
||||
trainNumber: null,
|
||||
scheduleStatus: null,
|
||||
direction: null,
|
||||
origin: null,
|
||||
destination: null,
|
||||
stations: [],
|
||||
checkpoints: [],
|
||||
currentSequenceNo: -1,
|
||||
actualDepartureAt: null,
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Pull the live corridor + checkpoints for the assigned schedule. If the
|
||||
// schedule was removed, fall back to the pre-dispatch state rather than 500.
|
||||
let track: Awaited<
|
||||
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
|
||||
>;
|
||||
try {
|
||||
track = await this.trainSchedulingService.getScheduleCheckpoints(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: true,
|
||||
scheduleId: track.scheduleId,
|
||||
trainNumber: track.trainNumber,
|
||||
scheduleStatus: track.status as Freight.TrainScheduleStatus,
|
||||
direction: track.direction,
|
||||
origin: track.origin,
|
||||
destination: track.destination,
|
||||
stations: track.stations,
|
||||
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
|
||||
currentSequenceNo: track.currentSequenceNo,
|
||||
actualDepartureAt: track.actualDepartureAt,
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
// process.env.PAYMENT_API_URL ??
|
||||
"https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
|
||||
@@ -145,9 +145,7 @@ export class PaymentService {
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -159,8 +157,8 @@ export class PaymentService {
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
||||
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||
}
|
||||
|
||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
||||
}
|
||||
}
|
||||
|
||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
});
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
})
|
||||
: {
|
||||
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||
scheduleId,
|
||||
)),
|
||||
warnings: [] as string[],
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
|
||||
@@ -721,27 +721,37 @@ export class TrainSchedulingService {
|
||||
: null;
|
||||
|
||||
if (route) {
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
// `route.milestones` is the complete ordered corridor and already includes
|
||||
// the origin (first) and destination (last) yards — `route.originYardId`
|
||||
// and `route.destinationYardId` are derived from them. Use the milestones
|
||||
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||||
const milestones = [...(route.milestones ?? [])].sort(
|
||||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
|
||||
if (milestones.length > 0) {
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
return stations;
|
||||
}
|
||||
|
||||
// Route with no milestones recorded — fall back to its origin/destination.
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
stations.push({
|
||||
sequenceNo: 0,
|
||||
yardId: route.originYardId,
|
||||
label: origin?.label ?? origin?.code ?? 'Origin',
|
||||
code: origin?.code ?? '',
|
||||
});
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i + 1,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
stations.push({
|
||||
sequenceNo: milestones.length + 1,
|
||||
sequenceNo: 1,
|
||||
yardId: route.destinationYardId,
|
||||
label: destination?.label ?? destination?.code ?? 'Destination',
|
||||
code: destination?.code ?? '',
|
||||
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
|
||||
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
|
||||
// Resolve each checkpoint's position by its yard against the canonical
|
||||
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||||
// under an older station numbering still line up with the current stations.
|
||||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||||
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||
|
||||
const currentSequenceNo = events.length
|
||||
? Math.max(...events.map((e) => e.sequenceNo))
|
||||
? Math.max(...events.map(resolvedSeq))
|
||||
: -1;
|
||||
|
||||
return {
|
||||
@@ -790,13 +808,19 @@ export class TrainSchedulingService {
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? schedule.actualArrivalAt.toISOString()
|
||||
: null,
|
||||
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||||
? schedule.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||||
? schedule.scheduledArrivalDate.toISOString()
|
||||
: null,
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
sequenceNo: e.sequenceNo,
|
||||
sequenceNo: resolvedSeq(e),
|
||||
yardId: e.yardId,
|
||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||
kind: e.kind,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
|
||||
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
export class CreateVehicleDto {
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@IsEnum(VehicleType)
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@IsString()
|
||||
manufacturer!: string;
|
||||
|
||||
@IsString()
|
||||
model!: string;
|
||||
|
||||
@IsNumber()
|
||||
year!: number;
|
||||
|
||||
@IsEnum(FuelType)
|
||||
fuelType!: FuelType;
|
||||
|
||||
@IsNumber()
|
||||
capacity!: number;
|
||||
|
||||
@IsEnum(VehicleStatus)
|
||||
status!: VehicleStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||
|
||||
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Entity, Column, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
export enum VehicleType {
|
||||
TRUCK = 'TRUCK',
|
||||
VAN = 'VAN',
|
||||
CAR = 'CAR',
|
||||
BUS = 'BUS',
|
||||
TRAILER = 'TRAILER',
|
||||
TANKER = 'TANKER',
|
||||
FLATBED = 'FLATBED',
|
||||
}
|
||||
|
||||
export enum FuelType {
|
||||
PETROL = 'PETROL',
|
||||
DIESEL = 'DIESEL',
|
||||
ELECTRIC = 'ELECTRIC',
|
||||
HYBRID = 'HYBRID',
|
||||
}
|
||||
|
||||
export enum VehicleStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
MAINTENANCE = 'MAINTENANCE',
|
||||
RETIRED = 'RETIRED',
|
||||
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicles', schema: 'freight' })
|
||||
@Index(['plateNumber'])
|
||||
@Index(['registrationNumber'])
|
||||
@Index(['status'])
|
||||
@Index(['vehicleType'])
|
||||
@Index(['manufacturer'])
|
||||
export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'plate_number', unique: true })
|
||||
plateNumber!: string;
|
||||
|
||||
@Column({ name: 'registration_number', unique: true })
|
||||
registrationNumber!: string;
|
||||
|
||||
@Column({ name: 'vehicle_type', type: 'varchar' })
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@Column()
|
||||
manufacturer!: string;
|
||||
|
||||
@Column()
|
||||
model!: string;
|
||||
|
||||
@Column()
|
||||
year!: number;
|
||||
|
||||
@Column({ name: 'fuel_type', type: 'varchar' })
|
||||
fuelType!: FuelType;
|
||||
|
||||
@Column()
|
||||
capacity!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
|
||||
status!: VehicleStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
|
||||
@ApiTags('vehicles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('vehicles')
|
||||
@FleetView()
|
||||
export class VehiclesController {
|
||||
constructor(private readonly vehiclesService: VehiclesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new vehicle' })
|
||||
create(@Body() createVehicleDto: CreateVehicleDto) {
|
||||
return this.vehiclesService.create(createVehicleDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all vehicles with filters' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||
) {
|
||||
return this.vehiclesService.findAll({
|
||||
search,
|
||||
status: status as any,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get vehicle by id' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a vehicle' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateVehicleDto: UpdateVehicleDto,
|
||||
) {
|
||||
return this.vehiclesService.update(id, updateVehicleDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a vehicle' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||
providers: [VehiclesService],
|
||||
controllers: [VehiclesController],
|
||||
exports: [VehiclesService],
|
||||
})
|
||||
export class VehiclesModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
repository: Repository<Vehicle>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
});
|
||||
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async findAll(query: {
|
||||
search?: string;
|
||||
status?: VehicleStatus | string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.status) where.status = query.status;
|
||||
|
||||
let qb = this.vehicleRepo.createQueryBuilder('v');
|
||||
|
||||
if (query.search) {
|
||||
qb = qb.where(
|
||||
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb = qb.andWhere('v.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
|
||||
query.sortBy ?? '',
|
||||
)
|
||||
? query.sortBy
|
||||
: 'createdAt';
|
||||
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
|
||||
|
||||
const [data, total] = await qb
|
||||
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Vehicle> {
|
||||
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
|
||||
if (!vehicle) {
|
||||
throw new NotFoundException(`Vehicle ${id} not found`);
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
|
||||
const vehicle = await this.findById(id);
|
||||
|
||||
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.vehicleRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.seedWagons(manager);
|
||||
await this.seedApprovalRules(manager);
|
||||
// Demo freight data (wagons + approval rules) disabled — keep only the
|
||||
// 4 staff users. The seeders are retained for easy re-enabling; flip
|
||||
// SEED_DEMO_FREIGHT_DATA=true to run them.
|
||||
if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
|
||||
await this.seedWagons(manager);
|
||||
await this.seedApprovalRules(manager);
|
||||
}
|
||||
await this.seedStaffUsers(manager);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Badge as MantineBadge } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import "@/components/overview/overview.css";
|
||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||
import type {
|
||||
PaymentMethod,
|
||||
@@ -39,11 +42,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||
{ key: "success", label: "Success", statuses: "success" },
|
||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||
{
|
||||
key: "processing",
|
||||
label: "Processing",
|
||||
statuses: "processing,action-required",
|
||||
icon: Loader2,
|
||||
},
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||
] as const;
|
||||
|
||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
||||
|
||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||
|
||||
const tabCounts: Record<StatusTabKey, number | undefined> = {
|
||||
all:
|
||||
summary === undefined
|
||||
? undefined
|
||||
: (summary.success ?? 0) +
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
success: summary?.success,
|
||||
processing: summary?.processing,
|
||||
failed: summary?.failed,
|
||||
refunded: summary?.refunded,
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PaymentRow>[] = [
|
||||
{
|
||||
id: "order",
|
||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
||||
setStatusTab((value as StatusTabKey) ?? "all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
{STATUS_TABS.map((t) => {
|
||||
const isActive = statusTab === t.key;
|
||||
const count = tabCounts[t.key];
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={t.key}
|
||||
value={t.key}
|
||||
leftSection={<Icon size={17} strokeWidth={1.85} />}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<MantineBadge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</MantineBadge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -158,6 +160,9 @@ const App = () => {
|
||||
path="/booking/check-status/:orderId"
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
|
||||
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
<svg
|
||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="auth-grid" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
|
||||
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
||||
<LeftPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
Support
|
||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
{taglineBody ??
|
||||
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<span>Eng</span>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const FormFooter = () => (
|
||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Help & Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||
<RightPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
|
||||
interface BookingRowProps {
|
||||
booking: any;
|
||||
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
|
||||
const Icon = cfg.icon;
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||
// instead of navigating to the detail page.
|
||||
const canPay =
|
||||
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
|
||||
{cfg.badgeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
px={15}
|
||||
py={8}
|
||||
bg={ap.bg}
|
||||
bd={ap.bd}
|
||||
className="cursor-pointer rounded-[9px]"
|
||||
>
|
||||
<Text fz={13} fw={700} c={ap.c}>
|
||||
{cfg.action.label}
|
||||
</Text>
|
||||
{AIcon && (
|
||||
<AIcon
|
||||
size={15}
|
||||
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{canPay ? (
|
||||
<PayNowButton booking={booking} size="sm" />
|
||||
) : (
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
px={15}
|
||||
py={8}
|
||||
bg={ap.bg}
|
||||
bd={ap.bd}
|
||||
className="cursor-pointer rounded-[9px]"
|
||||
>
|
||||
<Text fz={13} fw={700} c={ap.c}>
|
||||
{cfg.action.label}
|
||||
</Text>
|
||||
{AIcon && (
|
||||
<AIcon
|
||||
size={15}
|
||||
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
|
||||
Outstanding balance
|
||||
</Text>
|
||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||
{formatCurrency(totalOutstanding || 377500, "ETB")}
|
||||
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
|
||||
@@ -1,39 +1,51 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
type LoginMethod = "email" | "phone";
|
||||
|
||||
const loginMethods: Array<{
|
||||
value: LoginMethod;
|
||||
label: string;
|
||||
icon: typeof Mail;
|
||||
placeholder: string;
|
||||
}> = [
|
||||
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
||||
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const [method, setMethod] = useState<LoginMethod>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("+251");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [countryCode] = useState("+251");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const currentMethod = loginMethods.find((item) => item.value === method)!;
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const loginId =
|
||||
method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
||||
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||
const result = await login({ email: loginId, password });
|
||||
if (result.success) {
|
||||
const from = (location.state as { from?: { pathname: string } } | null)
|
||||
?.from?.pathname;
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||
?.pathname;
|
||||
navigate(from ?? "/portal", { replace: true });
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
@@ -46,154 +58,105 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Welcome Back",
|
||||
title: "Sign in to your freight operations account",
|
||||
description:
|
||||
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box
|
||||
w={48}
|
||||
h={48}
|
||||
bg="edr-soft"
|
||||
className="flex items-center justify-center rounded-2xl"
|
||||
>
|
||||
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
<AuthShell>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Welcome back
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Enter your credentials to access your portal
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your credentials to access your freight portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v as LoginMethod)}
|
||||
fullWidth
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Mail size={15} />
|
||||
<Text size="sm">Email</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "email",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Phone size={15} />
|
||||
<Text size="sm">Phone</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "phone",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{method === "email" ? (
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="name@company.com"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{
|
||||
value: countryCode,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCountryCode(e.target.value),
|
||||
}}
|
||||
phone={{
|
||||
value: phoneNumber,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPhoneNumber(e.target.value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Password
|
||||
</Text>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="xs"
|
||||
c="edr-green.6"
|
||||
p={0}
|
||||
h="auto"
|
||||
fz={12}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||
disabled={loading}
|
||||
className={`${fieldClass} appearance-none pr-10`}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</Group>
|
||||
<PasswordInput
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
{loginMethods.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
{currentMethod.label} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={currentMethod.placeholder}
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
@@ -9,8 +8,9 @@ import { z } from "zod";
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
@@ -20,11 +20,22 @@ const passwordRequirements = [
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
|
||||
const ETHIOPIA_COUNTRY_CODE = "+251";
|
||||
|
||||
const isValidEthiopianMobile = (value: string) => {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||
return /^9\d{8}$/.test(normalized);
|
||||
};
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
countryCode: z.string().min(1, "Country code is required"),
|
||||
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
|
||||
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
@@ -44,11 +55,16 @@ const userSchema = z
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
const errorText = (msg?: string) =>
|
||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -59,7 +75,7 @@ export default function SignupPage() {
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
countryCode: "+251",
|
||||
countryCode: ETHIOPIA_COUNTRY_CODE,
|
||||
phone: "",
|
||||
userType: userType.individual,
|
||||
firstName: { en: "", am: "" },
|
||||
@@ -73,7 +89,8 @@ export default function SignupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
|
||||
const digits = data.phone.replace(/\D/g, "");
|
||||
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
@@ -102,145 +119,194 @@ export default function SignupPage() {
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Smart Freight Operations",
|
||||
title: "Create your freight operations account",
|
||||
description:
|
||||
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Create Account
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
{errorText(errors.firstName?.en?.message)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
{errorText(errors.lastName?.en?.message)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="John"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
disabled={loading}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
className={fieldClass}
|
||||
{...register("email")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{errorText(errors.email?.message)}
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
disabled={loading}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="hidden" {...register("countryCode")} />
|
||||
<div
|
||||
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
|
||||
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
|
||||
{ETHIOPIA_COUNTRY_CODE}
|
||||
</span>
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel-national"
|
||||
placeholder="0912345678"
|
||||
maxLength={10}
|
||||
disabled={loading}
|
||||
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
|
||||
{...register("phone", {
|
||||
onChange: (event) => {
|
||||
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errorText(errors.phone?.message)}
|
||||
</div>
|
||||
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{ ...register("countryCode") }}
|
||||
phone={{ ...register("phone") }}
|
||||
countryCodeError={errors.countryCode}
|
||||
phoneError={errors.phone}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 && (
|
||||
<Stack gap={4} mt={8}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.password?.message)}
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={16}
|
||||
radius="xl"
|
||||
variant={met ? "filled" : "light"}
|
||||
color={met ? "edr-green" : "gray"}
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check size={10} /> : <X size={10} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.confirmPassword?.message)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||
@@ -60,6 +61,8 @@ export function DraftBookingView({
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [docError, setDocError] = useState("");
|
||||
const [priceChangeModal, setPriceChangeModal] =
|
||||
useState<SubmitBookingResponse | null>(null);
|
||||
|
||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||
const uploadedCodes = useMemo(
|
||||
@@ -72,9 +75,11 @@ export function DraftBookingView({
|
||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||
|
||||
const { data: generatedPricing } = useQuery(
|
||||
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
|
||||
|
||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
||||
api.bookings.generatePrice.queryOptions({
|
||||
input: { id: booking.id },
|
||||
enabled:
|
||||
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
|
||||
!booking.pricingBreakdown,
|
||||
}),
|
||||
);
|
||||
const pricing = (booking.pricingBreakdown ??
|
||||
@@ -82,8 +87,17 @@ export function DraftBookingView({
|
||||
null) as Freight.PricingBreakdown | null;
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
||||
mutationFn: async (files: Record<string, File | File[] | null>) => {
|
||||
if (booking.status === "CHANGES_REQUESTED") {
|
||||
const result = await api.bookings.update.call({
|
||||
id: booking.id,
|
||||
dto: {},
|
||||
documents: files,
|
||||
});
|
||||
return result.booking;
|
||||
}
|
||||
return api.bookings.uploadDocuments.call({ id: booking.id, files });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedFiles({});
|
||||
setDocError("");
|
||||
@@ -93,7 +107,20 @@ export function DraftBookingView({
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
|
||||
onSuccess: (result) => {
|
||||
if (result.priceChanged) {
|
||||
setPriceChangeModal(result);
|
||||
return;
|
||||
}
|
||||
onBookingUpdated();
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
},
|
||||
});
|
||||
|
||||
const confirmSubmitMutation = useMutation({
|
||||
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
|
||||
onSuccess: () => {
|
||||
setPriceChangeModal(null);
|
||||
onBookingUpdated();
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
},
|
||||
@@ -155,7 +182,12 @@ export function DraftBookingView({
|
||||
/>
|
||||
|
||||
<MutationErrors
|
||||
mutations={[uploadMutation, submitMutation, cancelMutation]}
|
||||
mutations={[
|
||||
uploadMutation,
|
||||
submitMutation,
|
||||
confirmSubmitMutation,
|
||||
cancelMutation,
|
||||
]}
|
||||
/>
|
||||
|
||||
<StatusHero booking={booking}>
|
||||
@@ -163,7 +195,9 @@ export function DraftBookingView({
|
||||
booking.latestChangeRequestNote ? (
|
||||
<ActionRequiredBanner
|
||||
title="Review the requested changes, then resubmit."
|
||||
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
|
||||
onAction={() =>
|
||||
navigate(`/bookings/${booking.id}/edit?section=documents`)
|
||||
}
|
||||
>
|
||||
{booking.latestChangeRequestNote}
|
||||
</ActionRequiredBanner>
|
||||
@@ -260,6 +294,8 @@ export function DraftBookingView({
|
||||
const isUploaded = uploadedCodes.has(doc.key);
|
||||
const selected = selectedFiles[doc.key];
|
||||
const file = booking.files?.find((f) => f.code === doc.key);
|
||||
const allowReplace =
|
||||
!isUploaded || booking.status === "CHANGES_REQUESTED";
|
||||
return (
|
||||
<DocRow
|
||||
key={doc.key}
|
||||
@@ -276,13 +312,19 @@ export function DraftBookingView({
|
||||
isUploaded ? "verified" : selected ? "ready" : "missing"
|
||||
}
|
||||
action={
|
||||
isUploaded ? (
|
||||
isUploaded && !allowReplace ? (
|
||||
<IconSquare
|
||||
href={file?.signedUrl ?? file?.url}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isUploaded && (
|
||||
<IconSquare
|
||||
href={file?.signedUrl ?? file?.url}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputRefs.current[doc.key] = el;
|
||||
@@ -318,7 +360,7 @@ export function DraftBookingView({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selected ? "Change" : "Add"}
|
||||
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
|
||||
</Button>
|
||||
{selected && (
|
||||
<ActionIcon
|
||||
@@ -375,6 +417,72 @@ export function DraftBookingView({
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={priceChangeModal !== null}
|
||||
onClose={() => setPriceChangeModal(null)}
|
||||
title={<Text fw={700}>Price has changed</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
{priceChangeModal && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceChangeModal.message ??
|
||||
"The booking price has been updated. Confirm to submit with the new total."}
|
||||
</Text>
|
||||
{priceChangeModal.previousTotalAmount !== undefined && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Previous total
|
||||
</Text>
|
||||
<Text size="sm" td="line-through">
|
||||
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
|
||||
{priceChangeModal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>New total</Text>
|
||||
<Text fw={800} c="edr-green">
|
||||
{priceChangeModal.totalAmount.toLocaleString()}{" "}
|
||||
{priceChangeModal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{priceChangeModal.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setPriceChangeModal(null)}
|
||||
>
|
||||
Review later
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={confirmSubmitMutation.isPending}
|
||||
onClick={() => confirmSubmitMutation.mutate()}
|
||||
>
|
||||
Confirm & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
}}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
|
||||
@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Smartphone, type LucideIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
@@ -8,7 +8,10 @@ interface ProviderOption {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
logo: string;
|
||||
/** Currencies this provider settles in. */
|
||||
currencies: string[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
label: "telebirr",
|
||||
description: "Ethiopian mobile money",
|
||||
icon: Smartphone,
|
||||
description: "Ethiopian mobile money · ETB",
|
||||
logo: "/assets/telebirr.jpeg",
|
||||
currencies: ["ETB"],
|
||||
accent: "#0A6F4D",
|
||||
},
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "Waafi",
|
||||
description: "Djibouti mobile money",
|
||||
icon: Smartphone,
|
||||
description: "Djibouti mobile money · USD",
|
||||
logo: "/assets/waafi.jpeg",
|
||||
currencies: ["USD"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||
*/
|
||||
function providersForCurrency(currency?: string | null): ProviderOption[] {
|
||||
const cur = currency?.trim().toUpperCase();
|
||||
if (!cur) return PROVIDERS;
|
||||
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
return matched.length > 0 ? matched : PROVIDERS;
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
option,
|
||||
selected,
|
||||
@@ -36,55 +54,75 @@ function ProviderRow({
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Group
|
||||
onClick={onSelect}
|
||||
gap={12}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
gap={14}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
padding: "13px 14px",
|
||||
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
||||
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
||||
transition: "border-color .12s, background-color .12s",
|
||||
borderRadius: 14,
|
||||
padding: "14px 16px",
|
||||
border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
|
||||
backgroundColor: selected ? "#F6FBF8" : "#fff",
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
|
||||
: "none",
|
||||
transition: "border-color .14s, box-shadow .14s, background-color .14s",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
||||
color: selected ? "#fff" : "#475569",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
border: "1px solid #EEF2F6",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fz="14px" fw={700} c="#10202F">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
<Text fz="12.5px" c="#7A8794" truncate>
|
||||
{option.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
width: 22,
|
||||
height: 22,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
||||
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
||||
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
||||
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
|
||||
backgroundColor: selected ? option.accent : "transparent",
|
||||
transition: "all .14s",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
|
||||
opened,
|
||||
onClose,
|
||||
amountLabel,
|
||||
currency,
|
||||
onConfirm,
|
||||
processing,
|
||||
error,
|
||||
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
|
||||
onClose: () => void;
|
||||
/** Human-readable total, e.g. "ETB 12,500". */
|
||||
amountLabel?: string;
|
||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||
currency?: string | null;
|
||||
onConfirm: (method: PaymentMethod) => void;
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
|
||||
const providers = useMemo(() => providersForCurrency(currency), [currency]);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
if (!providers.some((p) => p.method === method)) {
|
||||
setMethod(providers[0].method);
|
||||
}
|
||||
}, [providers, method]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size={460}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fw={800} fz="17px" c="#10202F">
|
||||
Choose a payment method
|
||||
</Text>
|
||||
{amountLabel && (
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
Amount due: {amountLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
radius={18}
|
||||
size={480}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{PROVIDERS.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
{/* Header */}
|
||||
<Box px={24} pt={24} pb={18}>
|
||||
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
|
||||
Complete your payment
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
Choose how you'd like to pay for this booking.
|
||||
</Text>
|
||||
|
||||
{amountLabel && (
|
||||
<Group
|
||||
mt={16}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background:
|
||||
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
|
||||
border: "1px solid #F2E4C4",
|
||||
}}
|
||||
>
|
||||
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Amount due
|
||||
</Text>
|
||||
<Text fz="20px" fw={800} c="#10202F">
|
||||
{amountLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Provider options */}
|
||||
<Box px={24} pb={4}>
|
||||
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
|
||||
Payment method
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box px={24} pt={16} pb={22}>
|
||||
{error && (
|
||||
<Text fz="12.5px" c="#C0392B" fw={600}>
|
||||
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt={6}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
disabled={processing}
|
||||
loading={processing}
|
||||
onClick={() => onConfirm(method)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
</Button>
|
||||
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
||||
You'll be redirected to your provider to complete payment securely.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6} align="center" justify="center" mb={12}>
|
||||
<ShieldCheck size={14} color="#0A8A5F" />
|
||||
<Text fz="11.5px" c="#7A8794">
|
||||
Secured · you'll be redirected to your provider to pay
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
styles={{
|
||||
root: { height: 48, flex: "0 0 38%" },
|
||||
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
disabled={processing}
|
||||
loading={processing}
|
||||
onClick={() => onConfirm(method)}
|
||||
styles={{
|
||||
root: { height: 48, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,92 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { AlertTriangle, Check, FileText, History } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FileText,
|
||||
History,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
||||
import { fmtDate, isDraftLike, isNegative } from "../utils";
|
||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||
import { SectionCard } from "./layout";
|
||||
|
||||
const ACCENT = "#F2A516";
|
||||
|
||||
/** Origin → destination strip rendered above the progress tracker. */
|
||||
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
|
||||
const origin = yardLabel(booking.originYard);
|
||||
const destination = yardLabel(booking.destinationYard);
|
||||
return (
|
||||
<Box
|
||||
mb={22}
|
||||
px={18}
|
||||
py={14}
|
||||
className="rounded-2xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
|
||||
border: "1px solid #F2E4C4",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<RouteEndpoint label="Origin" value={origin} />
|
||||
<Box
|
||||
className="flex items-center justify-center rounded-full shrink-0"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: "#fff",
|
||||
border: `1px solid ${ACCENT}33`,
|
||||
color: ACCENT,
|
||||
}}
|
||||
>
|
||||
<MoveRight size={18} />
|
||||
</Box>
|
||||
<RouteEndpoint label="Destination" value={destination} alignRight />
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteEndpoint({
|
||||
label,
|
||||
value,
|
||||
alignRight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
alignRight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify={alignRight ? "flex-end" : "flex-start"}
|
||||
>
|
||||
<MapPin size={12} color={ACCENT} />
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
c="#B07D14"
|
||||
tt="uppercase"
|
||||
className="tracking-[0.6px]"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusHero({
|
||||
booking,
|
||||
children,
|
||||
@@ -91,6 +171,8 @@ export function StatusHero({
|
||||
|
||||
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
|
||||
|
||||
{!negative && <RouteStrip booking={booking} />}
|
||||
|
||||
{children ?? (
|
||||
<ProgressTracker
|
||||
current={cfg.stage}
|
||||
|
||||
@@ -70,11 +70,11 @@ export function EstimateCard({
|
||||
mb={4}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#F1F4F7",
|
||||
backgroundColor: "#FEF6E6",
|
||||
padding: "3px 7px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#6B7C8E",
|
||||
color: "#B07D14",
|
||||
}}
|
||||
>
|
||||
est.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
CountChip,
|
||||
DocRow,
|
||||
@@ -52,12 +53,33 @@ import {
|
||||
type BookingFormValues,
|
||||
} from "./new-booking-form/schema";
|
||||
import { SelectField } from "./new-booking-form/shared";
|
||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
|
||||
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
|
||||
|
||||
function yardNameFromBooking(
|
||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
||||
const EDIT_SECTIONS = [
|
||||
"service",
|
||||
"route",
|
||||
"cargo",
|
||||
"schedule",
|
||||
"documents",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
type EditSection = (typeof EDIT_SECTIONS)[number];
|
||||
|
||||
function isEditSection(value: string | null): value is EditSection {
|
||||
return EDIT_SECTIONS.includes(value as EditSection);
|
||||
}
|
||||
|
||||
function yardIdFromBooking(
|
||||
yard: Freight.IYard | null | undefined,
|
||||
referenceData: Freight.BookingReferenceData,
|
||||
): string {
|
||||
return yard?.label ?? yard?.name ?? yard?.code ?? "";
|
||||
if (yard?.id) return yard.id;
|
||||
const label = yard?.label ?? "";
|
||||
return (
|
||||
referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback container type for a size, used only when a booking row has no
|
||||
@@ -108,14 +130,20 @@ function mapBookingToFormValues(
|
||||
booking.equipmentReturn === "WITH_RETURN"
|
||||
? "with_return"
|
||||
: "without_return",
|
||||
originYard: yardNameFromBooking(booking.originYard),
|
||||
destinationYard: yardNameFromBooking(booking.destinationYard),
|
||||
originYard: yardIdFromBooking(booking.originYard, referenceData),
|
||||
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
|
||||
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||
isHazardous: booking.isHazardous ?? false,
|
||||
isRefrigerated: booking.isRefrigerated ?? false,
|
||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||
scheduledDate: booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||
: "",
|
||||
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
||||
notes: "",
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
@@ -237,9 +265,20 @@ const DIRECTION_LABEL: Record<string, string> = {
|
||||
export default function EditBookingPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const sectionParam = searchParams.get("section");
|
||||
const activeSection: EditSection = isEditSection(sectionParam)
|
||||
? sectionParam
|
||||
: "service";
|
||||
|
||||
function setSection(section: EditSection) {
|
||||
setSearchParams({ section });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const bookingQuery = useQuery(
|
||||
api.bookings.get.queryOptions({
|
||||
input: { id: id! },
|
||||
@@ -269,18 +308,17 @@ export default function EditBookingPage() {
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
|
||||
const result = await api.bookings.update.call({ id: id!, dto: payload });
|
||||
|
||||
// Upload any newly attached documents against the existing booking.
|
||||
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
||||
const hasDocuments = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocuments) {
|
||||
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
|
||||
const newDocuments: BookingDocuments = {};
|
||||
for (const [key, value] of Object.entries(documents)) {
|
||||
if (value) newDocuments[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
return api.bookings.update.call({
|
||||
id: id!,
|
||||
dto: payload,
|
||||
documents:
|
||||
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
@@ -304,9 +342,9 @@ export default function EditBookingPage() {
|
||||
);
|
||||
|
||||
const direction = useMemo(() => {
|
||||
const origin = referenceData?.yard.find((y) => y.name === originYard);
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const destination = referenceData?.yard.find(
|
||||
(y) => y.name === destinationYard,
|
||||
(y) => y.id === destinationYard,
|
||||
);
|
||||
return getRouteDirection(origin, destination);
|
||||
}, [originYard, destinationYard, referenceData]);
|
||||
@@ -314,7 +352,7 @@ export default function EditBookingPage() {
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({
|
||||
value: y.name,
|
||||
value: y.id,
|
||||
label: y.name,
|
||||
country: y.country,
|
||||
}));
|
||||
@@ -338,14 +376,9 @@ export default function EditBookingPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
const yards = referenceData?.yard ?? [];
|
||||
const services = referenceData?.service ?? [];
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
|
||||
const findYardId = (name: string): string =>
|
||||
yards.find((y) => y.name === name)?.id ?? "";
|
||||
|
||||
const findShippingLineId = (name: string): string | undefined =>
|
||||
shippingLines.find((l) => l.name === name)?.id;
|
||||
|
||||
@@ -369,10 +402,15 @@ export default function EditBookingPage() {
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
|
||||
const selectedSvc = referenceData?.service.find(
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
);
|
||||
|
||||
const apiPayload: Partial<CreateBookingPayload> = {
|
||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: undefined,
|
||||
trainScheduleId: data.trainScheduleId || undefined,
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
@@ -380,8 +418,8 @@ export default function EditBookingPage() {
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
originYardId: findYardId(data.originYard),
|
||||
destinationYardId: findYardId(data.destinationYard),
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
tradeDirection:
|
||||
direction === "EXPORT"
|
||||
? "EXPORT"
|
||||
@@ -391,9 +429,8 @@ export default function EditBookingPage() {
|
||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
paymentCurrency: "USD",
|
||||
paymentCurrency: data.paymentCurrency,
|
||||
allowConsolidation: data.consolidationEnabled,
|
||||
// @ts-ignore
|
||||
freightType:
|
||||
data.cargoType === "container"
|
||||
? ("CONTAINER" as const)
|
||||
@@ -505,9 +542,34 @@ export default function EditBookingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap={36} mt="xl">
|
||||
{/* ── Section 1: Service ── */}
|
||||
<Stack gap="md">
|
||||
{booking.status === "CHANGES_REQUESTED" && (
|
||||
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
|
||||
<Text size="sm" fw={600}>
|
||||
Staff requested changes
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
Update the sections below and save. Then return to the booking page to
|
||||
resubmit for review.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onChange={(value) => value && setSection(value as EditSection)}
|
||||
mt="xl"
|
||||
>
|
||||
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
|
||||
<Tabs.Tab value="service">Service</Tabs.Tab>
|
||||
<Tabs.Tab value="route">Route</Tabs.Tab>
|
||||
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
|
||||
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
|
||||
<Tabs.Tab value="documents">Documents</Tabs.Tab>
|
||||
<Tabs.Tab value="notes">Notes</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="service">
|
||||
<Stack gap="md">
|
||||
<SectionHeading
|
||||
title="Service"
|
||||
description="Select the service combination and configure trucking options."
|
||||
@@ -548,6 +610,8 @@ export default function EditBookingPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<PaymentCurrencyField control={form.control} />
|
||||
|
||||
{(selectedService?.includesFirstMile ||
|
||||
selectedService?.includesLastMile ||
|
||||
selectedService?.includesCustoms) && (
|
||||
@@ -648,10 +712,9 @@ export default function EditBookingPage() {
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 3: Route ── */}
|
||||
<Tabs.Panel value="route">
|
||||
<Stack gap="md">
|
||||
<SectionHeading
|
||||
title="Route"
|
||||
@@ -746,10 +809,9 @@ export default function EditBookingPage() {
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 4: Cargo ── */}
|
||||
<Tabs.Panel value="cargo">
|
||||
<Box>
|
||||
<Step5CargoDetails
|
||||
form={form}
|
||||
@@ -758,10 +820,13 @@ export default function EditBookingPage() {
|
||||
isLoading={!referenceData}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Divider />
|
||||
<Tabs.Panel value="schedule">
|
||||
<StepScheduling form={form} referenceData={referenceData} />
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Section 5: Documents ── */}
|
||||
<Tabs.Panel value="documents">
|
||||
<Stack gap="md">
|
||||
<SectionHeading
|
||||
title="Documents"
|
||||
@@ -855,10 +920,9 @@ export default function EditBookingPage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 6: Notes ── */}
|
||||
<Tabs.Panel value="notes">
|
||||
<Stack gap="md">
|
||||
<SectionHeading
|
||||
title="Notes"
|
||||
@@ -878,7 +942,8 @@ export default function EditBookingPage() {
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Submit ── */}
|
||||
<Group
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -8,14 +8,44 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileEdit,
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
|
||||
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"COMPLETED",
|
||||
"DELIVERED",
|
||||
]);
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -24,25 +54,86 @@ import {
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
|
||||
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
|
||||
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
|
||||
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
|
||||
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
|
||||
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
|
||||
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
|
||||
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
|
||||
};
|
||||
const STATUS_FILTERS = [
|
||||
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
statuses:
|
||||
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
|
||||
},
|
||||
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
const STAT_CARDS: Array<{
|
||||
key: StatusFilterKey;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status] ?? {
|
||||
bg: "#F1F4F7",
|
||||
dot: "#94A3B8",
|
||||
color: "#475569",
|
||||
label: status.replace(/_/g, " "),
|
||||
};
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
|
||||
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: cfg.bg,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: cfg.dot,
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
|
||||
{cfg.label}
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
|
||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||
|
||||
function PrimaryAction({
|
||||
status,
|
||||
id,
|
||||
booking,
|
||||
onNavigate,
|
||||
}: {
|
||||
status: string;
|
||||
id: string;
|
||||
booking: Freight.IBooking;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
@@ -89,57 +180,39 @@ function PrimaryAction({
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (status === "AWAITING_PAYMENT") {
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
color="orange"
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
|
||||
>
|
||||
Pay
|
||||
Review changes
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (status === "IN_TRANSIT") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
);
|
||||
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
>
|
||||
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column header label ───────────────────────────────────────────────────────
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Text
|
||||
@@ -155,20 +228,164 @@ function ColHeader({ label }: { label: string }) {
|
||||
|
||||
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
return data?.meta?.total;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
card,
|
||||
active,
|
||||
count,
|
||||
onSelect,
|
||||
}: {
|
||||
card: (typeof STAT_CARDS)[number];
|
||||
active: boolean;
|
||||
count: number | undefined;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Paper
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "box-shadow 140ms ease, border-color 140ms ease",
|
||||
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
|
||||
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: card.iconBg,
|
||||
color: card.iconColor,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={24} fw={800} lh={1.05} c="edr-text">
|
||||
{count ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{card.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyBookings() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
|
||||
const bookings = data?.items ?? [];
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
const total = bookings.length;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
|
||||
const selectFilter = (key: StatusFilterKey) => {
|
||||
setStatusFilter(key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
[statuses, pagination.pageIndex, pagination.pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
active: activeCount,
|
||||
payment: paymentCount,
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
closed: undefined,
|
||||
};
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
const total = data?.meta?.total ?? allItems.length;
|
||||
|
||||
// Server handles status + pagination; reference search is applied on the page.
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allItems;
|
||||
return allItems.filter((b) =>
|
||||
[b.reference, b.originYard?.label, b.destinationYard?.label]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allItems, query]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty =
|
||||
!isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
@@ -178,8 +395,7 @@ export default function MyBookings() {
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
|
||||
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -217,7 +433,7 @@ export default function MyBookings() {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
||||
const sub = b.scheduledDate ?? b.createdAt ?? "";
|
||||
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
@@ -245,7 +461,10 @@ export default function MyBookings() {
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
|
||||
const b = row.original as Freight.IBooking & {
|
||||
totalAmount?: number;
|
||||
amount?: number;
|
||||
};
|
||||
const amount = b.totalAmount ?? b.amount ?? null;
|
||||
if (!amount) {
|
||||
return (
|
||||
@@ -267,24 +486,42 @@ export default function MyBookings() {
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<Train size={14} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
||||
View Details
|
||||
View details
|
||||
</Menu.Item>
|
||||
{trackable && (
|
||||
<Menu.Item
|
||||
leftSection={<Train size={15} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track shipment
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -293,8 +530,6 @@ export default function MyBookings() {
|
||||
},
|
||||
];
|
||||
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
@@ -305,81 +540,112 @@ export default function MyBookings() {
|
||||
Bookings
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Manage every cargo booking — from draft to delivery.
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={12}>
|
||||
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New Booking
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New booking
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
|
||||
{STAT_CARDS.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
card={card}
|
||||
active={statusFilter === card.key}
|
||||
count={cardCounts[card.key]}
|
||||
onSelect={() => selectFilter(card.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Bookings table card ──────────────────────────────────────── */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Toolbar */}
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
justify="space-between"
|
||||
gap={12}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
radius="md"
|
||||
leftSection={<ArrowUpDown size={14} />}
|
||||
>
|
||||
Sort
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
radius="md"
|
||||
leftSection={<Filter size={14} />}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
placeholder="Search reference or route…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
|
||||
/>
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 200 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{total} booking{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Empty state */}
|
||||
{total === 0 && dataTableStatus === "success" ? (
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
No bookings yet
|
||||
{query ? "No bookings match your search" : "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
You haven't made any booking requests yet. Create your first one to get started.
|
||||
{query
|
||||
? "Try a different reference or clear the search."
|
||||
: "Create your first booking to get started."}
|
||||
</Text>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
mt="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
>
|
||||
Create first booking
|
||||
</Button>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
mt="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
>
|
||||
Create first booking
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||
pagination={{
|
||||
@@ -391,6 +657,8 @@ export default function MyBookings() {
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
@@ -398,6 +666,20 @@ export default function MyBookings() {
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingBooking !== null}
|
||||
onClose={() => setTrackingBooking(null)}
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
trackingBooking?.destinationYard?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { api } from "@/services/api";
|
||||
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
SubmitBookingResponse,
|
||||
} from "@/services/bookings.service";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
stepFields,
|
||||
type BookingDocuments,
|
||||
type BookingFormValues,
|
||||
} from "./new-booking-form/schema";
|
||||
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
||||
@@ -48,6 +51,8 @@ import {
|
||||
StepScheduling,
|
||||
} from "./new-booking-form/steps";
|
||||
|
||||
type PriceModalMode = "submit" | "draft";
|
||||
|
||||
export default function NewBookingPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (payload: CreateBookingPayload) => {
|
||||
const booking = await api.bookings.create.call(payload);
|
||||
const persistAndPriceMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
mode,
|
||||
existingBookingId,
|
||||
}: {
|
||||
payload: CreateBookingPayload;
|
||||
mode: PriceModalMode;
|
||||
existingBookingId: string | null;
|
||||
}) => {
|
||||
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
||||
let bookingId = existingBookingId;
|
||||
|
||||
// Documents can't ride along with creation — upload them against the
|
||||
// new booking id once it exists. Optional here; the booking detail page
|
||||
// remains the catch-all for any docs the user skips.
|
||||
const documents = form.getValues("documents") ?? {};
|
||||
const hasDocuments = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocuments) {
|
||||
await api.bookings.uploadDocuments.call({
|
||||
id: booking.id,
|
||||
files: documents,
|
||||
});
|
||||
if (bookingId) {
|
||||
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
|
||||
} else {
|
||||
const booking = await api.bookings.create.call({ payload, documents });
|
||||
bookingId = booking.id;
|
||||
}
|
||||
|
||||
return booking;
|
||||
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
|
||||
return { bookingId, pricing, mode };
|
||||
},
|
||||
onSuccess: (booking) => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const createAndPriceMutation = useMutation({
|
||||
mutationFn: async (payload: CreateBookingPayload) => {
|
||||
const booking = await api.bookings.create.call(payload);
|
||||
|
||||
const documents = form.getValues("documents") ?? {};
|
||||
const hasDocs = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocs) {
|
||||
await api.bookings.uploadDocuments.call({
|
||||
id: booking.id,
|
||||
files: documents,
|
||||
});
|
||||
}
|
||||
|
||||
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
||||
|
||||
return { bookingId: booking.id, pricing };
|
||||
},
|
||||
onSuccess: ({ bookingId, pricing }) => {
|
||||
onSuccess: ({ bookingId, pricing, mode }) => {
|
||||
setPriceBookingId(bookingId);
|
||||
setPricingData(pricing);
|
||||
setPricingPhase("ready");
|
||||
setPriceModalMode(mode);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setPricingPhase("idle");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
await api.bookings.submit.call({ id: priceBookingId });
|
||||
return api.bookings.submit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.priceChanged) {
|
||||
setPriceChangeResult(result);
|
||||
return;
|
||||
}
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmSubmitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
return api.bookings.confirmSubmit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPriceChangeResult(null);
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
|
||||
return route;
|
||||
}, [originYard, destinationYard]);
|
||||
|
||||
const docValues = form.watch("documents") ?? {};
|
||||
const hasDocuments = useMemo(
|
||||
() =>
|
||||
Object.values(docValues).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
),
|
||||
[docValues],
|
||||
);
|
||||
|
||||
const [pricingPhase, setPricingPhase] = useState<
|
||||
"idle" | "generating" | "ready"
|
||||
>("idle");
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
||||
null,
|
||||
);
|
||||
const [priceChangeResult, setPriceChangeResult] =
|
||||
useState<SubmitBookingResponse | null>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
|
||||
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
|
||||
form.setError("documents", {
|
||||
type: "manual",
|
||||
message: "Upload all four required documents.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||
}
|
||||
|
||||
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
|
||||
)!;
|
||||
|
||||
return {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: new Date().toISOString(),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
@@ -273,7 +274,7 @@ export default function NewBookingPage() {
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
paymentCurrency: "USD",
|
||||
paymentCurrency: data.paymentCurrency,
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
tradeDirection: direction!,
|
||||
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
|
||||
};
|
||||
}
|
||||
|
||||
const handleDraftSubmit = form.handleSubmit((data) => {
|
||||
const handleSaveDraft = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
createMutation.mutate(apiPayload);
|
||||
persistAndPriceMutation.mutate({
|
||||
payload: apiPayload,
|
||||
mode: "draft",
|
||||
existingBookingId: priceBookingId,
|
||||
});
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||
const handleSubmitBooking = form.handleSubmit((data) => {
|
||||
if (!hasAllRequiredDocuments(data.documents)) {
|
||||
form.setError("documents", {
|
||||
type: "manual",
|
||||
message: "Upload all four required documents.",
|
||||
});
|
||||
setStep(6);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
setPricingPhase("generating");
|
||||
createAndPriceMutation.mutate(apiPayload);
|
||||
persistAndPriceMutation.mutate({
|
||||
payload: apiPayload,
|
||||
mode: "submit",
|
||||
existingBookingId: priceBookingId,
|
||||
});
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
const isPricing =
|
||||
persistAndPriceMutation.isPending || confirmMutation.isPending;
|
||||
|
||||
function closePriceModal() {
|
||||
setPriceModalMode(null);
|
||||
if (priceModalMode === "draft" && priceBookingId) {
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDraftModalOk() {
|
||||
setPriceModalMode(null);
|
||||
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -376,14 +407,14 @@ export default function NewBookingPage() {
|
||||
id="new-booking-form"
|
||||
className="flex flex-col"
|
||||
style={{ flex: 1 }}
|
||||
onSubmit={handleDraftSubmit}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
<StepIndicator step={step} />
|
||||
</Box>
|
||||
|
||||
{createMutation.isError && (
|
||||
{persistAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to save draft
|
||||
Failed to save booking or generate price
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{createAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
radius="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to generate price estimate
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{createAndPriceMutation.error instanceof Error
|
||||
? createAndPriceMutation.error.message
|
||||
{persistAndPriceMutation.error instanceof Error
|
||||
? persistAndPriceMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
pricingPhase={pricingPhase}
|
||||
pricingData={pricingData}
|
||||
onConfirm={() => confirmMutation.mutate()}
|
||||
onContinueLater={
|
||||
priceBookingId
|
||||
? () => navigate(`/bookings/${priceBookingId}`)
|
||||
: undefined
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmitBooking}
|
||||
saveDraftPending={
|
||||
persistAndPriceMutation.isPending &&
|
||||
persistAndPriceMutation.variables?.mode === "draft"
|
||||
}
|
||||
submitPending={
|
||||
persistAndPriceMutation.isPending &&
|
||||
persistAndPriceMutation.variables?.mode === "submit"
|
||||
}
|
||||
onAbort={() => setCancelDialogOpen(true)}
|
||||
confirmPending={confirmMutation.isPending}
|
||||
abortPending={abortMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : pricingPhase === "idle" ? (
|
||||
<Group>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
variant={hasDocuments ? "outline" : "filled"}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createMutation.isPending}
|
||||
leftSection={
|
||||
createMutation.isPending ? undefined : <Check size={16} />
|
||||
}
|
||||
>
|
||||
{createMutation.isPending
|
||||
? "Saving Draft..."
|
||||
: "Save as Draft"}
|
||||
</Button>
|
||||
{hasDocuments && (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createAndPriceMutation.isPending}
|
||||
leftSection={
|
||||
createAndPriceMutation.isPending ? undefined : (
|
||||
<Send size={16} />
|
||||
)
|
||||
}
|
||||
onClick={() => handleGeneratePrice()}
|
||||
>
|
||||
{createAndPriceMutation.isPending
|
||||
? "Generating price…"
|
||||
: "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : pricingPhase === "generating" ? (
|
||||
<Button type="button" color="edr-green" radius="md" loading>
|
||||
Generating price estimate…
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={handleSubmitBooking}
|
||||
loading={isPricing}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
opened={priceModalMode !== null && pricingData !== null}
|
||||
onClose={closePriceModal}
|
||||
title={
|
||||
<Text fw={700}>
|
||||
{priceModalMode === "submit"
|
||||
? "Confirm booking submission"
|
||||
: "Draft saved — price estimate"}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{pricingData && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceModalMode === "submit"
|
||||
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
|
||||
: "Your booking has been saved as a draft. Here is the estimated price."}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Text fw={800} size="md">
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} size="lg" c="edr-green">
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
|
||||
{pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm" mt="md">
|
||||
{priceModalMode === "submit" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={closePriceModal}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
loading={confirmMutation.isPending}
|
||||
>
|
||||
Confirm & submit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
|
||||
OK
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={priceChangeResult !== null}
|
||||
onClose={() => setPriceChangeResult(null)}
|
||||
title={<Text fw={700}>Price has changed</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
{priceChangeResult && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceChangeResult.message ??
|
||||
"The booking price has been updated. Confirm to submit with the new total."}
|
||||
</Text>
|
||||
{priceChangeResult.previousTotalAmount !== undefined && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Previous total
|
||||
</Text>
|
||||
<Text size="sm" td="line-through">
|
||||
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
|
||||
{priceChangeResult.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>New total</Text>
|
||||
<Text fw={800} c="edr-green">
|
||||
{priceChangeResult.totalAmount.toLocaleString()}{" "}
|
||||
{priceChangeResult.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setPriceChangeResult(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={confirmSubmitMutation.isPending}
|
||||
onClick={() => confirmSubmitMutation.mutate()}
|
||||
>
|
||||
Confirm & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
|
||||
@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { STEPS } from "./schema";
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-5)";
|
||||
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
|
||||
const BORDER = "var(--mantine-color-edr-border-0)";
|
||||
const MUTED = "var(--mantine-color-edr-muted-0)";
|
||||
const INK = "var(--mantine-color-edr-text-0)";
|
||||
|
||||
export function StepIndicator({ step }: { step: number }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-1">
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
transition: "all 0.2s",
|
||||
...(step > item.id
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-edr-green-5)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
|
||||
}
|
||||
: step === item.id
|
||||
<div className="flex items-start">
|
||||
{STEPS.map((item, index) => {
|
||||
const done = step > item.id;
|
||||
const active = step === item.id;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
transition: "all 0.2s",
|
||||
...(done
|
||||
? {
|
||||
border: "2.5px solid var(--mantine-color-edr-green-5)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
backgroundColor: "#fff",
|
||||
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
|
||||
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "#fff",
|
||||
color: "var(--mantine-color-edr-muted-0)",
|
||||
border: "2px solid var(--mantine-color-edr-border-0)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{step > item.id ? (
|
||||
<Check style={{ width: 13, height: 13 }} />
|
||||
) : (
|
||||
item.id
|
||||
)}
|
||||
: active
|
||||
? {
|
||||
border: `2.5px solid ${GREEN}`,
|
||||
color: GREEN_DEEP,
|
||||
backgroundColor: "#fff",
|
||||
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "#fff",
|
||||
color: MUTED,
|
||||
border: `2px solid ${BORDER}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: active ? 700 : 500,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: 72,
|
||||
display: "none",
|
||||
transition: "color 0.2s",
|
||||
color: step >= item.id ? INK : MUTED,
|
||||
}}
|
||||
className="md:!block"
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
display: "none",
|
||||
transition: "color 0.2s",
|
||||
color:
|
||||
step >= item.id
|
||||
? "var(--mantine-color-edr-text-0)"
|
||||
: "var(--mantine-color-edr-muted-0)",
|
||||
}}
|
||||
className="lg:!block"
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
borderRadius: 999,
|
||||
margin: "0 6px",
|
||||
marginBottom: 14,
|
||||
transition: "background-color 0.3s",
|
||||
backgroundColor:
|
||||
step > item.id
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
margin: "16px 8px 0",
|
||||
transition: "background 0.3s",
|
||||
background: done
|
||||
? "linear-gradient(90deg, #0A8A5F, #12B981)"
|
||||
: BORDER,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Banknote, DollarSign } from "lucide-react";
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import {
|
||||
PAYMENT_CURRENCY_OPTIONS,
|
||||
type BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
type PaymentCurrency,
|
||||
} from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
|
||||
|
||||
const CURRENCY_ICONS: Record<
|
||||
PaymentCurrency,
|
||||
{ icon: typeof DollarSign; bg: string; color: string }
|
||||
> = {
|
||||
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
|
||||
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
|
||||
};
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
control,
|
||||
}: {
|
||||
control: Control<BookingFormInputValues, any, BookingFormValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||
Choose the currency for your freight quote and invoices.
|
||||
</Text>
|
||||
<Controller
|
||||
name="paymentCurrency"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
|
||||
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||
return (
|
||||
<OptionCard
|
||||
key={option.value}
|
||||
selected={field.value === option.value}
|
||||
onClick={() => field.onChange(option.value)}
|
||||
icon={<Icon className="h-5 w-5" />}
|
||||
iconBg={CURRENCY_ICONS[option.value].bg}
|
||||
iconColor={CURRENCY_ICONS[option.value].color}
|
||||
title={option.label}
|
||||
description={option.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,7 @@ export const STEPS = [
|
||||
|
||||
/**
|
||||
* Shipment documents collected during booking creation. The fileKeys mirror
|
||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
|
||||
* here shows up as "Uploaded" on the booking detail page. All optional in this
|
||||
* flow — the detail page remains the catch-all for uploading them later.
|
||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
|
||||
*/
|
||||
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
|
||||
|
||||
@@ -34,7 +32,7 @@ function docField(
|
||||
fileKey,
|
||||
fileLabel,
|
||||
helpText: null,
|
||||
isRequired: false,
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
||||
code: "booking_documents",
|
||||
label: "Booking Documents",
|
||||
description:
|
||||
"Attach your shipment documents now, or skip and upload them later from the booking page.",
|
||||
"Attach all four required shipment documents before submitting your booking.",
|
||||
entity: "booking",
|
||||
fields: [
|
||||
docField("commercial_invoice", "Commercial Invoice", 1),
|
||||
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
||||
|
||||
export type BookingDocuments = Record<string, File | File[] | null>;
|
||||
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
value: PaymentCurrency;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "USD",
|
||||
label: "USD",
|
||||
description: "US Dollar — international pricing and invoicing.",
|
||||
},
|
||||
{
|
||||
value: "ETB",
|
||||
label: "ETB",
|
||||
description: "Ethiopian Birr — local pricing and invoicing.",
|
||||
},
|
||||
];
|
||||
|
||||
export const bookingFormSchema = z
|
||||
.object({
|
||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||
previousContractRef: z.string(),
|
||||
serviceTypeId: z.string("Select a service type."),
|
||||
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||
|
||||
firstMile: z
|
||||
.object({
|
||||
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
previousContractRef: "",
|
||||
|
||||
serviceTypeId: "",
|
||||
paymentCurrency: "USD",
|
||||
firstMile: {
|
||||
enabled: false,
|
||||
pickUpAddress: "",
|
||||
@@ -232,6 +252,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
1: ["contractType", "previousContractRef"],
|
||||
2: [
|
||||
"serviceTypeId",
|
||||
"paymentCurrency",
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
"equipmentReturn",
|
||||
|
||||
@@ -1,48 +1,167 @@
|
||||
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
|
||||
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Paper,
|
||||
Select,
|
||||
Text,
|
||||
Title,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import type { BookingFormInputValues } from "./schema";
|
||||
|
||||
// Brand tokens (kept local so the form reads consistently with the booking
|
||||
// detail page and the scheduling step).
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const GREEN = "#0EA371";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||
if (!error?.message) return null;
|
||||
return (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{error.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium selectable option card with an icon tile, title, and description.
|
||||
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
|
||||
* `children` (legacy callers still work).
|
||||
*/
|
||||
export function OptionCard({
|
||||
selected,
|
||||
onClick,
|
||||
disabled,
|
||||
icon,
|
||||
iconBg = "#ECF6F1",
|
||||
iconColor = GREEN_DARK,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconBg?: string;
|
||||
iconColor?: string;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 150ms ease",
|
||||
border: `1.5px solid ${
|
||||
disabled ? BORDER : selected ? GREEN : BORDER
|
||||
}`,
|
||||
background: disabled
|
||||
? "#F6F8FA"
|
||||
: selected
|
||||
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
|
||||
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
|
||||
}`}
|
||||
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
|
||||
: "#FFFFFF",
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
opacity: disabled ? 0.65 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = "#BFE3D2";
|
||||
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = BORDER;
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected && !disabled && (
|
||||
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
height: 22,
|
||||
width: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
background: GREEN,
|
||||
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
|
||||
}}
|
||||
>
|
||||
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Structured form (icon + title + description) */}
|
||||
{(icon || title || description) && (
|
||||
<Box>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: "flex",
|
||||
height: 42,
|
||||
width: 42,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
{title && (
|
||||
<Text fz={15} fw={800} c={INK}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -63,7 +182,7 @@ export function AlertBox({
|
||||
};
|
||||
const { color, icon } = map[tone];
|
||||
return (
|
||||
<Alert color={color} icon={icon} radius="md" fz="sm">
|
||||
<Alert color={color} icon={icon} radius="lg" fz="sm">
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
@@ -71,31 +190,89 @@ export function AlertBox({
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={MUTED}
|
||||
style={{ letterSpacing: "0.07em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card shell that wraps a step's body. Gives every step the same premium
|
||||
* surface, padding, and an optional eyebrow.
|
||||
*/
|
||||
export function StepCard({
|
||||
children,
|
||||
eyebrow,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p={{ base: "lg", sm: 28 }}
|
||||
withBorder
|
||||
bg="white"
|
||||
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||
>
|
||||
{eyebrow}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Title order={3} className="tracking-tight">
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||
color: GREEN_DARK,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared Mantine input styling so every field in the form matches. */
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
|
||||
} as const;
|
||||
|
||||
export function SelectField({
|
||||
field,
|
||||
error,
|
||||
@@ -103,6 +280,7 @@ export function SelectField({
|
||||
placeholder,
|
||||
disabled,
|
||||
data,
|
||||
leftSection,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormInputValues>;
|
||||
error?: RhfFieldError;
|
||||
@@ -110,6 +288,7 @@ export function SelectField({
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
data: string[] | { value: string; label: string }[];
|
||||
leftSection?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
@@ -122,6 +301,11 @@ export function SelectField({
|
||||
onBlur={field.onBlur}
|
||||
error={error?.message}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
|
||||
};
|
||||
|
||||
return (
|
||||
<Input.Wrapper label={label} error={error?.message}>
|
||||
<Combobox store={combobox} disabled={disabled}>
|
||||
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
|
||||
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircle2, FileUp } from "lucide-react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BookingDocuments,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
const total = BOOKING_DOCS_SETTING.fields.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileUp size={22} />}
|
||||
title="Shipment Documents"
|
||||
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
|
||||
description="Attach your shipment documents now, or skip and upload them later from the booking page."
|
||||
/>
|
||||
|
||||
<Group
|
||||
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
|
||||
centered
|
||||
size="sm"
|
||||
styles={{
|
||||
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
|
||||
body: { padding: 24 },
|
||||
}}
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
<Text fz={13} c="edr-muted" fw={500}>
|
||||
Choose a departure time
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</Text>
|
||||
{selectedDayForModal?.schedules.map((schedule) => (
|
||||
<Button
|
||||
key={schedule.id}
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
style={{ height: 64, justifyContent: "flex-start" }}
|
||||
styles={{
|
||||
inner: { justifyContent: "flex-start" },
|
||||
root: {
|
||||
borderColor: theme.colors["edr-border"][0],
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition: "all 150ms ease",
|
||||
"&:hover": {
|
||||
borderColor: theme.colors["edr-green"][5],
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group gap={16} w="100%">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][5]} />
|
||||
</Box>
|
||||
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
|
||||
<Text fw={700} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
{schedule.trainNumber && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
Train {schedule.trainNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Button>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Schedule times */}
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{d.hasSchedule && (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
|
||||
{d.schedules.slice(0, 2).map((s) => (
|
||||
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={12}
|
||||
fw={700}
|
||||
c="edr-text.0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{format(new Date(s.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{d.schedules.length > 2 && (
|
||||
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
|
||||
+{d.schedules.length - 2} more
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
AsyncComboboxField,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
import { FileSignature } from "lucide-react";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -52,7 +55,6 @@ export function Step1ContractType({
|
||||
);
|
||||
|
||||
const contractOptions = useMemo<PreviousContractOption[]>(() => {
|
||||
console.log("Bookings data:", bookings);
|
||||
if (!bookings) return [];
|
||||
|
||||
return bookings?.items
|
||||
@@ -182,10 +184,11 @@ export function Step1ContractType({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileSignature size={22} />}
|
||||
title="Contract Type"
|
||||
description="New contract or renewal of an existing one."
|
||||
description="Start a new contract or renew an existing one to reuse its details."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -193,40 +196,33 @@ export function Step1ContractType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value === "new"}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="New Contract"
|
||||
description="Create a fresh freight contract from scratch."
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
form.setValue("previousContractRef", "");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<FileText className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">New Contract</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Create a new contract.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
|
||||
<OptionCard
|
||||
selected={field.value === "renewal"}
|
||||
icon={<RefreshCw className="h-5 w-5" />}
|
||||
iconBg="#EAF1FB"
|
||||
iconColor="#2E5B96"
|
||||
title="Contract Renewal"
|
||||
description="Pick a previous reference to auto-fill historical parameters."
|
||||
onClick={() => {
|
||||
field.onChange("renewal");
|
||||
form.clearErrors("contractType");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
|
||||
<RefreshCw className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Contract Renewal</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Select a previous reference to auto-populate historical
|
||||
parameters.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -234,7 +230,7 @@ export function Step1ContractType({
|
||||
/>
|
||||
|
||||
{contractType === "renewal" && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<Stack gap={12} mt={22}>
|
||||
{error && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load previous contracts. Please try again later.
|
||||
@@ -263,8 +259,8 @@ export function Step1ContractType({
|
||||
details will be pre-filled.
|
||||
</AlertBox>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
import { FileText, Train, Truck } from "lucide-react";
|
||||
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileText, Layers, Train, Truck } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
import {
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -61,10 +70,11 @@ export function Step2ServiceType({
|
||||
const showServiceSections =
|
||||
includesCustoms || includesFirstMile || includesLastMile;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Layers size={22} />}
|
||||
title="Service Type"
|
||||
description="Select the service combination and configure trucking options."
|
||||
description="Choose the service combination, then configure your trucking options."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -72,211 +82,225 @@ export function Step2ServiceType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{referenceData?.service
|
||||
.filter((s) => s.canBeBookedAlone)
|
||||
.map((s) => {
|
||||
return (
|
||||
<OptionCard
|
||||
selected={field.value === s.id}
|
||||
onClick={() => field.onChange(s.id)}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
|
||||
<Train className="h-4 w-4 text-indigo-600" />
|
||||
</div>
|
||||
<p className="font-semibold">{s.serviceName}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{s.description}
|
||||
</p>
|
||||
</OptionCard>
|
||||
);
|
||||
})}
|
||||
.map((s) => (
|
||||
<OptionCard
|
||||
key={s.id}
|
||||
selected={field.value === s.id}
|
||||
onClick={() => field.onChange(s.id)}
|
||||
icon={<Train className="h-5 w-5" />}
|
||||
iconBg="#EEF0FB"
|
||||
iconColor="#4F46E5"
|
||||
title={s.serviceName}
|
||||
description={s.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PaymentCurrencyField control={form.control} />
|
||||
|
||||
{showServiceSections && (
|
||||
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
|
||||
<Stack gap={12} mt={24}>
|
||||
<StepLabel>Trucking & customs options</StepLabel>
|
||||
{/* First Mile */}
|
||||
{includesFirstMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
First Mile — Pick-up
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck pick-up from your premises (Door to Port) to the
|
||||
origin rail yard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("firstMile.pickUpAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="firstMile.pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
mt="sm"
|
||||
placeholder="Pick-up address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="First Mile — Pick-up"
|
||||
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
|
||||
checked={field.value ?? false}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("firstMile.pickUpAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="firstMile.pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Pick-up address *"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last Mile */}
|
||||
{includesLastMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="lastMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Last Mile — Delivery
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck delivery from the destination rail yard to the
|
||||
final address (Port to Door).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("lastMile.deliveryAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("equipmentReturn", "with_return", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="lastMile.deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
mt="sm"
|
||||
placeholder="Delivery address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
<Controller
|
||||
name="lastMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Last Mile — Delivery"
|
||||
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
|
||||
checked={field.value ?? false}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("lastMile.deliveryAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("equipmentReturn", "with_return", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="lastMile.deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Delivery address *"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Equipment Return */}
|
||||
{includesLastMile && lastMileEnabled && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Equipment Return</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value === "with_return"}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.currentTarget.checked
|
||||
? "with_return"
|
||||
: "without_return",
|
||||
);
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Equipment Return"
|
||||
description={
|
||||
field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."
|
||||
}
|
||||
checked={field.value === "with_return"}
|
||||
onChange={(v) =>
|
||||
field.onChange(v ? "with_return" : "without_return")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customs Clearing */}
|
||||
{includesCustoms && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="customsClearingEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Customs Clearing Service
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
EDR handles customs documentation and clearance on
|
||||
your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="customsClearingEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<FileText size={18} />}
|
||||
title="Customs Clearing Service"
|
||||
description="EDR handles customs documentation and clearance on your behalf."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#ECF6F1" : "#F1F4F7",
|
||||
color: checked ? "#0A6F4D" : "#64748B",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
|
||||
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type BookingFormValues,
|
||||
getRouteDirection,
|
||||
} from "./schema";
|
||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -63,7 +63,6 @@ export function Step4Route({
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
@@ -85,10 +84,11 @@ export function Step4Route({
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<RouteIcon size={22} />}
|
||||
title="Route"
|
||||
description="Select the origin and destination yards."
|
||||
description="Choose the origin and destination yards for your shipment."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -96,7 +96,7 @@ export function Step4Route({
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
control={form.control}
|
||||
@@ -153,56 +153,108 @@ export function Step4Route({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Divider my={22} color="#EEF2F6" />
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hazardous Material</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies a Hazard Surcharge to the final bill.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge to the final bill."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Refrigerated Cargo</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Temperature-controlled transport applies a Refrigerator
|
||||
Surcharge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a refrigeration surcharge."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Skeleton,
|
||||
InputLabel,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
@@ -17,9 +16,11 @@ import {
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
SelectField,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton height={14} w={96} radius="sm" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="xl" />
|
||||
<Skeleton height={96} radius="xl" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="lg" />
|
||||
<Skeleton height={96} radius="lg" />
|
||||
</div>
|
||||
<Skeleton height={40} radius="md" />
|
||||
<Skeleton height={40} w="33%" radius="md" />
|
||||
<Skeleton height={44} radius="md" />
|
||||
<Skeleton height={44} w="33%" radius="md" />
|
||||
</div>
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
|
||||
{/* Cargo Type */}
|
||||
<div className="space-y-3">
|
||||
<InputLabel>Cargo Type *</InputLabel>
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={cargoType === "container"}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="Containerized"
|
||||
description="Pre-packed containerized cargo (20ft / 40ft)."
|
||||
onClick={() => {
|
||||
field.onChange("container");
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<Package className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Containerized</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Pre-packed containerized cargo (20ft / 40ft).
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
<OptionCard
|
||||
selected={cargoType === "bulk"}
|
||||
icon={<Weight className="h-5 w-5" />}
|
||||
iconBg="#FDF3E0"
|
||||
iconColor="#C77F09"
|
||||
title="General Cargo"
|
||||
description="Bulk commodities or break-bulk cargo."
|
||||
onClick={() => {
|
||||
field.onChange("bulk");
|
||||
form.setValue("containers", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Weight className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<p className="font-semibold">General Cargo</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Bulk commodities or break-bulk cargo.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
|
||||
placeholder="0.00"
|
||||
leftSection={<Weight className="h-4 w-4" />}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Package,
|
||||
Pencil,
|
||||
Route,
|
||||
Send,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@/types";
|
||||
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
type BookingDocuments,
|
||||
type BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
export const REVIEW_STEP_TARGETS = {
|
||||
contract: 1,
|
||||
service: 2,
|
||||
route: 3,
|
||||
cargo: 4,
|
||||
schedule: 5,
|
||||
documents: 6,
|
||||
} as const;
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
function OverviewSection({
|
||||
icon,
|
||||
title,
|
||||
onEdit,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex items-center justify-center rounded-lg"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Text fw={700} size="sm" c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
size="compact-xs"
|
||||
leftSection={<Pencil size={13} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" className="tracking-wide">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} ta="right" maw="60%">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessItem({
|
||||
done,
|
||||
label,
|
||||
}: {
|
||||
done: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{done ? (
|
||||
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<Circle size={18} className="shrink-0 text-gray-300" />
|
||||
)}
|
||||
<Text size="sm" c={done ? "dark" : "dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step8Review({
|
||||
form,
|
||||
setStep,
|
||||
direction,
|
||||
referenceData,
|
||||
pricingPhase = "idle",
|
||||
pricingData,
|
||||
onConfirm,
|
||||
onContinueLater,
|
||||
onAbort,
|
||||
confirmPending = false,
|
||||
abortPending = false,
|
||||
onSaveDraft,
|
||||
onSubmit,
|
||||
saveDraftPending = false,
|
||||
submitPending = false,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
setStep: (step: number) => void;
|
||||
direction: Freight.ScheduleTradeDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
pricingPhase?: "idle" | "generating" | "ready";
|
||||
pricingData?: GeneratePriceResponse | null;
|
||||
onConfirm?: () => void;
|
||||
onContinueLater?: () => void;
|
||||
onAbort?: () => void;
|
||||
confirmPending?: boolean;
|
||||
abortPending?: boolean;
|
||||
onSaveDraft?: () => void;
|
||||
onSubmit?: () => void;
|
||||
saveDraftPending?: boolean;
|
||||
submitPending?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === values.serviceTypeId,
|
||||
);
|
||||
|
||||
function CompactRow({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
target: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} className="truncate">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(target)}
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
|
||||
<Group gap="xs" mb="xs" wrap="nowrap">
|
||||
<Box c="edr-green">{Icon}</Box>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">{children}</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const containerSummary =
|
||||
values.cargoType === "container" && values.containers.length > 0
|
||||
? values.containers
|
||||
.filter((c) => +c.qty > 0)
|
||||
.map((c) => `${c.qty} × ${c.type}`)
|
||||
.join(", ")
|
||||
.filter((c) => +c.qty > 0)
|
||||
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
const totalVgm =
|
||||
values.cargoType === "container"
|
||||
? values.containers.reduce(
|
||||
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
||||
0,
|
||||
)
|
||||
: Number(values.cargoWeight || 0);
|
||||
|
||||
const documents = (values.documents ?? {}) as BookingDocuments;
|
||||
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
|
||||
const value = documents[f.fileKey];
|
||||
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
||||
}).length;
|
||||
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
||||
const allDocsReady = hasAllRequiredDocuments(documents);
|
||||
|
||||
const cargoValue = (() => {
|
||||
if (values.cargoType === "container") return containerSummary;
|
||||
if (values.cargoType === "container") return "Container freight";
|
||||
if (!referenceData) return "";
|
||||
const path = values.cargoTypePath ?? [];
|
||||
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
|
||||
@@ -143,226 +185,320 @@ export function Step8Review({
|
||||
return child ? `${group.name} — ${child.name}` : group.name;
|
||||
})();
|
||||
|
||||
const originYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.originYard,
|
||||
)?.name ?? values.originYard;
|
||||
const originYardName =
|
||||
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
|
||||
values.originYard;
|
||||
|
||||
const destinationYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.destinationYard,
|
||||
)?.name ?? values.destinationYard;
|
||||
const destinationYardName =
|
||||
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
|
||||
values.destinationYard;
|
||||
|
||||
const scheduleLabel = values.scheduledDate
|
||||
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
|
||||
: "—";
|
||||
|
||||
const directionLabel = direction
|
||||
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="lg">
|
||||
<StepHeader
|
||||
icon={<ClipboardCheck size={22} />}
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
description="Review your booking overview before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
{/* Pricing Card - Prominent at top */}
|
||||
{pricingPhase === "generating" && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Generating price estimate…
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pricingPhase === "ready" && pricingData && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||
💳 Price Breakdown
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between" py={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between" py={2}>
|
||||
<Text fw={700} size="md">
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} size="lg" c="edr-green">
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
</Text>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{/* Left — booking summary */}
|
||||
<Stack gap="md" className="min-w-0 flex-1">
|
||||
<Paper
|
||||
radius={20}
|
||||
p="lg"
|
||||
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||
Booking overview
|
||||
</Text>
|
||||
<Text fw={800} size="xl" c="#10202F">
|
||||
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{serviceType?.name ?? "—"} · {originYardName} → {destinationYardName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="lg" variant="light" color="edr-green" radius="md">
|
||||
{directionLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
|
||||
⚠️ {pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Package size={18} />}
|
||||
title="Contract & Service"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
|
||||
>
|
||||
<DetailRow
|
||||
label="Contract"
|
||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||
/>
|
||||
{values.contractType === "renewal" && values.previousContractRef && (
|
||||
<DetailRow label="Previous ref" value={values.previousContractRef} />
|
||||
)}
|
||||
<Group mt="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={confirmPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{confirmPending ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onContinueLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Continue later
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
|
||||
onClick={onAbort}
|
||||
loading={abortPending}
|
||||
>
|
||||
Abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Details - Compact Cards Grid */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
|
||||
<CompactCard icon={<Package size={16} />} title="Contract & Service">
|
||||
<CompactRow
|
||||
label="Type"
|
||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||
target={1}
|
||||
/>
|
||||
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Route size={16} />} title="Route">
|
||||
<CompactRow
|
||||
label="Origin → Destination"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
target={3}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Workflow"
|
||||
value={
|
||||
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Truck size={16} />} title="Logistics">
|
||||
<CompactRow
|
||||
label="First Mile"
|
||||
value={
|
||||
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Last Mile"
|
||||
value={
|
||||
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Equipment Return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Customs Clearing"
|
||||
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
||||
target={2}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Package size={16} />} title="Cargo Details">
|
||||
<CompactRow
|
||||
label="Weight (VGM)"
|
||||
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
||||
target={4}
|
||||
/>
|
||||
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
|
||||
<CompactRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Package size={16} />} title="Containers">
|
||||
<CompactRow
|
||||
label="Count & Type"
|
||||
value={containerSummary || "—"}
|
||||
target={4}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||
target={4}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<FileText size={16} />} title="Documents">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
Attached
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{docsAttached > 0
|
||||
? `${docsAttached} of ${docsTotal}`
|
||||
: "None"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
<DetailRow label="Service" value={serviceType?.name ?? ""} />
|
||||
<DetailRow
|
||||
label="Payment currency"
|
||||
value={values.paymentCurrency ?? "USD"}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setStep(5)}
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
mt={4}
|
||||
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</CompactCard>
|
||||
</SimpleGrid>
|
||||
Edit service options
|
||||
</Button>
|
||||
</OverviewSection>
|
||||
|
||||
{/* Notes */}
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id="notes"
|
||||
label="Additional Notes"
|
||||
placeholder="Any special instructions or notes for EDR operations…"
|
||||
rows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
<OverviewSection
|
||||
icon={<Route size={18} />}
|
||||
title="Route"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
|
||||
>
|
||||
<DetailRow
|
||||
label="Corridor"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
<DetailRow label="Trade direction" value={directionLabel} />
|
||||
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
|
||||
<DetailRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Truck size={18} />}
|
||||
title="Logistics"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
|
||||
>
|
||||
<DetailRow
|
||||
label="First mile"
|
||||
value={
|
||||
values.firstMile.enabled
|
||||
? values.firstMile.pickUpAddress
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Last mile"
|
||||
value={
|
||||
values.lastMile.enabled
|
||||
? values.lastMile.deliveryAddress
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Equipment return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With return"
|
||||
: "Without return"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Customs clearing"
|
||||
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Calendar size={18} />}
|
||||
title="Schedule"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
||||
>
|
||||
<DetailRow label="Shipment date" value={scheduleLabel} />
|
||||
<DetailRow
|
||||
label="Train schedule"
|
||||
value={values.trainScheduleId ? "Selected" : "—"}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Package size={18} />}
|
||||
title="Cargo"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
|
||||
>
|
||||
<DetailRow label="Freight type" value={cargoValue} />
|
||||
<DetailRow
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Consolidation"
|
||||
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
|
||||
/>
|
||||
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM (t)</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{values.containers
|
||||
.filter((c) => +c.qty > 0)
|
||||
.map((c, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{c.containerType || c.type}</Table.Td>
|
||||
<Table.Td>{c.qty}</Table.Td>
|
||||
<Table.Td>{c.vgm}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
{containerSummary && (
|
||||
<DetailRow label="Summary" value={containerSummary} />
|
||||
)}
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<FileText size={18} />}
|
||||
title="Documents"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{BOOKING_DOCS_SETTING.fields.map((field) => {
|
||||
const file = documents[field.fileKey];
|
||||
const attached = Array.isArray(file)
|
||||
? file.length > 0
|
||||
: Boolean(file);
|
||||
const fileName = attached
|
||||
? Array.isArray(file)
|
||||
? file[0]?.name
|
||||
: (file as File)?.name
|
||||
: null;
|
||||
return (
|
||||
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{attached ? (
|
||||
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
|
||||
) : (
|
||||
<Circle size={16} className="text-red-400 shrink-0" />
|
||||
)}
|
||||
<Text size="sm">{field.fileLabel}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
|
||||
{fileName ?? "Missing"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
|
||||
</Text>
|
||||
</OverviewSection>
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for EDR operations…"
|
||||
rows={3}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Right — sticky actions */}
|
||||
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
|
||||
<Stack gap="md">
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text fw={800} size="sm" mb="md" c="#10202F">
|
||||
Submission readiness
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<ReadinessItem done={Boolean(values.serviceTypeId)} label="Service configured" />
|
||||
<ReadinessItem
|
||||
done={Boolean(values.paymentCurrency)}
|
||||
label="Payment currency selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.originYard && values.destinationYard)}
|
||||
label="Route selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.scheduledDate && values.trainScheduleId)}
|
||||
label="Schedule selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={
|
||||
values.cargoType === "container"
|
||||
? values.containers.some((c) => +c.qty > 0)
|
||||
: Boolean(values.cargoWeight)
|
||||
}
|
||||
label="Cargo details complete"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={allDocsReady}
|
||||
label="All 4 documents attached"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{allDocsReady
|
||||
? "Ready to submit. You'll review the price estimate before final submission."
|
||||
: "Upload all four documents to enable submission."}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
size="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onSubmit}
|
||||
loading={submitPending}
|
||||
disabled={!allDocsReady || submitPending}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
onClick={onSaveDraft}
|
||||
loading={saveDraftPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
Save as draft
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
import { priceTotal } from "../BookingDetailPage/utils";
|
||||
import { useBookingPayment } from "./useBookingPayment";
|
||||
|
||||
interface PayNowButtonProps {
|
||||
booking: Freight.IBooking;
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained "Pay now" action: shows the payment-method modal in place
|
||||
* instead of navigating to the booking detail page. Drop it into list rows,
|
||||
* cards, or anywhere a payable booking surfaces.
|
||||
*/
|
||||
export function PayNowButton({
|
||||
booking,
|
||||
label = "Pay now",
|
||||
size = "xs",
|
||||
fullWidth,
|
||||
}: PayNowButtonProps) {
|
||||
const pay = useBookingPayment(booking.id);
|
||||
const pricing = booking.pricingBreakdown;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
// Don't let a surrounding row-click handler fire.
|
||||
e.stopPropagation();
|
||||
pay.open();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /payments/initiate, and redirects the browser to the provider (or the
|
||||
* fallback checkout page). Reused by the booking detail page, the booking list,
|
||||
* and the home page so "Pay now" behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.payments.initiate.call({ bookingId, method }),
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const open = () => setModalOpen(true);
|
||||
|
||||
const close = () => {
|
||||
if (!mutation.isPending) {
|
||||
setModalOpen(false);
|
||||
mutation.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const error = mutation.isError
|
||||
? mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
|
||||
return {
|
||||
modalOpen,
|
||||
open,
|
||||
close,
|
||||
processing: mutation.isPending,
|
||||
error,
|
||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageX,
|
||||
RefreshCw,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
checkpointKindLabel,
|
||||
corridorProgress,
|
||||
isArrived,
|
||||
isDispatched,
|
||||
shipmentStatusLabel,
|
||||
} from "./trackingStages";
|
||||
|
||||
const GREEN = "#0EA371";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
const ACCENT = "#F2A516";
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
|
||||
interface ShipmentTrackingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
originLabel?: string;
|
||||
destinationLabel?: string;
|
||||
}
|
||||
|
||||
export function ShipmentTrackingModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
bookingReference,
|
||||
originLabel,
|
||||
destinationLabel,
|
||||
}: ShipmentTrackingModalProps) {
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
refetchInterval: opened ? 30_000 : false,
|
||||
});
|
||||
|
||||
const hasSchedule = data?.hasSchedule ?? false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size={900}
|
||||
radius={20}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
|
||||
styles={{ content: { overflow: "hidden" } }}
|
||||
>
|
||||
<Header
|
||||
bookingReference={data?.bookingReference ?? bookingReference}
|
||||
trainNumber={data?.trainNumber ?? null}
|
||||
status={data?.scheduleStatus ?? null}
|
||||
currentSequenceNo={data?.currentSequenceNo ?? -1}
|
||||
onClose={onClose}
|
||||
onRefresh={() => refetch()}
|
||||
refreshing={isFetching}
|
||||
/>
|
||||
|
||||
<Box px={28} py={24}>
|
||||
{isLoading ? (
|
||||
<Center mih={280}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader color="edr-green" />
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Locating your train…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={() => refetch()} />
|
||||
) : !hasSchedule ? (
|
||||
<NotDispatchedState
|
||||
origin={originLabel ?? "Origin"}
|
||||
destination={destinationLabel ?? "Destination"}
|
||||
/>
|
||||
) : data ? (
|
||||
<Stack gap={26}>
|
||||
<SummaryBar data={data} />
|
||||
<Corridor data={data} />
|
||||
<CheckpointFeed data={data} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Header({
|
||||
bookingReference,
|
||||
trainNumber,
|
||||
status,
|
||||
currentSequenceNo,
|
||||
onClose,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
bookingReference: string;
|
||||
trainNumber: string | null;
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={28}
|
||||
py={22}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={14} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 13,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(255,255,255,0.12)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Train size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="#9FE9CC"
|
||||
style={{ letterSpacing: 0.7 }}
|
||||
>
|
||||
Live shipment tracking
|
||||
</Text>
|
||||
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
|
||||
{bookingReference}
|
||||
</Text>
|
||||
{trainNumber && (
|
||||
<Text fz="12px" c="#A9BBCB">
|
||||
Train {trainNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
|
||||
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
<IconButton title="Close" onClick={onClose}>
|
||||
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
|
||||
</IconButton>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
spinning,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 9,
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderStatusPill({
|
||||
status,
|
||||
currentSequenceNo,
|
||||
}: {
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
}) {
|
||||
const arrived = isArrived(status);
|
||||
const moving = isDispatched(status);
|
||||
const bg = arrived
|
||||
? "rgba(14,163,113,0.22)"
|
||||
: moving
|
||||
? "rgba(242,165,22,0.20)"
|
||||
: "rgba(255,255,255,0.12)";
|
||||
const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
|
||||
return (
|
||||
<Group
|
||||
gap={7}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={12}
|
||||
py={7}
|
||||
style={{ borderRadius: 999, backgroundColor: bg }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
|
||||
}}
|
||||
/>
|
||||
<Text fz="12px" fw={700} c="#fff">
|
||||
{shipmentStatusLabel(status, currentSequenceNo)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
|
||||
|
||||
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
||||
const arrived = isArrived(data.scheduleStatus);
|
||||
const items: Array<{ label: string; value: string; accent?: boolean }> = [
|
||||
{
|
||||
label: "Departed",
|
||||
value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
|
||||
},
|
||||
{
|
||||
label: arrived ? "Arrived" : "Est. arrival",
|
||||
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
|
||||
accent: !arrived,
|
||||
},
|
||||
{
|
||||
label: "Stations",
|
||||
value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<Box
|
||||
key={it.label}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "14px 16px",
|
||||
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
|
||||
background: it.accent ? "#FEFBF3" : "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={it.accent ? "#B07D14" : MUTED}
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{it.label}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c={INK} mt={2}>
|
||||
{it.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corridor: stations + train marker ──────────────────────────────────────────
|
||||
|
||||
function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||
const arrived = isArrived(data.scheduleStatus);
|
||||
const moving = isDispatched(data.scheduleStatus);
|
||||
const stations = data.stations;
|
||||
const current = data.currentSequenceNo;
|
||||
const progress = corridorProgress(stations.length, current, arrived);
|
||||
|
||||
// Map sequenceNo → latest checkpoint at that station for captions.
|
||||
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
|
||||
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group gap={8} align="center" mb={16}>
|
||||
<MapPin size={15} color={GREEN_DARK} />
|
||||
<Text fz="14px" fw={800} c={INK}>
|
||||
Where is your train
|
||||
</Text>
|
||||
<Text fz="12.5px" c={MUTED}>
|
||||
· {progress}% of the route
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Horizontal rail */}
|
||||
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
|
||||
{/* base rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 54,
|
||||
left: 16,
|
||||
right: 16,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: "#EAF0F5",
|
||||
}}
|
||||
/>
|
||||
{/* filled rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 54,
|
||||
left: 16,
|
||||
width: `calc((100% - 32px) * ${progress / 100})`,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
|
||||
transition: "width 600ms ease",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* train marker riding the filled rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 18,
|
||||
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
|
||||
transform: "translateX(-50%)",
|
||||
transition: "left 600ms ease",
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: arrived
|
||||
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
|
||||
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
|
||||
color: "#fff",
|
||||
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
|
||||
border: "3px solid #fff",
|
||||
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
|
||||
}}
|
||||
>
|
||||
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* station nodes */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{stations.map((s, i) => {
|
||||
const reached = arrived || (current >= 0 && i <= current);
|
||||
const isCurrent = !arrived && i === current;
|
||||
const isLast = i === stations.length - 1;
|
||||
const cp = checkpointBySeq.get(s.sequenceNo);
|
||||
return (
|
||||
<StationNode
|
||||
key={`${s.yardId}-${i}`}
|
||||
label={s.label}
|
||||
reached={reached}
|
||||
isCurrent={isCurrent}
|
||||
isEndpoint={i === 0 || isLast}
|
||||
arrivedHere={isLast && arrived}
|
||||
time={cp ? fmtTime(cp.occurredAt) : null}
|
||||
align={i === 0 ? "left" : isLast ? "right" : "center"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StationNode({
|
||||
label,
|
||||
reached,
|
||||
isCurrent,
|
||||
isEndpoint,
|
||||
arrivedHere,
|
||||
time,
|
||||
align,
|
||||
}: {
|
||||
label: string;
|
||||
reached: boolean;
|
||||
isCurrent: boolean;
|
||||
isEndpoint: boolean;
|
||||
arrivedHere: boolean;
|
||||
time: string | null;
|
||||
align: "left" | "center" | "right";
|
||||
}) {
|
||||
const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
flex: isEndpoint ? "0 0 auto" : 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 120,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: isCurrent ? 18 : 14,
|
||||
height: isCurrent ? 18 : 14,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
border: `3px solid ${color}`,
|
||||
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: isCurrent ? 7 : 5,
|
||||
height: isCurrent ? 7 : 5,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={reached ? 700 : 600}
|
||||
c={reached ? INK : "#9AA8B5"}
|
||||
mt={8}
|
||||
ta={align}
|
||||
truncate
|
||||
style={{ maxWidth: 110 }}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{time && (
|
||||
<Text fz="10px" c={MUTED} mt={1}>
|
||||
{time}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Checkpoint feed ────────────────────────────────────────────────────────────
|
||||
|
||||
function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
|
||||
// Newest first.
|
||||
const ordered = [...data.checkpoints].sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={20}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
|
||||
<Clock size={15} color={GREEN_DARK} />
|
||||
<Text fz="14px" fw={800} c={INK}>
|
||||
Journey log
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{ordered.length === 0 ? (
|
||||
<Text fz="13px" c={MUTED}>
|
||||
No checkpoints logged yet. Updates appear here as the train passes each
|
||||
station along the corridor.
|
||||
</Text>
|
||||
) : (
|
||||
<Box>
|
||||
{ordered.map((cp, i) => {
|
||||
const isLatest = i === 0;
|
||||
const last = i === ordered.length - 1;
|
||||
const Icon =
|
||||
cp.kind === Freight.TrainCheckpointKind.Arrived
|
||||
? CheckCircle2
|
||||
: cp.kind === Freight.TrainCheckpointKind.Departed
|
||||
? Flag
|
||||
: Train;
|
||||
return (
|
||||
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: isLatest ? "#ECF6F1" : "#F1F4F7",
|
||||
color: isLatest ? GREEN_DARK : "#64748B",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</Box>
|
||||
{!last && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
width: 2,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
background: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="center" wrap="wrap">
|
||||
<Text fz="13.5px" fw={700} c={INK}>
|
||||
{cp.label ?? "Checkpoint"}
|
||||
</Text>
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: "2px 9px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
background: isLatest ? "#ECF6F1" : "#F1F4F7",
|
||||
color: isLatest ? GREEN_DARK : "#475569",
|
||||
}}
|
||||
>
|
||||
{checkpointKindLabel(cp.kind)}
|
||||
</Box>
|
||||
{isLatest && (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: "2px 9px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
background: "#FEF6E6",
|
||||
color: "#B07D14",
|
||||
}}
|
||||
>
|
||||
Latest
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
{cp.note && (
|
||||
<Text fz="12.5px" c={MUTED} mt={2}>
|
||||
{cp.note}
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="11.5px" c="#9AA8B5" mt={3}>
|
||||
{fmtTime(cp.occurredAt)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty / error states ───────────────────────────────────────────────────────
|
||||
|
||||
function NotDispatchedState({
|
||||
origin,
|
||||
destination,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack align="center" gap={6} py={40} ta="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FEF6E6",
|
||||
color: ACCENT,
|
||||
}}
|
||||
>
|
||||
<PackageX size={30} />
|
||||
</Box>
|
||||
<Text fz="18px" fw={800} c={INK} mt={4}>
|
||||
Not on the rails yet
|
||||
</Text>
|
||||
<Text fz="13.5px" c={MUTED} maw={440}>
|
||||
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
|
||||
assigned to a train. Live tracking begins the moment it's dispatched and
|
||||
starts moving along the corridor.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={40} ta="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEAE7",
|
||||
color: "#C0392B",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={28} />
|
||||
</Box>
|
||||
<Text fz="16px" fw={800} c={INK}>
|
||||
Couldn't load tracking
|
||||
</Text>
|
||||
<Text fz="13px" c={MUTED}>
|
||||
Something went wrong fetching your shipment status.
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "9px 16px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#fff",
|
||||
color: INK,
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} /> Try again
|
||||
</button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// keyframes (injected once)
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.getElementById("edr-tracking-kf")
|
||||
) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "edr-tracking-kf";
|
||||
style.textContent = `
|
||||
@keyframes edr-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
const { TrainScheduleStatus } = Freight;
|
||||
|
||||
export function isArrived(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
): boolean {
|
||||
return status === TrainScheduleStatus.Arrived;
|
||||
}
|
||||
|
||||
export function isDispatched(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
): boolean {
|
||||
return status === TrainScheduleStatus.Dispatched;
|
||||
}
|
||||
|
||||
/** Human label for the schedule status, from the rider's point of view. */
|
||||
export function shipmentStatusLabel(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
currentSequenceNo = -1,
|
||||
): string {
|
||||
switch (status) {
|
||||
case TrainScheduleStatus.Arrived:
|
||||
return "Arrived";
|
||||
case TrainScheduleStatus.Dispatched:
|
||||
return currentSequenceNo <= 0 ? "Departed" : "In transit";
|
||||
case TrainScheduleStatus.Scheduled:
|
||||
return "Scheduled";
|
||||
case TrainScheduleStatus.Cancelled:
|
||||
return "Cancelled";
|
||||
case TrainScheduleStatus.Draft:
|
||||
return "Preparing";
|
||||
default:
|
||||
return "Not dispatched";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 0–100 progress across the corridor, derived from how many stations the train
|
||||
* has reached. Arrived → 100. Not departed → 0.
|
||||
*/
|
||||
export function corridorProgress(
|
||||
stationCount: number,
|
||||
currentSequenceNo: number,
|
||||
arrived: boolean,
|
||||
): number {
|
||||
if (arrived) return 100;
|
||||
if (stationCount <= 1 || currentSequenceNo < 0) return 0;
|
||||
const lastSeq = stationCount - 1;
|
||||
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
|
||||
}
|
||||
|
||||
/** Caption for a checkpoint kind. */
|
||||
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
|
||||
switch (kind) {
|
||||
case Freight.TrainCheckpointKind.Departed:
|
||||
return "Departed";
|
||||
case Freight.TrainCheckpointKind.Arrived:
|
||||
return "Arrived";
|
||||
case Freight.TrainCheckpointKind.Passed:
|
||||
default:
|
||||
return "Passed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { XCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a failed or
|
||||
* cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
|
||||
* charged and sends the customer back to their bookings to retry from "Pay now".
|
||||
*/
|
||||
export default function PaymentFailurePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-9 text-destructive" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment was not completed
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your payment didn't go through and you haven't been charged. You can
|
||||
try again from your booking using "Pay now".
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a successful
|
||||
* payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
|
||||
* customer back to their bookings, where the booking reflects the paid state.
|
||||
*/
|
||||
export default function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-9 text-primary" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment successful
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thank you — your payment has been received. Your booking will be
|
||||
updated shortly and is now confirmed for scheduling.
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Go to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
BookingListFilter,
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import {
|
||||
paymentsService,
|
||||
InitiatePaymentPayload,
|
||||
@@ -147,16 +149,29 @@ export const api = {
|
||||
({ id }) => bookingsService.get(id),
|
||||
),
|
||||
|
||||
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
||||
tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
|
||||
"bookings",
|
||||
"create",
|
||||
bookingsService.create,
|
||||
"tracking",
|
||||
({ id }) => bookingsService.tracking(id),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
>("bookings", "create", ({ payload, documents }) =>
|
||||
bookingsService.create(payload, documents),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: Partial<CreateBookingPayload> },
|
||||
{
|
||||
id: string;
|
||||
dto: Partial<CreateBookingPayload>;
|
||||
documents?: BookingDocuments;
|
||||
},
|
||||
{ booking: Freight.IBooking; warnings: string[] }
|
||||
>("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
|
||||
>("bookings", "update", ({ id, dto, documents }) =>
|
||||
bookingsService.update(id, dto, documents),
|
||||
),
|
||||
|
||||
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||
"bookings",
|
||||
@@ -180,12 +195,18 @@ export const api = {
|
||||
({ id }) => bookingsService.generatePrice(id),
|
||||
),
|
||||
|
||||
submit: endpoint<{ id: string }, Freight.IBooking>(
|
||||
submit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||
"bookings",
|
||||
"submit",
|
||||
({ id }) => bookingsService.submit(id),
|
||||
),
|
||||
|
||||
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||
"bookings",
|
||||
"confirmSubmit",
|
||||
({ id }) => bookingsService.confirmSubmit(id),
|
||||
),
|
||||
|
||||
uploadDocuments: endpoint<
|
||||
{ id: string; files: Record<string, File | File[] | null> },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CreateBookingPayload } from "./bookings.service";
|
||||
import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
|
||||
function appendValue(formData: FormData, key: string, value: unknown) {
|
||||
if (value === undefined || value === null) return;
|
||||
if (typeof value === "boolean") {
|
||||
formData.append(key, value ? "true" : "false");
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
formData.append(key, String(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function appendContainers(
|
||||
formData: FormData,
|
||||
containers: NonNullable<CreateBookingPayload["containers"]>,
|
||||
) {
|
||||
containers.forEach((container, index) => {
|
||||
formData.append(
|
||||
`containers[${index}][containerTypeId]`,
|
||||
container.containerTypeId,
|
||||
);
|
||||
formData.append(
|
||||
`containers[${index}][quantity]`,
|
||||
String(container.quantity),
|
||||
);
|
||||
formData.append(
|
||||
`containers[${index}][vgmPerUnitTons]`,
|
||||
String(container.vgmPerUnitTons),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function appendDocuments(
|
||||
formData: FormData,
|
||||
documents?: Record<string, File | File[] | null>,
|
||||
) {
|
||||
if (!documents) return;
|
||||
for (const [key, fileOrFiles] of Object.entries(documents)) {
|
||||
if (!fileOrFiles) continue;
|
||||
if (Array.isArray(fileOrFiles)) {
|
||||
for (const file of fileOrFiles) {
|
||||
formData.append(key, file);
|
||||
}
|
||||
} else {
|
||||
formData.append(key, fileOrFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten a booking payload (and optional document files) into multipart FormData. */
|
||||
export function buildBookingFormData(
|
||||
payload: Partial<CreateBookingPayload>,
|
||||
documents?: BookingDocuments,
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
const skipKeys = new Set(["containers", "freightShapeValidation"]);
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (skipKeys.has(key)) continue;
|
||||
appendValue(formData, key, value);
|
||||
}
|
||||
|
||||
if (payload.containers?.length) {
|
||||
appendContainers(formData, payload.containers);
|
||||
}
|
||||
|
||||
appendDocuments(formData, documents);
|
||||
return formData;
|
||||
}
|
||||
|
||||
/** Returns true when every required booking document field has a file attached. */
|
||||
export function hasAllRequiredDocuments(
|
||||
documents: BookingDocuments | undefined | null,
|
||||
): boolean {
|
||||
const docs = documents ?? {};
|
||||
return BOOKING_DOCS_SETTING.fields.every((field) => {
|
||||
const value = docs[field.fileKey];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return Boolean(value);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import { buildBookingFormData } from "./booking-form-data";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface SubmitBookingResponse {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
priceChanged: boolean;
|
||||
previousTotalAmount?: number;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
lineItems?: PriceLineItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
@@ -54,6 +67,8 @@ export interface SignContractPayload {
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
statuses?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
@@ -71,8 +86,18 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post("/api/bookings", payload);
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (
|
||||
payload: CreateBookingPayload,
|
||||
documents?: BookingDocuments,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const formData = buildBookingFormData(payload, documents);
|
||||
const { data } = await client.post("/api/bookings", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data.data.booking;
|
||||
},
|
||||
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||
@@ -82,8 +107,12 @@ export const bookingsService = {
|
||||
update: async (
|
||||
id: string,
|
||||
payload: Partial<CreateBookingPayload>,
|
||||
documents?: BookingDocuments,
|
||||
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
|
||||
const { data } = await client.patch(`/api/bookings/${id}`, payload);
|
||||
const formData = buildBookingFormData(payload, documents);
|
||||
const { data } = await client.patch(`/api/bookings/${id}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
@@ -101,11 +130,16 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
submit: async (id: string): Promise<Freight.IBooking> => {
|
||||
submit: async (id: string): Promise<SubmitBookingResponse> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/submit`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
id: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
|
||||
@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
EMAIL_QUEUE=email_queue
|
||||
SMS_QUEUE=sms_queue
|
||||
|
||||
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
|
||||
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
|
||||
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- AlterTable: add return leg tracking columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set NEITHER_USED for existing confirmed round-trip bookings
|
||||
UPDATE "passenger"."Booking"
|
||||
SET "returnLegStatus" = 'NEITHER_USED'
|
||||
WHERE "bookingType" = 'ROUND_TRIP'
|
||||
AND "status" IN ('CONFIRMED', 'COMPLETED');
|
||||
|
||||
-- AlterTable: add leg column to GateValidationLog
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ADD COLUMN "leg" TEXT;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT;
|
||||
|
||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
|
||||
|
||||
-- Transit leg-2 columns (never migrated)
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT;
|
||||
|
||||
-- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
||||
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||
@@ -115,6 +115,16 @@ enum BookingStatus {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum ReturnLegStatus {
|
||||
NOT_APPLICABLE // one-way booking
|
||||
BOTH_USED // passenger used both legs
|
||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
||||
INBOUND_ONLY // outbound leg not used, return leg used
|
||||
NEITHER_USED // neither leg boarded yet
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum PaymentRegion {
|
||||
ETHIOPIA
|
||||
DJIBOUTI
|
||||
@@ -364,7 +374,8 @@ model TrainSchedule {
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
|
||||
coachAssignments CoachAssignment[]
|
||||
bookings Booking[]
|
||||
bookings Booking[] @relation("OutboundSchedule")
|
||||
returnBookings Booking[] @relation("ReturnSchedule")
|
||||
stopTimes TripStopTime[]
|
||||
liveStatus TripLiveStatus?
|
||||
menuItems MenuItem[]
|
||||
@@ -511,6 +522,19 @@ model Booking {
|
||||
returnDestinationStationId String?
|
||||
returnHoldId String?
|
||||
returnSeatClassId String?
|
||||
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
|
||||
// Transit leg-2 fields (single-booking transit)
|
||||
leg2ScheduleId String?
|
||||
leg2OriginStationId String?
|
||||
leg2DestinationStationId String?
|
||||
leg2SeatClassId String?
|
||||
// Round-trip transit: return journey transit fields
|
||||
returnLeg2ScheduleId String?
|
||||
returnLeg2OriginStationId String?
|
||||
returnLeg2DestStationId String?
|
||||
returnLeg2SeatClassId String?
|
||||
outboundBoardedAt DateTime?
|
||||
returnBoardedAt DateTime?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
@@ -520,7 +544,8 @@ model Booking {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
|
||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
@@ -539,6 +564,8 @@ model BookingSeat {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
seatId String
|
||||
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
|
||||
scheduleId String? // which schedule this seat belongs to
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
passengerCategory PassengerCategory @default(ADULT)
|
||||
@@ -1159,6 +1186,7 @@ model GateValidationLog {
|
||||
ticketId String
|
||||
validatorId String
|
||||
gateId String?
|
||||
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
|
||||
status String
|
||||
reason String?
|
||||
validatedAt DateTime @default(now())
|
||||
|
||||
@@ -34,19 +34,25 @@ async function bootstrap() {
|
||||
## Overview
|
||||
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## 🆕 Latest Updates
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
|
||||
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
|
||||
## Latest Updates
|
||||
- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
|
||||
- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
|
||||
- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
|
||||
- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2).
|
||||
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
|
||||
- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
|
||||
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts.
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🎫 Booking Lifecycle
|
||||
### Booking Lifecycle
|
||||
- Search trips with real-time availability
|
||||
- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
|
||||
- Age-based passenger categorization (Adult 5+ years, Child under 5)
|
||||
- Nationality-based verification (Ethiopian Fayda, International Passport)
|
||||
- Passenger information collection with verification
|
||||
- Coach and seat selection with real-time availability
|
||||
@@ -55,130 +61,143 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Modify bookings (seat changes, passenger updates)
|
||||
- Cancel bookings with automatic refunds
|
||||
- Multi-segment journey support
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
|
||||
- Round-trip booking with return journey scheduling
|
||||
- Transit booking (single journey via connecting train, single PNR, single ticket)
|
||||
- Round-trip transit booking (round trip where one or both directions use a connecting train)
|
||||
- Coach type selection with seat class and pricing options
|
||||
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||
- **NEW:** Display currency and converted pricing per booking
|
||||
- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
|
||||
- Display currency and converted pricing per booking
|
||||
- returnLegStatus field tracks which legs of a round-trip were used
|
||||
- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
|
||||
|
||||
### 👤 Passenger Verification
|
||||
1. **Ethiopian Nationals:**
|
||||
- Automatic Fayda verification for adults (≥5 years)
|
||||
### Passenger Verification
|
||||
1. Ethiopian Nationals:
|
||||
- Automatic Fayda verification for adults (5+ years)
|
||||
- Real-time national ID verification via government database
|
||||
- Retrieves verified passenger data (name, DOB, gender)
|
||||
- National IDs not stored (policy compliant)
|
||||
|
||||
2. **International Passengers:**
|
||||
2. International Passengers:
|
||||
- Passport information collection
|
||||
- Manual verification for Djiboutian and other nationals
|
||||
- No government database verification required
|
||||
|
||||
### 💰 Age-Based Pricing
|
||||
- **ADULT** (≥5 years): Pay 100% of base fare
|
||||
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
||||
### Age-Based Pricing
|
||||
- ADULT (5+ years): Pay 100% of base fare
|
||||
- CHILD (under 5): First child travels FREE, subsequent children pay 100%
|
||||
- Automatic age calculation from date of birth
|
||||
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
||||
- **NEW:** Premium charges and insurance fees per seat class
|
||||
- **NEW:** Transparent fee breakdown in pricing calculations
|
||||
- Example: 2 adults + 3 children = 4x base fare (first child free)
|
||||
- NEW: Premium charges and insurance fees per seat class
|
||||
- NEW: Transparent fee breakdown in pricing calculations
|
||||
|
||||
### 💳 Payment Integration
|
||||
1. **Ethiopian Payment Methods:**
|
||||
- **Telebirr** - Ethiopia's leading mobile money
|
||||
- **CBE Birr** - Commercial Bank of Ethiopia
|
||||
### Payment Integration
|
||||
1. Ethiopian Payment Methods: Telebirr, CBE Birr
|
||||
2. Djiboutian Payment Methods: Waafi
|
||||
3. International Payment Methods: Card, Wallet
|
||||
|
||||
2. **Djiboutian Payment Methods:**
|
||||
- **Waafi** - Djibouti's mobile money service
|
||||
|
||||
3. **International Payment Methods:**
|
||||
- **Card** - International card payments (Visa, Mastercard)
|
||||
- **Wallet** - Internal wallet system
|
||||
|
||||
### 🪑 Seat Management
|
||||
### Seat Management
|
||||
- Real-time seat availability by coach and class
|
||||
- Seat holds with 15-minute expiry
|
||||
- Auto-assign seats with contiguous algorithm
|
||||
- Seat blocking for maintenance
|
||||
- Coach-level seat maps (ordered by sequence)
|
||||
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
|
||||
- **NEW:** Sequence-based coach ordering for consistent display
|
||||
- NEW: Sequence-based coach ordering for consistent display
|
||||
|
||||
### 🎟️ Ticketing
|
||||
### Ticketing
|
||||
- QR code and barcode generation
|
||||
- PDF ticket generation
|
||||
- Gate validation with audit logs
|
||||
- Offline validation support
|
||||
- Multi-passenger tickets
|
||||
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
|
||||
- **NEW:** Complete audit trail for compliance and reporting
|
||||
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- NEW: Complete audit trail per leg for compliance and reporting
|
||||
|
||||
### 🏆 Loyalty Program
|
||||
### Booking Type Matrix
|
||||
|
||||
| bookingType | Holds required | Passenger seat fields | Legs in DB |
|
||||
|---|---|---|---|
|
||||
| ONE_WAY | holdId | seatId | 1 |
|
||||
| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) |
|
||||
| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) |
|
||||
| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 |
|
||||
|
||||
### Round-Trip Leg Tracking
|
||||
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
|
||||
- Gate validation POST /tickets/:ref/validate accepts optional leg field:
|
||||
- ONE_WAY: omit
|
||||
- TRANSIT: LEG1 | LEG2
|
||||
- ROUND_TRIP: OUTBOUND | RETURN
|
||||
- ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2
|
||||
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
|
||||
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
|
||||
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
|
||||
|
||||
### Round-Trip & Transit Bookings
|
||||
- ONE_WAY and ROUND_TRIP for direct routes
|
||||
- TRANSIT for single connecting journey (Dire Dawa hub), single PNR
|
||||
- ROUND_TRIP_TRANSIT for round trips via connecting trains
|
||||
- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
|
||||
- Separate seat management per leg; each leg stored with its scheduleId and leg number
|
||||
- returnLegStatus tracks which legs have been boarded for no-show management
|
||||
|
||||
### Loyalty Program
|
||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||
- Points accumulation on trips
|
||||
- Reward redemption
|
||||
- Tier-based benefits
|
||||
|
||||
### 💰 Wallet System
|
||||
### Wallet System
|
||||
- Top-up via payment methods
|
||||
- Pay with wallet balance
|
||||
- Transaction ledger
|
||||
- Refund to wallet
|
||||
|
||||
### 📍 Live Tracking
|
||||
### Live Tracking
|
||||
- Real-time trip status
|
||||
- Location updates
|
||||
- Delay notifications
|
||||
- Station crowd signals
|
||||
|
||||
### 🔒 Fraud Detection
|
||||
### Fraud Detection
|
||||
- Velocity checks (multiple bookings)
|
||||
- High-value transaction monitoring
|
||||
- Failed payment pattern detection
|
||||
- Automatic user blocking
|
||||
|
||||
### 👤 Passenger Profiles
|
||||
### Passenger Profiles
|
||||
- Comprehensive profile data: gender, date of birth, nationality
|
||||
- National ID for Ethiopian citizens (Fayda verified)
|
||||
- Passport information for international passengers
|
||||
- **NEW:** Complete demographic data for personalized services
|
||||
- **NEW:** Improved user targeting and communications
|
||||
- NEW: Complete demographic data for personalized services
|
||||
|
||||
### 🌍 Internationalization
|
||||
### Internationalization
|
||||
- Multi-language support (English, Amharic, French, Oromo)
|
||||
- Locale-based responses
|
||||
- Currency formatting (ETB, DJF, USD)
|
||||
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
|
||||
- NEW: Multi-currency display per booking (ETB, DJF, USD)
|
||||
|
||||
### 🚌 Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
|
||||
### Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
|
||||
- Dire Dawa as mandatory transit hub for international journeys
|
||||
- Dual-leg fare calculation (domestic + international)
|
||||
- Age-based pricing applied independently per leg
|
||||
- Seamless multi-segment booking workflow
|
||||
- Transit stop optimization and route planning
|
||||
|
||||
### 🔄 Round-Trip Booking
|
||||
- One-way and round-trip journey options
|
||||
- Flexible return date selection
|
||||
- Combined pricing for outbound + return legs
|
||||
- Separate seat management per leg
|
||||
- Independent modification/cancellation per leg
|
||||
- Return journey tracking and notifications
|
||||
- **NEW:** Booking type stored for analytics and reporting
|
||||
|
||||
### 🚐 Coach Type & Class Selection
|
||||
- Browse available coach types per route (standard coaches, premium coaches)
|
||||
### Coach Type & Class Selection
|
||||
- Browse available coach types per route
|
||||
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
|
||||
- Compare base prices by coach type and class
|
||||
- Real-time availability per coach configuration
|
||||
- Deferred pricing at seat selection stage
|
||||
- Coach amenities and features display
|
||||
- **NEW:** Sequence-based coach ordering for consistent UI
|
||||
- **NEW:** Premium and insurance fee transparency per class
|
||||
- NEW: Sequence-based coach ordering for consistent UI
|
||||
- NEW: Premium and insurance fee transparency per class
|
||||
|
||||
### 📊 Data Organization
|
||||
- **Stations:** Ordered by sequence (1-15) for consistent route display
|
||||
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
|
||||
- **Booking History:** Sorted chronologically with filtering options
|
||||
### Data Organization
|
||||
- Stations ordered by sequence (1-15) for consistent route display
|
||||
- Coaches ordered by sequence (1+) per type for predictable configuration
|
||||
- Booking history sorted chronologically with filtering options
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -195,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
|
||||
## Passenger Booking Flow
|
||||
|
||||
### Step 1: Search Trips
|
||||
\`POST /search\` with origin, destination, date, passenger counts, and nationality
|
||||
\`POST /search\` with origin, destination, date, passenger counts, and nationality.
|
||||
For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
|
||||
|
||||
### Step 2: Get Fare Quote
|
||||
\`POST /search/fare-quote\` with passenger counts and display currency
|
||||
\`POST /search/fare-quote\` with passenger counts and display currency.
|
||||
For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` - Passport information collection
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
|
||||
### Step 4: View Seat Map
|
||||
\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
|
||||
|
||||
### Step 5: Login & Hold Seats
|
||||
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
|
||||
### Step 5: Hold Seats
|
||||
\`POST /seats/hold\` to reserve seats for 15 minutes.
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
|
||||
### Step 6: Create Booking
|
||||
\`POST /bookings/guest\` with verified passenger details and held seats
|
||||
Choose the right endpoint and bookingType:
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
|
||||
### Step 7: Process Payment
|
||||
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
|
||||
@@ -265,7 +295,7 @@ Payment providers send notifications to:
|
||||
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
||||
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
|
||||
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
|
||||
@@ -282,7 +312,6 @@ Payment providers send notifications to:
|
||||
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
|
||||
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
|
||||
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
|
||||
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
|
||||
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
|
||||
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
|
||||
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
|
||||
@@ -291,8 +320,8 @@ Payment providers send notifications to:
|
||||
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
|
||||
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
|
||||
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
|
||||
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
|
||||
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
|
||||
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
// .addServer("https://api.edr-platform.com", "Production")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
@@ -75,21 +75,24 @@ export class BookingsController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of bookings with search and status filters'
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('returnLegStatus') returnLegStatus?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
status,
|
||||
status,
|
||||
returnLegStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
@@ -97,35 +100,153 @@ export class BookingsController {
|
||||
|
||||
@Post('guest')
|
||||
@ApiOperation({
|
||||
summary: 'Create guest booking without login (optional account creation)',
|
||||
description: `Creates a booking without requiring login. Features:
|
||||
|
||||
**Guest Checkout:**
|
||||
- No login required
|
||||
- Contact details from first passenger
|
||||
- Booking confirmation sent to email/phone
|
||||
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
|
||||
description: `Creates a booking without requiring login. Supports all four booking types.
|
||||
|
||||
**Optional Account Creation:**
|
||||
- Set createAccount=true with password
|
||||
- Account created using first passenger details
|
||||
- Automatic login after booking
|
||||
- Loyalty points and wallet created
|
||||
**bookingType: ONE_WAY (default)**
|
||||
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
|
||||
|
||||
**Passenger Details Storage:**
|
||||
- savePassengerDetails=true: Save for future bookings
|
||||
- Stored by userId (if account created) or deviceId
|
||||
- Retrieve saved passengers for quick booking
|
||||
**bookingType: ROUND_TRIP**
|
||||
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||
- passengers[]: each must include returnSeatId (seat on the return leg)
|
||||
|
||||
**Verifayda Verification:**
|
||||
- Ethiopian nationals: National ID verified via Verifayda
|
||||
- Other nationals: Passport details (no verification)
|
||||
**bookingType: TRANSIT**
|
||||
- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
|
||||
- passengers[]: each must include leg2SeatId
|
||||
|
||||
**Age-Based Pricing:**
|
||||
- ADULT (≥5 years): Full fare
|
||||
- CHILD (<5 years): First child FREE, subsequent children full fare`
|
||||
**bookingType: ROUND_TRIP_TRANSIT**
|
||||
- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
|
||||
- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
|
||||
|
||||
**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
|
||||
|
||||
**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
|
||||
@ApiBody({
|
||||
type: CreateGuestBookingDto,
|
||||
examples: {
|
||||
ONE_WAY: {
|
||||
summary: 'ONE_WAY — single direct journey (guest)',
|
||||
value: {
|
||||
scheduleId: 'schedule-uuid',
|
||||
holdId: 'hold-uuid',
|
||||
originStationId: 'station-uuid',
|
||||
destinationStationId: 'station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
phone: '+251911234567',
|
||||
email: 'abebe@email.com',
|
||||
}],
|
||||
savePassengerDetails: true,
|
||||
deviceId: 'device-uuid-123',
|
||||
},
|
||||
},
|
||||
ROUND_TRIP: {
|
||||
summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
|
||||
value: {
|
||||
scheduleId: 'outbound-schedule-uuid',
|
||||
holdId: 'outbound-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'djibouti-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
returnScheduleId: 'return-schedule-uuid',
|
||||
returnHoldId: 'return-hold-uuid',
|
||||
returnOriginStationId: 'djibouti-station-uuid',
|
||||
returnDestinationStationId: 'addis-station-uuid',
|
||||
returnSeatClassId: 'seat-class-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'outbound-seat-uuid',
|
||||
returnSeatId: 'return-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
phone: '+251911234567',
|
||||
}],
|
||||
savePassengerDetails: true,
|
||||
deviceId: 'device-uuid-123',
|
||||
},
|
||||
},
|
||||
TRANSIT: {
|
||||
summary: 'TRANSIT — connecting train, single PNR (guest)',
|
||||
value: {
|
||||
scheduleId: 'leg1-schedule-uuid',
|
||||
holdId: 'leg1-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'diredawa-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'TRANSIT',
|
||||
leg2ScheduleId: 'leg2-schedule-uuid',
|
||||
leg2HoldId: 'leg2-hold-uuid',
|
||||
transitStationId: 'diredawa-station-uuid',
|
||||
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'leg1-seat-uuid',
|
||||
leg2SeatId: 'leg2-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
phone: '+251911234567',
|
||||
}],
|
||||
deviceId: 'device-uuid-123',
|
||||
},
|
||||
},
|
||||
ROUND_TRIP_TRANSIT: {
|
||||
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
|
||||
value: {
|
||||
scheduleId: 'ob-leg1-schedule-uuid',
|
||||
holdId: 'ob-leg1-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'diredawa-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
leg2ScheduleId: 'ob-leg2-schedule-uuid',
|
||||
leg2HoldId: 'ob-leg2-hold-uuid',
|
||||
transitStationId: 'diredawa-station-uuid',
|
||||
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||
returnScheduleId: 'ret-leg1-schedule-uuid',
|
||||
returnHoldId: 'ret-leg1-hold-uuid',
|
||||
returnOriginStationId: 'djibouti-station-uuid',
|
||||
returnDestinationStationId: 'diredawa-station-uuid',
|
||||
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
|
||||
returnLeg2HoldId: 'ret-leg2-hold-uuid',
|
||||
returnTransitStationId: 'diredawa-station-uuid',
|
||||
returnLeg2DestinationStationId: 'addis-station-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'ob-leg1-seat-uuid',
|
||||
leg2SeatId: 'ob-leg2-seat-uuid',
|
||||
returnSeatId: 'ret-leg1-seat-uuid',
|
||||
returnLeg2SeatId: 'ret-leg2-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
phone: '+251911234567',
|
||||
}],
|
||||
deviceId: 'device-uuid-123',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
|
||||
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
|
||||
createGuest(@Body() dto: CreateGuestBookingDto) {
|
||||
return this.guestService.createGuestBooking(dto);
|
||||
}
|
||||
@@ -144,24 +265,149 @@ export class BookingsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create booking (one-way or round-trip)',
|
||||
description: `Creates a one-way or round-trip booking for logged-in users.
|
||||
|
||||
ONE_WAY booking:
|
||||
- scheduleId, holdId, originStationId, destinationStationId
|
||||
- passengers: array of PassengerInputDto with seatId
|
||||
- Single PNR, single payment
|
||||
summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
|
||||
description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
|
||||
|
||||
ROUND_TRIP booking:
|
||||
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
|
||||
- Combined PNR, single payment for both legs
|
||||
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
|
||||
**ONE_WAY**
|
||||
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
|
||||
|
||||
**ROUND_TRIP**
|
||||
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||
- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
|
||||
- Combined fare = outbound fare + return fare; single promo/loyalty deduction
|
||||
|
||||
**TRANSIT** (connecting train, single PNR)
|
||||
- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
|
||||
- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
|
||||
|
||||
**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
|
||||
- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
|
||||
- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
|
||||
- 4 holds required, 4 seat sets per passenger, single PNR, single payment
|
||||
|
||||
**Age-Based Pricing (all types)**
|
||||
- ADULT (≥5 years): full fare per leg
|
||||
- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
|
||||
})
|
||||
@ApiBody({
|
||||
type: CreateBookingDto,
|
||||
examples: {
|
||||
ONE_WAY: {
|
||||
summary: 'ONE_WAY — single direct journey',
|
||||
value: {
|
||||
passengerId: 'passenger-uuid',
|
||||
scheduleId: 'schedule-uuid',
|
||||
holdId: 'hold-uuid',
|
||||
originStationId: 'station-uuid',
|
||||
destinationStationId: 'station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
}],
|
||||
},
|
||||
},
|
||||
ROUND_TRIP: {
|
||||
summary: 'ROUND_TRIP — outbound + return, single PNR',
|
||||
value: {
|
||||
passengerId: 'passenger-uuid',
|
||||
scheduleId: 'outbound-schedule-uuid',
|
||||
holdId: 'outbound-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'djibouti-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
returnScheduleId: 'return-schedule-uuid',
|
||||
returnHoldId: 'return-hold-uuid',
|
||||
returnOriginStationId: 'djibouti-station-uuid',
|
||||
returnDestinationStationId: 'addis-station-uuid',
|
||||
returnSeatClassId: 'seat-class-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'outbound-seat-uuid',
|
||||
returnSeatId: 'return-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
}],
|
||||
},
|
||||
},
|
||||
TRANSIT: {
|
||||
summary: 'TRANSIT — connecting train, single PNR',
|
||||
value: {
|
||||
passengerId: 'passenger-uuid',
|
||||
scheduleId: 'leg1-schedule-uuid',
|
||||
holdId: 'leg1-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'diredawa-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'TRANSIT',
|
||||
leg2ScheduleId: 'leg2-schedule-uuid',
|
||||
leg2HoldId: 'leg2-hold-uuid',
|
||||
transitStationId: 'diredawa-station-uuid',
|
||||
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'leg1-seat-uuid',
|
||||
leg2SeatId: 'leg2-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
}],
|
||||
},
|
||||
},
|
||||
ROUND_TRIP_TRANSIT: {
|
||||
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
|
||||
value: {
|
||||
passengerId: 'passenger-uuid',
|
||||
scheduleId: 'ob-leg1-schedule-uuid',
|
||||
holdId: 'ob-leg1-hold-uuid',
|
||||
originStationId: 'addis-station-uuid',
|
||||
destinationStationId: 'diredawa-station-uuid',
|
||||
seatClassId: 'seat-class-uuid',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
leg2ScheduleId: 'ob-leg2-schedule-uuid',
|
||||
leg2HoldId: 'ob-leg2-hold-uuid',
|
||||
transitStationId: 'diredawa-station-uuid',
|
||||
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||
returnScheduleId: 'ret-leg1-schedule-uuid',
|
||||
returnHoldId: 'ret-leg1-hold-uuid',
|
||||
returnOriginStationId: 'djibouti-station-uuid',
|
||||
returnDestinationStationId: 'diredawa-station-uuid',
|
||||
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
|
||||
returnLeg2HoldId: 'ret-leg2-hold-uuid',
|
||||
returnTransitStationId: 'diredawa-station-uuid',
|
||||
returnLeg2DestinationStationId: 'addis-station-uuid',
|
||||
displayCurrency: 'ETB',
|
||||
passengers: [{
|
||||
seatId: 'ob-leg1-seat-uuid',
|
||||
leg2SeatId: 'ob-leg2-seat-uuid',
|
||||
returnSeatId: 'ret-leg1-seat-uuid',
|
||||
returnLeg2SeatId: 'ret-leg2-seat-uuid',
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
idDocumentNumber: 'ET123456789',
|
||||
nationality: 'Ethiopian',
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
||||
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
|
||||
@ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
|
||||
create(@Body() dto: CreateBookingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
|
||||
export class PassengerInputDto {
|
||||
@ApiProperty() @IsString() seatId: string;
|
||||
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
|
||||
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
|
||||
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
|
||||
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
|
||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@@ -15,17 +18,17 @@ export class PassengerInputDto {
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@ApiProperty({
|
||||
description: 'Outbound journey seat ID',
|
||||
example: 'seat-uuid-outbound'
|
||||
})
|
||||
@ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
|
||||
@IsString() outboundSeatId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Return journey seat ID',
|
||||
example: 'seat-uuid-return'
|
||||
})
|
||||
@ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
|
||||
@IsString() returnSeatId: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' })
|
||||
@IsOptional() @IsString() outboundLeg2SeatId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' })
|
||||
@IsOptional() @IsString() returnLeg2SeatId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Abebe Kebede',
|
||||
@@ -75,16 +78,16 @@ export class CreateBookingDto {
|
||||
@ApiProperty({ description: 'Passenger ID' })
|
||||
@IsString() passengerId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' })
|
||||
@ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' })
|
||||
@ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||
@@ -92,15 +95,33 @@ export class CreateBookingDto {
|
||||
|
||||
@ApiProperty({
|
||||
example: 'ONE_WAY',
|
||||
enum: ['ONE_WAY', 'ROUND_TRIP'],
|
||||
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
|
||||
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
|
||||
description: `Booking type:
|
||||
|
||||
**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
|
||||
|
||||
**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
|
||||
|
||||
**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
|
||||
|
||||
**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
|
||||
default: 'ONE_WAY'
|
||||
})
|
||||
@IsOptional() @IsString() bookingType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PassengerInputDto],
|
||||
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
|
||||
description: `Passenger array — required seat fields vary by bookingType:
|
||||
|
||||
**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
|
||||
|
||||
**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
|
||||
|
||||
**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
|
||||
|
||||
**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
|
||||
|
||||
**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
||||
passengers: PassengerInputDto[];
|
||||
@@ -114,31 +135,52 @@ export class CreateBookingDto {
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
|
||||
// Round-trip specific fields
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
// Transit-specific fields
|
||||
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' })
|
||||
@IsOptional() @IsString() leg2ScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' })
|
||||
@IsOptional() @IsString() leg2HoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' })
|
||||
@IsOptional() @IsString() transitStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' })
|
||||
@IsOptional() @IsString() leg2DestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' })
|
||||
@IsOptional() @IsString() leg2SeatClassId?: string;
|
||||
|
||||
// Round-trip transit: return direction transit fields
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' })
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
|
||||
})
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' })
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
|
||||
})
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' })
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' })
|
||||
@IsOptional() @IsString() returnHoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
|
||||
})
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' })
|
||||
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' })
|
||||
@IsOptional() @IsString() returnLeg2ScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' })
|
||||
@IsOptional() @IsString() returnLeg2HoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' })
|
||||
@IsOptional() @IsString() returnTransitStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' })
|
||||
@IsOptional() @IsString() returnLeg2DestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' })
|
||||
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
|
||||
@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
returnLegStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -36,6 +38,7 @@ export class BookingsService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
@@ -82,6 +85,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -159,6 +164,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -180,7 +187,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -194,9 +201,8 @@ export class BookingsService {
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
@@ -225,6 +231,10 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
schedule: {
|
||||
@@ -246,9 +256,9 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
if (dto.bookingType === 'ROUND_TRIP') {
|
||||
return this.createRoundTripBooking(dto);
|
||||
}
|
||||
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
|
||||
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
|
||||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
@@ -391,22 +401,42 @@ export class BookingsService {
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
@@ -436,6 +466,302 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async createTransitBooking(dto: CreateBookingDto) {
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||
}
|
||||
|
||||
const [leg1Hold, leg2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
]);
|
||||
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
|
||||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
|
||||
|
||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.leg2ScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||
|
||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
|
||||
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
||||
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
|
||||
|
||||
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
|
||||
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||
const [leg1Fare, leg2Fare] = await Promise.all([
|
||||
this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||
this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||
]);
|
||||
|
||||
const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor;
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.leg2ScheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
leg1BaseFareMinor: leg1Fare.baseFareMinor,
|
||||
leg2BaseFareMinor: leg2Fare.baseFareMinor,
|
||||
adultCount, childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount: leg1Fare.paidChildrenCount,
|
||||
combinedBaseFareMinor: combinedBase,
|
||||
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor, totalMinor,
|
||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async createRoundTripTransitBooking(dto: CreateBookingDto) {
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||
throw new BadRequestException(
|
||||
'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' +
|
||||
'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' +
|
||||
'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate all 4 holds
|
||||
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
|
||||
]);
|
||||
const now = new Date();
|
||||
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
|
||||
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
|
||||
|
||||
// Load all 4 schedules
|
||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
]);
|
||||
if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
|
||||
if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
|
||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||
|
||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
|
||||
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
|
||||
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
|
||||
const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
|
||||
if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found');
|
||||
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found');
|
||||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
|
||||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
|
||||
|
||||
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
const nat = passengersData[0]?.nationality;
|
||||
|
||||
const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||
const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId;
|
||||
const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||||
|
||||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||||
this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount),
|
||||
this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount),
|
||||
this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount),
|
||||
this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount),
|
||||
]);
|
||||
|
||||
const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor +
|
||||
retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor;
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
leg,
|
||||
scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
// Outbound transit leg-2
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: obL2SeatClassId,
|
||||
// Return transit
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnSeatClassId: retL1SeatClassId,
|
||||
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
|
||||
returnLeg2OriginStationId: dto.returnTransitStationId,
|
||||
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
||||
returnLeg2SeatClassId: retL2SeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: [
|
||||
// Outbound leg-1 (sequence 1)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
|
||||
// Outbound leg-2 (sequence 2)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
|
||||
// Return leg-1 (sequence 3)
|
||||
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
|
||||
// Return leg-2 (sequence 4)
|
||||
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)),
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)),
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
outboundLeg1FareMinor: obL1Fare.baseFareMinor,
|
||||
outboundLeg2FareMinor: obL2Fare.baseFareMinor,
|
||||
returnLeg1FareMinor: retL1Fare.baseFareMinor,
|
||||
returnLeg2FareMinor: retL2Fare.baseFareMinor,
|
||||
adultCount, childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount: obL1Fare.paidChildrenCount,
|
||||
combinedBaseFareMinor: combinedBase,
|
||||
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor, totalMinor,
|
||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async processPassengers(passengers: any[]) {
|
||||
const processedPassengers = [];
|
||||
for (const passenger of passengers) {
|
||||
@@ -560,76 +886,69 @@ export class BookingsService {
|
||||
destStopSeq?: number,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// Get schedule with route info
|
||||
|
||||
// 1. SegmentFareRule — most specific explicit price
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { route: true },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
|
||||
// Try segment fare rule first (most specific) if route info available
|
||||
|
||||
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
|
||||
// Try with nationality first
|
||||
const segmentFare = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: nationality || null,
|
||||
nationality: nationality ?? null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentFare) {
|
||||
return segmentFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// If no segment fare with nationality, try without nationality filter
|
||||
if (nationality) {
|
||||
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (segmentFareAny) return segmentFareAny.baseFareMinor;
|
||||
}
|
||||
}) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: null,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
}) : null);
|
||||
|
||||
if (segmentFare) return segmentFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Fall back to fare rules if no segment fare found
|
||||
|
||||
// 2. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
||||
if (bestMatch) return bestMatch.baseFareMinor;
|
||||
|
||||
const bestMatch = this.selectBestFareRule(
|
||||
candidates,
|
||||
scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
nationality,
|
||||
// 3. FareEngine — distance × rate-per-km from the schedule's route
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
|
||||
);
|
||||
|
||||
return bestMatch?.baseFareMinor ?? 35000;
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
@@ -646,7 +965,11 @@ export class BookingsService {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||
bookingType: booking.bookingType, createdAt: booking.createdAt,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
number: booking.schedule.train.number,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
@@ -751,6 +1074,37 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark round-trip bookings where the return train has departed but the return leg
|
||||
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
|
||||
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async markReturnLegNoShows() {
|
||||
const now = new Date();
|
||||
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
|
||||
const candidates = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
bookingType: 'ROUND_TRIP',
|
||||
status: 'CONFIRMED',
|
||||
returnLegStatus: 'NEITHER_USED' as any,
|
||||
outboundBoardedAt: { not: null },
|
||||
returnBoardedAt: null,
|
||||
returnScheduleId: { not: null },
|
||||
},
|
||||
include: { returnSchedule: { select: { departureAt: true } } },
|
||||
} as any);
|
||||
|
||||
for (const b of candidates) {
|
||||
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
|
||||
if (returnDep && returnDep < graceCutoff) {
|
||||
await this.prisma.booking.update({
|
||||
where: { id: b.id },
|
||||
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
|
||||
@@ -4,9 +4,18 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
|
||||
export class GuestPassengerDto {
|
||||
@ApiProperty({ example: 'seat-id-uuid' })
|
||||
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
|
||||
@IsString() seatId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
|
||||
@IsOptional() @IsString() returnSeatId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' })
|
||||
@IsOptional() @IsString() leg2SeatId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
|
||||
@IsOptional() @IsString() returnLeg2SeatId?: string;
|
||||
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@@ -36,24 +45,88 @@ export class GuestPassengerDto {
|
||||
}
|
||||
|
||||
export class CreateGuestBookingDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@ApiPropertyOptional({
|
||||
example: 'ONE_WAY',
|
||||
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
|
||||
default: 'ONE_WAY',
|
||||
description: `Booking type:
|
||||
**ONE_WAY:** scheduleId + holdId. Passenger: seatId.
|
||||
**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId.
|
||||
**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId.
|
||||
**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`
|
||||
})
|
||||
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
|
||||
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'hold-uuid' })
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
|
||||
@IsString() seatClassId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' })
|
||||
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' })
|
||||
@IsOptional() @IsString() leg2ScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' })
|
||||
@IsOptional() @IsString() leg2HoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' })
|
||||
@IsOptional() @IsString() transitStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' })
|
||||
@IsOptional() @IsString() leg2DestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' })
|
||||
@IsOptional() @IsString() leg2SeatClassId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' })
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' })
|
||||
@IsOptional() @IsString() returnHoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' })
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' })
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' })
|
||||
@IsOptional() @IsString() returnLeg2ScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' })
|
||||
@IsOptional() @IsString() returnLeg2HoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' })
|
||||
@IsOptional() @IsString() returnTransitStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' })
|
||||
@IsOptional() @IsString() returnLeg2DestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
|
||||
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [GuestPassengerDto],
|
||||
description: `Passenger array. Required seat fields vary by bookingType:
|
||||
- ONE_WAY: seatId
|
||||
- ROUND_TRIP: seatId + returnSeatId
|
||||
- TRANSIT: seatId + leg2SeatId
|
||||
- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId`
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@@ -66,7 +139,7 @@ export class CreateGuestBookingDto {
|
||||
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
|
||||
@IsOptional() @IsString() password?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
|
||||
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
|
||||
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
@@ -28,10 +29,18 @@ export class GuestBookingService {
|
||||
private seatsService: SeatsService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto) {
|
||||
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto);
|
||||
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto);
|
||||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto);
|
||||
return this.createGuestOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
@@ -157,76 +166,7 @@ export class GuestBookingService {
|
||||
|
||||
// Create or get guest passenger
|
||||
const firstPassenger = passengersData[0];
|
||||
let guestPassenger = null;
|
||||
let userId = null;
|
||||
let createdAccount = false;
|
||||
|
||||
// Optional account creation
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email already registered. Please login instead.');
|
||||
}
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: accountPhone,
|
||||
passwordHash,
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
passportNumber: firstPassenger.passportNumber,
|
||||
},
|
||||
});
|
||||
|
||||
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
|
||||
userId = user.id;
|
||||
createdAccount = true;
|
||||
} else {
|
||||
// Create anonymous guest passenger with minimal data
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Check if email exists and use a unique guest email if it does
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
if (firstPassenger.email) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) {
|
||||
// Email exists, use guest email instead for anonymous booking
|
||||
guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use a guaranteed-unique guest phone to avoid constraint collisions
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existingPhone) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
});
|
||||
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
|
||||
}
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
|
||||
|
||||
// Save passenger details for future use (if requested)
|
||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||
@@ -302,6 +242,649 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
}
|
||||
|
||||
// Validate both holds
|
||||
const [outboundHold, returnHold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
]);
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
|
||||
|
||||
// Validate passengers have returnSeatId
|
||||
for (const p of dto.passengers) {
|
||||
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
|
||||
}
|
||||
|
||||
// Load both schedules
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.returnScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
||||
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
||||
|
||||
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
|
||||
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
||||
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
||||
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
|
||||
|
||||
// Process passengers (verify identity once — same person travels both legs)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
|
||||
for (const passenger of dto.passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||||
passenger.nationality === 'ETHIOPIAN' ||
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
}
|
||||
nationality = 'Ethiopian';
|
||||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
nationality = 'Ethiopian';
|
||||
} else {
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
// Calculate fares for both legs
|
||||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
|
||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
||||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff
|
||||
? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
|
||||
: (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
||||
|
||||
// Create booking with outbound seats; return seats confirmed separately
|
||||
const outboundSeatIds = dto.passengers.map(p => p.seatId);
|
||||
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassenger.id,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
createdAccount,
|
||||
userId,
|
||||
fareBreakdown: {
|
||||
outboundBaseFareMinor: outboundBaseFare,
|
||||
returnBaseFareMinor: returnBaseFare,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto) {
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||
}
|
||||
|
||||
const [leg1Hold, leg2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
]);
|
||||
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found');
|
||||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
|
||||
|
||||
for (const p of dto.passengers) {
|
||||
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
|
||||
}
|
||||
|
||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.leg2ScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||
|
||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
|
||||
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
||||
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
|
||||
|
||||
// Process passengers (verify identity once)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
for (const passenger of dto.passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
nationality = 'Ethiopian';
|
||||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else {
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
|
||||
const [leg1BaseFare, leg2BaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
||||
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
||||
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
||||
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
||||
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
]);
|
||||
|
||||
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
||||
const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
|
||||
const combinedBase = leg1Total + leg2Total;
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassenger.id,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: leg2SeatClassId,
|
||||
userAgent: dto.deviceId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
seat: { connect: { id: p.leg2SeatId! } },
|
||||
leg: 2,
|
||||
scheduleId: dto.leg2ScheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
createdAccount,
|
||||
userId,
|
||||
fareBreakdown: {
|
||||
leg1BaseFareMinor: leg1BaseFare,
|
||||
leg2BaseFareMinor: leg2BaseFare,
|
||||
adultCount, childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor: combinedBase,
|
||||
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
|
||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) {
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||
throw new BadRequestException(
|
||||
'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
|
||||
);
|
||||
}
|
||||
for (const p of dto.passengers) {
|
||||
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
|
||||
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
|
||||
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
|
||||
]);
|
||||
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
|
||||
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
|
||||
|
||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
]);
|
||||
if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
|
||||
if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
|
||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||
|
||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
|
||||
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
|
||||
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
|
||||
const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
|
||||
if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
|
||||
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
|
||||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
|
||||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
|
||||
|
||||
// Process passengers (verify once)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
for (const passenger of dto.passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`);
|
||||
passengerName = v.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = v.passengerData?.profileData;
|
||||
nationality = 'Ethiopian';
|
||||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else {
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
const nat = passengersData[0]?.nationality;
|
||||
const paidChildren = Math.max(0, childCount - 1);
|
||||
const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||
const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
|
||||
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||||
|
||||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
|
||||
]);
|
||||
|
||||
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
||||
(obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
leg,
|
||||
scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
|
||||
displayCurrency,
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassenger.id,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: obL2ClassId,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnSeatClassId: retL1ClassId,
|
||||
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
|
||||
returnLeg2OriginStationId: dto.returnTransitStationId,
|
||||
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
||||
returnLeg2SeatClassId: retL2ClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||
...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
|
||||
...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
|
||||
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
createdAccount,
|
||||
userId,
|
||||
fareBreakdown: {
|
||||
outboundLeg1FareMinor: obL1Fare,
|
||||
outboundLeg2FareMinor: obL2Fare,
|
||||
returnLeg1FareMinor: retL1Fare,
|
||||
returnLeg2FareMinor: retL2Fare,
|
||||
adultCount, childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount: paidChildren,
|
||||
combinedBaseFareMinor: combinedBase,
|
||||
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
|
||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveGuestPassenger(
|
||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
firstPassenger: any,
|
||||
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: accountPhone,
|
||||
passwordHash: await bcrypt.hash(dto.password, 10),
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
passportNumber: firstPassenger.passportNumber,
|
||||
},
|
||||
});
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
return { guestPassenger, userId: user.id, createdAccount: true };
|
||||
}
|
||||
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
if (firstPassenger.email) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
}
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existing) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
});
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
|
||||
return { guestPassenger, userId: null, createdAccount: false };
|
||||
}
|
||||
|
||||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||||
if (!userId && !deviceId) {
|
||||
throw new BadRequestException('Either userId or deviceId is required');
|
||||
@@ -343,6 +926,8 @@ export class GuestBookingService {
|
||||
nationality?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// 1. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
@@ -368,14 +953,34 @@ export class GuestBookingService {
|
||||
|
||||
for (const priority of priorities) {
|
||||
const match = candidates.find(
|
||||
(c) =>
|
||||
c.tripId === priority.tripId &&
|
||||
c.route === priority.route &&
|
||||
c.nationality === priority.nationality,
|
||||
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match.baseFareMinor;
|
||||
}
|
||||
|
||||
return 35000; // Default fallback
|
||||
// 2. FareEngine — distance × rate-per-km from the schedule's route
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SendEmail {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
to: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceName?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
subject: string;
|
||||
body: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html?: string;
|
||||
templateKey?: string;
|
||||
context?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
body?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
context?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SendMessage {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
to: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export class SingleMessageDto {
|
||||
@ApiProperty({
|
||||
description: 'Recipient phone number',
|
||||
example: '+1234567890',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
to: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message content',
|
||||
example: 'Test Single SMS from',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sms: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
@ApiProperty({ type: [SendMessage] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SendMessage)
|
||||
messages: SendMessage[];
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { SendEmail } from "./dtos/email.dto";
|
||||
|
||||
@Injectable()
|
||||
export class EmailClientService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(EmailClientService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('EMAIL_SERVICE')
|
||||
@Inject("EMAIL_SERVICE")
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.emailServiceClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to Email service'))
|
||||
.catch((err) => this.logger.error('Error connecting to Email service', err));
|
||||
.then(() => this.logger.log("Connected to Email service"))
|
||||
.catch((err) =>
|
||||
this.logger.error("Error connecting to Email service", err),
|
||||
);
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
this.emailServiceClient.emit('send-email', {
|
||||
if (!this.enabled) return {};
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
import { SendMessage } from './dtos/sms.dto';
|
||||
import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
@@ -51,11 +51,20 @@ export class NotificationsController {
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
||||
@ApiBody({ type: SendMessage })
|
||||
sendSms(@Body() dto: SendMessage) {
|
||||
@ApiBody({ type: SingleMessageDto })
|
||||
sendSms(@Body() dto: SingleMessageDto) {
|
||||
return this.smsClient.sendSms(dto);
|
||||
}
|
||||
|
||||
@Post('send/sms/bulk')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
|
||||
@ApiBody({ type: BulkMessagesDto })
|
||||
sendBulkSms(@Body() dto: BulkMessagesDto) {
|
||||
return this.smsClient.sendBulkMessages(dto);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
@@ -11,34 +10,24 @@ import { SmsClientService } from './sms-client.service';
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.registerAsync([
|
||||
ClientsModule.register([
|
||||
{
|
||||
name: 'EMAIL_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.EMAIL_QUEUE ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SMS_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.SMS_QUEUE ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -20,8 +20,8 @@ export class NotificationsService {
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
|
||||
['PUSH', this.pushAdapter as NotificationChannel],
|
||||
]);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export class NotificationsService {
|
||||
await this.emailClient.sendEmail({
|
||||
to: passenger.user.email,
|
||||
subject: this.sanitize(dto.title),
|
||||
body: this.sanitize(dto.body),
|
||||
text: this.sanitize(dto.body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
|
||||
|
||||
@Injectable()
|
||||
export class SmsClientService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SmsClientService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('SMS_SERVICE')
|
||||
private readonly smsClient: ClientProxy,
|
||||
@Inject("SMS_SERVICE")
|
||||
private smsClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.smsClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to SMS service'))
|
||||
.catch((err) => this.logger.error('Error connecting to SMS service', err));
|
||||
.then(() => {
|
||||
this.logger.log("connected to SMS service");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error happened at SMS service", err);
|
||||
});
|
||||
}
|
||||
|
||||
async sendSms(dto: SendMessage) {
|
||||
this.smsClient.emit('send-sms', {
|
||||
async sendSms(dto: SingleMessageDto) {
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit("send-sms", {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||
this.smsClient.emit('ozeking-bulk-sms', {
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit("ozeking-bulk-sms", {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ export class PassengersService {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
const where: any = { user: { role: 'PASSENGER' } };
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
...where.user,
|
||||
OR: [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
@@ -68,7 +69,7 @@ export class PassengersService {
|
||||
userId: passenger.userId,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
|
||||
nationalId: user.nationalId,
|
||||
nationality: user.nationality,
|
||||
dateOfBirth: user.dateOfBirth ?? null,
|
||||
|
||||
@@ -79,6 +79,28 @@ export class PaymentsController {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("waafi/return")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
|
||||
"UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
|
||||
"WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
|
||||
})
|
||||
@ApiQuery({ name: "referenceId", required: true })
|
||||
@ApiQuery({ name: "state", required: true })
|
||||
@ApiQuery({ name: "transactionId", required: false })
|
||||
waafiReturn(
|
||||
@Query("referenceId") referenceId: string,
|
||||
@Query("state") state: string,
|
||||
@Query("transactionId") transactionId: string,
|
||||
) {
|
||||
return this.service.confirmWaafiReturnDemo({
|
||||
referenceId,
|
||||
state,
|
||||
transactionId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
||||
|
||||
@@ -44,6 +44,8 @@ export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly walletDemoAutoSucceed = true;
|
||||
|
||||
private readonly waafiDemoTrustReturn = true;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -192,6 +194,41 @@ export class PaymentsService {
|
||||
return { returnUrl, failureUrl };
|
||||
}
|
||||
|
||||
async confirmWaafiReturnDemo(params: {
|
||||
referenceId?: string;
|
||||
state?: string;
|
||||
transactionId?: string;
|
||||
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
|
||||
if (!this.waafiDemoTrustReturn) {
|
||||
return { confirmed: false, reason: "demo-disabled" };
|
||||
}
|
||||
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
|
||||
return { confirmed: false, reason: `not-approved (${params.state})` };
|
||||
}
|
||||
if (!params.referenceId) {
|
||||
return { confirmed: false, reason: "missing-referenceId" };
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findFirst({
|
||||
where: { merchantOrderId: params.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
|
||||
);
|
||||
return { confirmed: false, reason: "intent-not-found" };
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
|
||||
);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: params.transactionId,
|
||||
});
|
||||
return { confirmed: true, bookingId: intent.bookingId };
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
@@ -453,6 +490,30 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
|
||||
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
|
||||
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
|
||||
* booking still confirms.
|
||||
*/
|
||||
private sanitizePaidAt(value?: Date): Date {
|
||||
const now = new Date();
|
||||
if (!value) return now;
|
||||
const t = value.getTime();
|
||||
const oneDayMs = 86_400_000;
|
||||
if (
|
||||
Number.isNaN(t) ||
|
||||
t > now.getTime() + oneDayMs ||
|
||||
t < Date.UTC(2000, 0, 1)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
|
||||
);
|
||||
return now;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
@@ -477,7 +538,7 @@ export class PaymentsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
|
||||
@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
export class BulkSchedulesResponseDto {
|
||||
|
||||
@@ -44,6 +44,15 @@ export class SchedulesService {
|
||||
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// Assign coaches if provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
@@ -71,27 +71,38 @@ export class FareQuoteDto {
|
||||
}
|
||||
|
||||
export class CoachTypeOptionClass {
|
||||
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
|
||||
baseFareMinor: number;
|
||||
@ApiProperty({ example: 'Economy Regular' }) name: string;
|
||||
@ApiProperty({ example: 35000 }) baseFareMinor: number;
|
||||
}
|
||||
|
||||
export class CoachTypeOption {
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
|
||||
coachTypeId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
|
||||
coachTypeName: string;
|
||||
|
||||
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
|
||||
coachTypeCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
|
||||
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
|
||||
})
|
||||
classes: CoachTypeOptionClass[];
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
||||
@ApiProperty({ example: 'ECO' }) coachTypeCode: string;
|
||||
@ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
|
||||
}
|
||||
|
||||
export class TransitLegDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
|
||||
@ApiProperty() trainNumber: string;
|
||||
@ApiProperty() trainName: string;
|
||||
@ApiProperty() origin: object;
|
||||
@ApiProperty() destination: object;
|
||||
@ApiProperty() departureAt: Date;
|
||||
@ApiProperty() arrivalAt: Date;
|
||||
@ApiProperty() durationMinutes: number;
|
||||
@ApiProperty() availabilityByClass: object;
|
||||
@ApiProperty() faresByClass: object[];
|
||||
@ApiProperty() coachTypes: CoachTypeOption[];
|
||||
}
|
||||
|
||||
export class TransitResultDto {
|
||||
@ApiProperty({ example: 'TRANSIT' }) type: string;
|
||||
@ApiProperty({ example: 'station-uuid' }) transitStationId: string;
|
||||
@ApiProperty({ example: 'Dire Dawa' }) transitStationName: string;
|
||||
@ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number;
|
||||
@ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto;
|
||||
@ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto;
|
||||
@ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number;
|
||||
@ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number;
|
||||
}
|
||||
|
||||
@@ -18,31 +18,54 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const outbound = await this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const allInbound = await this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
]);
|
||||
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const [returnDirect, returnTransit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
]);
|
||||
|
||||
const allReturn = [...returnDirect, ...returnTransit];
|
||||
const latestOutboundArrival = outbound.length > 0
|
||||
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
|
||||
? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime()))
|
||||
: Date.now();
|
||||
|
||||
const inbound = allInbound.filter((schedule) =>
|
||||
new Date(schedule.departureAt).getTime() > latestOutboundArrival
|
||||
const inbound = allReturn.filter((s: any) =>
|
||||
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
@@ -60,7 +83,7 @@ export class SearchService {
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
@@ -81,126 +104,211 @@ export class SearchService {
|
||||
},
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||
// ── Transit search ─────────────────────────────────────────────────────────
|
||||
// Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
|
||||
// where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
|
||||
// to change trains at the transit station.
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||
|
||||
const availabilityByClass: Record<string, number> = {};
|
||||
private async searchTransitOptions(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
// Find all stations that can serve as transit points:
|
||||
// they must be a stop after origin on some schedule AND
|
||||
// a stop before destination on another schedule on the same day.
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||
// Load all schedules on this date that pass through origin
|
||||
const leg1Schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isBedCoach) {
|
||||
const bedPositions = ['upper', 'middle', 'lower'];
|
||||
for (const bedPosition of bedPositions) {
|
||||
let count = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.bedPosition !== bedPosition) continue;
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
const results: any[] = [];
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) count++;
|
||||
}
|
||||
for (const leg1 of leg1Schedules) {
|
||||
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
if (!originStop) continue;
|
||||
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((className: string) => {
|
||||
const classNameLower = className.toLowerCase();
|
||||
return (
|
||||
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
|
||||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
|
||||
(bedPosition === 'lower' && classNameLower.includes('lower'))
|
||||
);
|
||||
});
|
||||
if (matchingClass) {
|
||||
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
|
||||
availabilityByClass[matchingClass] += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let availableSeatsInCoach = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) availableSeatsInCoach++;
|
||||
}
|
||||
|
||||
for (const seatClassName of seatClassNames) {
|
||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||
availabilityByClass[seatClassName] += availableSeatsInCoach;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
nationality,
|
||||
// Every stop after origin on leg1 is a candidate transit station
|
||||
const candidateTransitStops = leg1.stopTimes.filter(
|
||||
(s: any) => s.sequence > originStop.sequence,
|
||||
);
|
||||
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
for (const transitStop of candidateTransitStops) {
|
||||
// leg1 must NOT already contain the final destination
|
||||
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
|
||||
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
|
||||
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
trainName: schedule.train.name,
|
||||
origin: {
|
||||
id: originStop.stationId,
|
||||
code: originStop.station.code,
|
||||
name: originStop.station.name,
|
||||
city: originStop.station.city,
|
||||
sequence: originStop.sequence,
|
||||
},
|
||||
destination: {
|
||||
id: destStop.stationId,
|
||||
code: destStop.station.code,
|
||||
name: destStop.station.name,
|
||||
city: destStop.station.city,
|
||||
sequence: destStop.sequence,
|
||||
},
|
||||
departureAt: legDepartureAt,
|
||||
arrivalAt: legArrivalAt,
|
||||
durationMinutes: Math.round(
|
||||
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
|
||||
),
|
||||
status: schedule.status,
|
||||
stops: schedule.stopTimes
|
||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map((st: any) => ({
|
||||
stationId: st.stationId,
|
||||
stationName: st.station.name,
|
||||
sequence: st.sequence,
|
||||
plannedArrivalAt: st.plannedArrivalAt,
|
||||
plannedDepartureAt: st.plannedDepartureAt,
|
||||
})),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
});
|
||||
const transitStationId = transitStop.stationId;
|
||||
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
|
||||
|
||||
// Find leg2 schedules departing from the transit station within the connection window,
|
||||
// and reaching the final destination. Search up to the next calendar day to handle
|
||||
// overnight connections.
|
||||
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
|
||||
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||
|
||||
const leg2Schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: connWindowStart, lte: connWindowEnd },
|
||||
stopTimes: { some: { stationId: transitStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const leg2 of leg2Schedules) {
|
||||
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
|
||||
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
|
||||
if (!leg2TransitStop || !leg2DestStop) continue;
|
||||
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
||||
|
||||
// Build individual leg result objects (reuse existing per-schedule logic)
|
||||
const [leg1Result, leg2Result] = await Promise.all([
|
||||
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
||||
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
||||
]);
|
||||
|
||||
if (!leg1Result || !leg2Result) continue;
|
||||
if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue;
|
||||
|
||||
const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
|
||||
const connectionMinutes = Math.round(
|
||||
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
||||
);
|
||||
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
|
||||
results.push({
|
||||
type: 'TRANSIT',
|
||||
transitStationId,
|
||||
transitStationName: transitStop.station.name,
|
||||
connectionMinutes,
|
||||
leg1: leg1Result,
|
||||
leg2: leg2Result,
|
||||
combinedMinFareMinor,
|
||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||
departureAt: leg1Result.departureAt,
|
||||
arrivalAt: leg2Result.arrivalAt,
|
||||
totalDurationMinutes:
|
||||
leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Builds the same result shape as searchSchedules for a single schedule+leg,
|
||||
// extracted so both direct and transit paths share identical output.
|
||||
private async buildScheduleResult(
|
||||
schedule: any,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
totalPassengers: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
||||
|
||||
const availabilityByClass: Record<string, number> = {};
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||
|
||||
if (isBedCoach) {
|
||||
for (const bedPosition of ['upper', 'middle', 'lower']) {
|
||||
let count = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
||||
if (free) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
||||
if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let available = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
||||
if (free) available++;
|
||||
}
|
||||
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
||||
}
|
||||
}
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
return {
|
||||
type: 'DIRECT',
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
trainName: schedule.train.name,
|
||||
origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence },
|
||||
destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence },
|
||||
departureAt: legDepartureAt,
|
||||
arrivalAt: legArrivalAt,
|
||||
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
|
||||
status: schedule.status,
|
||||
stops: schedule.stopTimes
|
||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
};
|
||||
}
|
||||
|
||||
async getFareQuote(dto: FareQuoteDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -244,7 +352,8 @@ export class SearchService {
|
||||
nationality,
|
||||
);
|
||||
|
||||
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
|
||||
const baseFareMinor = bestMatch?.baseFareMinor
|
||||
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
|
||||
|
||||
const adultCount = dto.adultCount;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
@@ -379,11 +488,8 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||
return seatClasses.map(sc => ({
|
||||
seatClassName: sc.name,
|
||||
baseFareMinor: this.getDefaultFareForClass(sc.name),
|
||||
}));
|
||||
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
@@ -420,11 +526,10 @@ export class SearchService {
|
||||
const classes = Array.from(classNames)
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
return {
|
||||
name: className,
|
||||
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
|
||||
};
|
||||
if (!fareInfo) return null;
|
||||
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
|
||||
})
|
||||
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
result.push({
|
||||
@@ -442,22 +547,28 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultFareForClass(className: string): number {
|
||||
const defaults: Record<string, number> = {
|
||||
'Economy Regular': 35000,
|
||||
'Economy Bed': 49000,
|
||||
'VIP Bed': 63000,
|
||||
};
|
||||
return defaults[className] ?? 35000;
|
||||
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
|
||||
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
}
|
||||
|
||||
private defaultFare(seatClassName: string): number {
|
||||
const fares: Record<string, number> = {
|
||||
'Economy Regular': 45000,
|
||||
'Economy Bed': 65000,
|
||||
'VIP Bed': 95000,
|
||||
};
|
||||
return fares[seatClassName] ?? 45000;
|
||||
private getDefaultFareForClass(_className: string): never {
|
||||
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
|
||||
}
|
||||
|
||||
private defaultFare(_seatClassName: string): never {
|
||||
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -547,16 +547,14 @@ export class SeatsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
for (const hold of expired) {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredIds = expired.map(h => h.id);
|
||||
for (const hold of expired) {
|
||||
await this.releaseSeats(hold.seatIds);
|
||||
try {
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@@ -30,15 +30,28 @@ export class TicketsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listTickets({
|
||||
search,
|
||||
status,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
@@ -56,17 +69,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details',
|
||||
description: `Returns ticket information including:
|
||||
- QR code for gate scanning
|
||||
- Barcode for offline validation
|
||||
- Passenger details (name, age category, nationality)
|
||||
- Journey details (origin, destination, seat, coach)
|
||||
- Fare breakdown with currency
|
||||
- PDF download link`
|
||||
summary: 'Get ticket with QR code and passenger details (public)',
|
||||
})
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
@@ -77,14 +81,30 @@ export class TicketsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
leg: {
|
||||
type: 'string',
|
||||
enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'],
|
||||
description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Param('bookingRef') ref: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId);
|
||||
@Body('gateId') gateId?: string,
|
||||
@Body('leg') leg?: string,
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId, leg);
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@@ -106,7 +126,31 @@ export class TicketsController {
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
validations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['bookingRef', 'validatorId', 'validatedAt'],
|
||||
properties: {
|
||||
bookingRef: { type: 'string' },
|
||||
validatorId: { type: 'string' },
|
||||
gateId: { type: 'string' },
|
||||
validatedAt: { type: 'string', format: 'date-time' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ interface OfflineValidation {
|
||||
validatorId: string;
|
||||
gateId?: string;
|
||||
validatedAt: string;
|
||||
leg?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -23,7 +24,19 @@ export class TicketsService {
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
where.booking = { status: filters.status };
|
||||
where.booking = { ...where.booking, status: filters.status };
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
const end = new Date(filters.arrivalDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
||||
}
|
||||
const tickets = await this.prisma.ticket.findMany({
|
||||
where,
|
||||
@@ -31,7 +44,7 @@ export class TicketsService {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
},
|
||||
@@ -49,6 +62,10 @@ export class TicketsService {
|
||||
booking: {
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
@@ -69,48 +86,65 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
async generate(bookingId: string) {
|
||||
if (!bookingId) {
|
||||
throw new BadRequestException('Booking ID is required');
|
||||
}
|
||||
|
||||
if (!bookingId) throw new BadRequestException('Booking ID is required');
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
const legSummary = this.buildLegSummary(booking);
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
legs: legSummary,
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
|
||||
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
|
||||
// Block all seats across all legs
|
||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of seatIds) {
|
||||
// Update seat status to BOOKED
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'BOOKED' },
|
||||
});
|
||||
// Create permanent seat blocks for all booked seats
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `Permanently booked in ticket ${ticket.id}`,
|
||||
blockedBy: 'SYSTEM',
|
||||
approvedBy: 'SYSTEM',
|
||||
}
|
||||
}).catch(() => null); // Ignore if already exists
|
||||
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
return ticket;
|
||||
|
||||
return { ...ticket, legs: legSummary };
|
||||
}
|
||||
|
||||
private buildLegSummary(booking: any) {
|
||||
const seatsByLeg = new Map<number, any[]>();
|
||||
for (const bs of booking.seats) {
|
||||
const leg = bs.leg ?? 1;
|
||||
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
|
||||
seatsByLeg.get(leg)!.push(bs);
|
||||
}
|
||||
return Array.from(seatsByLeg.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([leg, seats]) => ({
|
||||
leg,
|
||||
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
|
||||
passengers: seats.map(bs => ({
|
||||
name: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
coach: bs.seat?.coach?.number,
|
||||
seat: bs.seat?.seatNumber,
|
||||
fareMinor: bs.fareMinor,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateSeats(bookingId: string, newSeatIds: string[]) {
|
||||
@@ -213,22 +247,122 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string) {
|
||||
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
// Accept either a ticket UUID or a bookingRef
|
||||
let bookingRef = ticketIdOrRef;
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
|
||||
if (isUuid) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
bookingRef = ticket.bookingRef;
|
||||
}
|
||||
const resolvedValidatorId = validatorId || 'BACKOFFICE';
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
if (ticket.validatedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
|
||||
const type = booking.bookingType;
|
||||
const now = new Date();
|
||||
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
if (ticket.validatedAt) {
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
|
||||
// ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
|
||||
if (type === 'TRANSIT') {
|
||||
const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
|
||||
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
|
||||
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
|
||||
if (type === 'ROUND_TRIP') {
|
||||
let resolvedLeg = (leg ?? '').toUpperCase();
|
||||
// Auto-detect next unused leg when called from backoffice without a leg param
|
||||
if (!resolvedLeg) {
|
||||
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
} else if (resolvedLeg === 'RETURN') {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Return leg already used');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
} else {
|
||||
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
|
||||
}
|
||||
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
|
||||
if (type === 'ROUND_TRIP_TRANSIT') {
|
||||
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
|
||||
const resolvedLeg = (leg ?? '').toUpperCase();
|
||||
if (!validLegs.includes(resolvedLeg)) {
|
||||
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
|
||||
bookingData.outboundBoardedAt = now;
|
||||
}
|
||||
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// Fallback for unknown booking types — single scan
|
||||
if (ticket.validatedAt) {
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -243,7 +377,7 @@ export class TicketsService {
|
||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
});
|
||||
@@ -256,6 +390,8 @@ export class TicketsService {
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
bookingType: b.bookingType,
|
||||
returnLegStatus: (b as any).returnLegStatus ?? null,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
}));
|
||||
}
|
||||
@@ -265,11 +401,13 @@ export class TicketsService {
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
if (processedRefs.has(v.bookingRef)) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(v.bookingRef);
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
@@ -286,11 +424,24 @@ export class TicketsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt) {
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
|
||||
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For multi-leg bookings, check per-leg duplication
|
||||
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLeg && offlineLeg) {
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
@@ -301,11 +452,27 @@ export class TicketsService {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
// update boarding timestamps for multi-leg bookings
|
||||
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLegBooking && offlineLeg) {
|
||||
const bookingData: Record<string, any> = {};
|
||||
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
|
||||
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
|
||||
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
|
||||
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
|
||||
if (Object.keys(bookingData).length) {
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
}
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
|
||||
@@ -24,6 +24,19 @@ export default function BookingsPage() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
bookingRef: true,
|
||||
passenger: true,
|
||||
status: true,
|
||||
bookingType: false,
|
||||
passengerCount: false,
|
||||
totalMinor: true,
|
||||
paymentStatus: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -80,22 +93,23 @@ export default function BookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportBookings = async () => {
|
||||
const selectedColumns = prompt(
|
||||
'Select columns to export (comma-separated):\n\n' +
|
||||
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
|
||||
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
|
||||
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
|
||||
);
|
||||
|
||||
if (!selectedColumns) return;
|
||||
|
||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const exportItems = (data?.items || []).filter((b: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((booking: any) => {
|
||||
...exportItems.map((booking: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'bookingRef': return booking.bookingRef;
|
||||
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
||||
case 'status': return booking.status;
|
||||
@@ -108,28 +122,29 @@ export default function BookingsPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'bookingRef',
|
||||
{
|
||||
key: 'bookingRef',
|
||||
label: 'Reference',
|
||||
sortable: true,
|
||||
render: (booking: any) => (
|
||||
<span className="font-mono font-semibold">{booking.bookingRef}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'passenger',
|
||||
{
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
@@ -138,32 +153,39 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Class',
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
{
|
||||
{
|
||||
key: 'passengerCount',
|
||||
label: 'Passengers',
|
||||
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
|
||||
render: (booking: any) => {
|
||||
const adults = booking.adultCount || 0;
|
||||
const children = booking.childCount || 0;
|
||||
if (adults === 0 && children === 0) return '—';
|
||||
const parts = [`Adult: ${adults}`];
|
||||
if (children > 0) parts.push(`Child: ${children}`);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalMinor',
|
||||
{
|
||||
key: 'totalMinor',
|
||||
label: 'Amount',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
|
||||
@@ -171,8 +193,8 @@ export default function BookingsPage() {
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatDateTime(booking.createdAt),
|
||||
@@ -208,7 +230,7 @@ export default function BookingsPage() {
|
||||
<h1 className="text-2xl font-bold">Bookings</h1>
|
||||
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
||||
</div>
|
||||
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -253,7 +275,7 @@ export default function BookingsPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No bookings found"
|
||||
/>
|
||||
|
||||
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
@@ -264,15 +286,9 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Booking Details Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
title="Booking Details"
|
||||
size="xl"
|
||||
>
|
||||
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
{/* Booking Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||
@@ -281,9 +297,7 @@ export default function BookingsPage() {
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.status}>
|
||||
{selectedBooking.status}
|
||||
</Badge>
|
||||
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -298,7 +312,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Passenger Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -323,7 +336,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Booking Details */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -348,7 +360,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Payment Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -377,7 +388,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Additional Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -393,12 +403,7 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedBooking(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -407,10 +412,7 @@ export default function BookingsPage() {
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setBookingToDelete(null);
|
||||
}}
|
||||
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Booking"
|
||||
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
|
||||
@@ -419,6 +421,53 @@ export default function BookingsPage() {
|
||||
isLoading={deleteMutation.isPending}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Created)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Created)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'bookingRef', label: 'Booking Reference' },
|
||||
{ key: 'passenger', label: 'Passenger' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'bookingType', label: 'Booking Type' },
|
||||
{ key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function CoachesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -252,12 +252,17 @@ export default function CoachesPage() {
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
try {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
|
||||
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
};
|
||||
|
||||
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
|
||||
@@ -536,6 +541,8 @@ export default function CoachesPage() {
|
||||
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning={
|
||||
deleteConfirm.item?.isCoachType
|
||||
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
|
||||
|
||||
@@ -22,6 +22,12 @@ export default function PassengersPage() {
|
||||
});
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -52,22 +58,23 @@ export default function PassengersPage() {
|
||||
console.error('Passengers API Error:', error);
|
||||
}
|
||||
|
||||
const handleExportPassengers = async () => {
|
||||
const selectedColumns = prompt(
|
||||
'Select columns to export (comma-separated):\n\n' +
|
||||
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
|
||||
'Default: fullName, email, phone, gender, nationality, verified',
|
||||
'fullName, email, phone, gender, nationality, verified'
|
||||
);
|
||||
|
||||
if (!selectedColumns) return;
|
||||
|
||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
||||
const confirmExportPassengers = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const exportItems = (data?.items || []).filter((p: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((passenger: any) => {
|
||||
...exportItems.map((passenger: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'fullName': return passenger.fullName;
|
||||
case 'email': return passenger.email || '';
|
||||
case 'phone': return passenger.phone || '';
|
||||
@@ -79,15 +86,16 @@ export default function PassengersPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -160,7 +168,7 @@ export default function PassengersPage() {
|
||||
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -381,6 +389,51 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'fullName', label: 'Full Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'phone', label: 'Phone' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth' },
|
||||
{ key: 'gender', label: 'Gender' },
|
||||
{ key: 'nationality', label: 'Nationality' },
|
||||
{ key: 'verified', label: 'Verified' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { paymentsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['payments', filters],
|
||||
queryFn: () => paymentsApi.getAll(filters),
|
||||
queryFn: () => paymentsApi.getAll({
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
method: filters.method || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
|
||||
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
|
||||
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const actions: any[] = [];
|
||||
const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
|
||||
const exportItems = items.filter((p: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...exportItems.map((payment: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
|
||||
case 'booking': return payment.booking?.bookingRef || 'N/A';
|
||||
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
|
||||
case 'method': return payment.method || '';
|
||||
case 'status': return payment.status || '';
|
||||
case 'createdAt': return payment.createdAt || '';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
|
||||
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
|
||||
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Method</label>
|
||||
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
|
||||
<option value="">All Methods</option>
|
||||
<option value="TELEBIRR">Telebirr</option>
|
||||
<option value="CBE_BIRR">CBE Birr</option>
|
||||
<option value="EBIRR">eBirr</option>
|
||||
<option value="CARD">Card</option>
|
||||
<option value="WALLET">Wallet</option>
|
||||
<option value="CASH">Cash</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
actions={[]}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payments found"
|
||||
/>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'method', label: 'Payment Method' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,12 +362,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
value={generateRouteCode(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.code : undefined}
|
||||
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
disabled={!!editingRoute}
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route Name *</label>
|
||||
@@ -375,11 +379,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
value={generateRouteName(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.name : undefined}
|
||||
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ export default function SchedulesPage() {
|
||||
label: 'Train',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="font-medium">
|
||||
{schedule.train?.name} ({schedule.train?.number})
|
||||
<div className="font-medium font-mono">
|
||||
{schedule.train?.number}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function SeatsPage() {
|
||||
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
|
||||
if (isBedCoach && hasBedPositionData) {
|
||||
if (isBedCoach) {
|
||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||
@@ -548,8 +548,11 @@ export default function SeatsPage() {
|
||||
|
||||
{coachesWithSeats.map((coach: any, index: number) => {
|
||||
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
|
||||
const coachTypeName = coachData?.coachType?.type || 'Coach';
|
||||
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
|
||||
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
|
||||
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
|
||||
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
|
||||
coachTypeName.toLowerCase().includes('bed') ||
|
||||
(coach.seats || []).some((s: any) => s.bedPosition);
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
|
||||
@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
|
||||
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
||||
@@ -22,6 +22,8 @@ export default function TicketsPage() {
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
|
||||
ticketNumber: true,
|
||||
booking: true,
|
||||
@@ -35,7 +37,15 @@ export default function TicketsPage() {
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tickets', filters],
|
||||
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
|
||||
queryFn: () => ticketsApi.getAll({
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
originStationId: filters.originStationId || undefined,
|
||||
destinationStationId: filters.destinationStationId || undefined,
|
||||
arrivalDate: filters.arrivalDate || undefined,
|
||||
skip: 0,
|
||||
take: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: stationsData } = useQuery({
|
||||
@@ -94,25 +104,31 @@ export default function TicketsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTickets = async () => {
|
||||
setExportModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(selectedColumns)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([col]) => col);
|
||||
|
||||
|
||||
if (cols.length === 0) {
|
||||
alert('Please select at least one column');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportItems = (data?.items || []).filter((ticket: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = ticket.schedule?.arrivalAt
|
||||
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
|
||||
: null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((ticket: any) => {
|
||||
...exportItems.map((ticket: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || '';
|
||||
case 'booking': return ticket.booking?.bookingRef || '';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
||||
@@ -126,9 +142,9 @@ export default function TicketsPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -175,12 +191,12 @@ export default function TicketsPage() {
|
||||
},
|
||||
{
|
||||
key: 'seat',
|
||||
label: 'Seat',
|
||||
label: 'Seat/Bed',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -204,15 +220,31 @@ export default function TicketsPage() {
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.boardedAt ? (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
|
||||
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
|
||||
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
|
||||
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
|
||||
};
|
||||
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
|
||||
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
@@ -248,7 +280,7 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -301,12 +333,12 @@ export default function TicketsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Trip Date</label>
|
||||
<label className="label">Arrival Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.tripDate}
|
||||
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
|
||||
value={filters.arrivalDate}
|
||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -337,10 +369,7 @@ export default function TicketsPage() {
|
||||
{/* Board Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={boardConfirmOpen}
|
||||
onClose={() => {
|
||||
setBoardConfirmOpen(false);
|
||||
setTicketToBoard(null);
|
||||
}}
|
||||
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
|
||||
onConfirm={handleConfirmBoard}
|
||||
title="Board Ticket"
|
||||
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
|
||||
@@ -352,10 +381,7 @@ export default function TicketsPage() {
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setTicketToDelete(null);
|
||||
}}
|
||||
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Ticket"
|
||||
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
|
||||
@@ -368,10 +394,7 @@ export default function TicketsPage() {
|
||||
{/* Ticket Details Modal */}
|
||||
<Modal
|
||||
isOpen={detailsModalOpen}
|
||||
onClose={() => {
|
||||
setDetailsModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
||||
title="Ticket Details"
|
||||
size="lg"
|
||||
>
|
||||
@@ -444,21 +467,35 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTicket.boardedAt && (
|
||||
{selectedTicket.validatedAt && (
|
||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Boarded At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
|
||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDetailsModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
>
|
||||
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -466,49 +503,55 @@ export default function TicketsPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Export Columns Modal */}
|
||||
{/* Export Modal */}
|
||||
<Modal
|
||||
isOpen={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
title="Export Tickets - Select Columns"
|
||||
title="Export Tickets"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
||||
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded Status' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColumns[col.key] || false}
|
||||
onChange={(e) =>
|
||||
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Arrival)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Arrival)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
||||
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded Status' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColumns[col.key] || false}
|
||||
onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
|
||||
isLoading?: boolean;
|
||||
isDanger?: boolean;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
|
||||
isLoading = false,
|
||||
isDanger = false,
|
||||
warning,
|
||||
error,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelText}
|
||||
|
||||
@@ -37,3 +37,4 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user