mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
Merge pull request #224 from Tria-plc/freight_feature/priority
Freight feature/priority
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full wagon re-seed — runs in this order:
|
||||||
|
*
|
||||||
|
* 1. DELETE all existing wagons (hard delete, not soft).
|
||||||
|
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
|
||||||
|
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
|
||||||
|
* the 5 main operational yards (10 wagons per yard per type):
|
||||||
|
*
|
||||||
|
* KALITY — Kality Rail Terminal
|
||||||
|
* MOJO — Mojo Dry Port
|
||||||
|
* DIRE_DAWA — Dire Dawa Yard
|
||||||
|
* DJIB_PORT — Djibouti Port Terminal
|
||||||
|
* NAGAD — Nagad Terminal, Djibouti
|
||||||
|
*
|
||||||
|
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
|
||||||
|
* Yard IDs are fetched live from freight.yards so the migration is safe across
|
||||||
|
* all environments regardless of UUID values.
|
||||||
|
*/
|
||||||
|
export class SeedWagonsWithYardAssignment1784000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'SeedWagonsWithYardAssignment1784000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
|
||||||
|
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.wagon_types (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
capacity_tons,
|
||||||
|
length_meters,
|
||||||
|
max_wagons_per_train,
|
||||||
|
supported_load_types,
|
||||||
|
is_active,
|
||||||
|
tare_weight_tons
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
|
||||||
|
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
|
||||||
|
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
|
||||||
|
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
|
||||||
|
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
|
||||||
|
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
|
||||||
|
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
|
||||||
|
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
|
||||||
|
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
|
||||||
|
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
capacity_tons = EXCLUDED.capacity_tons,
|
||||||
|
length_meters = EXCLUDED.length_meters,
|
||||||
|
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||||
|
supported_load_types = EXCLUDED.supported_load_types,
|
||||||
|
is_active = true,
|
||||||
|
tare_weight_tons = EXCLUDED.tare_weight_tons,
|
||||||
|
deleted_at = NULL,
|
||||||
|
updated_at = now();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
wt RECORD;
|
||||||
|
yard_kality UUID;
|
||||||
|
yard_mojo UUID;
|
||||||
|
yard_dire_dawa UUID;
|
||||||
|
yard_djib_port UUID;
|
||||||
|
yard_nagad UUID;
|
||||||
|
yards UUID[];
|
||||||
|
i INT;
|
||||||
|
yard_id UUID;
|
||||||
|
wagon_num TEXT;
|
||||||
|
v_tare NUMERIC;
|
||||||
|
v_payload NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
|
||||||
|
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
|
||||||
|
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
|
||||||
|
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
|
||||||
|
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
|
||||||
|
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
|
||||||
|
|
||||||
|
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
|
||||||
|
OR yard_djib_port IS NULL OR yard_nagad IS NULL
|
||||||
|
THEN
|
||||||
|
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
yards := ARRAY[
|
||||||
|
yard_kality,
|
||||||
|
yard_mojo,
|
||||||
|
yard_dire_dawa,
|
||||||
|
yard_djib_port,
|
||||||
|
yard_nagad
|
||||||
|
];
|
||||||
|
|
||||||
|
FOR wt IN
|
||||||
|
SELECT id, code, capacity_tons, tare_weight_tons
|
||||||
|
FROM freight.wagon_types
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY code
|
||||||
|
LOOP
|
||||||
|
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
|
||||||
|
v_payload := COALESCE(wt.capacity_tons, 60.0);
|
||||||
|
|
||||||
|
FOR i IN 1 .. 50 LOOP
|
||||||
|
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
|
||||||
|
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
|
||||||
|
|
||||||
|
INSERT INTO freight.wagons (
|
||||||
|
id,
|
||||||
|
wagon_number,
|
||||||
|
wagon_type_id,
|
||||||
|
tare_weight,
|
||||||
|
max_payload_weight,
|
||||||
|
status,
|
||||||
|
current_yard_id,
|
||||||
|
train_id,
|
||||||
|
sequence_number,
|
||||||
|
notes,
|
||||||
|
train_set_wagon_id,
|
||||||
|
current_train_schedule_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
uuid_generate_v4(),
|
||||||
|
wagon_num,
|
||||||
|
wt.id,
|
||||||
|
v_tare,
|
||||||
|
v_payload,
|
||||||
|
'Available',
|
||||||
|
yard_id,
|
||||||
|
NULL, NULL, NULL, NULL, NULL,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (wagon_number) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Remove all seeded wagons (full wipe — mirrors what up() did)
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level booking pool: customers select a DAY (route + day), not a specific
|
||||||
|
* train. The batch engine's pool query filters bookings on
|
||||||
|
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
|
||||||
|
* index backs that scan.
|
||||||
|
*/
|
||||||
|
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
|
||||||
|
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -689,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
destinationStationId?: string;
|
destinationStationId?: string;
|
||||||
schedulingStatus?: string;
|
schedulingStatus?: string;
|
||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
|
/**
|
||||||
|
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
|
||||||
|
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||||
|
*/
|
||||||
|
day?: string;
|
||||||
}): Promise<Booking[]> {
|
}): Promise<Booking[]> {
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
@@ -706,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||||
.andWhere('scheduleBooking.id IS NULL');
|
.andWhere('scheduleBooking.id IS NULL');
|
||||||
|
|
||||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||||
if (options.trainScheduleId) {
|
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||||
|
// booking that still carries train_schedule_id).
|
||||||
|
if (options.day) {
|
||||||
|
qb.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day: options.day },
|
||||||
|
);
|
||||||
|
} else if (options.trainScheduleId) {
|
||||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||||
trainScheduleId: options.trainScheduleId,
|
trainScheduleId: options.trainScheduleId,
|
||||||
});
|
});
|
||||||
@@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
|
||||||
|
* EAT calendar day, regardless of which train they end up on. Same status
|
||||||
|
* rules and ordering as {@link findBatchPool}, but keyed on
|
||||||
|
* (origin, destination, day) instead of train_schedule_id — the engine then
|
||||||
|
* distributes these across all trains departing that day.
|
||||||
|
*/
|
||||||
|
findBatchPoolByRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
|
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||||
|
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||||
|
destinationYardId,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day },
|
||||||
|
)
|
||||||
|
.andWhere('sb.id IS NULL')
|
||||||
|
.andWhere(
|
||||||
|
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||||
|
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||||
|
)
|
||||||
|
.orderBy('booking.is_government', 'DESC')
|
||||||
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
@@ -273,8 +274,8 @@ export class BookingsService {
|
|||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
|
||||||
if (dto.trainScheduleId) {
|
if (dto.trainScheduleId) {
|
||||||
|
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||||
const schedule = await this.dataSource
|
const schedule = await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.findOne({ where: { id: dto.trainScheduleId } });
|
.findOne({ where: { id: dto.trainScheduleId } });
|
||||||
@@ -290,6 +291,22 @@ export class BookingsService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||||
|
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||||
|
// train later.
|
||||||
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
|
const hasDeparture =
|
||||||
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
|
dto.originYardId,
|
||||||
|
dto.destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
if (!hasDeparture) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No departures available on the selected day for this route',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
const reference = dto.reference || (await this.generateReference());
|
||||||
|
|||||||
@@ -91,12 +91,21 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
|
|
||||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
/**
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
* Staff-only manual pin to a specific train. Customers omit this — they pick a
|
||||||
|
* DAY via {@link scheduledDate} and the batch engine assigns a train within
|
||||||
|
* that (route, day) pool. When provided, the schedule must be OPEN and on the
|
||||||
|
* booking route.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
|
|
||||||
|
/** The day the customer wants to ship (the pool day key). */
|
||||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate!: string;
|
||||||
|
|||||||
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||||
scheduledAt?: Date | null;
|
scheduledAt?: Date | null;
|
||||||
|
|
||||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
/**
|
||||||
|
* The train this booking is assigned to. FK to train_schedules.
|
||||||
|
*
|
||||||
|
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
|
||||||
|
* this stays null at creation. The batch engine sets it when it assigns the
|
||||||
|
* booking to a specific train within its (route, day) pool; staff may also
|
||||||
|
* pin it manually. The day-level pool is keyed on
|
||||||
|
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
|
||||||
|
*/
|
||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
trainScheduleId?: string | null;
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
|
||||||
|
* key for day-level booking pools — it must match the day the portal calendar
|
||||||
|
* renders, so always derive day keys through this (never `toISOString().slice`).
|
||||||
|
*/
|
||||||
|
export function eatDay(date: Date): string {
|
||||||
|
const { year, month, day } = eatParts(date);
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||||
function eatToUtc(
|
function eatToUtc(
|
||||||
year: number,
|
year: number,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
let bookingsRepository: {
|
let bookingsRepository: {
|
||||||
findPaidUnlinkedForSchedule: jest.Mock;
|
findPaidUnlinkedForSchedule: jest.Mock;
|
||||||
findBatchPool: jest.Mock;
|
findBatchPool: jest.Mock;
|
||||||
|
findBatchPoolByRouteDay: jest.Mock;
|
||||||
findReservedForSchedule: jest.Mock;
|
findReservedForSchedule: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
};
|
};
|
||||||
let trainSchedulingService: {
|
let trainSchedulingService: {
|
||||||
tryAutoWagonAllocation: jest.Mock;
|
tryAutoWagonAllocation: jest.Mock;
|
||||||
|
getBookableSchedules: jest.Mock;
|
||||||
};
|
};
|
||||||
let dataSource: {
|
let dataSource: {
|
||||||
getRepository: jest.Mock;
|
getRepository: jest.Mock;
|
||||||
transaction: jest.Mock;
|
transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let notifier: {
|
||||||
|
payNow: jest.Mock;
|
||||||
|
secured: jest.Mock;
|
||||||
|
expired: jest.Mock;
|
||||||
|
unplaced: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
bookingsRepository = {
|
bookingsRepository = {
|
||||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||||
|
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
issues: [],
|
issues: [],
|
||||||
violations: [],
|
violations: [],
|
||||||
}),
|
}),
|
||||||
|
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingRepo = {
|
const bookingRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
dataSource = {
|
dataSource = {
|
||||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||||
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
notifier = {
|
||||||
|
payNow: jest.fn(),
|
||||||
|
secured: jest.fn(),
|
||||||
|
expired: jest.fn(),
|
||||||
|
unplaced: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
service = new BookingBatchService(
|
service = new BookingBatchService(
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
trainSchedulesRepository as never,
|
trainSchedulesRepository as never,
|
||||||
trainScheduleBookingsRepository as never,
|
trainScheduleBookingsRepository as never,
|
||||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
);
|
);
|
||||||
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fillRouteDay — day-level distribution', () => {
|
||||||
|
const originYardId = 'yard-origin';
|
||||||
|
const destinationYardId = 'yard-dest';
|
||||||
|
const day = '2026-06-20';
|
||||||
|
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||||||
|
const trainA = 'train-a';
|
||||||
|
const trainB = 'train-b';
|
||||||
|
|
||||||
|
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||||||
|
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||||||
|
|
||||||
|
const commercial = (id: string, priority: number): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: priority,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
bookingContainers: [],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||||
|
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: trainA,
|
||||||
|
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: trainB,
|
||||||
|
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
maxWagons: 1,
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
trainSetId: `set-${id}`,
|
||||||
|
trainSet: { locomotive: smallLoco },
|
||||||
|
scheduleBookings: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||||
|
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||||
|
commercial('hi', 30),
|
||||||
|
commercial('mid', 20),
|
||||||
|
commercial('lo', 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
// Both trains were processed.
|
||||||
|
expect(touched).toEqual([trainA, trainB]);
|
||||||
|
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||||||
|
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||||
|
expect(reservedOn).toEqual(['hi', 'mid']);
|
||||||
|
// The third booking fits no train and is reported unplaced (and only it).
|
||||||
|
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||||||
|
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||||||
|
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves the chosen train id on each commercial booking', async () => {
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'hi',
|
||||||
|
expect.objectContaining({
|
||||||
|
trainScheduleId: trainA,
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
|||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||||
import {
|
import {
|
||||||
BATCH_CRON,
|
BATCH_CRON,
|
||||||
BATCH_TIMEZONE,
|
BATCH_TIMEZONE,
|
||||||
@@ -42,6 +42,14 @@ interface Capacity {
|
|||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
|
interface RouteDayGroup {
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||||
|
day: string;
|
||||||
|
}
|
||||||
|
|
||||||
type WagonLengths = { container: number; bulk: number };
|
type WagonLengths = { container: number; bulk: number };
|
||||||
|
|
||||||
export type BatchBoardBookingState =
|
export type BatchBoardBookingState =
|
||||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const open = await this.trainSchedulesRepository.findAll({
|
const groups = await this.openRouteDayGroups();
|
||||||
where: { bookingWindowStatus: 'OPEN' },
|
for (const group of groups) {
|
||||||
});
|
|
||||||
for (const s of open) {
|
|
||||||
try {
|
try {
|
||||||
await this.processSchedule(s.id);
|
await this.processRouteDay(group);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
this.logger.warn(
|
||||||
|
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reserved = await this.dataSource
|
const reserved = await this.dataSource
|
||||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
/**
|
||||||
|
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||||
|
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||||
|
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||||
|
*/
|
||||||
enqueueScheduleProcessing(scheduleId: string): void {
|
enqueueScheduleProcessing(scheduleId: string): void {
|
||||||
void this.processSchedule(scheduleId).catch((err) =>
|
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
this.logger.error(
|
||||||
|
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||||
|
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (!schedule?.scheduledDepartureDate) return;
|
||||||
|
await this.processRouteDay({
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day: eatDay(schedule.scheduledDepartureDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||||
|
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||||
|
* schedule-scoped — only the fill is day-level).
|
||||||
|
*/
|
||||||
|
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||||
|
const scheduleIds = await this.fillRouteDay(
|
||||||
|
group.originYardId,
|
||||||
|
group.destinationYardId,
|
||||||
|
group.day,
|
||||||
|
);
|
||||||
|
for (const scheduleId of scheduleIds) {
|
||||||
|
await this.settleDueReservations(scheduleId);
|
||||||
|
await this.reconcilePaidUnlinked(scheduleId);
|
||||||
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||||
async processSchedule(scheduleId: string): Promise<void> {
|
async processSchedule(scheduleId: string): Promise<void> {
|
||||||
await this.fillSchedule(scheduleId);
|
await this.fillSchedule(scheduleId);
|
||||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||||
|
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||||
|
const open = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: { bookingWindowStatus: 'OPEN' },
|
||||||
|
});
|
||||||
|
const groups = new Map<string, RouteDayGroup>();
|
||||||
|
for (const s of open) {
|
||||||
|
if (!s.scheduledDepartureDate) continue;
|
||||||
|
const day = eatDay(s.scheduledDepartureDate);
|
||||||
|
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, {
|
||||||
|
originYardId: s.originStationId,
|
||||||
|
destinationYardId: s.destinationStationId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupLabel(group: RouteDayGroup): string {
|
||||||
|
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||||
async runBatchFill(): Promise<void> {
|
async runBatchFill(): Promise<void> {
|
||||||
const open = await this.trainSchedulesRepository.findAll({
|
const groups = await this.openRouteDayGroups();
|
||||||
where: { bookingWindowStatus: 'OPEN' },
|
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||||
});
|
for (const group of groups) {
|
||||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
|
||||||
for (const s of open) {
|
|
||||||
try {
|
try {
|
||||||
await this.processSchedule(s.id);
|
await this.processRouteDay(group);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
this.logger.error(
|
||||||
|
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (booking.isGovernment) {
|
if (booking.isGovernment) {
|
||||||
await this.allocate(scheduleId, booking, 'gov');
|
await this.allocate(scheduleId, booking, 'gov');
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking);
|
await this.reserve(booking, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget = this.subtract(budget, need);
|
||||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||||
|
* priority, filling each train (earliest departure first) until it's full and
|
||||||
|
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||||
|
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||||
|
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||||
|
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||||
|
*/
|
||||||
|
async fillRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||||
|
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
);
|
||||||
|
const scheduleIds = bookable
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.bookingWindowStatus === 'OPEN' &&
|
||||||
|
s.scheduleDate != null &&
|
||||||
|
eatDay(new Date(s.scheduleDate)) === day,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||||
|
)
|
||||||
|
.map((s) => s.id);
|
||||||
|
|
||||||
|
if (scheduleIds.length === 0) return [];
|
||||||
|
|
||||||
|
const rules = await this.loadGlobalRules();
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
|
||||||
|
// Live per-schedule budget + arm flag, in departure order.
|
||||||
|
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||||
|
for (const id of scheduleIds) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
|
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||||
|
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
|
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||||
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
|
trains.push({ id, budget, armed: false });
|
||||||
|
}
|
||||||
|
if (trains.length === 0) return [];
|
||||||
|
|
||||||
|
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const booking of pool) {
|
||||||
|
const need = this.needFor(booking, wagonLengths);
|
||||||
|
|
||||||
|
// First train (earliest departure) that fits this booking as-is.
|
||||||
|
let target = trains.find((t) => this.fits(need, t.budget));
|
||||||
|
|
||||||
|
if (!target && booking.isGovernment) {
|
||||||
|
// Government booking fits nowhere on its own — try to preempt commercial
|
||||||
|
// on each train (earliest first) until one frees enough room.
|
||||||
|
for (const t of trains) {
|
||||||
|
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||||
|
if (this.fits(need, t.budget)) {
|
||||||
|
target = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
// Fits no train this day — stays in the pool, retried next batch.
|
||||||
|
this.notifier.unplaced(booking, day);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.isGovernment) {
|
||||||
|
await this.allocate(target.id, booking, 'gov');
|
||||||
|
} else {
|
||||||
|
await this.reserve(booking, target.id);
|
||||||
|
target.armed = true;
|
||||||
|
}
|
||||||
|
target.budget = this.subtract(target.budget, need);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of trains) {
|
||||||
|
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||||
|
if (t.armed) this.armSettle(t.id);
|
||||||
|
void this.triggerWagonAllocation(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trains.map((t) => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- mutations ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
|
|
||||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
/**
|
||||||
private async reserve(booking: Booking): Promise<void> {
|
* Reserve capacity for a commercial booking on a specific train and open its
|
||||||
|
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||||
|
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||||
|
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||||
|
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||||
|
* so the engine sets it as it picks the train.
|
||||||
|
*/
|
||||||
|
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
status: 'SELECTED_FOR_BATCH',
|
status: 'SELECTED_FOR_BATCH',
|
||||||
selectedForBatchAt: now,
|
selectedForBatchAt: now,
|
||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
} as never);
|
} as never);
|
||||||
|
booking.trainScheduleId = scheduleId;
|
||||||
await this.notifier.payNow(booking, deadline);
|
await this.notifier.payNow(booking, deadline);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expire an unpaid reservation and free its capacity. */
|
/**
|
||||||
|
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||||
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
|
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||||
|
*/
|
||||||
private async expire(booking: Booking): Promise<void> {
|
private async expire(booking: Booking): Promise<void> {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
trainScheduleId: null,
|
||||||
status: 'EXPIRED',
|
status: 'EXPIRED',
|
||||||
schedulingStatus: 'ELIGIBLE',
|
schedulingStatus: 'ELIGIBLE',
|
||||||
paymentDeadline: null,
|
paymentDeadline: null,
|
||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
|
booking.trainScheduleId = null;
|
||||||
this.notifier.expired(booking);
|
this.notifier.expired(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ export class BookingNotifierService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff-facing warning when a pooled booking fits no train on its chosen day.
|
||||||
|
* It stays pending and is retried next batch; staff can add capacity or pin it
|
||||||
|
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
|
||||||
|
*/
|
||||||
|
unplaced(b: Booking, day: string): void {
|
||||||
|
this.logger.warn(
|
||||||
|
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
displaced(b: Booking): void {
|
displaced(b: Booking): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||||
void this.notifyContact(b, msg, 'DISPLACED');
|
void this.notifyContact(b, msg, 'DISPLACED');
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AvailableDaysQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationYardId?: string;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
|||||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
|
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("available-days")
|
||||||
|
// No staff guard: customers hit this while creating a booking to find which
|
||||||
|
// DAYS have a departure on their route. Day-level pooling — no capacity is
|
||||||
|
// returned, only the list of bookable days.
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
|
||||||
|
})
|
||||||
|
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
|
||||||
|
return this.trainSchedulingService.getAvailableDays(
|
||||||
|
query.originYardId,
|
||||||
|
query.destinationYardId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("container/eligible-bookings")
|
@Get("container/eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List eligible container bookings" })
|
@ApiOperation({ summary: "List eligible container bookings" })
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import {
|
|||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
} from './booking-batch.constants';
|
} from './booking-batch.constants';
|
||||||
|
import { eatDay } from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||||
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
|
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||||
|
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||||
|
// resolving the schedule's route + day and filtering on the day instead.
|
||||||
|
let day: string | undefined;
|
||||||
|
let originStationId = query.originStationId;
|
||||||
|
let destinationStationId = query.destinationStationId;
|
||||||
|
if (query.trainScheduleId) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||||||
|
if (schedule?.scheduledDepartureDate) {
|
||||||
|
day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
originStationId = originStationId ?? schedule.originStationId;
|
||||||
|
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||||||
freightType: query.freightType,
|
freightType: query.freightType,
|
||||||
originStationId: query.originStationId,
|
originStationId,
|
||||||
destinationStationId: query.destinationStationId,
|
destinationStationId,
|
||||||
schedulingStatus: query.schedulingStatus,
|
schedulingStatus: query.schedulingStatus,
|
||||||
trainScheduleId: query.trainScheduleId,
|
trainScheduleId: query.trainScheduleId,
|
||||||
|
day,
|
||||||
});
|
});
|
||||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||||
}
|
}
|
||||||
@@ -1989,6 +2006,33 @@ export class TrainSchedulingService {
|
|||||||
return filteredSchedules;
|
return filteredSchedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||||||
|
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||||||
|
* only the day strings, no capacity, counts or train info.
|
||||||
|
*/
|
||||||
|
async getAvailableDays(
|
||||||
|
originYardId?: string,
|
||||||
|
destinationYardId?: string,
|
||||||
|
): Promise<{ days: string[] }> {
|
||||||
|
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||||||
|
const days = new Set<string>();
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||||||
|
}
|
||||||
|
return { days: [...days].sort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||||
|
async existsOpenScheduleOnRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||||
|
return days.includes(day);
|
||||||
|
}
|
||||||
|
|
||||||
private async mapScheduleDetail(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
|
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||||
@@ -525,6 +526,8 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
|
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const FreightSidebar = ({
|
|||||||
return (
|
return (
|
||||||
<Box component="aside" className="fsb-aside">
|
<Box component="aside" className="fsb-aside">
|
||||||
<div className="fsb-brand">
|
<div className="fsb-brand">
|
||||||
<div className="fsb-logo">
|
<div className="fsb-logo">
|
||||||
<Train size={23} color="white" strokeWidth={2.1} />
|
<Train size={23} color="white" strokeWidth={2.1} />
|
||||||
</div>
|
</div>
|
||||||
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||||
|
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ export const useBookableSchedules = (
|
|||||||
enabled: Boolean(originYardId && destinationYardId),
|
enabled: Boolean(originYardId && destinationYardId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
|
||||||
|
* day (not a train) when creating a booking; the engine assigns the train.
|
||||||
|
*/
|
||||||
|
export const useAvailableDays = (
|
||||||
|
originYardId?: string | null,
|
||||||
|
destinationYardId?: string | null,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
|
"available-days",
|
||||||
|
originYardId ?? "",
|
||||||
|
destinationYardId ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
trainSchedulingService.getAvailableDays(
|
||||||
|
originYardId ?? undefined,
|
||||||
|
destinationYardId ?? undefined,
|
||||||
|
),
|
||||||
|
enabled: Boolean(originYardId && destinationYardId),
|
||||||
|
});
|
||||||
|
|
||||||
export const useTrainTrack = (id: string | undefined) =>
|
export const useTrainTrack = (id: string | undefined) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
|
|||||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
|
||||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
|
||||||
|
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||||
|
|
||||||
// container freight
|
// container freight
|
||||||
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
|
|||||||
label: c.name || c.email || c.tin || c.id,
|
label: c.name || c.email || c.tin || c.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
|
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||||
|
// train, no capacity). The batch engine assigns the train after booking.
|
||||||
|
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
);
|
);
|
||||||
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
|
const dayOptions = (availableDays ?? []).map((day) => ({
|
||||||
value: s.id,
|
value: day,
|
||||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
s.scheduleDate,
|
weekday: "short",
|
||||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
|
const hasAvailableDays = (availableDays ?? []).length > 0;
|
||||||
|
|
||||||
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
|
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
|
||||||
const effectiveDepartureIso = selectedSchedule
|
const effectiveDepartureIso = scheduledDay
|
||||||
? new Date(selectedSchedule.scheduleDate).toISOString()
|
? new Date(`${scheduledDay}T00:00:00`).toISOString()
|
||||||
: scheduledDate
|
: "";
|
||||||
? new Date(scheduledDate).toISOString()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const yardRecords = refData?.yard ?? [];
|
const yardRecords = refData?.yard ?? [];
|
||||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
|
||||||
|
|
||||||
|
// Reset the day when the route changes — available days depend on the route.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTrainScheduleId(null);
|
setScheduledDay(null);
|
||||||
}, [originYardId, destinationYardId]);
|
}, [originYardId, destinationYardId]);
|
||||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||||
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
|
|||||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||||
|
|
||||||
const scheduleSatisfied =
|
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
|
||||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
// the train afterwards (same flow as the customer portal).
|
||||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
const departureSatisfied = Boolean(scheduledDay);
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
Boolean(originYardId) &&
|
Boolean(originYardId) &&
|
||||||
Boolean(destinationYardId) &&
|
Boolean(destinationYardId) &&
|
||||||
!sameYard &&
|
!sameYard &&
|
||||||
Boolean(tradeDirection) &&
|
Boolean(tradeDirection) &&
|
||||||
scheduleSatisfied &&
|
|
||||||
Boolean(serviceTypeId) &&
|
Boolean(serviceTypeId) &&
|
||||||
departureSatisfied &&
|
departureSatisfied &&
|
||||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||||
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
|
|||||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
trainScheduleId: trainScheduleId || undefined,
|
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
||||||
serviceTypeId,
|
serviceTypeId,
|
||||||
shippingLineId: shippingLineId || undefined,
|
shippingLineId: shippingLineId || undefined,
|
||||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||||
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
|
|||||||
value={destinationYardId}
|
value={destinationYardId}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDestinationYardId(v);
|
setDestinationYardId(v);
|
||||||
setTrainScheduleId(null);
|
setScheduledDay(null);
|
||||||
}}
|
}}
|
||||||
searchable
|
searchable
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
error={sameYard ? "Same as origin" : undefined}
|
error={sameYard ? "Same as origin" : undefined}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{hasBookableSchedules ? (
|
<Select
|
||||||
<Select
|
label="Shipment day"
|
||||||
label="Train schedule"
|
placeholder={
|
||||||
placeholder={
|
originYardId && destinationYardId
|
||||||
originYardId && destinationYardId
|
? "Select a day with a departure"
|
||||||
? "Select an open schedule on this route"
|
: "Pick origin & destination first"
|
||||||
: "Pick origin & destination first"
|
}
|
||||||
}
|
data={dayOptions}
|
||||||
data={scheduleOptions}
|
value={scheduledDay}
|
||||||
value={trainScheduleId}
|
onChange={setScheduledDay}
|
||||||
onChange={setTrainScheduleId}
|
searchable
|
||||||
searchable
|
disabled={!originYardId || !destinationYardId || daysLoading}
|
||||||
required
|
nothingFoundMessage={
|
||||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
hasAvailableDays ? "No match" : "No departures on this route"
|
||||||
nothingFoundMessage="No open schedules on this route"
|
}
|
||||||
description="The booking will be batched against this schedule once its contract is signed."
|
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
||||||
/>
|
/>
|
||||||
) : originYardId && destinationYardId ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No open train schedule on this route — set a preferred departure below. Staff can
|
|
||||||
link a schedule later.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Group grow align="flex-end">
|
<Group grow align="flex-end">
|
||||||
<Select
|
<Select
|
||||||
label="Service type"
|
label="Service type"
|
||||||
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
{selectedSchedule ? (
|
<TextInput
|
||||||
<TextInput
|
label="Shipment day"
|
||||||
label="Departure"
|
value={
|
||||||
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
|
scheduledDay
|
||||||
readOnly
|
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
description="Taken from the selected train schedule"
|
weekday: "short",
|
||||||
/>
|
year: "numeric",
|
||||||
) : (
|
month: "short",
|
||||||
<TextInput
|
day: "numeric",
|
||||||
label="Preferred departure"
|
})
|
||||||
type="datetime-local"
|
: ""
|
||||||
value={scheduledDate}
|
}
|
||||||
onChange={(e) => setScheduledDate(e.target.value)}
|
placeholder="Pick a day in the Route section"
|
||||||
/>
|
readOnly
|
||||||
)}
|
description="The engine assigns the train on this day"
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Payment currency"
|
label="Payment currency"
|
||||||
data={[
|
data={[
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Home,
|
||||||
|
Layers,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||||
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
|
import {
|
||||||
|
getRuleEngineResource,
|
||||||
|
type FormFieldDef,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
import {
|
||||||
|
useRuleEngineList,
|
||||||
|
useRuleEngineMutations,
|
||||||
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
const CARGO_SLUG = "cargo-types";
|
||||||
|
const BASE_PATH = "/dashboard/configuration/cargo-types";
|
||||||
|
|
||||||
|
interface CargoNode extends RuleEngineRecord {
|
||||||
|
cargoTypeName?: string;
|
||||||
|
code?: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
showFreeTextBox?: boolean;
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||||
|
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||||
|
|
||||||
|
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||||
|
const FORM_FIELDS: FormFieldDef[] = [
|
||||||
|
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||||
|
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||||
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
|
||||||
|
|
||||||
|
const CargoTypesPage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: currentId } = useParams<{ id: string }>();
|
||||||
|
const config = getRuleEngineResource(CARGO_SLUG);
|
||||||
|
|
||||||
|
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||||
|
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||||
|
|
||||||
|
// One fetch of the whole (small) set; the tree, ancestry and each level are
|
||||||
|
// derived client-side so drilling between levels is instant.
|
||||||
|
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
sortBy: "displayOrder",
|
||||||
|
sortOrder: "ASC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||||
|
|
||||||
|
const all = (data?.data ?? []) as CargoNode[];
|
||||||
|
|
||||||
|
const { byId, childrenOf } = useMemo(() => {
|
||||||
|
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
|
||||||
|
const childrenOf = new Map<string, CargoNode[]>();
|
||||||
|
for (const node of all) {
|
||||||
|
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
|
||||||
|
const key = parentId || "__root__";
|
||||||
|
const list = childrenOf.get(key) ?? [];
|
||||||
|
list.push(node);
|
||||||
|
childrenOf.set(key, list);
|
||||||
|
}
|
||||||
|
for (const list of childrenOf.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) =>
|
||||||
|
orderOf(a) - orderOf(b) ||
|
||||||
|
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { byId, childrenOf };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
// Current node (null at root) and its ancestor chain for the breadcrumb.
|
||||||
|
const current = currentId ? byId.get(currentId) ?? null : null;
|
||||||
|
const ancestors = useMemo(() => {
|
||||||
|
const chain: CargoNode[] = [];
|
||||||
|
let node = current;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (node && !seen.has(node.id)) {
|
||||||
|
chain.unshift(node);
|
||||||
|
seen.add(node.id);
|
||||||
|
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}, [current, byId]);
|
||||||
|
|
||||||
|
const levelKey = current ? current.id : "__root__";
|
||||||
|
const levelNodes = childrenOf.get(levelKey) ?? [];
|
||||||
|
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
const matches = (n: CargoNode) =>
|
||||||
|
!term ||
|
||||||
|
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||||
|
str(n.code).toLowerCase().includes(term);
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => (term ? levelNodes.filter(matches) : levelNodes),
|
||||||
|
[levelNodes, term],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
// A bad/stale :id (after data loads) → fall back to the root list.
|
||||||
|
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
|
||||||
|
|
||||||
|
const atRoot = !current;
|
||||||
|
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||||
|
|
||||||
|
const handleSubmit = (values: Record<string, unknown>) => {
|
||||||
|
const payload: Record<string, unknown> = { ...values };
|
||||||
|
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||||
|
if (formMode?.kind === "create" && current) {
|
||||||
|
payload.parentGroupId = current.id;
|
||||||
|
}
|
||||||
|
const done = () => setFormMode(null);
|
||||||
|
if (formMode?.kind === "edit") {
|
||||||
|
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||||
|
} else {
|
||||||
|
create.mutate(payload, { onSuccess: done });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLabel = atRoot ? "Add category" : "Add cargo type";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
p="lg"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||||
|
>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
|
||||||
|
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
|
||||||
|
Cargo Types
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
{ancestors.map((node, i) => {
|
||||||
|
const isLast = i === ancestors.length - 1;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={node.id}
|
||||||
|
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
|
||||||
|
style={{ cursor: isLast ? "default" : "pointer" }}
|
||||||
|
>
|
||||||
|
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
|
||||||
|
{str(node.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||||
|
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={800} fz={22} c="dark.8" truncate>
|
||||||
|
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{!atRoot && current?.code ? (
|
||||||
|
<Badge variant="default" radius="sm">
|
||||||
|
{str(current.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{!atRoot && current?.isActive === false ? (
|
||||||
|
<Badge variant="light" color="gray" radius="sm">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={13} c="dimmed" mt={2}>
|
||||||
|
{atRoot
|
||||||
|
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
|
||||||
|
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
placeholder="Search this level…"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
w={240}
|
||||||
|
/>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create" })}
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Level list ─────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
p={0}
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader color="teal" />
|
||||||
|
</Group>
|
||||||
|
) : isError ? (
|
||||||
|
<Text p="xl" c="red" ta="center">
|
||||||
|
Failed to load cargo types.
|
||||||
|
</Text>
|
||||||
|
) : visibleNodes.length === 0 ? (
|
||||||
|
<Stack align="center" gap="sm" py={56}>
|
||||||
|
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||||
|
<Package size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} c="dark.6">
|
||||||
|
{term
|
||||||
|
? "Nothing matches your search"
|
||||||
|
: atRoot
|
||||||
|
? "No cargo categories yet"
|
||||||
|
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||||
|
</Text>
|
||||||
|
{!term && canManage && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create" })}
|
||||||
|
>
|
||||||
|
{atRoot ? "Add your first category" : "Add the first cargo type"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visibleNodes.map((node, i) => (
|
||||||
|
<CargoRow
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||||
|
topBorder={i > 0}
|
||||||
|
canManage={canManage}
|
||||||
|
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||||
|
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||||
|
onDelete={() => setDeleteTarget(node)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||||
|
<RuleEngineFormDialog
|
||||||
|
open={Boolean(formMode)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setFormMode(null);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
formMode?.kind === "edit"
|
||||||
|
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||||
|
: atRoot
|
||||||
|
? "Add category"
|
||||||
|
: `Add cargo under “${str(current?.cargoTypeName)}”`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
formMode?.kind === "edit"
|
||||||
|
? "Update this cargo type."
|
||||||
|
: atRoot
|
||||||
|
? "Create a top-level cargo category."
|
||||||
|
: "Create a cargo type inside this category. It's attached here automatically."
|
||||||
|
}
|
||||||
|
fields={FORM_FIELDS}
|
||||||
|
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||||
|
isSubmitting={create.isPending || update.isPending}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(deleteTarget)}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
title="Delete cargo type?"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text span fw={600}>
|
||||||
|
{str(deleteTarget?.cargoTypeName)}
|
||||||
|
</Text>{" "}
|
||||||
|
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
|
||||||
|
leaves them without a category. Continue?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This will delete{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{str(deleteTarget?.cargoTypeName)}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── A single cargo row — drills into its own page on click ──────────────────
|
||||||
|
interface CargoRowProps {
|
||||||
|
node: CargoNode;
|
||||||
|
childCount: number;
|
||||||
|
topBorder: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CargoRow({
|
||||||
|
node,
|
||||||
|
childCount,
|
||||||
|
topBorder,
|
||||||
|
canManage,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: CargoRowProps) {
|
||||||
|
const inactive = node.isActive === false;
|
||||||
|
const hasChildren = childCount > 0;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
|
||||||
|
transition: "background 120ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||||
|
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={650} fz={15} c="dark.8" truncate>
|
||||||
|
{str(node.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{node.code ? (
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{str(node.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{node.requiresDirectorApproval ? (
|
||||||
|
<Tooltip label="Requires director approval" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={11} />}
|
||||||
|
>
|
||||||
|
Approval
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{node.showFreeTextBox ? (
|
||||||
|
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<FileText size={11} />}
|
||||||
|
>
|
||||||
|
Free text
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{inactive ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={12.5} c="dimmed" mt={2}>
|
||||||
|
{hasChildren
|
||||||
|
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
|
||||||
|
: "No cargo types inside yet — open to add"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Edit" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Open" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CargoTypesPage;
|
||||||
@@ -109,6 +109,21 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
|
||||||
|
* a day; the batch engine assigns the train. No capacity is returned.
|
||||||
|
*/
|
||||||
|
getAvailableDays: async (
|
||||||
|
originYardId?: string,
|
||||||
|
destinationYardId?: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const response = await client.get<{ days: string[] }>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||||
|
{ params: { originYardId, destinationYardId } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data).days;
|
||||||
|
},
|
||||||
|
|
||||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||||
const response = await client.post<BatchBoardScheduleDetail>(
|
const response = await client.post<BatchBoardScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
|
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||||
},
|
},
|
||||||
|
|
||||||
PAYMENTS: {
|
PAYMENTS: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/logo.svg";
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
type LoginMethod = "email" | "phone";
|
type LoginMethod = "email" | "phone";
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import useAuth from "@/hooks/useAuth";
|
|||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/logo.svg";
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
const passwordRequirements = [
|
const passwordRequirements = [
|
||||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
|
|||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { DocRow, IconSquare } from "./components/Documents";
|
import { DocRow, IconSquare } from "./components/Documents";
|
||||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||||
import { CancelledBanner } from "./components/Notices";
|
import {
|
||||||
|
CancelledBanner,
|
||||||
|
ConsolidationPairedNotice,
|
||||||
|
ConsolidationWaitingBanner,
|
||||||
|
} from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
const isExpired = status === "EXPIRED";
|
||||||
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
|
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||||
|
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||||
|
// before approval, so it doesn't linger for the rest of the booking's life.
|
||||||
|
const showPairedNotice =
|
||||||
|
!!booking.consolidationPartnerId &&
|
||||||
|
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||||
onRebook={() => navigate("/bookings/new")}
|
onRebook={() => navigate("/bookings/new")}
|
||||||
/>
|
/>
|
||||||
|
) : isPendingConsolidation ? (
|
||||||
|
<ConsolidationWaitingBanner
|
||||||
|
priceLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusHero booking={booking} />
|
<StatusHero booking={booking} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||||
|
|
||||||
<ContractCard booking={booking} navigate={navigate} />
|
<ContractCard booking={booking} navigate={navigate} />
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Link2,
|
||||||
|
PencilLine,
|
||||||
|
StickyNote,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function NoticeBanner({
|
export function NoticeBanner({
|
||||||
@@ -186,6 +194,90 @@ export function CancelledBanner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
|
||||||
|
* waiting for another shipment to share the wagon. The price shown is this
|
||||||
|
* booking's own held amount — bookings and contracts are independent, so the
|
||||||
|
* partner's amount is never shown. Once a partner is found the backend moves
|
||||||
|
* the booking back to SUBMITTED and it continues the normal flow.
|
||||||
|
*/
|
||||||
|
export function ConsolidationWaitingBanner({
|
||||||
|
priceLabel,
|
||||||
|
}: {
|
||||||
|
priceLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
|
||||||
|
<Group gap={16} align="center" wrap="nowrap" miw={0}>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
|
||||||
|
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
|
||||||
|
>
|
||||||
|
<Link2 size={24} />
|
||||||
|
</div>
|
||||||
|
<Box miw={0}>
|
||||||
|
<span
|
||||||
|
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
|
||||||
|
style={{ backgroundColor: "#C77F12" }}
|
||||||
|
>
|
||||||
|
Waiting for a partner
|
||||||
|
</span>
|
||||||
|
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
|
||||||
|
Your shipment is waiting to share a wagon
|
||||||
|
</Text>
|
||||||
|
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
|
||||||
|
Your cargo only fills part of a wagon, so we’re pairing it with
|
||||||
|
another shipment on the same route to share the space. As soon as a
|
||||||
|
matching shipment is found, your booking continues automatically —
|
||||||
|
acceptance, approval and contract stay independent and yours alone.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
{priceLabel && (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
|
||||||
|
style={{ backgroundColor: "#fff" }}
|
||||||
|
>
|
||||||
|
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
|
||||||
|
Your price (held)
|
||||||
|
</Text>
|
||||||
|
<Text mt={3} fz="18px" fw={800} c="#10202F">
|
||||||
|
{priceLabel}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A brief positive notice shown once a consolidation partner has been found and
|
||||||
|
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
|
||||||
|
* Reassures the customer the wait ended; the booking proceeds independently.
|
||||||
|
*/
|
||||||
|
export function ConsolidationPairedNotice() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-[14px] border p-4"
|
||||||
|
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Text fz="13.5px" fw={800} c="#1B7A3D">
|
||||||
|
Consolidation partner found
|
||||||
|
</Text>
|
||||||
|
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
|
||||||
|
A matching shipment was found to share the wagon, so your booking is
|
||||||
|
back on track and now moving through review and approval as usual.
|
||||||
|
Nothing more is needed from you for now.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MutationErrors({
|
export function MutationErrors({
|
||||||
mutations,
|
mutations,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export function ScheduleCard({
|
|||||||
: "Rail only";
|
: "Rail only";
|
||||||
const equipmentReturn =
|
const equipmentReturn =
|
||||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||||
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
|
|
||||||
const assignedTrain: Row = {
|
const assignedTrain: Row = {
|
||||||
label: "Assigned train",
|
label: "Assigned train",
|
||||||
value: booking.trainId ?? "Not yet assigned",
|
value: booking.trainId ?? "Not yet assigned",
|
||||||
@@ -80,7 +79,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
statusRow,
|
statusRow,
|
||||||
@@ -88,7 +86,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
],
|
],
|
||||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||||
],
|
],
|
||||||
[
|
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
|
||||||
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
|
|
||||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -136,14 +136,13 @@ function mapBookingToFormValues(
|
|||||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||||
isHazardous: booking.isHazardous ?? false,
|
isHazardous: booking.isHazardous ?? false,
|
||||||
isRefrigerated: booking.isRefrigerated ?? false,
|
isRefrigerated: booking.isRefrigerated ?? false,
|
||||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
shippingLine: (booking as any).shippingLine?.id ?? "",
|
||||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||||
paymentCurrency:
|
paymentCurrency:
|
||||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||||
scheduledDate: booking.scheduledDate
|
scheduledDate: booking.scheduledDate
|
||||||
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||||
: "",
|
: "",
|
||||||
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
|
||||||
notes: "",
|
notes: "",
|
||||||
containers: [],
|
containers: [],
|
||||||
} as BookingFormInputValues;
|
} as BookingFormInputValues;
|
||||||
@@ -360,10 +359,16 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({
|
// Dedupe by name (the value the form keys on) so two lines sharing a name
|
||||||
value: sl.name,
|
// can't produce a duplicate Select option and crash Mantine.
|
||||||
label: sl.name,
|
const seen = new Set<string>();
|
||||||
}));
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (const sl of referenceData.shipping_line) {
|
||||||
|
if (!sl.name || seen.has(sl.name)) continue;
|
||||||
|
seen.add(sl.name);
|
||||||
|
options.push({ value: sl.name, label: sl.name });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const setDocument = (key: string, file: File | null) => {
|
const setDocument = (key: string, file: File | null) => {
|
||||||
@@ -376,12 +381,8 @@ export default function EditBookingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
|
||||||
|
|
||||||
const cargoTypePath = data.cargoTypePath ?? [];
|
const cargoTypePath = data.cargoTypePath ?? [];
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
||||||
@@ -410,7 +411,8 @@ export default function EditBookingPage() {
|
|||||||
scheduledDate: data.scheduledDate
|
scheduledDate: data.scheduledDate
|
||||||
? new Date(data.scheduledDate).toISOString()
|
? new Date(data.scheduledDate).toISOString()
|
||||||
: undefined,
|
: undefined,
|
||||||
trainScheduleId: data.trainScheduleId || undefined,
|
// Day-level pool: the customer edits only the day; the engine assigns the
|
||||||
|
// train, so trainScheduleId is not sent.
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
@@ -456,7 +458,7 @@ export default function EditBookingPage() {
|
|||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
? { shippingLineId: data.shippingLine }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -231,13 +231,9 @@ export default function NewBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
const cargoTree = referenceData?.cargo_type ?? [];
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
|
||||||
|
|
||||||
const findContainerTypeId = (name: string): string => {
|
const findContainerTypeId = (name: string): string => {
|
||||||
for (const group of containerGroups) {
|
for (const group of containerGroups) {
|
||||||
const ct = group.types.find((t) => t.name === name);
|
const ct = group.types.find((t) => t.name === name);
|
||||||
@@ -279,7 +275,8 @@ export default function NewBookingPage() {
|
|||||||
destinationYardId: data.destinationYard,
|
destinationYardId: data.destinationYard,
|
||||||
tradeDirection: direction!,
|
tradeDirection: direction!,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
trainScheduleId: data.trainScheduleId,
|
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||||
|
// engine assigns the train, so no trainScheduleId is sent.
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
@@ -308,7 +305,7 @@ export default function NewBookingPage() {
|
|||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
? { shippingLineId: data.shippingLine }
|
||||||
: {}),
|
: {}),
|
||||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,8 +113,9 @@ export const bookingFormSchema = z
|
|||||||
originYard: z.string().min(1, "Select an origin yard."),
|
originYard: z.string().min(1, "Select an origin yard."),
|
||||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||||
shippingLine: z.string(),
|
shippingLine: z.string(),
|
||||||
|
// Day-level pool: the customer selects only a DAY. The batch engine assigns
|
||||||
|
// the specific train later, so no trainScheduleId is collected here.
|
||||||
scheduledDate: z.string().min(1, "Select a shipment date."),
|
scheduledDate: z.string().min(1, "Select a shipment date."),
|
||||||
trainScheduleId: z.string().min(1, "Select a shipment date."),
|
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
cargoTypePath: z.array(z.string()).default([]),
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
@@ -137,7 +138,10 @@ export const bookingFormSchema = z
|
|||||||
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
consolidationEnabled: z.boolean(),
|
// Consolidation is system-managed, not a customer choice. The backend only
|
||||||
|
// consolidates partial-wagon bookings, so this is always allowed; the
|
||||||
|
// customer neither sees nor toggles it.
|
||||||
|
consolidationEnabled: z.boolean().default(true),
|
||||||
documents: z.record(z.string(), z.any()).default({}),
|
documents: z.record(z.string(), z.any()).default({}),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
})
|
})
|
||||||
@@ -236,14 +240,13 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
shippingLine: "",
|
shippingLine: "",
|
||||||
scheduledDate: "",
|
scheduledDate: "",
|
||||||
trainScheduleId: "",
|
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
cargoTypePath: [],
|
cargoTypePath: [],
|
||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
consolidationEnabled: false,
|
consolidationEnabled: true,
|
||||||
documents: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
@@ -265,14 +268,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"shippingLine",
|
"shippingLine",
|
||||||
],
|
],
|
||||||
4: [
|
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
|
||||||
"cargoType",
|
5: ["scheduledDate"],
|
||||||
"cargoWeight",
|
|
||||||
"cargoTypePath",
|
|
||||||
"containers",
|
|
||||||
"consolidationEnabled",
|
|
||||||
],
|
|
||||||
5: ["scheduledDate", "trainScheduleId"],
|
|
||||||
6: ["documents"],
|
6: ["documents"],
|
||||||
7: ["notes"],
|
7: ["notes"],
|
||||||
};
|
};
|
||||||
@@ -288,22 +285,33 @@ export interface WagonConfig {
|
|||||||
type: "20ft" | "40ft";
|
type: "20ft" | "40ft";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the trade direction from the origin/destination yard countries.
|
||||||
|
*
|
||||||
|
* Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
|
||||||
|
* sends always matches what the API re-derives (the API rejects mismatches):
|
||||||
|
* - origin in Djibouti → IMPORT
|
||||||
|
* - destination in Djibouti (origin not) → EXPORT
|
||||||
|
* - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
|
||||||
|
*
|
||||||
|
* Returns null only while a yard is still unselected, so the UI can wait.
|
||||||
|
*/
|
||||||
export function getRouteDirection(
|
export function getRouteDirection(
|
||||||
origin: Freight.BookingReferenceYard | null | undefined,
|
origin: Freight.BookingReferenceYard | null | undefined,
|
||||||
dest: Freight.BookingReferenceYard | null | undefined,
|
dest: Freight.BookingReferenceYard | null | undefined,
|
||||||
): Freight.ScheduleTradeDirection | null {
|
): Freight.ScheduleTradeDirection | null {
|
||||||
if (!origin || !dest) return null;
|
if (!origin || !dest) return null;
|
||||||
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
|
|
||||||
return "DOMESTIC";
|
const originCountry = origin.country?.trim();
|
||||||
}
|
const destCountry = dest.country?.trim();
|
||||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
|
||||||
return "EXPORT";
|
if (originCountry === "Djibouti") {
|
||||||
}
|
|
||||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
|
||||||
return "IMPORT";
|
return "IMPORT";
|
||||||
}
|
}
|
||||||
|
if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
|
||||||
return null;
|
return "EXPORT";
|
||||||
|
}
|
||||||
|
return "DOMESTIC";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcWagons(containers: ContainerConfig[]) {
|
export function calcWagons(containers: ContainerConfig[]) {
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
useMantineTheme
|
useMantineTheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -46,17 +45,15 @@ interface DayData {
|
|||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
isCurrentMonth: boolean;
|
isCurrentMonth: boolean;
|
||||||
isSelectedDate: boolean;
|
isSelectedDate: boolean;
|
||||||
schedules: Freight.BookableScheduleItem[];
|
/** True when the route has at least one departure on this day. */
|
||||||
hasSchedule: boolean;
|
hasDeparture: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
|
|
||||||
|
|
||||||
const selectedDate = form.watch("scheduledDate");
|
const selectedDate = form.watch("scheduledDate");
|
||||||
const selectedScheduleId = form.watch("trainScheduleId");
|
|
||||||
const originYardId = form.watch("originYard");
|
const originYardId = form.watch("originYard");
|
||||||
const destinationYardId = form.watch("destinationYard");
|
const destinationYardId = form.watch("destinationYard");
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
[referenceData, destinationYardId],
|
[referenceData, destinationYardId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: bookableSchedules } = useQuery(
|
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
|
||||||
api.bookings.getBookableSchedules.queryOptions({
|
// days have a departure — no capacity, no per-train detail. The batch engine
|
||||||
|
// assigns the train later, distributing the day's pool by priority.
|
||||||
|
const { data: availableDays } = useQuery(
|
||||||
|
api.bookings.getAvailableDays.queryOptions({
|
||||||
input: { originYardId, destinationYardId },
|
input: { originYardId, destinationYardId },
|
||||||
enabled: !!originYardId && !!destinationYardId,
|
enabled: !!originYardId && !!destinationYardId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group all schedules per date — multiple departures per day are allowed.
|
const departureDays = useMemo(
|
||||||
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
|
() => new Set(availableDays ?? []),
|
||||||
// match the format used by the calendar day keys.
|
[availableDays],
|
||||||
const schedulesByDate = useMemo(() => {
|
|
||||||
const map = new Map<string, Freight.BookableScheduleItem[]>();
|
|
||||||
if (bookableSchedules) {
|
|
||||||
for (const s of bookableSchedules) {
|
|
||||||
const dateKey = s.scheduleDate.slice(0, 10);
|
|
||||||
const existing = map.get(dateKey) ?? [];
|
|
||||||
map.set(dateKey, [...existing, s]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [bookableSchedules]);
|
|
||||||
|
|
||||||
const selectedSchedule = useMemo(
|
|
||||||
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
|
|
||||||
[bookableSchedules, selectedScheduleId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableCount = useMemo(() => {
|
|
||||||
let count = 0;
|
|
||||||
schedulesByDate.forEach((schedules) => {
|
|
||||||
if (schedules.some((s) => s.remainingWagons > 0)) count++;
|
|
||||||
});
|
|
||||||
return count;
|
|
||||||
}, [schedulesByDate]);
|
|
||||||
|
|
||||||
const days = useMemo((): DayData[] => {
|
const days = useMemo((): DayData[] => {
|
||||||
const monthStart = startOfMonth(currentDate);
|
const monthStart = startOfMonth(currentDate);
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const monthEnd = endOfMonth(currentDate);
|
||||||
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
|
|
||||||
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
||||||
const dateString = format(date, "yyyy-MM-dd");
|
const dateString = format(date, "yyyy-MM-dd");
|
||||||
const schedules = schedulesByDate.get(dateString) ?? [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
dateString,
|
dateString,
|
||||||
isToday: isToday(date),
|
isToday: isToday(date),
|
||||||
isCurrentMonth: isSameMonth(date, currentDate),
|
isCurrentMonth: isSameMonth(date, currentDate),
|
||||||
isSelectedDate: selectedDate === dateString,
|
isSelectedDate: selectedDate === dateString,
|
||||||
schedules,
|
hasDeparture: departureDays.has(dateString),
|
||||||
hasSchedule: schedules.length > 0,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [currentDate, schedulesByDate, selectedDate]);
|
}, [currentDate, departureDays, selectedDate]);
|
||||||
|
|
||||||
|
const availableCount = useMemo(
|
||||||
|
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
|
||||||
|
[days],
|
||||||
|
);
|
||||||
|
|
||||||
const cargoSummary = useMemo(() => {
|
const cargoSummary = useMemo(() => {
|
||||||
if (!cargoType) return "Not selected";
|
if (!cargoType) return "Not selected";
|
||||||
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
const weeksCount = Math.ceil(days.length / 7);
|
const weeksCount = Math.ceil(days.length / 7);
|
||||||
|
|
||||||
const handleDayClick = (day: DayData) => {
|
const handleDayClick = (day: DayData) => {
|
||||||
if (day.schedules.length > 1) {
|
if (!day.hasDeparture) return;
|
||||||
setSelectedDayForModal(day);
|
// Record only the day — no specific train is chosen.
|
||||||
} else if (day.schedules.length === 1) {
|
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
|
||||||
form.setValue("scheduledDate", day.dateString, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
form.setValue("trainScheduleId", day.schedules[0].id, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectScheduleFromModal = (scheduleId: string) => {
|
|
||||||
if (selectedDayForModal) {
|
|
||||||
form.setValue("scheduledDate", selectedDayForModal.dateString, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
form.setValue("trainScheduleId", scheduleId, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
setSelectedDayForModal(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
<Stack gap={14} px={24} py={18}>
|
<Stack gap={14} px={24} py={18}>
|
||||||
<Text fz={13} fw={600} c="edr-text.0">
|
<Text fz={13} fw={600} c="edr-text.0">
|
||||||
{originYardId && destinationYardId
|
{originYardId && destinationYardId
|
||||||
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
|
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||||
: "Select origin and destination to see available departures"}
|
: "Select origin and destination to see available departures"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
||||||
<DayCell
|
<DayCell key={di} day={d} onDayClick={handleDayClick} />
|
||||||
key={di}
|
|
||||||
day={d}
|
|
||||||
onDayClick={handleDayClick}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
value={cargoSummary}
|
value={cargoSummary}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedSchedule && selectedDate && (
|
{selectedDate && (
|
||||||
<Box
|
<Box
|
||||||
p={14}
|
p={14}
|
||||||
style={{
|
style={{
|
||||||
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
c="edr-green.7"
|
c="edr-green.7"
|
||||||
style={{ letterSpacing: "0.08em" }}
|
style={{ letterSpacing: "0.08em" }}
|
||||||
>
|
>
|
||||||
SELECTED DEPARTURE
|
SELECTED DAY
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={800} fz={16} c="edr-text.0">
|
<Text fw={800} fz={16} c="edr-text.0">
|
||||||
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="space-between">
|
<Text fz={12.5} c="edr-muted">
|
||||||
<Text fz={12.5} c="edr-muted">
|
Your train is confirmed by our freight desk after booking.
|
||||||
Train
|
</Text>
|
||||||
</Text>
|
|
||||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
|
||||||
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz={12.5} c="edr-muted">
|
|
||||||
Wagons available
|
|
||||||
</Text>
|
|
||||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
|
||||||
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* ── Schedule Selection Modal ──────────────────────────── */}
|
|
||||||
<Modal
|
|
||||||
opened={!!selectedDayForModal}
|
|
||||||
onClose={() => setSelectedDayForModal(null)}
|
|
||||||
centered
|
|
||||||
size={520}
|
|
||||||
radius={18}
|
|
||||||
padding={0}
|
|
||||||
withCloseButton={false}
|
|
||||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
|
||||||
>
|
|
||||||
{/* 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>
|
|
||||||
<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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -537,7 +332,7 @@ interface DayCellProps {
|
|||||||
onDayClick: (day: DayData) => void;
|
onDayClick: (day: DayData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
function DayCell({ day: d, onDayClick }: DayCellProps) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
if (!d.isCurrentMonth) {
|
if (!d.isCurrentMonth) {
|
||||||
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
|||||||
|
|
||||||
const cellBg = d.isSelectedDate
|
const cellBg = d.isSelectedDate
|
||||||
? theme.colors["edr-soft"][0]
|
? theme.colors["edr-soft"][0]
|
||||||
: d.hasSchedule
|
: d.hasDeparture
|
||||||
? "#FFFFFF"
|
? "#FFFFFF"
|
||||||
: "transparent";
|
: "transparent";
|
||||||
|
|
||||||
const cellBorder = d.isSelectedDate
|
const cellBorder = d.isSelectedDate
|
||||||
? `2px solid ${theme.colors["edr-green"][5]}`
|
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||||
: d.hasSchedule
|
: d.hasDeparture
|
||||||
? `1px solid ${theme.colors["edr-border"][0]}`
|
? `1px solid ${theme.colors["edr-border"][0]}`
|
||||||
: "none";
|
: "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={() => d.hasSchedule && onDayClick(d)}
|
onClick={() => d.hasDeparture && onDayClick(d)}
|
||||||
style={{
|
style={{
|
||||||
height: 92,
|
height: 92,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
cursor: d.hasSchedule ? "pointer" : "default",
|
cursor: d.hasDeparture ? "pointer" : "default",
|
||||||
transition: "all 150ms ease",
|
transition: "all 150ms ease",
|
||||||
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (d.hasSchedule && !d.isSelectedDate) {
|
if (d.hasDeparture && !d.isSelectedDate) {
|
||||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (d.hasSchedule && !d.isSelectedDate) {
|
if (d.hasDeparture && !d.isSelectedDate) {
|
||||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
||||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||||
}
|
}
|
||||||
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
|||||||
c={
|
c={
|
||||||
d.isToday && !d.isSelectedDate
|
d.isToday && !d.isSelectedDate
|
||||||
? "edr-green.6"
|
? "edr-green.6"
|
||||||
: d.hasSchedule
|
: d.hasDeparture
|
||||||
? "edr-text.0"
|
? "edr-text.0"
|
||||||
: "edr-muted"
|
: "edr-muted"
|
||||||
}
|
}
|
||||||
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check size={14} color="white" strokeWidth={3} />
|
||||||
size={14}
|
|
||||||
color="white"
|
|
||||||
strokeWidth={3}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
{/* Availability marker — a single dot for days that have a departure.
|
||||||
{d.hasSchedule && (
|
No counts or capacity are shown: it's a day-level pool. */}
|
||||||
|
{d.hasDeparture && (
|
||||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||||
<Group
|
<Box
|
||||||
gap={6}
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
px={9}
|
|
||||||
py={4}
|
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 999,
|
width: 8,
|
||||||
backgroundColor: d.isSelectedDate
|
height: 8,
|
||||||
? "#fff"
|
borderRadius: "50%",
|
||||||
: theme.colors["edr-soft"][0],
|
backgroundColor: theme.colors["edr-green"][5],
|
||||||
border: `1px solid ${
|
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||||
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>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ export function Step1ContractType({
|
|||||||
form.setValue("containers", mappedContainers);
|
form.setValue("containers", mappedContainers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Consolidation ───────────────────────────────────────────────────
|
// Consolidation is system-managed (always allowed) — not copied from the
|
||||||
form.setValue("consolidationEnabled", booking.allowConsolidation);
|
// previous booking and not customer-controllable.
|
||||||
|
|
||||||
// ── Scheduled date ──────────────────────────────────────────────────
|
// ── Scheduled date ──────────────────────────────────────────────────
|
||||||
if (booking.scheduledDate) {
|
if (booking.scheduledDate) {
|
||||||
|
|||||||
@@ -35,10 +35,17 @@ export function Step4Route({
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({
|
// The form keys shipping line by name, so options are keyed by name too.
|
||||||
value: sl.name,
|
// Dedupe by name: if the reference data has two lines sharing a name, a
|
||||||
label: sl.name,
|
// duplicate option would crash Mantine's Select ("Duplicate options...").
|
||||||
}));
|
const seen = new Set<string>();
|
||||||
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (const sl of referenceData.shipping_line) {
|
||||||
|
if (!sl.name || seen.has(sl.name)) continue;
|
||||||
|
seen.add(sl.name);
|
||||||
|
options.push({ value: sl.name, label: sl.name });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const originData = useMemo(() => {
|
const originData = useMemo(() => {
|
||||||
|
|||||||
@@ -326,10 +326,6 @@ export function Step8Review({
|
|||||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
||||||
>
|
>
|
||||||
<DetailRow label="Shipment date" value={scheduleLabel} />
|
<DetailRow label="Shipment date" value={scheduleLabel} />
|
||||||
<DetailRow
|
|
||||||
label="Train schedule"
|
|
||||||
value={values.trainScheduleId ? "Selected" : "—"}
|
|
||||||
/>
|
|
||||||
</OverviewSection>
|
</OverviewSection>
|
||||||
|
|
||||||
<OverviewSection
|
<OverviewSection
|
||||||
@@ -342,10 +338,6 @@ export function Step8Review({
|
|||||||
label="Total VGM"
|
label="Total VGM"
|
||||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
|
||||||
label="Consolidation"
|
|
||||||
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
|
|
||||||
/>
|
|
||||||
{values.cargoType === "container" && values.containers.length > 0 && (
|
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -444,8 +436,8 @@ export function Step8Review({
|
|||||||
label="Route selected"
|
label="Route selected"
|
||||||
/>
|
/>
|
||||||
<ReadinessItem
|
<ReadinessItem
|
||||||
done={Boolean(values.scheduledDate && values.trainScheduleId)}
|
done={Boolean(values.scheduledDate)}
|
||||||
label="Schedule selected"
|
label="Shipment day selected"
|
||||||
/>
|
/>
|
||||||
<ReadinessItem
|
<ReadinessItem
|
||||||
done={
|
done={
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
|
import {
|
||||||
import { Button } from "@edr/ui-common";
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
function extractOrderId(): string | null {
|
function extractOrderId(): string | null {
|
||||||
@@ -13,6 +21,35 @@ function extractOrderId(): string | null {
|
|||||||
return segments[segments.length - 1] ?? null;
|
return segments[segments.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PaymentCard({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
minHeight: "100dvh",
|
||||||
|
background: "#f8fafc",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 460,
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 24,
|
||||||
|
border: "1.5px solid #e5e7eb",
|
||||||
|
boxShadow: "0 4px 24px 0 rgba(0,0,0,0.07)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CheckPaymentPage() {
|
export default function CheckPaymentPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const orderId = useMemo(() => extractOrderId(), []);
|
const orderId = useMemo(() => extractOrderId(), []);
|
||||||
@@ -29,105 +66,307 @@ export default function CheckPaymentPage() {
|
|||||||
|
|
||||||
if (!orderId) {
|
if (!orderId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<PaymentCard>
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
<Box
|
||||||
<div className="flex flex-col items-center gap-4">
|
style={{
|
||||||
<XCircle className="size-10 text-destructive" />
|
background: "linear-gradient(135deg, #f97316 0%, #fb923c 100%)",
|
||||||
<p className="text-lg font-bold text-foreground">
|
padding: "40px 32px 32px",
|
||||||
No payment reference found
|
textAlign: "center",
|
||||||
</p>
|
}}
|
||||||
<Button
|
>
|
||||||
type="button"
|
<ThemeIcon
|
||||||
variant="outline"
|
size={80}
|
||||||
onClick={() => navigate("/bookings")}
|
radius="xl"
|
||||||
>
|
style={{
|
||||||
Back to My Bookings
|
background: "rgba(255,255,255,0.18)",
|
||||||
</Button>
|
border: "2px solid rgba(255,255,255,0.3)",
|
||||||
</div>
|
margin: "0 auto 20px",
|
||||||
</div>
|
display: "flex",
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<FileSearch size={42} color="#fff" />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
|
No payment reference
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8}>
|
||||||
|
We could not find a payment order to verify.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Stack gap={10} p={32}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="orange"
|
||||||
|
onClick={() => navigate("/bookings")}
|
||||||
|
styles={{ root: { height: 48, fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Back to my bookings
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</PaymentCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isLoading) {
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
return (
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
<PaymentCard>
|
||||||
{isLoading && (
|
<Box style={{ padding: "64px 32px", textAlign: "center" }}>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<Loader size={48} color="edr-green" type="dots" mx="auto" mb={24} />
|
||||||
<LoaderCircle className="size-10 animate-spin text-primary" />
|
<Text fw={700} fz={18} c="#10202F">
|
||||||
<p className="text-lg font-semibold text-foreground">
|
Verifying your payment…
|
||||||
Checking payment status…
|
</Text>
|
||||||
</p>
|
<Text fz={14} c="dimmed" mt={8}>
|
||||||
</div>
|
Please wait, this usually takes a few seconds.
|
||||||
)}
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</PaymentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isSuccess && (
|
if (isSuccess) {
|
||||||
<div className="flex flex-col items-center gap-4">
|
return (
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
|
<PaymentCard>
|
||||||
<CheckCircle2 className="size-8 text-primary" />
|
<Box
|
||||||
</div>
|
style={{
|
||||||
<p className="text-lg font-bold text-foreground">
|
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
|
||||||
Payment was successful!
|
padding: "40px 32px 32px",
|
||||||
</p>
|
textAlign: "center",
|
||||||
<p className="text-sm text-muted-foreground">
|
}}
|
||||||
Your booking has been confirmed and payment is complete.
|
>
|
||||||
</p>
|
<ThemeIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.2)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.35)",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
|
Payment verified
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||||
|
Your booking is confirmed and payment is complete.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack gap={0} p={32}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz={13.5} c="#14532d" lh={1.5} ta="center">
|
||||||
|
EDR staff will assign a train and you will be notified of any updates.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Divider my={24} color="#e5e7eb" />
|
||||||
|
<Stack gap={10}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<FileText size={17} />}
|
||||||
onClick={() => navigate("/bookings")}
|
onClick={() => navigate("/bookings")}
|
||||||
className="mt-2"
|
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||||
>
|
>
|
||||||
Go to My Bookings
|
Go to my bookings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && data && !isSuccess && (
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
|
||||||
<XCircle className="size-8 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
Payment status: {data.status}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Please try again or contact support if the issue persists.
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
fullWidth
|
||||||
variant="outline"
|
size="md"
|
||||||
onClick={() => navigate("/bookings")}
|
radius={12}
|
||||||
className="mt-2"
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Home size={17} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||||
>
|
>
|
||||||
Back to My Bookings
|
Back to home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||||
|
Questions?{" "}
|
||||||
|
<Text span c="edr-green" fw={600}>
|
||||||
|
support@edr.et
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</PaymentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isError && (
|
if (isError) {
|
||||||
<div className="flex flex-col items-center gap-4">
|
return (
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
<PaymentCard>
|
||||||
<XCircle className="size-8 text-destructive" />
|
<Box
|
||||||
</div>
|
style={{
|
||||||
<p className="text-lg font-bold text-foreground">
|
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
|
||||||
Something went wrong
|
padding: "40px 32px 32px",
|
||||||
</p>
|
textAlign: "center",
|
||||||
<p className="text-sm text-muted-foreground">
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.18)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.3)",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
|
Verification failed
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||||
|
We could not verify your payment status.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack gap={0} p={32}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#fff7f7",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz={13.5} c="#7f1d1d" ta="center" lh={1.5}>
|
||||||
{error instanceof Error
|
{error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "Failed to check payment status."}
|
: "An unexpected error occurred. Please try again or contact support."}
|
||||||
</p>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Divider my={24} color="#e5e7eb" />
|
||||||
|
<Stack gap={10}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
fullWidth
|
||||||
variant="outline"
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="red"
|
||||||
|
leftSection={<RotateCcw size={17} />}
|
||||||
onClick={() => navigate("/bookings")}
|
onClick={() => navigate("/bookings")}
|
||||||
className="mt-2"
|
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||||
>
|
>
|
||||||
Back to My Bookings
|
Back to my bookings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
)}
|
fullWidth
|
||||||
</div>
|
size="md"
|
||||||
</div>
|
radius={12}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Home size={17} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||||
|
>
|
||||||
|
Back to home
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||||
|
Need help?{" "}
|
||||||
|
<Text span c="red.6" fw={600}>
|
||||||
|
support@edr.et
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</PaymentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-success status (e.g. PAY_FAIL, PENDING, etc.)
|
||||||
|
return (
|
||||||
|
<PaymentCard>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #d97706 0%, #f59e0b 100%)",
|
||||||
|
padding: "40px 32px 32px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.18)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.3)",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
|
Payment incomplete
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||||
|
Status:{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{data?.status ?? "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack gap={0} p={32}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#fffbeb",
|
||||||
|
border: "1px solid #fde68a",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz={13.5} c="#78350f" ta="center" lh={1.5}>
|
||||||
|
Your payment did not complete successfully. Nothing has been charged.
|
||||||
|
You can retry from your booking page.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Divider my={24} color="#e5e7eb" />
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="orange"
|
||||||
|
leftSection={<RotateCcw size={17} />}
|
||||||
|
onClick={() => navigate("/bookings")}
|
||||||
|
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||||
|
>
|
||||||
|
Back to my bookings — retry payment
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Home size={17} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||||
|
>
|
||||||
|
Back to home
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||||
|
Need help?{" "}
|
||||||
|
<Text span c="orange.7" fw={600}>
|
||||||
|
support@edr.et
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</PaymentCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,171 @@
|
|||||||
import { XCircle } from "lucide-react";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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() {
|
export default function PaymentFailurePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<Box
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
style={{
|
||||||
<div className="flex flex-col items-center gap-4">
|
minHeight: "100dvh",
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
background: "linear-gradient(135deg, #fff7f7 0%, #f8fafc 60%, #fef2f2 100%)",
|
||||||
<XCircle className="size-9 text-destructive" />
|
display: "flex",
|
||||||
</div>
|
alignItems: "center",
|
||||||
<p className="text-xl font-bold text-foreground">
|
justifyContent: "center",
|
||||||
Payment was not completed
|
padding: "24px",
|
||||||
</p>
|
}}
|
||||||
<p className="text-sm text-muted-foreground">
|
>
|
||||||
Your payment didn't go through and you haven't been charged. You can
|
<Box
|
||||||
try again from your booking using "Pay now".
|
style={{
|
||||||
</p>
|
width: "100%",
|
||||||
<div className="mt-2 flex w-full flex-col gap-2">
|
maxWidth: 460,
|
||||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
background: "#fff",
|
||||||
Back to My Bookings
|
borderRadius: 24,
|
||||||
|
border: "1.5px solid #fecaca",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 24px 0 rgba(220,38,38,0.07), 0 1px 4px 0 rgba(0,0,0,0.04)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Red header stripe */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
|
||||||
|
padding: "40px 32px 32px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.18)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.3)",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
|
Payment not completed
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||||
|
Nothing was charged — your booking is still active.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Stack gap={0} p={32}>
|
||||||
|
<Stack gap={16}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#fff7f7",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "16px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#ef4444",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||||
|
Your payment was declined or cancelled. No charge was made.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#ef4444",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||||
|
You can retry using the <strong>Pay now</strong> button on
|
||||||
|
your booking page.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#ef4444",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||||
|
Contact support if the problem persists.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider my={24} color="#e5e7eb" />
|
||||||
|
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="red"
|
||||||
|
leftSection={<RotateCcw size={17} />}
|
||||||
|
onClick={() => navigate("/bookings")}
|
||||||
|
styles={{
|
||||||
|
root: { height: 48, fontWeight: 700, fontSize: 15 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to my bookings — retry payment
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
fullWidth
|
||||||
variant="outline"
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Home size={17} />}
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
|
styles={{
|
||||||
|
root: { height: 44, fontWeight: 600, fontSize: 14 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Back to home
|
Back to home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
|
||||||
</div>
|
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
|
||||||
</div>
|
Need help?{" "}
|
||||||
|
<Text span c="red.6" fw={600}>
|
||||||
|
support@edr.et
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,170 @@
|
|||||||
import { CheckCircle2 } from "lucide-react";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { CheckCircle2, FileText, Home } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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() {
|
export default function PaymentSuccessPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<Box
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
style={{
|
||||||
<div className="flex flex-col items-center gap-4">
|
minHeight: "100dvh",
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
background: "linear-gradient(135deg, #f0fdf4 0%, #f8fafc 60%, #ecfdf5 100%)",
|
||||||
<CheckCircle2 className="size-9 text-primary" />
|
display: "flex",
|
||||||
</div>
|
alignItems: "center",
|
||||||
<p className="text-xl font-bold text-foreground">
|
justifyContent: "center",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 460,
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 24,
|
||||||
|
border: "1.5px solid #d1fae5",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 24px 0 rgba(10,111,77,0.08), 0 1px 4px 0 rgba(0,0,0,0.04)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Green header stripe */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
|
||||||
|
padding: "40px 32px 32px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.2)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.35)",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||||
Payment successful
|
Payment successful
|
||||||
</p>
|
</Text>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||||
Thank you — your payment has been received. Your booking will be
|
Your payment has been received and confirmed.
|
||||||
updated shortly and is now confirmed for scheduling.
|
</Text>
|
||||||
</p>
|
</Box>
|
||||||
<div className="mt-2 flex w-full flex-col gap-2">
|
|
||||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
{/* Body */}
|
||||||
Go to My Bookings
|
<Stack gap={0} p={32}>
|
||||||
|
<Stack gap={16}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "16px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#16a34a",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||||
|
Your booking is now confirmed for scheduling.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#16a34a",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||||
|
A receipt will be sent to your registered email.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#16a34a",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||||
|
EDR staff will process your booking and assign a train.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider my={24} color="#e5e7eb" />
|
||||||
|
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<FileText size={17} />}
|
||||||
|
onClick={() => navigate("/bookings")}
|
||||||
|
styles={{
|
||||||
|
root: { height: 48, fontWeight: 700, fontSize: 15 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to my bookings
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
fullWidth
|
||||||
variant="outline"
|
size="md"
|
||||||
|
radius={12}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Home size={17} />}
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
|
styles={{
|
||||||
|
root: { height: 44, fontWeight: 600, fontSize: 14 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Back to home
|
Back to home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
|
||||||
</div>
|
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
|
||||||
</div>
|
Questions? Contact{" "}
|
||||||
|
<Text span c="edr-green" fw={600}>
|
||||||
|
support@edr.et
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: "customer_documents" },
|
input: { code: "customer_file_documents" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,13 @@ export const api = {
|
|||||||
destinationYardId,
|
destinationYardId,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getAvailableDays: endpoint<
|
||||||
|
{ originYardId?: string; destinationYardId?: string },
|
||||||
|
string[]
|
||||||
|
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
|
||||||
|
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
payments: {
|
payments: {
|
||||||
|
|||||||
@@ -195,4 +195,18 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: the days that have a departure on the route. The customer
|
||||||
|
* picks a day; the engine assigns the train. No capacity is returned.
|
||||||
|
*/
|
||||||
|
getAvailableDays: async (
|
||||||
|
query: Freight.AvailableDaysQuery = {},
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const { data } = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||||
|
{ params: query },
|
||||||
|
);
|
||||||
|
return (data.data as Freight.AvailableDaysResponse).days;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"@sendgrid/mail": "^8.1.0",
|
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
|||||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
|
-- gender is created here on a clean migration history (no prior migration adds it);
|
||||||
|
-- on an already-drifted DB where it exists as varchar, normalize it to TEXT.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT;
|
||||||
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
|
-- Create passenger schema if it doesn't exist
|
||||||
|
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||||
|
|
||||||
|
-- Move all enums from public to passenger schema
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
e text;
|
||||||
|
BEGIN
|
||||||
|
FOR e IN
|
||||||
|
SELECT typname FROM pg_type
|
||||||
|
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
|
||||||
|
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Move all tables from public to passenger schema
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t text;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN
|
||||||
|
SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Add missing columns to Booking
|
||||||
ALTER TABLE "passenger"."Booking"
|
ALTER TABLE "passenger"."Booking"
|
||||||
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT;
|
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
|
||||||
|
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,
|
||||||
|
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
-- Add ReturnLegStatus enum and column
|
||||||
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
|
DO $$ BEGIN
|
||||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
||||||
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
||||||
@@ -30,9 +54,15 @@ DO $$ BEGIN
|
|||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
ALTER TABLE "passenger"."Booking"
|
ALTER TABLE "passenger"."Booking"
|
||||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
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 missing columns to other tables
|
||||||
ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
|
||||||
|
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["multiSchema"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -79,7 +80,6 @@ model CoachType {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
coaches Coach[]
|
coaches Coach[]
|
||||||
seatClasses SeatClass[]
|
seatClasses SeatClass[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +116,11 @@ enum BookingStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ReturnLegStatus {
|
enum ReturnLegStatus {
|
||||||
NOT_APPLICABLE // one-way booking
|
NOT_APPLICABLE
|
||||||
BOTH_USED // passenger used both legs
|
BOTH_USED
|
||||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
OUTBOUND_ONLY
|
||||||
INBOUND_ONLY // outbound leg not used, return leg used
|
INBOUND_ONLY
|
||||||
NEITHER_USED // neither leg boarded yet
|
NEITHER_USED
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,6 @@ model User {
|
|||||||
fraudAlerts FraudAlert[]
|
fraudAlerts FraudAlert[]
|
||||||
|
|
||||||
faydaVerificationSessions FaydaVerificationSession[]
|
faydaVerificationSessions FaydaVerificationSession[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +282,6 @@ model Session {
|
|||||||
lastActivityAt DateTime @default(now())
|
lastActivityAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +313,6 @@ model TravelerProfile {
|
|||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +347,6 @@ model Train {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
schedules TrainSchedule[]
|
schedules TrainSchedule[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +408,6 @@ model TripLiveStatus {
|
|||||||
platformLabel String?
|
platformLabel String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +495,6 @@ model FareRule {
|
|||||||
validFrom DateTime
|
validFrom DateTime
|
||||||
validUntil DateTime?
|
validUntil DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,7 +578,6 @@ model BookingSeat {
|
|||||||
displayFareMinor Int?
|
displayFareMinor Int?
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
seat Seat @relation(fields: [seatId], references: [id])
|
seat Seat @relation(fields: [seatId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +593,6 @@ model PaymentMethod {
|
|||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +653,6 @@ model PaymentRefund {
|
|||||||
status String
|
status String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
|
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +672,6 @@ model Ticket {
|
|||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
validationLogs GateValidationLog[]
|
validationLogs GateValidationLog[]
|
||||||
seats TicketSeat[]
|
seats TicketSeat[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +699,6 @@ model LoyaltyAccount {
|
|||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
ledger LoyaltyLedgerEntry[]
|
ledger LoyaltyLedgerEntry[]
|
||||||
rewards LoyaltyReward[]
|
rewards LoyaltyReward[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,7 +711,6 @@ model LoyaltyLedgerEntry {
|
|||||||
balanceAfter Int
|
balanceAfter Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,7 +722,6 @@ model LoyaltyReward {
|
|||||||
available Boolean @default(true)
|
available Boolean @default(true)
|
||||||
description String?
|
description String?
|
||||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,7 +750,6 @@ model WalletLedgerEntry {
|
|||||||
relatedBookingId String?
|
relatedBookingId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
wallet WalletAccount @relation(fields: [walletId], references: [id])
|
wallet WalletAccount @relation(fields: [walletId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +764,6 @@ model Notification {
|
|||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,7 +779,6 @@ model Promotion {
|
|||||||
deepLink String?
|
deepLink String?
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,7 +792,6 @@ model StationCrowdSignal {
|
|||||||
observedAt DateTime?
|
observedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
station Station @relation(fields: [stationId], references: [id])
|
station Station @relation(fields: [stationId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +803,6 @@ model WeatherAlert {
|
|||||||
message String
|
message String
|
||||||
validUntil DateTime
|
validUntil DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,7 +810,6 @@ model MenuCategory {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
items MenuItem[]
|
items MenuItem[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +824,6 @@ model MenuItem {
|
|||||||
availableUntil DateTime?
|
availableUntil DateTime?
|
||||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||||
category MenuCategory @relation(fields: [categoryId], references: [id])
|
category MenuCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +838,6 @@ model FoodOrder {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
items FoodOrderItem[]
|
items FoodOrderItem[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +850,6 @@ model FoodOrderItem {
|
|||||||
unitPriceMinor Int?
|
unitPriceMinor Int?
|
||||||
lineTotalMinor Int
|
lineTotalMinor Int
|
||||||
order FoodOrder @relation(fields: [orderId], references: [id])
|
order FoodOrder @relation(fields: [orderId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +858,6 @@ model FaqCategory {
|
|||||||
title String
|
title String
|
||||||
iconKey String?
|
iconKey String?
|
||||||
articles FaqArticle[]
|
articles FaqArticle[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,7 +868,6 @@ model FaqArticle {
|
|||||||
answerMarkdown String
|
answerMarkdown String
|
||||||
rank Int @default(0)
|
rank Int @default(0)
|
||||||
category FaqCategory @relation(fields: [categoryId], references: [id])
|
category FaqCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,7 +878,6 @@ model SupportConversation {
|
|||||||
status SupportConversationStatus @default(OPEN)
|
status SupportConversationStatus @default(OPEN)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
messages SupportMessage[]
|
messages SupportMessage[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,7 +889,6 @@ model SupportMessage {
|
|||||||
attachments Json?
|
attachments Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +908,6 @@ model UserPreferences {
|
|||||||
darkMode Boolean @default(false)
|
darkMode Boolean @default(false)
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,7 +920,6 @@ model Device {
|
|||||||
trusted Boolean @default(false)
|
trusted Boolean @default(false)
|
||||||
lastSeenAt DateTime @default(now())
|
lastSeenAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,7 +933,6 @@ model SavedRoute {
|
|||||||
tripCount Int @default(0)
|
tripCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,7 +944,6 @@ model Journey {
|
|||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
journeySegments JourneySegment[]
|
journeySegments JourneySegment[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +958,6 @@ model JourneySegment {
|
|||||||
arrivalStationId String
|
arrivalStationId String
|
||||||
journey Journey @relation(fields: [journeyId], references: [id])
|
journey Journey @relation(fields: [journeyId], references: [id])
|
||||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,7 +1001,6 @@ model Route {
|
|||||||
fareRules RouteFareRule[]
|
fareRules RouteFareRule[]
|
||||||
segmentFares SegmentFareRule[]
|
segmentFares SegmentFareRule[]
|
||||||
schedules TrainSchedule[]
|
schedules TrainSchedule[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,7 +1070,6 @@ model Agent {
|
|||||||
bookings AgentBooking[]
|
bookings AgentBooking[]
|
||||||
shifts AgentShift[]
|
shifts AgentShift[]
|
||||||
commissions AgentCommission[]
|
commissions AgentCommission[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,7 +1084,6 @@ model AgentBooking {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
agent Agent @relation(fields: [agentId], references: [id])
|
agent Agent @relation(fields: [agentId], references: [id])
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1143,6 @@ model BookingCancellation {
|
|||||||
processedAt DateTime?
|
processedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,7 +1170,6 @@ model BaggageAllowance {
|
|||||||
excessFeePerKg Int
|
excessFeePerKg Int
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,7 +1213,6 @@ model NotificationTemplate {
|
|||||||
bodyTemplate String
|
bodyTemplate String
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,7 +1251,6 @@ model FraudRule {
|
|||||||
config Json?
|
config Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ async function seedSegmentFares() {
|
|||||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
});
|
});
|
||||||
const seatClasses = await prisma.seatClass.findMany();
|
const seatClasses = await prisma.seatClass.findMany();
|
||||||
const validFrom = new Date('2024-01-01');
|
const validFrom = new Date('2026-01-01');
|
||||||
|
|
||||||
if (route && route.stops.length > 2) {
|
if (route && route.stops.length > 2) {
|
||||||
for (const sc of seatClasses) {
|
for (const sc of seatClasses) {
|
||||||
@@ -531,7 +531,7 @@ async function seedSegmentFares() {
|
|||||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
||||||
validFrom,
|
validFrom,
|
||||||
},
|
},
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
|
|
||||||
await prisma.segmentFareRule.create({
|
await prisma.segmentFareRule.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -542,7 +542,7 @@ async function seedSegmentFares() {
|
|||||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
||||||
validFrom,
|
validFrom,
|
||||||
},
|
},
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
}
|
}
|
||||||
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
||||||
}
|
}
|
||||||
@@ -550,18 +550,24 @@ async function seedSegmentFares() {
|
|||||||
|
|
||||||
async function seedNotificationTemplates() {
|
async function seedNotificationTemplates() {
|
||||||
console.log('\n🔔 Seeding notification templates...');
|
console.log('\n🔔 Seeding notification templates...');
|
||||||
|
// NOTE: `code` must match the templateKey passed by NotificationsService.send(...).
|
||||||
|
// The event-driven handlers use the dotted event names (booking.created, payment.succeeded).
|
||||||
const templates = [
|
const templates = [
|
||||||
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
|
{ id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
|
||||||
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
|
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
|
||||||
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
|
||||||
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
|
||||||
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
// Templates below are not wired to handlers yet (Phase 2 — full event coverage).
|
||||||
|
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
||||||
|
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||||
|
{ id: uuidv4(), code: 'promotion.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const t of templates) {
|
for (const t of templates) {
|
||||||
await prisma.notificationTemplate.upsert({
|
await prisma.notificationTemplate.upsert({
|
||||||
where: { code: t.code },
|
where: { code: t.code },
|
||||||
update: {},
|
// Refresh the editable fields on re-seed so template tweaks actually take effect.
|
||||||
|
update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true },
|
||||||
create: t,
|
create: t,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -589,13 +595,13 @@ async function seedMenuAndFood() {
|
|||||||
|
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => { }); // ignore if exists
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => { }); // ignore if exists
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => { }); // ignore if exists
|
||||||
}
|
}
|
||||||
console.log(` ✅ Menu categories and items created`);
|
console.log(` ✅ Menu categories and items created`);
|
||||||
}
|
}
|
||||||
@@ -682,6 +688,10 @@ async function main() {
|
|||||||
|
|
||||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||||
['system users', seedSystemUsers],
|
['system users', seedSystemUsers],
|
||||||
|
['fare rules', seedFareRules],
|
||||||
|
['segment fares', seedSegmentFares],
|
||||||
|
['currency', seedCurrency],
|
||||||
|
['notification templates', seedNotificationTemplates]
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@ export class BookingsService {
|
|||||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||||
|
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
|
||||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export class FareCalculateDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
|
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
|
||||||
@IsOptional() @IsString() promoCode?: string;
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' })
|
||||||
|
@IsOptional() @IsString() scheduleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FareBreakdownDto {
|
export class FareBreakdownDto {
|
||||||
|
|||||||
@@ -32,20 +32,59 @@ export class FareEngineService {
|
|||||||
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||||
);
|
);
|
||||||
|
|
||||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
|
||||||
if (missingDistance.length > 0)
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
|
||||||
|
|
||||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||||
|
|
||||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
|
||||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
const now = new Date();
|
||||||
|
const [originStation, destStation] = await Promise.all([
|
||||||
|
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||||
|
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||||
|
]);
|
||||||
|
const segmentRoute = originStation && destStation
|
||||||
|
? `${originStation.code}-${destStation.code}` : null;
|
||||||
|
const fullRoute = `${route.code}`;
|
||||||
|
|
||||||
|
const fareRuleCandidates = await this.prisma.fareRule.findMany({
|
||||||
|
where: {
|
||||||
|
seatClassId: dto.seatClassId,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fareRule = this.pickBestFareRule(
|
||||||
|
fareRuleCandidates,
|
||||||
|
dto.scheduleId,
|
||||||
|
segmentRoute,
|
||||||
|
fullRoute,
|
||||||
|
dto.nationality,
|
||||||
|
);
|
||||||
|
|
||||||
|
let baseFarePerPassengerMinor: number;
|
||||||
|
let ratePerKmMinor: number;
|
||||||
|
let totalDistanceKm: number;
|
||||||
|
let fareSource: string;
|
||||||
|
|
||||||
|
if (fareRule) {
|
||||||
|
// Flat fare from FareRule — distance is informational only
|
||||||
|
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||||
|
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||||
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
|
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||||
|
} else {
|
||||||
|
// Distance × rate fallback
|
||||||
|
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||||
|
if (missingDistance.length > 0)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||||
|
);
|
||||||
|
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||||
|
ratePerKmMinor = seatClass.baseFareMinor;
|
||||||
|
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||||
|
fareSource = 'DISTANCE_RATE';
|
||||||
|
}
|
||||||
|
|
||||||
// Premium and insurance fees applied per passenger
|
// Premium and insurance fees applied per passenger
|
||||||
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
||||||
@@ -84,11 +123,6 @@ export class FareEngineService {
|
|||||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||||
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
|
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
|
||||||
|
|
||||||
const [originStation, destStation] = await Promise.all([
|
|
||||||
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
|
||||||
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const calculation = [
|
const calculation = [
|
||||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||||
@@ -110,9 +144,11 @@ export class FareEngineService {
|
|||||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||||
|
`Fare source: ${fareSource}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
fareSource,
|
||||||
routeCode: route.code,
|
routeCode: route.code,
|
||||||
originName: originStation?.name ?? dto.originStationId,
|
originName: originStation?.name ?? dto.originStationId,
|
||||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||||
@@ -161,6 +197,37 @@ export class FareEngineService {
|
|||||||
return results.filter(Boolean);
|
return results.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickBestFareRule(
|
||||||
|
candidates: any[],
|
||||||
|
scheduleId?: string,
|
||||||
|
segmentRoute?: string | null,
|
||||||
|
fullRoute?: string,
|
||||||
|
nationality?: string,
|
||||||
|
): any | null {
|
||||||
|
const nat = nationality ?? null;
|
||||||
|
const priorities = [
|
||||||
|
{ tripId: scheduleId, route: segmentRoute, nationality: nat },
|
||||||
|
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||||
|
{ tripId: scheduleId, route: fullRoute, nationality: nat },
|
||||||
|
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||||
|
{ tripId: scheduleId, route: null, nationality: nat },
|
||||||
|
{ tripId: scheduleId, route: null, nationality: null },
|
||||||
|
{ tripId: null, route: segmentRoute, nationality: nat },
|
||||||
|
{ tripId: null, route: segmentRoute, nationality: null },
|
||||||
|
{ tripId: null, route: fullRoute, nationality: nat },
|
||||||
|
{ tripId: null, route: fullRoute, nationality: null },
|
||||||
|
{ tripId: null, route: null, nationality: nat },
|
||||||
|
{ tripId: null, route: null, nationality: null },
|
||||||
|
];
|
||||||
|
for (const p of priorities) {
|
||||||
|
const match = candidates.find(
|
||||||
|
c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality,
|
||||||
|
);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
@@ -175,6 +242,7 @@ export class FareEngineService {
|
|||||||
destinationStationId: schedule.destinationStationId,
|
destinationStationId: schedule.destinationStationId,
|
||||||
seatClassId,
|
seatClassId,
|
||||||
nationality,
|
nationality,
|
||||||
|
scheduleId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +267,7 @@ export class FareEngineService {
|
|||||||
destinationStationId: schedule.destinationStationId,
|
destinationStationId: schedule.destinationStationId,
|
||||||
seatClassId: sc.id,
|
seatClassId: sc.id,
|
||||||
nationality,
|
nationality,
|
||||||
|
scheduleId,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,18 +40,5 @@ export class SendEmail {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
context?: Record<string, any>;
|
context?: Record<string, any>;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
templateName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
from?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
replyTo?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class SingleMessageDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
sms: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BulkMessagesDto {
|
export class BulkMessagesDto {
|
||||||
|
|||||||
@@ -28,12 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEmail(dto: SendEmail) {
|
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
|
||||||
if (!this.enabled) return {};
|
if (!this.enabled) {
|
||||||
|
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
|
||||||
|
return { queued: false };
|
||||||
|
}
|
||||||
this.emailServiceClient.emit("send-email", {
|
this.emailServiceClient.emit("send-email", {
|
||||||
...dto,
|
...dto,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
});
|
});
|
||||||
return {};
|
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
|
||||||
|
this.logger.log(
|
||||||
|
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||||
|
);
|
||||||
|
// Recipient + content are PII — keep them at debug level only.
|
||||||
|
this.logger.debug(
|
||||||
|
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
|
||||||
|
);
|
||||||
|
return { queued: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +1,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as sgMail from '@sendgrid/mail';
|
|
||||||
import { HttpService } from '@nestjs/axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
export interface NotificationChannel {
|
export interface NotificationChannel {
|
||||||
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
|
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EmailAdapter implements NotificationChannel {
|
|
||||||
private readonly logger = new Logger(EmailAdapter.name);
|
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {
|
|
||||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
|
||||||
if (apiKey) {
|
|
||||||
sgMail.setApiKey(apiKey);
|
|
||||||
this.logger.log('SendGrid Email adapter initialized');
|
|
||||||
} else {
|
|
||||||
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(
|
|
||||||
recipient: string,
|
|
||||||
subject: string,
|
|
||||||
body: string,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
|
||||||
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const msg: sgMail.MailDataRequired = {
|
|
||||||
to: recipient,
|
|
||||||
from: fromEmail,
|
|
||||||
subject,
|
|
||||||
text: body,
|
|
||||||
html: this.formatHtml(body, context),
|
|
||||||
};
|
|
||||||
|
|
||||||
await sgMail.send(msg);
|
|
||||||
this.logger.log(`Email sent successfully to ${recipient}`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatHtml(body: string, context?: Record<string, unknown>): string {
|
|
||||||
const contextHtml = context
|
|
||||||
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
|
||||||
<small>${JSON.stringify(context, null, 2)}</small>
|
|
||||||
</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
||||||
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
|
|
||||||
.content { padding: 20px; background: white; }
|
|
||||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h2>Ethio-Djibouti Railway</h2>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
${body.replace(/\n/g, '<br>')}
|
|
||||||
${contextHtml}
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SmsAdapter implements NotificationChannel {
|
|
||||||
private readonly logger = new Logger(SmsAdapter.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly http: HttpService,
|
|
||||||
) {
|
|
||||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
|
||||||
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(
|
|
||||||
recipient: string,
|
|
||||||
subject: string,
|
|
||||||
body: string,
|
|
||||||
_context?: Record<string, unknown>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
|
||||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
|
||||||
|
|
||||||
if (!provider || !apiKey) {
|
|
||||||
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (provider.toLowerCase()) {
|
|
||||||
case 'twilio':
|
|
||||||
return await this.sendViaTwilio(recipient, body);
|
|
||||||
case 'africastalking':
|
|
||||||
return await this.sendViaAfricasTalking(recipient, body);
|
|
||||||
default:
|
|
||||||
this.logger.warn(`Unknown SMS provider: ${provider}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
|
|
||||||
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
|
|
||||||
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
|
|
||||||
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
|
|
||||||
|
|
||||||
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
|
|
||||||
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
|
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.http.post(
|
|
||||||
url,
|
|
||||||
new URLSearchParams({
|
|
||||||
To: to,
|
|
||||||
From: fromNumber || '',
|
|
||||||
Body: body,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Authorization': `Basic ${auth}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.status === 201;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
|
|
||||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
|
||||||
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
|
|
||||||
const from = this.config.get<string>('AFRICASTALKING_FROM');
|
|
||||||
|
|
||||||
const url = 'https://api.africastalking.com/version1/messaging';
|
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.http.post(
|
|
||||||
url,
|
|
||||||
new URLSearchParams({
|
|
||||||
username: username || '',
|
|
||||||
to,
|
|
||||||
message: body,
|
|
||||||
from: from || '',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'apiKey': apiKey || '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.status === 201;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PushAdapter implements NotificationChannel {
|
export class PushAdapter implements NotificationChannel {
|
||||||
private readonly logger = new Logger(PushAdapter.name);
|
private readonly logger = new Logger(PushAdapter.name);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { HttpModule } from '@nestjs/axios';
|
|||||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
import { PushAdapter } from './notification.adapters';
|
||||||
import { EmailClientService } from './email-client.service';
|
import { EmailClientService } from './email-client.service';
|
||||||
import { SmsClientService } from './sms-client.service';
|
import { SmsClientService } from './sms-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
// Required by IamGuard (injects HttpService) used in NotificationsController.
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
ClientsModule.register([
|
ClientsModule.register([
|
||||||
{
|
{
|
||||||
@@ -34,8 +35,6 @@ import { SmsClientService } from './sms-client.service';
|
|||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [
|
providers: [
|
||||||
NotificationsService,
|
NotificationsService,
|
||||||
EmailAdapter,
|
|
||||||
SmsAdapter,
|
|
||||||
PushAdapter,
|
PushAdapter,
|
||||||
EmailClientService,
|
EmailClientService,
|
||||||
SmsClientService,
|
SmsClientService,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
|
||||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||||
import { EmailClientService } from './email-client.service';
|
import { EmailClientService } from './email-client.service';
|
||||||
import { SmsClientService } from './sms-client.service';
|
import { SmsClientService } from './sms-client.service';
|
||||||
@@ -20,8 +19,8 @@ export class NotificationsService {
|
|||||||
private pushAdapter: PushAdapter,
|
private pushAdapter: PushAdapter,
|
||||||
) {
|
) {
|
||||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
|
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }],
|
||||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
|
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }],
|
||||||
['PUSH', this.pushAdapter as NotificationChannel],
|
['PUSH', this.pushAdapter as NotificationChannel],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -38,27 +37,38 @@ export class NotificationsService {
|
|||||||
recipient: string,
|
recipient: string,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
channels?: NotificationChannelType[],
|
channels?: NotificationChannelType[],
|
||||||
): Promise<{ sent: boolean; channels: string[] }> {
|
): Promise<{ queued: boolean; channels: string[] }> {
|
||||||
const template = await this.prisma.notificationTemplate.findUnique({
|
const template = await this.prisma.notificationTemplate.findUnique({
|
||||||
where: { code: templateKey },
|
where: { code: templateKey },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template || !template.active) {
|
if (!template || !template.active) {
|
||||||
this.logger.warn(`Template ${templateKey} not found or inactive`);
|
this.logger.warn(`Template ${templateKey} not found or inactive`);
|
||||||
return { sent: false, channels: [] };
|
return { queued: false, channels: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subject, body } = this.interpolate(template, context);
|
const { subject, body } = this.interpolate(template, context);
|
||||||
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
|
|
||||||
const sentChannels: string[] = [];
|
|
||||||
|
|
||||||
// Always create in-app notification
|
// Channel resolution: explicit argument wins; otherwise honor the template's declared
|
||||||
if (targetChannels.includes('IN_APP')) {
|
// channel(s); otherwise fall back to the recipient's preferences.
|
||||||
await this.createInAppNotification(recipient, subject, body, context);
|
let targetChannels: NotificationChannelType[];
|
||||||
sentChannels.push('IN_APP');
|
if (channels) {
|
||||||
|
targetChannels = channels;
|
||||||
|
} else if (template.channel) {
|
||||||
|
targetChannels = this.parseTemplateChannels(template.channel);
|
||||||
|
} else {
|
||||||
|
targetChannels = await this.getUserPreferredChannels(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ).
|
||||||
|
// NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation.
|
||||||
|
const queuedChannels: string[] = [];
|
||||||
|
|
||||||
|
if (targetChannels.includes('IN_APP')) {
|
||||||
|
await this.createInAppNotification(recipient, subject, body, context);
|
||||||
|
queuedChannels.push('IN_APP');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send via other channels
|
|
||||||
for (const channelType of targetChannels) {
|
for (const channelType of targetChannels) {
|
||||||
if (channelType === 'IN_APP') continue;
|
if (channelType === 'IN_APP') continue;
|
||||||
|
|
||||||
@@ -74,44 +84,26 @@ export class NotificationsService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await adapter.send(recipientAddress, subject, body, context);
|
const queued = await adapter.send(recipientAddress, subject, body, context);
|
||||||
if (success) {
|
if (queued) {
|
||||||
sentChannels.push(channelType);
|
queuedChannels.push(channelType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sent: sentChannels.length > 0, channels: sentChannels };
|
return { queued: queuedChannels.length > 0, channels: queuedChannels };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy method for backward compatibility
|
* Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel
|
||||||
|
* types, always including IN_APP so an in-app record is created.
|
||||||
*/
|
*/
|
||||||
async sendDirect(dto: SendNotificationDto) {
|
private parseTemplateChannels(channel: string): NotificationChannelType[] {
|
||||||
const notification = await this.prisma.notification.create({
|
const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
||||||
data: {
|
const parsed = channel
|
||||||
passengerId: dto.passengerId,
|
.split(',')
|
||||||
title: dto.title,
|
.map((c) => c.trim().toUpperCase())
|
||||||
body: dto.body,
|
.filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType));
|
||||||
category: dto.category as any,
|
return Array.from(new Set<NotificationChannelType>(['IN_APP', ...parsed]));
|
||||||
deepLink: dto.deepLink,
|
|
||||||
metadata: dto.metadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const passenger = await this.prisma.passenger.findUnique({
|
|
||||||
where: { id: dto.passengerId },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (passenger?.user) {
|
|
||||||
await this.emailClient.sendEmail({
|
|
||||||
to: passenger.user.email,
|
|
||||||
subject: this.sanitize(dto.title),
|
|
||||||
text: this.sanitize(dto.body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return notification;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createInAppNotification(
|
private async createInAppNotification(
|
||||||
@@ -154,22 +146,31 @@ export class NotificationsService {
|
|||||||
template: { subject?: string | null; bodyTemplate: string },
|
template: { subject?: string | null; bodyTemplate: string },
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): { subject: string; body: string } {
|
): { subject: string; body: string } {
|
||||||
const subject = template.subject || 'Notification';
|
return {
|
||||||
let body = template.bodyTemplate;
|
subject: this.applyVars(template.subject || 'Notification', context),
|
||||||
|
body: this.applyVars(template.bodyTemplate, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Simple template interpolation: {{variable}}
|
/** Replaces {{variable}} placeholders in a string with values from the context. */
|
||||||
|
private applyVars(text: string, context: Record<string, unknown>): string {
|
||||||
|
let out = text;
|
||||||
for (const [key, value] of Object.entries(context)) {
|
for (const [key, value] of Object.entries(context)) {
|
||||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||||
body = body.replace(regex, String(value));
|
out = out.replace(regex, String(value));
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
return { subject, body };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
OR: [
|
||||||
|
{ id: recipient },
|
||||||
|
{ email: recipient },
|
||||||
|
{ phone: recipient },
|
||||||
|
{ passenger: { id: recipient } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: { preferences: true },
|
include: { preferences: true },
|
||||||
});
|
});
|
||||||
@@ -192,7 +193,12 @@ export class NotificationsService {
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
OR: [
|
||||||
|
{ id: recipient },
|
||||||
|
{ email: recipient },
|
||||||
|
{ phone: recipient },
|
||||||
|
{ passenger: { id: recipient } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,12 +217,6 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitize(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/[\r\n]/g, ' ')
|
|
||||||
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
|
||||||
}
|
|
||||||
|
|
||||||
getForPassenger(passengerId: string) {
|
getForPassenger(passengerId: string) {
|
||||||
return this.prisma.notification.findMany({
|
return this.prisma.notification.findMany({
|
||||||
where: { passengerId },
|
where: { passengerId },
|
||||||
@@ -239,27 +239,238 @@ export class NotificationsService {
|
|||||||
|
|
||||||
@OnEvent('booking.created')
|
@OnEvent('booking.created')
|
||||||
async onBookingCreated(payload: any) {
|
async onBookingCreated(payload: any) {
|
||||||
|
const booking = payload.booking;
|
||||||
await this.send(
|
await this.send(
|
||||||
'booking.created',
|
'booking.created',
|
||||||
payload.booking.passengerId,
|
booking.passengerId,
|
||||||
{
|
{
|
||||||
bookingRef: payload.booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
|
amount: this.formatAmount(booking),
|
||||||
|
currency: booking.displayCurrency ?? 'ETB',
|
||||||
category: 'BOOKING',
|
category: 'BOOKING',
|
||||||
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
|
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||||
},
|
},
|
||||||
|
// For now, always notify the travelling passenger on every channel.
|
||||||
|
['IN_APP', 'EMAIL', 'SMS'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment succeeded → one combined "payment successful, here is your ticket" notification.
|
||||||
|
* Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow
|
||||||
|
* event payload is re-fetched with the relations needed to render the ticket.
|
||||||
|
*/
|
||||||
@OnEvent('payment.succeeded')
|
@OnEvent('payment.succeeded')
|
||||||
async onPaymentSucceeded(payload: any) {
|
async onPaymentSucceeded(payload: any) {
|
||||||
await this.send(
|
const passengerId = payload.booking.passengerId;
|
||||||
'payment.succeeded',
|
const bookingId = payload.booking.id;
|
||||||
payload.booking.passengerId,
|
|
||||||
{
|
const booking = await this.prisma.booking.findUnique({
|
||||||
bookingRef: payload.booking.bookingRef,
|
where: { id: bookingId },
|
||||||
category: 'PAYMENT',
|
include: {
|
||||||
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
|
||||||
|
|
||||||
|
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||||
|
const amount = this.formatAmount(booking ?? payload.booking);
|
||||||
|
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
|
||||||
|
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`;
|
||||||
|
|
||||||
|
// IN_APP — always created.
|
||||||
|
await this.createInAppNotification(
|
||||||
|
passengerId,
|
||||||
|
'Payment successful',
|
||||||
|
`Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`,
|
||||||
|
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
||||||
|
if (!ticket || !booking) {
|
||||||
|
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
||||||
|
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
||||||
|
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
||||||
|
await this.deliverSms(passengerId, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMS — short pointer (no HTML/QR over SMS).
|
||||||
|
await this.deliverSms(
|
||||||
|
passengerId,
|
||||||
|
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// EMAIL — rich HTML ticket with plain-text fallback.
|
||||||
|
await this.deliverEmail(
|
||||||
|
passengerId,
|
||||||
|
`Your EDR ticket — ${ref}`,
|
||||||
|
this.buildTicketEmailText(booking, amount, currency, ticketUrl),
|
||||||
|
this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise<void> {
|
||||||
|
const to = await this.getRecipientAddress(recipient, 'EMAIL');
|
||||||
|
if (!to) {
|
||||||
|
this.logger.warn(`No EMAIL address for recipient: ${recipient}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.emailClient.sendEmail({ to, subject, text, html });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverSms(recipient: string, message: string): Promise<void> {
|
||||||
|
const to = await this.getRecipientAddress(recipient, 'SMS');
|
||||||
|
if (!to) {
|
||||||
|
this.logger.warn(`No SMS address for recipient: ${recipient}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.smsClient.sendSms({ to, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
|
||||||
|
const s = booking.schedule ?? {};
|
||||||
|
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
|
||||||
|
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
|
||||||
|
return [
|
||||||
|
`Booking ${booking.bookingRef} confirmed.`,
|
||||||
|
`${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
|
||||||
|
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
||||||
|
`Departs: ${dep}`,
|
||||||
|
passengers ? `Passengers: ${passengers}` : '',
|
||||||
|
`Total paid: ${amount} ${currency}`,
|
||||||
|
`View your ticket: ${url}`,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string {
|
||||||
|
const s = booking.schedule ?? {};
|
||||||
|
const fmt = (d: any) =>
|
||||||
|
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||||
|
const seatRows = (booking.seats ?? [])
|
||||||
|
.map((bs: any) => {
|
||||||
|
const coach = bs.seat?.coach?.number ?? '-';
|
||||||
|
const seatNo = bs.seat?.seatNumber ?? '-';
|
||||||
|
const cls = bs.seat?.coach?.coachType?.name ?? '-';
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:8px;border-bottom:1px solid #eee;">${bs.passengerName ?? ''}</td>
|
||||||
|
<td style="padding:8px;border-bottom:1px solid #eee;">${coach}</td>
|
||||||
|
<td style="padding:8px;border-bottom:1px solid #eee;">${seatNo}</td>
|
||||||
|
<td style="padding:8px;border-bottom:1px solid #eee;">${cls}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||||
|
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
|
||||||
|
<div style="max-width:600px;margin:0 auto;background:#fff;">
|
||||||
|
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
|
||||||
|
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
|
||||||
|
<p style="margin:8px 0 0;">Payment successful — your ticket is ready</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<p>Booking reference: <strong>${booking.bookingRef}</strong></p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;color:#666;">From</td>
|
||||||
|
<td style="padding:8px 0;text-align:right;"><strong>${s.originStation?.name ?? ''}</strong> (${s.originStation?.code ?? ''})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;color:#666;">To</td>
|
||||||
|
<td style="padding:8px 0;text-align:right;"><strong>${s.destinationStation?.name ?? ''}</strong> (${s.destinationStation?.code ?? ''})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;color:#666;">Train</td>
|
||||||
|
<td style="padding:8px 0;text-align:right;">${s.train?.name ?? s.train?.number ?? ''}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;color:#666;">Departs</td>
|
||||||
|
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;color:#666;">Arrives</td>
|
||||||
|
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="margin:16px 0 8px;">Passengers</h3>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr style="text-align:left;color:#666;">
|
||||||
|
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
|
||||||
|
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
|
||||||
|
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
|
||||||
|
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
|
||||||
|
</tr>
|
||||||
|
${seatRows}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin:24px 0;">
|
||||||
|
<p style="color:#666;margin:0 0 8px;">Show this QR code at the gate</p>
|
||||||
|
<img src="${ticket.qrPayload}" alt="Ticket QR code" width="180" height="180" style="border:1px solid #eee;padding:8px;background:#fff;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width:100%;border-collapse:collapse;border-top:2px solid #eee;margin-top:16px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;font-size:16px;"><strong>Total paid</strong></td>
|
||||||
|
<td style="padding:12px 0;font-size:16px;text-align:right;"><strong>${amount} ${currency}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin:24px 0;">
|
||||||
|
<a href="${url}" style="background:#0066cc;color:#fff;text-decoration:none;padding:12px 28px;border-radius:4px;display:inline-block;">View ticket</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
|
||||||
|
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('payment.failed')
|
||||||
|
async onPaymentFailed(payload: any) {
|
||||||
|
const booking = payload.booking;
|
||||||
|
await this.send(
|
||||||
|
'payment.failed',
|
||||||
|
booking.passengerId,
|
||||||
|
{
|
||||||
|
bookingRef: booking.bookingRef,
|
||||||
|
category: 'PAYMENT',
|
||||||
|
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||||
|
},
|
||||||
|
['IN_APP', 'EMAIL', 'SMS'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('booking.cancelled')
|
||||||
|
async onBookingCancelled(payload: any) {
|
||||||
|
const booking = payload.booking;
|
||||||
|
await this.send(
|
||||||
|
'booking.cancelled',
|
||||||
|
booking.passengerId,
|
||||||
|
{
|
||||||
|
bookingRef: booking.bookingRef,
|
||||||
|
// refundAmount is computed in ETB minor units in BookingsService.cancel().
|
||||||
|
refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2),
|
||||||
|
currency: 'ETB',
|
||||||
|
category: 'BOOKING',
|
||||||
|
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||||
|
},
|
||||||
|
['IN_APP', 'EMAIL', 'SMS'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a booking's payable amount from minor units into a major-unit string.
|
||||||
|
* Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point
|
||||||
|
* drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00".
|
||||||
|
*/
|
||||||
|
private formatAmount(booking: any): string {
|
||||||
|
const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0;
|
||||||
|
return (minor / 100).toFixed(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,21 +30,39 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSms(dto: SingleMessageDto) {
|
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
|
||||||
if (!this.enabled) return {};
|
if (!this.enabled) {
|
||||||
|
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||||
|
return { queued: false };
|
||||||
|
}
|
||||||
this.smsClient.emit("send-sms", {
|
this.smsClient.emit("send-sms", {
|
||||||
...dto,
|
to: dto.to,
|
||||||
|
text: dto.message,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
});
|
});
|
||||||
return {};
|
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||||
|
this.logger.log(
|
||||||
|
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
|
||||||
|
);
|
||||||
|
// Recipient + content are PII — debug only.
|
||||||
|
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||||
|
return { queued: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||||
if (!this.enabled) return {};
|
if (!this.enabled) {
|
||||||
|
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||||
|
return { queued: false };
|
||||||
|
}
|
||||||
|
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||||
this.smsClient.emit("ozeking-bulk-sms", {
|
this.smsClient.emit("ozeking-bulk-sms", {
|
||||||
...dto,
|
messages,
|
||||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||||
});
|
});
|
||||||
return {};
|
this.logger.log(
|
||||||
|
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
|
||||||
|
);
|
||||||
|
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||||
|
return { queued: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ export class PaymentsService {
|
|||||||
referenceType: PaymentReferenceType.BOOKING,
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
referenceId: booking.id,
|
referenceId: booking.id,
|
||||||
orderRef: booking.bookingRef,
|
orderRef: booking.bookingRef,
|
||||||
amountMinor: booking.totalMinor,
|
// Send the REAL (major) price, not minor units. The payment API no longer divides by 100
|
||||||
|
// (freight already passes the real price), so the providers charge this value as-is.
|
||||||
|
amountMinor: booking.totalMinor / 100,
|
||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
provider: method as unknown as ProviderMethod,
|
provider: method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
@@ -620,6 +622,12 @@ export class PaymentsService {
|
|||||||
failureMessage: event.failureMessage,
|
failureMessage: event.failureMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const failedBooking = await this.prisma.booking.findUnique({
|
||||||
|
where: { id: event.referenceId },
|
||||||
|
});
|
||||||
|
if (failedBooking) {
|
||||||
|
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
|
||||||
|
}
|
||||||
return { processed: true };
|
return { processed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,11 +642,15 @@ export class PaymentsService {
|
|||||||
return { processed: false, reason: "booking-not-found" };
|
return { processed: false, reason: "booking-not-found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.totalMinor !== event.amountMinor) {
|
// The event carries the REAL (major) price the provider charged (passenger now sends
|
||||||
|
// booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
|
||||||
|
// with booking.totalMinor (which is in minor units).
|
||||||
|
const eventAmountMinor = Math.round(event.amountMinor * 100);
|
||||||
|
if (booking.totalMinor !== eventAmountMinor) {
|
||||||
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
||||||
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
|
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Event amount does not match booking total",
|
"Event amount does not match booking total",
|
||||||
|
|||||||
@@ -12,34 +12,22 @@ export class SchedulesController {
|
|||||||
|
|
||||||
@Post('bulk-generate')
|
@Post('bulk-generate')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
||||||
summary: 'Bulk generate repetitive schedules',
|
|
||||||
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
|
||||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||||
return this.service.bulkGenerateSchedules(dto);
|
return this.service.bulkGenerateSchedules(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
||||||
summary: 'Create a train schedule from a route template',
|
|
||||||
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Train or route not found' })
|
|
||||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||||
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
@ApiQuery({ name: 'date', required: false })
|
||||||
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
|
@ApiQuery({ name: 'routeId', required: false })
|
||||||
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
|
@ApiQuery({ name: 'trainId', required: false })
|
||||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
|
@ApiQuery({ name: 'status', required: false, enum: TripStatus })
|
||||||
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
|
|
||||||
listSchedules(
|
listSchedules(
|
||||||
@Query('date') date?: string,
|
@Query('date') date?: string,
|
||||||
@Query('routeId') routeId?: string,
|
@Query('routeId') routeId?: string,
|
||||||
@@ -57,57 +45,63 @@ export class SchedulesController {
|
|||||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||||
|
|
||||||
|
@Patch('fares/:id')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Update a fare rule' })
|
||||||
|
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||||
|
updateFareRule(@Param('id') id: string, @Body() dto: Partial<CreateFareRuleDto>) {
|
||||||
|
return this.service.updateFareRule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('fares/:id')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||||
|
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Fare rule deleted' })
|
||||||
|
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||||
|
|
||||||
@Post('segment-fares')
|
@Post('segment-fares')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||||
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
|
||||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||||
|
|
||||||
@Get('routes/:routeId/segment-fares')
|
@Get('routes/:routeId/segment-fares')
|
||||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
|
||||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||||
|
|
||||||
@Patch('segment-fares/:id')
|
@Patch('segment-fares/:id')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
|
||||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||||
|
|
||||||
@Delete('segment-fares/:id')
|
@Delete('segment-fares/:id')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
|
||||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||||
|
|
||||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
@ApiOperation({ summary: 'Get schedule detail' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||||
return this.service.updateSchedulePartial(id, dto);
|
return this.service.updateSchedulePartial(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
@ApiOperation({ summary: 'Update schedule status' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Status updated' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||||
return this.service.updateScheduleStatus(id, dto);
|
return this.service.updateScheduleStatus(id, dto);
|
||||||
}
|
}
|
||||||
@@ -116,26 +110,18 @@ export class SchedulesController {
|
|||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Delete a schedule' })
|
@ApiOperation({ summary: 'Delete a schedule' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Schedule deleted' })
|
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
deleteSchedule(@Param('id') id: string) {
|
|
||||||
return this.service.deleteSchedule(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/stops')
|
@Get(':id/stops')
|
||||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||||
|
|
||||||
@Patch(':id/stops/:sequence')
|
@Patch(':id/stops/:sequence')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
|
@ApiOperation({ summary: 'Update a stop time' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||||
@ApiResponse({ status: 200, description: 'Stop updated' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
|
|
||||||
updateStop(
|
updateStop(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('sequence', ParseIntPipe) sequence: number,
|
@Param('sequence', ParseIntPipe) sequence: number,
|
||||||
@@ -145,19 +131,26 @@ export class SchedulesController {
|
|||||||
@Get(':scheduleId/fares/stored')
|
@Get(':scheduleId/fares/stored')
|
||||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
|
|
||||||
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
||||||
return this.service.getFareRules(scheduleId);
|
return this.service.getFareRules(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':scheduleId/fares')
|
@Get(':scheduleId/fares/all')
|
||||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
@ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
|
@ApiQuery({ name: 'nationality', required: false })
|
||||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
|
getAllFares(
|
||||||
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
|
@Param('scheduleId') scheduleId: string,
|
||||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
@Query('nationality') nationality?: string,
|
||||||
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
|
) {
|
||||||
|
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':scheduleId/fares')
|
||||||
|
@ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' })
|
||||||
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiQuery({ name: 'seatClassId', required: true })
|
||||||
|
@ApiQuery({ name: 'nationality', required: false })
|
||||||
getFare(
|
getFare(
|
||||||
@Param('scheduleId') scheduleId: string,
|
@Param('scheduleId') scheduleId: string,
|
||||||
@Query('seatClassId') seatClassId: string,
|
@Query('seatClassId') seatClassId: string,
|
||||||
@@ -166,42 +159,15 @@ export class SchedulesController {
|
|||||||
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
|
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':scheduleId/fares/all')
|
|
||||||
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
|
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
|
||||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
getAllFares(
|
|
||||||
@Param('scheduleId') scheduleId: string,
|
|
||||||
@Query('nationality') nationality?: string,
|
|
||||||
) {
|
|
||||||
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/fares/sync')
|
@Post(':id/fares/sync')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
||||||
summary: 'Sync fares from fare engine',
|
|
||||||
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
|
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||||
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
|
||||||
syncFares(@Param('id') id: string) {
|
|
||||||
return this.service.syncFaresFromEngine(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/coaches')
|
@Post(':id/coaches')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
||||||
summary: 'Assign coaches to a schedule',
|
|
||||||
description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
|
||||||
assignCoaches(
|
assignCoaches(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
|
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
|
||||||
@@ -212,21 +178,14 @@ export class SchedulesController {
|
|||||||
@Get(':id/coaches')
|
@Get(':id/coaches')
|
||||||
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
|
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
|
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
||||||
getAssignedCoaches(@Param('id') id: string) {
|
|
||||||
return this.service.getAssignedCoaches(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id/coaches/:coachId')
|
@Delete(':id/coaches/:coachId')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Coach assignment removed' })
|
removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) {
|
||||||
removeCoachAssignment(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('coachId') coachId: string,
|
|
||||||
) {
|
|
||||||
return this.service.removeCoachAssignment(id, coachId);
|
return this.service.removeCoachAssignment(id, coachId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export class SchedulesService {
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const scheduleIds: string[] = [];
|
const scheduleIds: string[] = [];
|
||||||
|
|
||||||
// Validate route and get stops for plannedTimes generation
|
|
||||||
const route = await this.prisma.route.findUnique({
|
const route = await this.prisma.route.findUnique({
|
||||||
where: { id: dto.routeId },
|
where: { id: dto.routeId },
|
||||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
@@ -45,7 +44,6 @@ export class SchedulesService {
|
|||||||
const schedule = await this.createSchedule(createDto);
|
const schedule = await this.createSchedule(createDto);
|
||||||
scheduleIds.push(schedule.id);
|
scheduleIds.push(schedule.id);
|
||||||
|
|
||||||
// Assign coaches if provided
|
|
||||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||||
await this.assignCoaches(
|
await this.assignCoaches(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
@@ -58,15 +56,10 @@ export class SchedulesService {
|
|||||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to next repetition
|
|
||||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { schedulesCreated: scheduleCount, errors, scheduleIds };
|
||||||
schedulesCreated: scheduleCount,
|
|
||||||
errors,
|
|
||||||
scheduleIds,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSchedules(dto: ListSchedulesDto) {
|
async listSchedules(dto: ListSchedulesDto) {
|
||||||
@@ -104,7 +97,6 @@ export class SchedulesService {
|
|||||||
const arr = new Date(dto.arrivalAt);
|
const arr = new Date(dto.arrivalAt);
|
||||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||||
|
|
||||||
// Validate route exists and has stops
|
|
||||||
const route = await this.prisma.route.findUnique({
|
const route = await this.prisma.route.findUnique({
|
||||||
where: { id: dto.routeId },
|
where: { id: dto.routeId },
|
||||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
@@ -113,21 +105,13 @@ export class SchedulesService {
|
|||||||
if (!route.active) throw new BadRequestException('Route is not active');
|
if (!route.active) throw new BadRequestException('Route is not active');
|
||||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||||
|
|
||||||
// Check for duplicate schedule with same train, route, and date
|
|
||||||
const depDate = new Date(dep);
|
const depDate = new Date(dep);
|
||||||
depDate.setHours(0, 0, 0, 0);
|
depDate.setHours(0, 0, 0, 0);
|
||||||
const nextDay = new Date(depDate);
|
const nextDay = new Date(depDate);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||||
where: {
|
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
|
||||||
trainId: dto.trainId,
|
|
||||||
routeId: dto.routeId,
|
|
||||||
departureAt: {
|
|
||||||
gte: depDate,
|
|
||||||
lt: nextDay,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSchedule) {
|
if (existingSchedule) {
|
||||||
@@ -136,7 +120,6 @@ export class SchedulesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate plannedTimes if not provided or empty
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
@@ -144,7 +127,6 @@ export class SchedulesService {
|
|||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
let stopTime: Date;
|
let stopTime: Date;
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
stopTime = dep;
|
stopTime = dep;
|
||||||
} else if (index === route.stops.length - 1) {
|
} else if (index === route.stops.length - 1) {
|
||||||
@@ -154,7 +136,6 @@ export class SchedulesService {
|
|||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||||
@@ -163,14 +144,12 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all route stop sequences are covered by plannedTimes
|
|
||||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
||||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||||
if (missingSeqs.length > 0) {
|
if (missingSeqs.length > 0) {
|
||||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive origin and destination from first and last route stop
|
|
||||||
const firstStop = route.stops[0];
|
const firstStop = route.stops[0];
|
||||||
const lastStop = route.stops[route.stops.length - 1];
|
const lastStop = route.stops[route.stops.length - 1];
|
||||||
|
|
||||||
@@ -188,9 +167,7 @@ export class SchedulesService {
|
|||||||
include: { train: true, originStation: true, destinationStation: true },
|
include: { train: true, originStation: true, destinationStation: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const plannedTimesMap = Object.fromEntries(
|
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||||
plannedTimes.map(t => [t.sequence, t]),
|
|
||||||
);
|
|
||||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||||
|
|
||||||
return this.getSchedule(schedule.id);
|
return this.getSchedule(schedule.id);
|
||||||
@@ -230,10 +207,7 @@ export class SchedulesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveEffectiveStatuses(
|
private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise<Map<string, string>> {
|
||||||
scheduleId: string,
|
|
||||||
seatIds: string[],
|
|
||||||
): Promise<Map<string, string>> {
|
|
||||||
const statusMap = new Map<string, string>();
|
const statusMap = new Map<string, string>();
|
||||||
if (seatIds.length === 0) return statusMap;
|
if (seatIds.length === 0) return statusMap;
|
||||||
|
|
||||||
@@ -304,7 +278,6 @@ export class SchedulesService {
|
|||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
let stopTime: Date;
|
let stopTime: Date;
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
stopTime = dep;
|
stopTime = dep;
|
||||||
} else if (index === route.stops.length - 1) {
|
} else if (index === route.stops.length - 1) {
|
||||||
@@ -314,7 +287,6 @@ export class SchedulesService {
|
|||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||||
@@ -323,9 +295,7 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannedTimesMap = Object.fromEntries(
|
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||||
plannedTimes.map(t => [t.sequence, t]),
|
|
||||||
);
|
|
||||||
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
||||||
|
|
||||||
return this.getSchedule(id);
|
return this.getSchedule(id);
|
||||||
@@ -376,9 +346,35 @@ export class SchedulesService {
|
|||||||
validFrom: new Date(validFrom),
|
validFrom: new Date(validFrom),
|
||||||
validUntil: validUntil ? new Date(validUntil) : null,
|
validUntil: validUntil ? new Date(validUntil) : null,
|
||||||
},
|
},
|
||||||
|
include: { seatClass: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
|
||||||
|
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||||
|
|
||||||
|
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||||
|
return this.prisma.fareRule.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
...(scheduleId !== undefined && { tripId: scheduleId }),
|
||||||
|
...(nationality !== undefined && { nationality }),
|
||||||
|
...(validFrom && { validFrom: new Date(validFrom) }),
|
||||||
|
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
|
||||||
|
},
|
||||||
|
include: { seatClass: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFareRule(id: string) {
|
||||||
|
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||||
|
await this.prisma.fareRule.delete({ where: { id } });
|
||||||
|
return { deleted: true, id };
|
||||||
|
}
|
||||||
|
|
||||||
createSegmentFareRule(dto: any) {
|
createSegmentFareRule(dto: any) {
|
||||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||||
return this.prisma.segmentFareRule.create({
|
return this.prisma.segmentFareRule.create({
|
||||||
@@ -419,7 +415,6 @@ export class SchedulesService {
|
|||||||
async getFareRules(scheduleId?: string) {
|
async getFareRules(scheduleId?: string) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (scheduleId) where.tripId = scheduleId;
|
if (scheduleId) where.tripId = scheduleId;
|
||||||
|
|
||||||
return this.prisma.fareRule.findMany({
|
return this.prisma.fareRule.findMany({
|
||||||
where,
|
where,
|
||||||
include: { seatClass: true },
|
include: { seatClass: true },
|
||||||
@@ -439,11 +434,10 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||||
|
|
||||||
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
|
error instanceof Error ? error.message : 'Failed to calculate fares for schedule',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,20 +477,13 @@ export class SchedulesService {
|
|||||||
return { synced, errors };
|
return { synced, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignCoaches(
|
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||||
scheduleId: string,
|
|
||||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
|
||||||
) {
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
const coachIds = coaches.map(c => c.coachId);
|
const coachIds = coaches.map(c => c.coachId);
|
||||||
const existingCoaches = await this.prisma.coach.findMany({
|
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||||
where: { id: { in: coachIds } },
|
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||||
});
|
|
||||||
if (existingCoaches.length !== coachIds.length) {
|
|
||||||
throw new NotFoundException('One or more coaches not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||||
|
|
||||||
@@ -508,20 +495,13 @@ export class SchedulesService {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await this.prisma.coachAssignment.createMany({ data });
|
await this.prisma.coachAssignment.createMany({ data });
|
||||||
|
|
||||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAssignedCoaches(scheduleId: string) {
|
async getAssignedCoaches(scheduleId: string) {
|
||||||
return this.prisma.coachAssignment.findMany({
|
return this.prisma.coachAssignment.findMany({
|
||||||
where: { scheduleId },
|
where: { scheduleId },
|
||||||
include: {
|
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||||
coach: {
|
|
||||||
include: {
|
|
||||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -535,30 +515,22 @@ export class SchedulesService {
|
|||||||
if (dto.departureAt || dto.arrivalAt) {
|
if (dto.departureAt || dto.arrivalAt) {
|
||||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||||
|
|
||||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||||
|
|
||||||
updateData.departureAt = dep;
|
updateData.departureAt = dep;
|
||||||
updateData.arrivalAt = arr;
|
updateData.arrivalAt = arr;
|
||||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.status) {
|
if (dto.status) updateData.status = dto.status;
|
||||||
updateData.status = dto.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await this.prisma.trainSchedule.update({
|
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.coaches !== undefined) {
|
if (dto.coaches !== undefined) {
|
||||||
if (dto.coaches.length > 0) {
|
if (dto.coaches.length > 0) {
|
||||||
await this.assignCoaches(id, dto.coaches);
|
await this.assignCoaches(id, dto.coaches);
|
||||||
} else {
|
} else {
|
||||||
// Remove all coach assignments when empty array is sent
|
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,11 +539,8 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
|
||||||
where: { scheduleId, coachId },
|
|
||||||
});
|
|
||||||
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
||||||
|
|
||||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||||
return { message: 'Coach assignment removed' };
|
return { message: 'Coach assignment removed' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export class SearchService {
|
|||||||
destinationStationId,
|
destinationStationId,
|
||||||
seatClassId: sc.id,
|
seatClassId: sc.id,
|
||||||
nationality,
|
nationality,
|
||||||
|
scheduleId: schedule.id,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
seatClassName: fare.seatClassName,
|
seatClassName: fare.seatClassName,
|
||||||
@@ -499,11 +500,12 @@ export class SearchService {
|
|||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
coachTypeCode: string;
|
coachTypeCode: string;
|
||||||
|
coachId: string;
|
||||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||||
}>> {
|
}>> {
|
||||||
const coachTypeMap = new Map<
|
const coachTypeMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ coachType: any; classNames: Set<string> }
|
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
@@ -514,6 +516,7 @@ export class SearchService {
|
|||||||
coachTypeMap.set(coachType.id, {
|
coachTypeMap.set(coachType.id, {
|
||||||
coachType,
|
coachType,
|
||||||
classNames: new Set(),
|
classNames: new Set(),
|
||||||
|
coachId: assignment.coach.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +525,7 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const [, { coachType, classNames }] of coachTypeMap) {
|
for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
|
||||||
const classes = Array.from(classNames)
|
const classes = Array.from(classNames)
|
||||||
.map((className) => {
|
.map((className) => {
|
||||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||||
@@ -536,6 +539,7 @@ export class SearchService {
|
|||||||
coachTypeId: coachType.id,
|
coachTypeId: coachType.id,
|
||||||
coachTypeName: coachType.name,
|
coachTypeName: coachType.name,
|
||||||
coachTypeCode: coachType.code,
|
coachTypeCode: coachType.code,
|
||||||
|
coachId,
|
||||||
classes,
|
classes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export class TicketsService {
|
|||||||
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
|
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
|
||||||
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=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 logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||||
if (alreadyValidated) {
|
if (alreadyValidated) {
|
||||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||||
@@ -333,7 +333,7 @@ export class TicketsService {
|
|||||||
if (!validLegs.includes(resolvedLeg)) {
|
if (!validLegs.includes(resolvedLeg)) {
|
||||||
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
||||||
}
|
}
|
||||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
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 });
|
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`);
|
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||||
@@ -435,7 +435,7 @@ export class TicketsService {
|
|||||||
booking.bookingType === 'TRANSIT' ||
|
booking.bookingType === 'TRANSIT' ||
|
||||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
if (isMultiLeg && offlineLeg) {
|
if (isMultiLeg && offlineLeg) {
|
||||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||||
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
||||||
results.duplicate++;
|
results.duplicate++;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
|
import { Edit, Loader2, RefreshCw } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
interface Currency {
|
interface CurrencyRate {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,203 +17,104 @@ interface Currency {
|
|||||||
exchangeRate: number;
|
exchangeRate: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CURRENCY_META: Record<string, { name: string; symbol: string }> = {
|
||||||
|
ETB: { name: 'Ethiopian Birr', symbol: 'Br' },
|
||||||
|
DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' },
|
||||||
|
USD: { name: 'US Dollar', symbol: '$' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function CurrenciesPage() {
|
export default function CurrenciesPage() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
|
||||||
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
|
const [rateInput, setRateInput] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
|
|
||||||
isOpen: false,
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [currencyForm, setCurrencyForm] = useState({
|
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
symbol: '',
|
|
||||||
baseCurrencyCode: 'ETB',
|
|
||||||
exchangeRate: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: currencies = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['currencies'],
|
queryKey: ['currencies'],
|
||||||
queryFn: () => apiClient.get('/currencies'),
|
queryFn: () => apiClient.get('/currencies'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: any) => apiClient.post('/currencies', data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
||||||
resetForm();
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
setError(err.response?.data?.message || 'Failed to create currency');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
|
mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
|
||||||
|
apiClient.patch(`/currencies/${id}`, { exchangeRate }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
setEditingCurrency(null);
|
setEditingRate(null);
|
||||||
resetForm();
|
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Failed to update currency');
|
setError(err.response?.data?.message || 'Failed to update exchange rate');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
||||||
setDeleteConfirm({ isOpen: false, id: null });
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
setError(err.response?.data?.message || 'Failed to delete currency');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncRatesMutation = useMutation({
|
|
||||||
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
|
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
setError(err.response?.data?.message || 'Failed to sync exchange rates');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const handleEdit = (currency: CurrencyRate) => {
|
||||||
setCurrencyForm({
|
setEditingRate(currency);
|
||||||
code: '',
|
setRateInput(currency.exchangeRate.toString());
|
||||||
name: '',
|
|
||||||
symbol: '',
|
|
||||||
baseCurrencyCode: 'ETB',
|
|
||||||
exchangeRate: '',
|
|
||||||
});
|
|
||||||
setEditingCurrency(null);
|
|
||||||
setShowModal(false);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCurrency = (currency: Currency) => {
|
const handleSave = async () => {
|
||||||
setEditingCurrency(currency);
|
const rate = parseFloat(rateInput);
|
||||||
setCurrencyForm({
|
|
||||||
code: currency.code,
|
|
||||||
name: currency.name,
|
|
||||||
symbol: currency.symbol,
|
|
||||||
baseCurrencyCode: currency.baseCurrencyCode,
|
|
||||||
exchangeRate: currency.exchangeRate.toString(),
|
|
||||||
});
|
|
||||||
setError(null);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveCurrency = async () => {
|
|
||||||
setError(null);
|
|
||||||
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
|
|
||||||
setError('All fields are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rate = parseFloat(currencyForm.exchangeRate);
|
|
||||||
if (isNaN(rate) || rate <= 0) {
|
if (isNaN(rate) || rate <= 0) {
|
||||||
setError('Exchange rate must be a positive number');
|
setError('Exchange rate must be a positive number');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
|
||||||
const payload = {
|
|
||||||
code: currencyForm.code.toUpperCase(),
|
|
||||||
name: currencyForm.name,
|
|
||||||
symbol: currencyForm.symbol,
|
|
||||||
baseCurrencyCode: currencyForm.baseCurrencyCode,
|
|
||||||
exchangeRate: rate,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingCurrency) {
|
|
||||||
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
|
|
||||||
} else {
|
|
||||||
await createMutation.mutateAsync(payload);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
|
||||||
if (deleteConfirm.id) {
|
|
||||||
await deleteMutation.mutateAsync(deleteConfirm.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'code',
|
key: 'code',
|
||||||
label: 'Code',
|
label: 'Currency',
|
||||||
render: (currency: Currency) => (
|
render: (c: CurrencyRate) => (
|
||||||
<span className="font-mono font-semibold text-primary">{currency.code}</span>
|
<div className="flex items-center gap-3">
|
||||||
),
|
<span className="text-2xl font-bold text-muted-foreground w-10 text-center">
|
||||||
},
|
{CURRENCY_META[c.code]?.symbol ?? c.symbol}
|
||||||
{
|
</span>
|
||||||
key: 'name',
|
<div>
|
||||||
label: 'Name',
|
<div className="font-semibold">{c.code}</div>
|
||||||
render: (currency: Currency) => (
|
<div className="text-xs text-muted-foreground">{CURRENCY_META[c.code]?.name ?? c.name}</div>
|
||||||
<span className="font-medium">{currency.name}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'symbol',
|
|
||||||
label: 'Symbol',
|
|
||||||
render: (currency: Currency) => (
|
|
||||||
<span className="text-lg">{currency.symbol}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'baseCurrencyCode',
|
|
||||||
label: 'Base Currency',
|
|
||||||
render: (currency: Currency) => (
|
|
||||||
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'exchangeRate',
|
|
||||||
label: 'Exchange Rate',
|
|
||||||
render: (currency: Currency) => (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-mono font-semibold">
|
|
||||||
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'baseCurrencyCode',
|
||||||
label: 'Status',
|
label: 'Base',
|
||||||
render: (currency: Currency) => (
|
render: (c: CurrencyRate) => (
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
|
||||||
currency.isActive
|
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
|
|
||||||
}`}>
|
|
||||||
{currency.isActive ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'updatedAt',
|
key: 'exchangeRate',
|
||||||
|
label: 'Exchange Rate',
|
||||||
|
render: (c: CurrencyRate) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-mono font-semibold">
|
||||||
|
1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
label: 'Last Updated',
|
label: 'Last Updated',
|
||||||
render: (currency: Currency) => (
|
render: (c: CurrencyRate) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{new Date(currency.updatedAt).toLocaleDateString()}
|
{new Date(c.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -222,140 +122,93 @@ export default function CurrenciesPage() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit Rate',
|
||||||
onClick: handleEditCurrency,
|
onClick: handleEdit,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
|
|
||||||
variant: 'danger' as const,
|
|
||||||
icon: Trash2,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
|
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
||||||
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
|
<p className="text-muted-foreground mt-1">
|
||||||
</div>
|
Manage ETB exchange rates for display currencies (DJF, USD)
|
||||||
<div className="flex gap-3">
|
</p>
|
||||||
<ActionButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => syncRatesMutation.mutate()}
|
|
||||||
loading={syncRatesMutation.isPending}
|
|
||||||
>
|
|
||||||
Sync Rates
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
icon={Plus}
|
|
||||||
onClick={() => {
|
|
||||||
setError(null);
|
|
||||||
setEditingCurrency(null);
|
|
||||||
setCurrencyForm({
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
symbol: '',
|
|
||||||
baseCurrencyCode: 'ETB',
|
|
||||||
exchangeRate: '',
|
|
||||||
});
|
|
||||||
setShowModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Currency
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => syncMutation.mutate()}
|
||||||
|
loading={syncMutation.isPending}
|
||||||
|
>
|
||||||
|
Sync Rates
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && !editingRate && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{(['ETB', 'DJF', 'USD'] as const).map((code) => {
|
||||||
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
|
const entry = currenciesArray.find((c: CurrencyRate) => c.code === code);
|
||||||
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
|
return (
|
||||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
|
<div
|
||||||
{currenciesArray.length}
|
key={code}
|
||||||
|
className="p-4 rounded-lg border bg-muted/30 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">{CURRENCY_META[code].name}</div>
|
||||||
|
<div className="text-2xl font-bold mt-1">{code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{entry ? (
|
||||||
|
<>
|
||||||
|
<div className="font-mono font-semibold text-lg">{entry.exchangeRate}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">per ETB</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Not configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
|
})}
|
||||||
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
|
|
||||||
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
|
|
||||||
{currenciesArray.filter((c: Currency) => c.isActive).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
|
|
||||||
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
|
|
||||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
|
|
||||||
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
|
|
||||||
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
|
|
||||||
{currenciesArray.length > 0
|
|
||||||
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
) : currenciesArray.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<p>No currencies configured. Click "Add Currency" to create one.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={currenciesArray}
|
|
||||||
columns={columns}
|
|
||||||
actions={actions}
|
|
||||||
loading={false}
|
|
||||||
emptyMessage="No currencies found."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={currenciesArray}
|
||||||
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage="No exchange rates configured."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm text-blue-800 dark:text-blue-300 space-y-1">
|
||||||
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
|
<p className="font-semibold text-blue-900 dark:text-blue-200 mb-2">How it works</p>
|
||||||
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
<p>• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)</p>
|
||||||
<li>
|
<p>• DJF and USD rates are used to display prices to passengers in their preferred currency</p>
|
||||||
• <strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
|
<p>• Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>Display Currencies:</strong> Configure which currencies customers can view prices in
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>Sync Rates:</strong> Automatically update exchange rates from external sources
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={deleteConfirm.isOpen}
|
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
title="Delete Currency"
|
|
||||||
message="Are you sure you want to delete this currency? This action cannot be undone."
|
|
||||||
confirmText="Delete"
|
|
||||||
isDanger={true}
|
|
||||||
warning="This will remove the currency from the system."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={!!editingRate}
|
||||||
onClose={resetForm}
|
onClose={() => { setEditingRate(null); setError(null); }}
|
||||||
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
|
title={`Update Rate — ${editingRate?.code}`}
|
||||||
size="lg"
|
size="sm"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -364,108 +217,38 @@ export default function CurrenciesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="p-3 bg-muted/40 rounded-lg text-sm">
|
||||||
<div>
|
<span className="text-muted-foreground">Currency: </span>
|
||||||
<label className="label">Currency Code *</label>
|
<span className="font-semibold">{editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}</span>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currencyForm.code}
|
|
||||||
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="e.g., USD"
|
|
||||||
maxLength={3}
|
|
||||||
disabled={!!editingCurrency}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Currency Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currencyForm.name}
|
|
||||||
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="e.g., United States Dollar"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="label">Symbol *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currencyForm.symbol}
|
|
||||||
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="e.g., $"
|
|
||||||
maxLength={3}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Base Currency *</label>
|
|
||||||
<select
|
|
||||||
value={currencyForm.baseCurrencyCode}
|
|
||||||
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option value="ETB">ETB (Ethiopian Birr)</option>
|
|
||||||
<option value="USD">USD (US Dollar)</option>
|
|
||||||
<option value="DJF">DJF (Djiboutian Franc)</option>
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Exchange Rate *</label>
|
<label className="label">
|
||||||
<div className="flex items-center gap-2">
|
1 {editingRate?.baseCurrencyCode} = ? {editingRate?.code}
|
||||||
<input
|
</label>
|
||||||
type="number"
|
<input
|
||||||
min="0"
|
type="number"
|
||||||
step="0.0001"
|
min="0.0001"
|
||||||
value={currencyForm.exchangeRate}
|
step="0.0001"
|
||||||
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
|
value={rateInput}
|
||||||
className="input w-full"
|
onChange={(e) => setRateInput(e.target.value)}
|
||||||
placeholder="e.g., 0.018"
|
className="input w-full"
|
||||||
required
|
placeholder="e.g., 3.25"
|
||||||
/>
|
autoFocus
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
/>
|
||||||
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
|
{rateInput && parseFloat(rateInput) > 0 && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</div>
|
≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}
|
||||||
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
<p className="font-semibold mb-1">Exchange Rate Example:</p>
|
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>
|
||||||
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
|
|
||||||
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
|
||||||
<ActionButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={resetForm}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<ActionButton onClick={handleSave} loading={updateMutation.isPending}>
|
||||||
onClick={handleSaveCurrency}
|
Save Rate
|
||||||
loading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{editingCurrency ? 'Update Currency' : 'Add Currency'}
|
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -731,14 +731,19 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Route Code (Optional)</label>
|
<label className="label">Route Code (Optional)</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
value={fareForm.route}
|
value={fareForm.route}
|
||||||
onChange={(e) => setFareForm({ ...fareForm, route: e.target.value })}
|
onChange={(e) => setFareForm({ ...fareForm, route: e.target.value })}
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
placeholder="e.g., ADD-DJI"
|
>
|
||||||
/>
|
<option value="">All routes</option>
|
||||||
<p className="text-xs text-muted-foreground mt-1">e.g., ADD-DJI for full route</p>
|
{routesArray.map((route: Route) => (
|
||||||
|
<option key={route.id} value={route.code}>
|
||||||
|
{route.code} — {route.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Scope this fare to a specific route</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Schedule } from '@/types';
|
import { Schedule } from '@/types';
|
||||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react';
|
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export default function ResultsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
|
const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
|
||||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
const [selectedCoachTypes, setSelectedCoachTypes] = useState<Record<string, { id: string; code: string; name: string }>>({});
|
||||||
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(null);
|
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(null);
|
||||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||||
@@ -142,27 +142,22 @@ export default function ResultsPage() {
|
|||||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||||
: outboundSchedules.length > 0;
|
: outboundSchedules.length > 0;
|
||||||
|
|
||||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||||
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||||
const selectedClass = selectedClasses[scheduleId];
|
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||||
|
|
||||||
if (!selectedClass) {
|
if (!selectedCoachType) {
|
||||||
alert('Please select a seat class before continuing');
|
alert('Please select a coach type before continuing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedClassFare = schedule.faresByClass?.find(
|
// Find the coach type to get pricing info
|
||||||
(f: any) => f.seatClassName === selectedClass
|
const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id);
|
||||||
);
|
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
|
||||||
|
|
||||||
if (!selectedClassFare) {
|
|
||||||
alert('Unable to find fare for selected class');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
@@ -176,10 +171,13 @@ export default function ResultsPage() {
|
|||||||
departureTime: schedule.departureAt || schedule.departureTime || '',
|
departureTime: schedule.departureAt || schedule.departureTime || '',
|
||||||
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
|
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
|
||||||
duration: durationStr,
|
duration: durationStr,
|
||||||
baseFareAdult: selectedClassFare.baseFareMinor,
|
baseFareAdult: minFare,
|
||||||
baseFareChild: selectedClassFare.baseFareMinor,
|
baseFareChild: minFare,
|
||||||
selectedSeatClass: selectedClass,
|
selectedSeatClass: selectedCoachType.name,
|
||||||
selectedSeatClassName: selectedClass,
|
selectedSeatClassName: selectedCoachType.name,
|
||||||
|
selectedCoachId: selectedCoachType.id,
|
||||||
|
selectedCoachTypeCode: selectedCoachType.code,
|
||||||
|
selectedCoachTypeName: selectedCoachType.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For round trip, store outbound and wait for inbound selection
|
// For round trip, store outbound and wait for inbound selection
|
||||||
@@ -211,10 +209,16 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
|
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||||
const selectedClass = selectedClasses[scheduleId];
|
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
|
||||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
// Calculate lowest fare from coach types
|
||||||
: null;
|
let lowestFare = null;
|
||||||
|
if (schedule.coachTypes?.length) {
|
||||||
|
const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0);
|
||||||
|
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||||
|
} else if (schedule.faresByClass?.length) {
|
||||||
|
lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0));
|
||||||
|
}
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
const durationStr = `${hours}h ${minutes}m`;
|
const durationStr = `${hours}h ${minutes}m`;
|
||||||
@@ -284,16 +288,16 @@ export default function ResultsPage() {
|
|||||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||||
{selectedClass && (
|
{selectedCoachType && (
|
||||||
<p className="text-xs text-primary font-semibold mb-2">
|
<p className="text-xs text-primary font-semibold mb-2">
|
||||||
{selectedClass.replace(/_/g, ' ')} selected
|
{selectedCoachType.name} selected
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
|
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
|
||||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{selectedClass ? 'Change class' : 'Select class'}
|
{selectedCoachType ? 'Change' : 'Select'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,95 +492,177 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
{classModal && (() => {
|
{classModal && (() => {
|
||||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||||
const selectedClass = selectedClasses[scheduleId];
|
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||||
const isOutbound = (classModal as any).isOutbound;
|
const isOutbound = (classModal as any).isOutbound;
|
||||||
|
const coachTypes = classModal.coachTypes || [];
|
||||||
|
|
||||||
|
const getCoachIcon = (typeName: string) => {
|
||||||
|
const lower = typeName.toLowerCase();
|
||||||
|
if (lower.includes('soft') || lower.includes('vip')) return Star;
|
||||||
|
if (lower.includes('bed')) return Bed;
|
||||||
|
return Armchair;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||||
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||||
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
|
||||||
<Train className="w-3 h-3" />
|
<Train className="w-3.5 h-3.5" />
|
||||||
{classModal.trainNumber} · {classModal.origin?.name} → {classModal.destination?.name}
|
<span className="font-medium">{classModal.trainNumber}</span>
|
||||||
|
<span className="text-gray-400">·</span>
|
||||||
|
<span>{classModal.origin?.name} → {classModal.destination?.name}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setClassModal(null)}
|
onClick={() => setClassModal(null)}
|
||||||
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
||||||
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
|
{coachTypes.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||||
{classModal.faresByClass.map((fareClass: any) => {
|
{coachTypes.map((coachType: any, index: number) => {
|
||||||
const isSelected = selectedClass === fareClass.seatClassName;
|
const isSelected = selectedCoachType?.id === coachType.coachId;
|
||||||
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
|
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||||
const isAvailable = availableSeats > 0;
|
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||||
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={fareClass.seatClassName}
|
key={coachType.coachId}
|
||||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
onClick={() => handleSelectCoachType(scheduleId, coachType.coachId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||||
disabled={!isAvailable}
|
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
|
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
|
||||||
: isAvailable
|
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
|
||||||
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
|
|
||||||
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
}`}
|
||||||
|
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||||
<Check className="w-3.5 h-3.5 text-white" />
|
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="font-semibold text-gray-900 dark:text-white pr-8">
|
|
||||||
{fareClass.seatClassName.replace(/_/g, ' ')}
|
<div className="flex flex-col">
|
||||||
</p>
|
<div className="flex items-start gap-4 pr-2">
|
||||||
<p className="text-xl font-bold text-primary dark:text-white mt-2">
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
|
isSelected
|
||||||
</p>
|
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||||
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
|
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||||
<p className={`text-xs mt-2 font-medium ${
|
}`}>
|
||||||
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
|
<CoachIcon className={`w-6 h-6 transition-colors ${
|
||||||
}`}>
|
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||||
{isAvailable
|
}`} />
|
||||||
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
|
</div>
|
||||||
: 'Sold out'}
|
|
||||||
</p>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
|
||||||
|
{coachType.coachTypeName}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||||
|
<span className={`text-2xl font-bold tracking-tight ${
|
||||||
|
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{(minPrice / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">ETB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coachType.classes.length > 0 && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Class Options
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{coachType.classes.length} available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{coachType.classes.map((cls: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{cls.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||||
|
{(cls.baseFareMinor / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
ETB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Train className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
|
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
|
||||||
<button
|
<div className="w-full max-w-md">
|
||||||
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
|
<button
|
||||||
disabled={!selectedClass}
|
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
|
||||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
disabled={!selectedCoachType}
|
||||||
>
|
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return Flight' : 'Continue'}</span>
|
>
|
||||||
<ArrowRight className="w-4 h-4" />
|
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
|
||||||
</button>
|
<ArrowRight className="w-4 h-4" />
|
||||||
{!selectedClass && (
|
</button>
|
||||||
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
|
{!selectedCoachType && (
|
||||||
)}
|
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
|
||||||
|
Select a coach type to continue
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
|
<style>{`
|
||||||
|
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||||
|
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
|
||||||
|
`}</style>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -144,9 +144,14 @@ export default function ReviewPage() {
|
|||||||
const createBookingMutation = useMutation({
|
const createBookingMutation = useMutation({
|
||||||
mutationFn: (data: any) => {
|
mutationFn: (data: any) => {
|
||||||
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
|
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
|
||||||
|
console.log('=== API REQUEST ===');
|
||||||
|
console.log('Endpoint:', endpoint);
|
||||||
|
console.log('Request Data:', JSON.stringify(data, null, 2));
|
||||||
return apiClient.post(endpoint, data);
|
return apiClient.post(endpoint, data);
|
||||||
},
|
},
|
||||||
onSuccess: (data: any) => {
|
onSuccess: (data: any) => {
|
||||||
|
console.log('=== API RESPONSE SUCCESS ===');
|
||||||
|
console.log('Response Data:', JSON.stringify(data, null, 2));
|
||||||
console.log('Booking created successfully:', data);
|
console.log('Booking created successfully:', data);
|
||||||
const bookingIdValue = data.bookingId || data.id;
|
const bookingIdValue = data.bookingId || data.id;
|
||||||
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
|
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
|
||||||
@@ -178,7 +183,12 @@ export default function ReviewPage() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('Booking creation failed:', error);
|
console.log('=== API RESPONSE ERROR ===');
|
||||||
|
console.error('Error Object:', error);
|
||||||
|
console.error('Error Response:', error?.response);
|
||||||
|
console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2));
|
||||||
|
console.error('Error Status:', error?.response?.status);
|
||||||
|
console.error('Error Message:', error?.message);
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
|
||||||
alert(errorMessage);
|
alert(errorMessage);
|
||||||
},
|
},
|
||||||
@@ -196,7 +206,7 @@ export default function ReviewPage() {
|
|||||||
console.log('Inbound schedule:', inboundSchedule);
|
console.log('Inbound schedule:', inboundSchedule);
|
||||||
console.log('Passengers:', passengers);
|
console.log('Passengers:', passengers);
|
||||||
|
|
||||||
if (!seatHold?.holdId) {
|
if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) {
|
||||||
console.error('No seat hold found');
|
console.error('No seat hold found');
|
||||||
alert('Please select seats before continuing.');
|
alert('Please select seats before continuing.');
|
||||||
router.push('/booking/seats');
|
router.push('/booking/seats');
|
||||||
@@ -275,7 +285,7 @@ export default function ReviewPage() {
|
|||||||
bookingData = {
|
bookingData = {
|
||||||
passengerId: passengerId,
|
passengerId: passengerId,
|
||||||
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
||||||
holdId: seatHold.holdId,
|
holdId: seatHold?.holdId || '',
|
||||||
originStationId: searchCriteria.originStationId,
|
originStationId: searchCriteria.originStationId,
|
||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
@@ -302,7 +312,7 @@ export default function ReviewPage() {
|
|||||||
bookingData.returnScheduleId = inboundSchedule.id;
|
bookingData.returnScheduleId = inboundSchedule.id;
|
||||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
|
||||||
bookingData.returnSeatClassId = returnSeatClassId;
|
bookingData.returnSeatClassId = returnSeatClassId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +324,7 @@ export default function ReviewPage() {
|
|||||||
// For guests: send full passenger details array
|
// For guests: send full passenger details array
|
||||||
bookingData = {
|
bookingData = {
|
||||||
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
|
||||||
holdId: seatHold.holdId,
|
holdId: seatHold?.holdId || '',
|
||||||
originStationId: searchCriteria.originStationId,
|
originStationId: searchCriteria.originStationId,
|
||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
@@ -346,7 +356,7 @@ export default function ReviewPage() {
|
|||||||
bookingData.returnScheduleId = inboundSchedule.id;
|
bookingData.returnScheduleId = inboundSchedule.id;
|
||||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
|
||||||
bookingData.returnSeatClassId = returnSeatClassId;
|
bookingData.returnSeatClassId = returnSeatClassId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +370,8 @@ export default function ReviewPage() {
|
|||||||
localStorage.setItem('deviceId', bookingData.deviceId);
|
localStorage.setItem('deviceId', bookingData.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating booking with payload:', bookingData);
|
console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2));
|
||||||
|
console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||||
await createBookingMutation.mutateAsync(bookingData);
|
await createBookingMutation.mutateAsync(bookingData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in handleConfirm:', error);
|
console.error('Error in handleConfirm:', error);
|
||||||
|
|||||||
@@ -60,11 +60,35 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
|
||||||
|
const coachId = (currentSchedule as any)?.selectedCoachId;
|
||||||
|
const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode;
|
||||||
|
|
||||||
const { data: seatMapData, isLoading, error } = useQuery({
|
const { data: seatMapData, isLoading, error } = useQuery({
|
||||||
queryKey: ['seatmap', currentSchedule?.id, currentJourneyType],
|
queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType],
|
||||||
queryFn: () => apiClient.get(`/seats/seatmap/${currentSchedule?.id}`),
|
queryFn: async () => {
|
||||||
enabled: !!currentSchedule?.id,
|
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`;
|
||||||
|
console.log('🪑 Seatmap Request:', {
|
||||||
|
endpoint,
|
||||||
|
scheduleId: currentSchedule?.id,
|
||||||
|
coachId,
|
||||||
|
coachTypeCode,
|
||||||
|
currentJourneyType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.get(endpoint);
|
||||||
|
|
||||||
|
console.log('✅ Seatmap Response:', {
|
||||||
|
endpoint,
|
||||||
|
fullResponse: response,
|
||||||
|
dataCoaches: (response as any)?.data?.coaches?.length || 0,
|
||||||
|
rootCoaches: (response as any)?.coaches?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalData = (response as any)?.data || response;
|
||||||
|
console.log('🎯 Final data structure:', finalData);
|
||||||
|
return finalData;
|
||||||
|
},
|
||||||
|
enabled: !!currentSchedule?.id && !!coachId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const holdMutation = useMutation({
|
const holdMutation = useMutation({
|
||||||
@@ -106,22 +130,40 @@ export default function SeatsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
const coaches = useMemo(() => {
|
||||||
|
const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || [];
|
||||||
|
console.log('📦 Raw coaches data:', {
|
||||||
|
fromRoot: (seatMapData as any)?.coaches?.length || 0,
|
||||||
|
fromData: (seatMapData as any)?.data?.coaches?.length || 0,
|
||||||
|
using: rawCoaches.length,
|
||||||
|
seatMapData
|
||||||
|
});
|
||||||
|
return rawCoaches;
|
||||||
|
}, [seatMapData]);
|
||||||
|
|
||||||
const filteredCoaches = useMemo(() => {
|
const filteredCoaches = useMemo(() => {
|
||||||
|
console.log('🔍 Filtering coaches:', {
|
||||||
|
totalCoaches: coaches.length,
|
||||||
|
selectedSeatClass: currentSchedule?.selectedSeatClass,
|
||||||
|
coachesData: coaches.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
label: c.label,
|
||||||
|
seatClass: c.seatClass,
|
||||||
|
seatClasses: c.seatClasses,
|
||||||
|
seatsCount: c.seats?.length || 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0);
|
||||||
|
|
||||||
if (!currentSchedule?.selectedSeatClass) {
|
if (!currentSchedule?.selectedSeatClass) {
|
||||||
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
|
console.log('✅ No filter applied, returning all coaches:', coachesWithSeats.length);
|
||||||
|
return coachesWithSeats;
|
||||||
}
|
}
|
||||||
|
|
||||||
let filtered = coaches.filter((c: any) => {
|
console.log('✅ No seat class filter - returning all coaches with seats:', coachesWithSeats.length);
|
||||||
const seatClasses = c.seatClasses || [c.seatClass] || [];
|
return coachesWithSeats;
|
||||||
return seatClasses.some((seatClassName: string) =>
|
|
||||||
seatClassName === currentSchedule.selectedSeatClass ||
|
|
||||||
seatClassName.replace(/_/g, ' ').toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase() ||
|
|
||||||
seatClassName.toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
|
||||||
}, [coaches, currentSchedule?.selectedSeatClass]);
|
}, [coaches, currentSchedule?.selectedSeatClass]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -142,7 +184,10 @@ export default function SeatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validSeats = useMemo(() => {
|
const validSeats = useMemo(() => {
|
||||||
let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
let seats = allSeats.filter((s: any) => {
|
||||||
|
const seatLabel = s.label || s.number || s.seatNumber || '';
|
||||||
|
return seatLabel && !seatLabel.startsWith('-');
|
||||||
|
});
|
||||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||||
@@ -288,10 +333,22 @@ export default function SeatsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bookingId]);
|
}, [bookingId]);
|
||||||
|
|
||||||
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => {
|
||||||
if (!arrangement) return [2, 2];
|
if (!arrangement) return [2, 2];
|
||||||
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
|
|
||||||
return parts.length === 2 ? parts : [2, 2];
|
// Check if this is a bed coach based on seat classes
|
||||||
|
const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed'));
|
||||||
|
|
||||||
|
if (isBedCoach) {
|
||||||
|
// For bed coaches, arrangement like "3+0" means 3 beds stacked vertically
|
||||||
|
// We want to render them as single column, so return [1]
|
||||||
|
const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
|
||||||
|
return parts.length > 0 ? [Math.max(...parts)] : [3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular seats, parse normally (e.g., "3+2" -> [3, 2])
|
||||||
|
const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
|
||||||
|
return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBedLabel = (bedPosition: string | null): string => {
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
@@ -302,8 +359,7 @@ export default function SeatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
||||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
const arrangement = parseSeatArrangement(coach.seatArrangement, coach.seatClasses || [coach.seatClass]);
|
||||||
const leftCount = arrangement[0];
|
|
||||||
|
|
||||||
if (validSeats.length === 0) {
|
if (validSeats.length === 0) {
|
||||||
return <div className="text-xs text-muted-foreground">No seats</div>;
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||||
@@ -312,36 +368,91 @@ export default function SeatsPage() {
|
|||||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||||
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
|
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
|
||||||
|
|
||||||
|
// Bed coach with bed positions (Upper, Middle, Lower)
|
||||||
if (isBedCoach && hasBedPositionData) {
|
if (isBedCoach && hasBedPositionData) {
|
||||||
|
// Group by the base seat number (column), not by row
|
||||||
|
// For beds, seats with same number but different positions should be grouped together
|
||||||
|
const seatGroups = new Map<string, any[]>();
|
||||||
|
|
||||||
|
for (const seat of validSeats) {
|
||||||
|
const baseNumber = seat.seatNumber || seat.number || seat.label || '';
|
||||||
|
if (!seatGroups.has(baseNumber)) {
|
||||||
|
seatGroups.set(baseNumber, []);
|
||||||
|
}
|
||||||
|
seatGroups.get(baseNumber)!.push(seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort groups by seat number
|
||||||
|
const sortedGroups = Array.from(seatGroups.entries())
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const numA = parseInt(a) || 0;
|
||||||
|
const numB = parseInt(b) || 0;
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 w-40">
|
<div className="space-y-4">
|
||||||
{validSeats.map((seat: any) => {
|
{sortedGroups.map(([seatNumber, beds], idx) => {
|
||||||
const rowNumber = seat.row || 1;
|
const shouldFlipIcon = idx % 2 === 0;
|
||||||
const shouldFlipIcon = rowNumber % 2 === 0;
|
|
||||||
|
// Order: lower, middle, upper (bottom to top)
|
||||||
|
const orderedBeds = ['lower', 'middle', 'upper']
|
||||||
|
.map(pos => beds.find(seat => seat.bedPosition === pos))
|
||||||
|
.filter(seat => seat !== undefined);
|
||||||
|
|
||||||
|
if (orderedBeds.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={seat.id}>
|
<div key={`bed-group-${seatNumber}`} className="pb-4 border-b-2 border-dashed border-gray-300 dark:border-gray-600 last:border-b-0">
|
||||||
{shouldFlipIcon && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
{shouldFlipIcon && (
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
<div className="flex flex-col gap-3">
|
||||||
|
{orderedBeds.map((seat: any) => {
|
||||||
|
const seatLabel = seat.seatNumber || seat.number || seat.label || '';
|
||||||
|
const bedLabelFull = seat.bedPosition ? (
|
||||||
|
seat.bedPosition === 'upper' ? 'Upper' :
|
||||||
|
seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
|
||||||
|
) : '';
|
||||||
|
return (
|
||||||
|
<div key={seat.id} className="w-20 text-xs font-bold text-foreground text-right">
|
||||||
|
{seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{orderedBeds.map((seat: any) => (
|
||||||
|
<SeatButton
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
|
onToggle={handleSeatClick}
|
||||||
|
isBedCoach={true}
|
||||||
|
bedLabel={getBedLabel(seat.bedPosition)}
|
||||||
|
coachSeatClass={seatClassStr}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="flex">
|
{!shouldFlipIcon && (
|
||||||
<SeatButton
|
<div className="flex flex-col gap-3">
|
||||||
key={seat.id}
|
{orderedBeds.map((seat: any) => {
|
||||||
seat={seat}
|
const seatLabel = seat.seatNumber || seat.number || seat.label || '';
|
||||||
isSelected={selectedSeats.includes(seat.id)}
|
const bedLabelFull = seat.bedPosition ? (
|
||||||
onToggle={handleSeatClick}
|
seat.bedPosition === 'upper' ? 'Upper' :
|
||||||
isBedCoach={true}
|
seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
|
||||||
bedLabel={getBedLabel(seat.bedPosition)}
|
) : '';
|
||||||
coachSeatClass={seatClassStr}
|
return (
|
||||||
/>
|
<div key={seat.id} className="w-20 text-xs font-bold text-foreground">
|
||||||
|
{seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!shouldFlipIcon && (
|
|
||||||
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -349,68 +460,64 @@ export default function SeatsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [];
|
// Regular seats with row/column arrangement
|
||||||
const processedRows = new Set();
|
const rowMap = new Map<number, any[]>();
|
||||||
for (const seat of validSeats) {
|
for (const seat of validSeats) {
|
||||||
if (!processedRows.has(seat.row)) {
|
if (!rowMap.has(seat.row)) {
|
||||||
rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
|
rowMap.set(seat.row, []);
|
||||||
const colA = a.col.charCodeAt(0);
|
|
||||||
const colB = b.col.charCodeAt(0);
|
|
||||||
return colA - colB;
|
|
||||||
}));
|
|
||||||
processedRows.add(seat.row);
|
|
||||||
}
|
}
|
||||||
|
rowMap.get(seat.row)!.push(seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rows = Array.from(rowMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{rows.map((rowSeats: any[], rowIdx: number) => {
|
{rows.map((rowSeats: any[], rowIdx: number) => {
|
||||||
const leftSeats = rowSeats.slice(0, leftCount);
|
const groups: any[][] = [];
|
||||||
const rightSeats = rowSeats.slice(leftCount);
|
|
||||||
|
// Split seats into groups based on arrangement
|
||||||
|
if (arrangement.length === 1) {
|
||||||
|
// Single group (all seats together)
|
||||||
|
groups.push(rowSeats);
|
||||||
|
} else {
|
||||||
|
// Multiple groups with aisle separation
|
||||||
|
arrangement.forEach((_groupSize, groupIdx) => {
|
||||||
|
const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0);
|
||||||
|
const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0);
|
||||||
|
const currentGroup = rowSeats.slice(startIdx, endIdx);
|
||||||
|
if (currentGroup.length > 0) groups.push(currentGroup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const rowNumber = rowSeats[0]?.row || 1;
|
const rowNumber = rowSeats[0]?.row || 1;
|
||||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||||
const showSpacing = rowIdx % 2 === 1;
|
const showSpacing = rowIdx % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`row-${rowSeats[0]?.id}`}>
|
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
|
||||||
{shouldFlipArmchair && (
|
{shouldFlipArmchair && (
|
||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
{groups.map((group, gIdx) => (
|
||||||
{leftSeats.map((seat: any) => (
|
<div key={`num-before-group-${gIdx}`} className="flex gap-0.5">
|
||||||
<div key={`num-before-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
{group.map((seat: any) => {
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
const seatLabel = seat.label || seat.number || seat.seatNumber || '';
|
||||||
</div>
|
return (
|
||||||
))}
|
<div key={`num-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground">
|
||||||
</div>
|
{seatLabel}
|
||||||
{rightSeats.length > 0 && <div className="w-3" />}
|
</div>
|
||||||
{rightSeats.length > 0 && (
|
);
|
||||||
<div className="flex gap-0.5">
|
})}
|
||||||
{rightSeats.map((seat: any) => (
|
|
||||||
<div key={`num-before-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-0.5 justify-start">
|
|
||||||
<div className="flex gap-0.5">
|
|
||||||
{leftSeats.map((seat: any) => (
|
|
||||||
<SeatButton
|
|
||||||
key={seat.id}
|
|
||||||
seat={seat}
|
|
||||||
isSelected={selectedSeats.includes(seat.id)}
|
|
||||||
onToggle={handleSeatClick}
|
|
||||||
isBedCoach={false}
|
|
||||||
bedLabel=""
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{rightSeats.length > 0 && <div className="w-3" />}
|
)}
|
||||||
{rightSeats.length > 0 && (
|
<div className="flex gap-3 justify-start">
|
||||||
<div className="flex gap-0.5">
|
{groups.map((group, gIdx) => (
|
||||||
{rightSeats.map((seat: any) => (
|
<div key={`group-${gIdx}`} className="flex gap-0.5">
|
||||||
|
{group.map((seat: any) => (
|
||||||
<SeatButton
|
<SeatButton
|
||||||
key={seat.id}
|
key={seat.id}
|
||||||
seat={seat}
|
seat={seat}
|
||||||
@@ -418,35 +525,31 @@ export default function SeatsPage() {
|
|||||||
onToggle={handleSeatClick}
|
onToggle={handleSeatClick}
|
||||||
isBedCoach={false}
|
isBedCoach={false}
|
||||||
bedLabel=""
|
bedLabel=""
|
||||||
|
coachSeatClass={seatClassStr}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!shouldFlipArmchair && (
|
{!shouldFlipArmchair && (
|
||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
{groups.map((group, gIdx) => (
|
||||||
{leftSeats.map((seat: any) => (
|
<div key={`num-after-group-${gIdx}`} className="flex gap-0.5">
|
||||||
<div key={`num-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
{group.map((seat: any) => {
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
const seatLabel = seat.label || seat.number || seat.seatNumber || '';
|
||||||
</div>
|
return (
|
||||||
))}
|
<div key={`num-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground">
|
||||||
</div>
|
{seatLabel}
|
||||||
{rightSeats.length > 0 && <div className="w-3" />}
|
</div>
|
||||||
{rightSeats.length > 0 && (
|
);
|
||||||
<div className="flex gap-0.5">
|
})}
|
||||||
{rightSeats.map((seat: any) => (
|
|
||||||
<div key={`num-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSpacing && <div className="h-2" />}
|
{showSpacing && <div className="h-3 border-b border-gray-200 dark:border-gray-700" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -456,6 +559,23 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
if (!selectedSchedule || !passengers.length) return null;
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
|
if (!coachId) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md">
|
||||||
|
<p className="text-red-500 font-medium mb-2">No coach selected</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Please go back and select a coach type</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/booking/results')}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Back to Results
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allSelected = selectedSeats.length === passengers.length;
|
const allSelected = selectedSeats.length === passengers.length;
|
||||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export interface Schedule {
|
|||||||
availableSeats?: number;
|
availableSeats?: number;
|
||||||
availabilityByClass?: Record<string, number>; // API returns this
|
availabilityByClass?: Record<string, number>; // API returns this
|
||||||
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this
|
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this
|
||||||
|
coachTypes?: Array<{
|
||||||
|
coachId: string;
|
||||||
|
coachTypeName: string;
|
||||||
|
coachTypeCode: string;
|
||||||
|
classes: Array<{
|
||||||
|
name: string;
|
||||||
|
baseFareMinor: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
serviceClass?: string;
|
serviceClass?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
hasAvailability?: boolean;
|
hasAvailability?: boolean;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
|
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
|
||||||
env_file:
|
env_file:
|
||||||
- apps/edr-freight-api/.env
|
- apps/edr-freight-api/.env
|
||||||
|
extra_hosts:
|
||||||
|
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||||
|
|
||||||
passenger-api:
|
passenger-api:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -497,6 +497,20 @@ export interface BookableSchedulesQuery {
|
|||||||
destinationYardId?: string;
|
destinationYardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AvailableDaysQuery {
|
||||||
|
originYardId?: string;
|
||||||
|
destinationYardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level booking pool: the EAT calendar days that have at least one OPEN
|
||||||
|
* departure on a route. `days` are `yyyy-MM-dd` strings, e.g. `["2026-06-20"]`.
|
||||||
|
* No capacity is exposed — customers pick a day, not a train.
|
||||||
|
*/
|
||||||
|
export interface AvailableDaysResponse {
|
||||||
|
days: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookableScheduleLocomotive {
|
export interface BookableScheduleLocomotive {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -452,9 +452,6 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.19.3
|
specifier: ^6.19.3
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
||||||
'@sendgrid/mail':
|
|
||||||
specifier: ^8.1.0
|
|
||||||
version: 8.1.6
|
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.7.7
|
specifier: ^1.7.7
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
@@ -3756,18 +3753,6 @@ packages:
|
|||||||
'@sec-ant/readable-stream@0.4.1':
|
'@sec-ant/readable-stream@0.4.1':
|
||||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||||
|
|
||||||
'@sendgrid/client@8.1.6':
|
|
||||||
resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==}
|
|
||||||
engines: {node: '>=12.*'}
|
|
||||||
|
|
||||||
'@sendgrid/helpers@8.0.0':
|
|
||||||
resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
|
|
||||||
'@sendgrid/mail@8.1.6':
|
|
||||||
resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==}
|
|
||||||
engines: {node: '>=12.*'}
|
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.10':
|
'@sinclair/typebox@0.27.10':
|
||||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||||
|
|
||||||
@@ -16642,26 +16627,6 @@ snapshots:
|
|||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
|
|
||||||
'@sendgrid/client@8.1.6':
|
|
||||||
dependencies:
|
|
||||||
'@sendgrid/helpers': 8.0.0
|
|
||||||
axios: 1.17.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- debug
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@sendgrid/helpers@8.0.0':
|
|
||||||
dependencies:
|
|
||||||
deepmerge: 4.3.1
|
|
||||||
|
|
||||||
'@sendgrid/mail@8.1.6':
|
|
||||||
dependencies:
|
|
||||||
'@sendgrid/client': 8.1.6
|
|
||||||
'@sendgrid/helpers': 8.0.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- debug
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.10': {}
|
'@sinclair/typebox@0.27.10': {}
|
||||||
|
|
||||||
'@sindresorhus/merge-streams@4.0.0': {}
|
'@sindresorhus/merge-streams@4.0.0': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user