Merge pull request #224 from Tria-plc/freight_feature/priority

Freight feature/priority
This commit is contained in:
marshal
2026-06-19 16:11:36 +03:00
committed by GitHub
69 changed files with 3385 additions and 1739 deletions

View File

@@ -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;`);
}
}

View File

@@ -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;`);
}
}

View File

@@ -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

View File

@@ -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());

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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',
}),
);
});
});
}); });

View File

@@ -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);
} }

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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" })

View File

@@ -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,
) { ) {

View File

@@ -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

View File

@@ -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 }}>

View File

@@ -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) =>

View File

@@ -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 ?? ""),

View File

@@ -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={[

View File

@@ -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;

View File

@@ -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),

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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 },

View File

@@ -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

View File

@@ -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 were 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,
}: { }: {

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 }
: {}), : {}),
}; };

View File

@@ -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 } : {}),
}; };

View File

@@ -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[]) {

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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={

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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" },
}), }),
); );

View File

@@ -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: {

View File

@@ -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;
},
}; };

View File

@@ -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",

View File

@@ -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

View File

@@ -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");

View File

@@ -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")
} }

View File

@@ -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;

View File

@@ -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' };
} }

View File

@@ -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 {

View File

@@ -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),
), ),
); );

View File

@@ -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;
} }

View File

@@ -34,7 +34,7 @@ export class SingleMessageDto {
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
sms: string; message: string;
} }
export class BulkMessagesDto { export class BulkMessagesDto {

View File

@@ -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 };
} }
} }

View File

@@ -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);

View File

@@ -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,

View File

@@ -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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[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);
}
} }

View File

@@ -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 };
} }
} }

View File

@@ -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",

View File

@@ -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);
} }
} }

View File

@@ -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' };
} }

View File

@@ -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,
}); });
} }

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</> </>
); );
})()} })()}

View File

@@ -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);

View File

@@ -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');

View File

@@ -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;

View File

@@ -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:

View File

@@ -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
View File

@@ -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': {}